forked from bitcoinafterlife/bal-electrum-plugin
feat(bal): export calendar .ics as separate reminder events (v0.3.7)
- open_or_save_calendar(): emit one VEVENT per reminder instead of a single event with VALARM blocks. Dates come from compute_reminder_offsets(), spread across the check-alive period, with the LAST event one day before the locktime. Unique UID per event (bal-<wallet>-<offset>d) and numbered summaries ' (reminder N/total)'. - Removed the now-unused create_alarms() method (no more VALARM). - Default save filename changed from will_event.ics to BAL_will_event.ics. - common.py: added timedelta to the datetime import. - plugin_base.py: updated the NUM_REMINDERS comment for the new behaviour. - tests: replaced the VALARM E1 test with test_e1_build_separate_events_for_giovanna. - Bumped version to 0.3.7 (4 files) and added CHANGELOG entry #13. Tests: 239 passed. ruff: no new errors.
This commit is contained in:
47
CHANGELOG.md
47
CHANGELOG.md
@@ -709,3 +709,50 @@ asked to translate the file *names* of two of them.
|
|||||||
|
|
||||||
**Outcome:** DONE (documentation-only change; no plugin code touched, so the
|
**Outcome:** DONE (documentation-only change; no plugin code touched, so the
|
||||||
zip-first step does not apply).
|
zip-first step does not apply).
|
||||||
|
|
||||||
|
## 13. Calendar (.ics): separate reminder events instead of one event with alarms
|
||||||
|
|
||||||
|
**Request:** the exported `.ics` added a single calendar entry (on the locktime)
|
||||||
|
with internal VALARM reminders, which most calendars show as just one
|
||||||
|
appointment. The user asked for **N separate events** (default 3), each with its
|
||||||
|
own visible date, with the **last one one day before the inheritance locktime**.
|
||||||
|
|
||||||
|
**What changed (option A):**
|
||||||
|
- `bal/gui/qt/widgets.py`
|
||||||
|
- Rewrote `WillSettingsWidget.open_or_save_calendar()` to emit **one VEVENT
|
||||||
|
per reminder**, each placed on `locktime - offset` days, instead of one
|
||||||
|
VEVENT carrying VALARM blocks. The offsets come from the existing
|
||||||
|
`compute_reminder_offsets()`, so they are spread uniformly across the
|
||||||
|
check-alive period and the **last event is always one day before the
|
||||||
|
locktime**.
|
||||||
|
- Each event gets a **unique UID** (`bal-<wallet>-<offset>d`) so calendars do
|
||||||
|
not merge them, and its summary is suffixed with **" (reminder N/total)"**
|
||||||
|
to tell the events apart. The description still reuses `EVENT_DESCRIPTION`
|
||||||
|
with the usual `$wallet_name` / `$heirs_complete` substitutions.
|
||||||
|
- Removed the now-unused `create_alarms()` method (no more VALARM blocks).
|
||||||
|
- Changed the default save filename from `will_event.ics` to
|
||||||
|
**`BAL_will_event.ics`** (still defaulting to the Desktop).
|
||||||
|
- `bal/gui/qt/common.py`
|
||||||
|
- Added `timedelta` to the `datetime` import (needed for the per-event date
|
||||||
|
arithmetic; re-exported via the GUI star-import).
|
||||||
|
- `bal/core/plugin_base.py`
|
||||||
|
- Updated the `NUM_REMINDERS` comment to describe the new "separate events"
|
||||||
|
behaviour.
|
||||||
|
- `tests/test_group_e_mock_giovanna7.py`
|
||||||
|
- Replaced the old VALARM-shape E1 test with
|
||||||
|
`test_e1_build_separate_events_for_giovanna`, which asserts the new
|
||||||
|
structure: N distinct VEVENTs, no VALARM, unique UIDs, numbered summaries,
|
||||||
|
and the last event one day before the locktime.
|
||||||
|
|
||||||
|
**Effect:** with the default of 3 reminders over, say, a 30-day period, the
|
||||||
|
`.ics` now produces 3 separate calendar appointments (e.g. 30, 16 and 1 day
|
||||||
|
before the deadline) instead of a single one. Short periods automatically
|
||||||
|
produce fewer events (at most one per day).
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- `py_compile` on the changed files: OK.
|
||||||
|
- `ruff check`: no new errors.
|
||||||
|
- Full test suite: see run below.
|
||||||
|
|
||||||
|
**Outcome:** DONE (delivered as a test ZIP v0.3.7 for the user to try before
|
||||||
|
commit).
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.3.6
|
0.3.7
|
||||||
|
|||||||
@@ -34,4 +34,4 @@ The plugin targets Electrum 4.7.2 (the last stable release exposing
|
|||||||
``json_db.register_dict``) and PyQt6.
|
``json_db.register_dict``) and PyQt6.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.3.6"
|
__version__ = "0.3.7"
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ class BalPlugin(BasePlugin):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
_version = None
|
_version = None
|
||||||
__version__ = "0.3.6" # AUTOMATICALLY GENERATED DO NOT EDIT
|
__version__ = "0.3.7" # AUTOMATICALLY GENERATED DO NOT EDIT
|
||||||
|
|
||||||
# Command used to open an .ics calendar file, per operating system.
|
# Command used to open an .ics calendar file, per operating system.
|
||||||
default_app = {
|
default_app = {
|
||||||
@@ -181,12 +181,13 @@ class BalPlugin(BasePlugin):
|
|||||||
# stay display-only outside the wizard unless the user opts in.
|
# stay display-only outside the wizard unless the user opts in.
|
||||||
self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False)
|
self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False)
|
||||||
|
|
||||||
# NUM_REMINDERS (Group D / D1): how many reminder alarms (VALARM) the
|
# NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the
|
||||||
# exported .ics calendar event should contain. The reminders are spread
|
# exported .ics calendar should contain. Each reminder becomes its own
|
||||||
# uniformly across the check-alive period and always fall BEFORE the
|
# VEVENT (its own date in the calendar). The dates are spread uniformly
|
||||||
# delivery deadline. Default 3; the settings dialog caps it at 5 and the
|
# across the check-alive period and the LAST one always falls one day
|
||||||
# alarm builder additionally limits it to at most one alarm per available
|
# before the delivery deadline (locktime). Default 3; the settings
|
||||||
# day.
|
# dialog caps it at 5 and the date builder additionally limits it to at
|
||||||
|
# most one event per available day.
|
||||||
self.NUM_REMINDERS = BalConfig(config, "bal_num_reminders", 3)
|
self.NUM_REMINDERS = BalConfig(config, "bal_num_reminders", 3)
|
||||||
|
|
||||||
self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", True)
|
self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", True)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import subprocess
|
|||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import Any, Callable, Mapping, Optional, Union
|
from typing import Any, Callable, Mapping, Optional, Union
|
||||||
|
|||||||
@@ -738,85 +738,97 @@ class WillSettingsWidget(QWidget):
|
|||||||
# editable outside the wizard only when the setting is ticked.
|
# editable outside the wizard only when the setting is ticked.
|
||||||
self.widgets["baltx_fees"].set_read_only(not editable_dates)
|
self.widgets["baltx_fees"].set_read_only(not editable_dates)
|
||||||
|
|
||||||
def create_alarms(self, alarm_start, alarm_end):
|
def open_or_save_calendar(self):
|
||||||
"""Build the VALARM reminder blocks for the .ics event (Group D / D1).
|
"""Build and save an .ics calendar file with SEPARATE reminder events.
|
||||||
|
|
||||||
The number of reminders is read from the NUM_REMINDERS setting (default
|
Group D / D1 (revised). Instead of a single calendar event holding
|
||||||
3, capped at 5 by the settings dialog). They are spread uniformly across
|
internal VALARM reminders (which most calendars show as just one entry),
|
||||||
the check-alive period and always fall before the delivery deadline; if
|
this exports N *separate* VEVENTs, one per reminder date, so the user
|
||||||
the period is shorter than the requested number, at most one reminder
|
sees several distinct appointments in their calendar.
|
||||||
per day is produced (see compute_reminder_offsets).
|
|
||||||
|
|
||||||
Args:
|
The number of events is read from the NUM_REMINDERS setting (default 3,
|
||||||
alarm_start: the check-alive datetime (start of the period).
|
capped at 5 by the settings dialog). Their dates are computed with
|
||||||
alarm_end: the delivery-time / locktime datetime (the deadline).
|
``compute_reminder_offsets``: the offsets are spread uniformly across the
|
||||||
|
check-alive period and the LAST event always falls one day before the
|
||||||
|
delivery deadline (locktime). If the period is shorter than the
|
||||||
|
requested number of reminders, at most one event per day is produced.
|
||||||
|
|
||||||
Returns:
|
Each event:
|
||||||
A list of .ics text lines (possibly empty) describing the VALARMs.
|
* is placed on ``locktime - offset`` days (its own visible date);
|
||||||
|
* carries a unique UID (``bal-<wallet>-<offset>d``) so calendars do
|
||||||
|
not merge the events into one;
|
||||||
|
* has its summary suffixed with " (reminder N/total)" to tell the
|
||||||
|
events apart at a glance;
|
||||||
|
* reuses the configured EVENT_DESCRIPTION (with the usual
|
||||||
|
``$wallet_name`` / ``$heirs_complete`` substitutions).
|
||||||
|
|
||||||
|
The resulting file is written to a temp location and then copied to the
|
||||||
|
path the user picks in the save dialog (default name "BAL_will_event.ics"
|
||||||
|
on the Desktop).
|
||||||
"""
|
"""
|
||||||
days = (alarm_end - alarm_start).days
|
now = BalCalendar.format_time(datetime.now())
|
||||||
|
|
||||||
# How many reminders the user asked for (default 3 if unreadable).
|
# locktime = delivery deadline; threshold = start of the check-alive
|
||||||
|
# period. Both are datetimes exposed by the date widgets as ``.alarm``.
|
||||||
|
locktime = self.widgets["locktime"].alarm
|
||||||
|
threshold = self.widgets["threshold"].alarm
|
||||||
|
|
||||||
|
# Whole days available between check-alive and the deadline.
|
||||||
|
days = (locktime - threshold).days
|
||||||
|
|
||||||
|
# How many reminder events the user asked for (default 3 if unreadable).
|
||||||
try:
|
try:
|
||||||
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
|
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
|
||||||
except Exception:
|
except Exception:
|
||||||
count = 3
|
count = 3
|
||||||
|
|
||||||
|
# Day-offsets BEFORE the deadline, e.g. [30, 16, 1]. The list always
|
||||||
|
# ends with 1 (one day before the locktime) when >= 2 reminders fit.
|
||||||
offsets = compute_reminder_offsets(days, count)
|
offsets = compute_reminder_offsets(days, count)
|
||||||
|
|
||||||
# Reminder text shown by the calendar app when each alarm fires.
|
# Per-event heir details and the shared description/summary templates.
|
||||||
description = _(
|
heirs_details = "\r\n".join(
|
||||||
"BAL reminder: check in before your will is delivered to your heirs."
|
f" {heir} - {self.bal_window.heirs[heir][0]}, {self.bal_window.heirs[heir][1]}"
|
||||||
|
for heir in self.bal_window.heirs
|
||||||
)
|
)
|
||||||
|
|
||||||
lines = []
|
|
||||||
for offset in offsets:
|
|
||||||
lines.extend(
|
|
||||||
[
|
|
||||||
"BEGIN:VALARM",
|
|
||||||
# Fire "offset" days before the event end (the deadline).
|
|
||||||
f"TRIGGER;RELATED=END:-P{offset}D",
|
|
||||||
"ACTION:DISPLAY",
|
|
||||||
f"DESCRIPTION:{BalCalendar.ical_escape(description)}",
|
|
||||||
"END:VALARM",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return lines
|
|
||||||
|
|
||||||
def open_or_save_calendar(self):
|
|
||||||
now = BalCalendar.format_time(datetime.now())
|
|
||||||
|
|
||||||
locktime = self.widgets["locktime"].alarm
|
|
||||||
threshold = self.widgets["threshold"].alarm
|
|
||||||
alarm_end = BalCalendar.format_time(locktime)
|
|
||||||
alarm_start = BalCalendar.format_time(threshold)
|
|
||||||
|
|
||||||
heirs_details = "\r\n".join(f" {heir} - {self.bal_window.heirs[heir][0]}, {self.bal_window.heirs[heir][1]}" for heir in self.bal_window.heirs)
|
|
||||||
event_description = BalCalendar.ical_escape(
|
event_description = BalCalendar.ical_escape(
|
||||||
f"{self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()}".replace("$wallet_name",str(self.bal_window.wallet)).replace("$heirs_complete",heirs_details)
|
f"{self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()}"
|
||||||
|
.replace("$wallet_name", str(self.bal_window.wallet))
|
||||||
|
.replace("$heirs_complete", heirs_details)
|
||||||
)
|
)
|
||||||
#event_description =f"{event_description}{heirs_details}"
|
summary_base = (
|
||||||
uid = f"bal-{str(self.bal_window.wallet)}"
|
f"{self.bal_window.bal_plugin.EVENT_SUMMARY.get()}"
|
||||||
summary = BalCalendar.ical_escape(
|
.replace("$wallet_name", str(self.bal_window.wallet))
|
||||||
f"{self.bal_window.bal_plugin.EVENT_SUMMARY.get()}".replace("$wallet_name",str(self.bal_window.wallet))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
"BEGIN:VCALENDAR",
|
"BEGIN:VCALENDAR",
|
||||||
"VERSION:2.0",
|
"VERSION:2.0",
|
||||||
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{BalPlugin.__version__}",
|
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{BalPlugin.__version__}",
|
||||||
"BEGIN:VEVENT",
|
|
||||||
f"UID:{uid}",
|
|
||||||
f"DTSTAMP:{now}",
|
|
||||||
f"DTSTART:{alarm_end}",
|
|
||||||
f"DTEND:{alarm_end}",
|
|
||||||
f"SUMMARY:{summary}",
|
|
||||||
f"DESCRIPTION:{event_description}",
|
|
||||||
]
|
]
|
||||||
lines.extend(self.create_alarms(threshold, locktime))
|
|
||||||
lines.extend([
|
# One separate VEVENT per reminder offset (its own date in the calendar).
|
||||||
"END:VEVENT",
|
total = len(offsets)
|
||||||
"END:VCALENDAR",
|
for idx, offset in enumerate(offsets, start=1):
|
||||||
])
|
# The visible date of this event: "offset" days before the deadline.
|
||||||
|
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
|
||||||
|
# Suffix the summary so the N events are easy to tell apart.
|
||||||
|
summary = BalCalendar.ical_escape(
|
||||||
|
f"{summary_base} (reminder {idx}/{total})"
|
||||||
|
)
|
||||||
|
lines.extend([
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
# Offset in the UID keeps each event unique (no merging).
|
||||||
|
f"UID:bal-{str(self.bal_window.wallet)}-{offset}d",
|
||||||
|
f"DTSTAMP:{now}",
|
||||||
|
f"DTSTART:{event_dt}",
|
||||||
|
f"DTEND:{event_dt}",
|
||||||
|
f"SUMMARY:{summary}",
|
||||||
|
f"DESCRIPTION:{event_description}",
|
||||||
|
"END:VEVENT",
|
||||||
|
])
|
||||||
|
|
||||||
|
lines.append("END:VCALENDAR")
|
||||||
|
|
||||||
lines = [s.rstrip("\r\n") for s in lines]
|
lines = [s.rstrip("\r\n") for s in lines]
|
||||||
ics_content = "\r\n".join(lines) + "\r\n"
|
ics_content = "\r\n".join(lines) + "\r\n"
|
||||||
@@ -829,7 +841,7 @@ class WillSettingsWidget(QWidget):
|
|||||||
# dialog opens on the user's Desktop by default, with an .ics filter and
|
# dialog opens on the user's Desktop by default, with an .ics filter and
|
||||||
# a sensible default filename.
|
# a sensible default filename.
|
||||||
desktop = BalCalendar.desktop_dir()
|
desktop = BalCalendar.desktop_dir()
|
||||||
default_path = os.path.join(desktop, "will_event.ics")
|
default_path = os.path.join(desktop, "BAL_will_event.ics")
|
||||||
target = getSaveFileName(
|
target = getSaveFileName(
|
||||||
parent=self.bal_window.window,
|
parent=self.bal_window.window,
|
||||||
title=_("Save calendar reminder (.ics)"),
|
title=_("Save calendar reminder (.ics)"),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "bal",
|
"name": "bal",
|
||||||
"fullname": "Bitcoin After Life",
|
"fullname": "Bitcoin After Life",
|
||||||
"version": "0.3.6",
|
"version": "0.3.7",
|
||||||
"description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.",
|
"description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.",
|
||||||
"author": "Svatantrya",
|
"author": "Svatantrya",
|
||||||
"licence": "MIT",
|
"licence": "MIT",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ the other ``test_group_*`` and ``test_core_*`` suites.
|
|||||||
|
|
||||||
The four sections are:
|
The four sections are:
|
||||||
|
|
||||||
* E1 - calendar / .ics: reminder-offset distribution, VALARM/TRIGGER shape,
|
* E1 - calendar / .ics: reminder-offset distribution, separate-VEVENT shape,
|
||||||
description escaping and temporary .ics file creation.
|
description escaping and temporary .ics file creation.
|
||||||
* E2 - inheritance / states: WillItem status transitions and heir-change
|
* E2 - inheritance / states: WillItem status transitions and heir-change
|
||||||
detection for giovanna7's inheritance.
|
detection for giovanna7's inheritance.
|
||||||
@@ -170,38 +170,53 @@ def test_e1_reminder_offsets_single():
|
|||||||
assert compute_reminder_offsets(10, 1) == [1]
|
assert compute_reminder_offsets(10, 1) == [1]
|
||||||
|
|
||||||
|
|
||||||
def test_e1_build_valarms_for_giovanna():
|
def test_e1_build_separate_events_for_giovanna():
|
||||||
"""Build the VALARM blocks for giovanna7 the same way create_alarms does
|
"""Build the SEPARATE reminder VEVENTs for giovanna7 the same way
|
||||||
(offsets -> TRIGGER lines) and verify their shape.
|
open_or_save_calendar does (one VEVENT per offset, each on its own date)
|
||||||
|
and verify their shape.
|
||||||
|
|
||||||
We replicate the pure part of WillSettingsWidget.create_alarms here so the
|
We replicate the pure part of WillSettingsWidget.open_or_save_calendar here
|
||||||
test stays GUI-free (no Qt widget construction), while still asserting the
|
so the test stays GUI-free (no Qt widget construction), while still
|
||||||
exact iCalendar TRIGGER syntax the plugin emits.
|
asserting the exact iCalendar structure the plugin emits: N distinct events,
|
||||||
|
unique UIDs, "(reminder N/total)" summaries, and the last event placed one
|
||||||
|
day before the locktime.
|
||||||
"""
|
"""
|
||||||
description = (
|
from datetime import datetime, timedelta, timezone
|
||||||
"BAL reminder: check in before your will is delivered to your heirs."
|
|
||||||
)
|
# 30-day period -> offsets [30, 16, 1] (the last one = 1 day before deadline).
|
||||||
|
locktime = datetime(2026, 7, 23, 9, 0, 0, tzinfo=timezone.utc)
|
||||||
offsets = compute_reminder_offsets(30, 3)
|
offsets = compute_reminder_offsets(30, 3)
|
||||||
|
total = len(offsets)
|
||||||
|
wallet = "giovanna7"
|
||||||
|
summary_base = f"BAL - Will execution of {wallet}"
|
||||||
|
|
||||||
lines = []
|
lines = ["BEGIN:VCALENDAR", "VERSION:2.0"]
|
||||||
for offset in offsets:
|
for idx, offset in enumerate(offsets, start=1):
|
||||||
lines.extend(
|
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
|
||||||
[
|
summary = BalCalendar.ical_escape(f"{summary_base} (reminder {idx}/{total})")
|
||||||
"BEGIN:VALARM",
|
lines.extend([
|
||||||
f"TRIGGER;RELATED=END:-P{offset}D",
|
"BEGIN:VEVENT",
|
||||||
"ACTION:DISPLAY",
|
f"UID:bal-{wallet}-{offset}d",
|
||||||
f"DESCRIPTION:{BalCalendar.ical_escape(description)}",
|
f"DTSTART:{event_dt}",
|
||||||
"END:VALARM",
|
f"DTEND:{event_dt}",
|
||||||
]
|
f"SUMMARY:{summary}",
|
||||||
)
|
"END:VEVENT",
|
||||||
|
])
|
||||||
|
lines.append("END:VCALENDAR")
|
||||||
|
|
||||||
# One full VALARM block (5 lines) per reminder offset.
|
# One separate VEVENT per reminder offset (no VALARM blocks any more).
|
||||||
assert lines.count("BEGIN:VALARM") == len(offsets)
|
assert lines.count("BEGIN:VEVENT") == len(offsets)
|
||||||
assert lines.count("END:VALARM") == len(offsets)
|
assert lines.count("END:VEVENT") == len(offsets)
|
||||||
assert len(lines) == 5 * len(offsets)
|
assert "BEGIN:VALARM" not in lines
|
||||||
# The trigger uses "days before the event END" (the deadline).
|
# UIDs are unique per event so calendars do not merge them.
|
||||||
assert "TRIGGER;RELATED=END:-P30D" in lines
|
uids = [ln for ln in lines if ln.startswith("UID:")]
|
||||||
assert "TRIGGER;RELATED=END:-P1D" in lines
|
assert len(uids) == len(set(uids)) == len(offsets)
|
||||||
|
# Summaries are numbered to tell the events apart.
|
||||||
|
assert any("(reminder 1/3)" in ln for ln in lines)
|
||||||
|
assert any("(reminder 3/3)" in ln for ln in lines)
|
||||||
|
# The LAST event (offset 1) sits one day before the locktime.
|
||||||
|
last_dt = BalCalendar.format_time(locktime - timedelta(days=1))
|
||||||
|
assert f"DTSTART:{last_dt}" in lines
|
||||||
|
|
||||||
|
|
||||||
def test_e1_event_description_escaping():
|
def test_e1_event_description_escaping():
|
||||||
|
|||||||
Reference in New Issue
Block a user