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:
2026-06-28 23:01:54 -04:00
parent c963f61424
commit 50129d3b77
8 changed files with 173 additions and 98 deletions

View File

@@ -1 +1 @@
0.3.6
0.3.7

View File

@@ -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"

View File

@@ -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)

View File

@@ -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

View File

@@ -738,85 +738,97 @@ 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__}",
"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([
"END:VEVENT",
"END:VCALENDAR",
])
# 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",
# 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]
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
# 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)"),

View File

@@ -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",