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
|
||||
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.
|
||||
"""
|
||||
|
||||
__version__ = "0.3.6"
|
||||
__version__ = "0.3.7"
|
||||
|
||||
@@ -91,7 +91,7 @@ class BalPlugin(BasePlugin):
|
||||
"""
|
||||
|
||||
_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.
|
||||
default_app = {
|
||||
@@ -181,12 +181,13 @@ class BalPlugin(BasePlugin):
|
||||
# stay display-only outside the wizard unless the user opts in.
|
||||
self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False)
|
||||
|
||||
# NUM_REMINDERS (Group D / D1): how many reminder alarms (VALARM) the
|
||||
# exported .ics calendar event should contain. The reminders are spread
|
||||
# uniformly across the check-alive period and always fall BEFORE the
|
||||
# delivery deadline. Default 3; the settings dialog caps it at 5 and the
|
||||
# alarm builder additionally limits it to at most one alarm per available
|
||||
# day.
|
||||
# NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the
|
||||
# exported .ics calendar should contain. Each reminder becomes its own
|
||||
# VEVENT (its own date in the calendar). The dates are spread uniformly
|
||||
# across the check-alive period and the LAST one always falls one day
|
||||
# before the delivery deadline (locktime). Default 3; the settings
|
||||
# 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.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", True)
|
||||
|
||||
@@ -22,7 +22,7 @@ import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from functools import partial
|
||||
from typing import Any, Callable, Mapping, Optional, Union
|
||||
|
||||
@@ -738,86 +738,98 @@ class WillSettingsWidget(QWidget):
|
||||
# editable outside the wizard only when the setting is ticked.
|
||||
self.widgets["baltx_fees"].set_read_only(not editable_dates)
|
||||
|
||||
def create_alarms(self, alarm_start, alarm_end):
|
||||
"""Build the VALARM reminder blocks for the .ics event (Group D / D1).
|
||||
def open_or_save_calendar(self):
|
||||
"""Build and save an .ics calendar file with SEPARATE reminder events.
|
||||
|
||||
The number of reminders is read from the NUM_REMINDERS setting (default
|
||||
3, capped at 5 by the settings dialog). They are spread uniformly across
|
||||
the check-alive period and always fall before the delivery deadline; if
|
||||
the period is shorter than the requested number, at most one reminder
|
||||
per day is produced (see compute_reminder_offsets).
|
||||
Group D / D1 (revised). Instead of a single calendar event holding
|
||||
internal VALARM reminders (which most calendars show as just one entry),
|
||||
this exports N *separate* VEVENTs, one per reminder date, so the user
|
||||
sees several distinct appointments in their calendar.
|
||||
|
||||
Args:
|
||||
alarm_start: the check-alive datetime (start of the period).
|
||||
alarm_end: the delivery-time / locktime datetime (the deadline).
|
||||
The number of events is read from the NUM_REMINDERS setting (default 3,
|
||||
capped at 5 by the settings dialog). Their dates are computed with
|
||||
``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:
|
||||
A list of .ics text lines (possibly empty) describing the VALARMs.
|
||||
Each event:
|
||||
* 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:
|
||||
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
|
||||
except Exception:
|
||||
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)
|
||||
|
||||
# Reminder text shown by the calendar app when each alarm fires.
|
||||
description = _(
|
||||
"BAL reminder: check in before your will is delivered to your heirs."
|
||||
# Per-event heir details and the shared description/summary templates.
|
||||
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
|
||||
)
|
||||
|
||||
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(
|
||||
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}"
|
||||
uid = f"bal-{str(self.bal_window.wallet)}"
|
||||
summary = BalCalendar.ical_escape(
|
||||
f"{self.bal_window.bal_plugin.EVENT_SUMMARY.get()}".replace("$wallet_name",str(self.bal_window.wallet))
|
||||
summary_base = (
|
||||
f"{self.bal_window.bal_plugin.EVENT_SUMMARY.get()}"
|
||||
.replace("$wallet_name", str(self.bal_window.wallet))
|
||||
)
|
||||
|
||||
lines = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{BalPlugin.__version__}",
|
||||
]
|
||||
|
||||
# One separate VEVENT per reminder offset (its own date in the calendar).
|
||||
total = len(offsets)
|
||||
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",
|
||||
f"UID:{uid}",
|
||||
# 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:{alarm_end}",
|
||||
f"DTEND:{alarm_end}",
|
||||
f"DTSTART:{event_dt}",
|
||||
f"DTEND:{event_dt}",
|
||||
f"SUMMARY:{summary}",
|
||||
f"DESCRIPTION:{event_description}",
|
||||
]
|
||||
lines.extend(self.create_alarms(threshold, locktime))
|
||||
lines.extend([
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
])
|
||||
|
||||
lines.append("END:VCALENDAR")
|
||||
|
||||
lines = [s.rstrip("\r\n") for s in lines]
|
||||
ics_content = "\r\n".join(lines) + "\r\n"
|
||||
# Keep the generated .ics in a temp file; it is copied to the path the
|
||||
@@ -829,7 +841,7 @@ class WillSettingsWidget(QWidget):
|
||||
# dialog opens on the user's Desktop by default, with an .ics filter and
|
||||
# a sensible default filename.
|
||||
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(
|
||||
parent=self.bal_window.window,
|
||||
title=_("Save calendar reminder (.ics)"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "bal",
|
||||
"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.",
|
||||
"author": "Svatantrya",
|
||||
"licence": "MIT",
|
||||
|
||||
@@ -8,7 +8,7 @@ the other ``test_group_*`` and ``test_core_*`` suites.
|
||||
|
||||
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.
|
||||
* E2 - inheritance / states: WillItem status transitions and heir-change
|
||||
detection for giovanna7's inheritance.
|
||||
@@ -170,38 +170,53 @@ def test_e1_reminder_offsets_single():
|
||||
assert compute_reminder_offsets(10, 1) == [1]
|
||||
|
||||
|
||||
def test_e1_build_valarms_for_giovanna():
|
||||
"""Build the VALARM blocks for giovanna7 the same way create_alarms does
|
||||
(offsets -> TRIGGER lines) and verify their shape.
|
||||
def test_e1_build_separate_events_for_giovanna():
|
||||
"""Build the SEPARATE reminder VEVENTs for giovanna7 the same way
|
||||
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
|
||||
test stays GUI-free (no Qt widget construction), while still asserting the
|
||||
exact iCalendar TRIGGER syntax the plugin emits.
|
||||
We replicate the pure part of WillSettingsWidget.open_or_save_calendar here
|
||||
so the test stays GUI-free (no Qt widget construction), while still
|
||||
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 = (
|
||||
"BAL reminder: check in before your will is delivered to your heirs."
|
||||
)
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# 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)
|
||||
total = len(offsets)
|
||||
wallet = "giovanna7"
|
||||
summary_base = f"BAL - Will execution of {wallet}"
|
||||
|
||||
lines = []
|
||||
for offset in offsets:
|
||||
lines.extend(
|
||||
[
|
||||
"BEGIN:VALARM",
|
||||
f"TRIGGER;RELATED=END:-P{offset}D",
|
||||
"ACTION:DISPLAY",
|
||||
f"DESCRIPTION:{BalCalendar.ical_escape(description)}",
|
||||
"END:VALARM",
|
||||
]
|
||||
)
|
||||
lines = ["BEGIN:VCALENDAR", "VERSION:2.0"]
|
||||
for idx, offset in enumerate(offsets, start=1):
|
||||
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
|
||||
summary = BalCalendar.ical_escape(f"{summary_base} (reminder {idx}/{total})")
|
||||
lines.extend([
|
||||
"BEGIN:VEVENT",
|
||||
f"UID:bal-{wallet}-{offset}d",
|
||||
f"DTSTART:{event_dt}",
|
||||
f"DTEND:{event_dt}",
|
||||
f"SUMMARY:{summary}",
|
||||
"END:VEVENT",
|
||||
])
|
||||
lines.append("END:VCALENDAR")
|
||||
|
||||
# One full VALARM block (5 lines) per reminder offset.
|
||||
assert lines.count("BEGIN:VALARM") == len(offsets)
|
||||
assert lines.count("END:VALARM") == len(offsets)
|
||||
assert len(lines) == 5 * len(offsets)
|
||||
# The trigger uses "days before the event END" (the deadline).
|
||||
assert "TRIGGER;RELATED=END:-P30D" in lines
|
||||
assert "TRIGGER;RELATED=END:-P1D" in lines
|
||||
# One separate VEVENT per reminder offset (no VALARM blocks any more).
|
||||
assert lines.count("BEGIN:VEVENT") == len(offsets)
|
||||
assert lines.count("END:VEVENT") == len(offsets)
|
||||
assert "BEGIN:VALARM" not in lines
|
||||
# UIDs are unique per event so calendars do not merge them.
|
||||
uids = [ln for ln in lines if ln.startswith("UID:")]
|
||||
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():
|
||||
|
||||
Reference in New Issue
Block a user