feat(bal): Group D / D1 - configurable distributed calendar reminders + save-only .ics
D1 - number of reminders (default 3, max 5):
- new NUM_REMINDERS config in plugin_base.py
- new BalSpinBox widget bound to a BalConfig
- new pure helper compute_reminder_offsets(days, count): reminders spread
uniformly across the check-alive period, always before the deadline
(offset >= 1), at most one per available day, de-duplicated, earliest
first (e.g. (30,3)->[30,16,1], (2,3)->[2,1], (1,3)->[1], (0,3)->[])
- create_alarms() rewritten to use it and emit one VALARM per offset with a
DESCRIPTION reminder text
- 'Number of reminders' spin box (range 1..5) added to the settings dialog
and to the Reset list
D1b - save-only .ics (ask where, default to Desktop):
- BalCalendar.desktop_dir() helper (~/Desktop with home fallback)
- open_or_save_calendar() no longer opens the file with a calendar app; it
always shows a 'save as' dialog (.ics filter, default will_event.ics,
starting on the Desktop) and copies the file there, then confirms.
Identical behaviour on Windows/Linux/macOS. save_to_cwd -> save_ics_to.
Follow-up: removed the now-unused 'Calendar App' field from the settings
dialog (and from the Reset list). CALENDAR_APP config and
open_with_default_app left in place (unused) to avoid unrelated changes.
D2 intentionally skipped (per user request).
tests/test_group_d_alarms.py: 7 new tests (NUM_REMINDERS default/change and
all distribution rules). Full suite: 217 passed.
This commit is contained in:
@@ -181,6 +181,14 @@ 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.
|
||||
self.NUM_REMINDERS = BalConfig(config, "bal_num_reminders", 3)
|
||||
|
||||
self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", True)
|
||||
self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True)
|
||||
self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True)
|
||||
|
||||
@@ -30,6 +30,25 @@ class BalCalendar:
|
||||
_logger.error(f"starting calendar app {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def desktop_dir():
|
||||
"""Return the user's Desktop directory (Group D / D1b).
|
||||
|
||||
Used as the initial folder of the "save .ics" dialog. On Windows this is
|
||||
normally ``C:\\Users\\<name>\\Desktop``; on Linux/macOS ``~/Desktop`` is
|
||||
used when it exists. If the Desktop cannot be located the home directory
|
||||
is returned as a safe fallback, so the save dialog always opens
|
||||
somewhere sensible.
|
||||
|
||||
Returns:
|
||||
An absolute directory path (string).
|
||||
"""
|
||||
home = os.path.expanduser("~")
|
||||
desktop = os.path.join(home, "Desktop")
|
||||
if os.path.isdir(desktop):
|
||||
return desktop
|
||||
return home
|
||||
|
||||
|
||||
@staticmethod
|
||||
def format_time(time):
|
||||
|
||||
@@ -19,7 +19,7 @@ from electrum.gui.qt.main_window import StatusBarButton
|
||||
from .common import *
|
||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||
from .common import read_QIcon_from_bytes
|
||||
from .widgets import BalCheckBox, BalLineEdit, BalTextEdit
|
||||
from .widgets import BalCheckBox, BalLineEdit, BalSpinBox, BalTextEdit
|
||||
from .window import BalWindow
|
||||
from .dialogs import BalDialog
|
||||
|
||||
@@ -404,10 +404,17 @@ class Plugin(BalPlugin):
|
||||
# fields immediately become editable/read-only.
|
||||
heir_editable_dates = BalCheckBox(self.EDITABLE_DATES, on_multiverse_change)
|
||||
|
||||
# Number of reminders spin box (Group D / D1). Sets how many reminder
|
||||
# alarms the exported .ics calendar event contains. Bound to the
|
||||
# persisted NUM_REMINDERS config (default 3), with a range of 1..5.
|
||||
heir_num_reminders = BalSpinBox(self.NUM_REMINDERS, minimum=1, maximum=5)
|
||||
|
||||
# Editable line/text widgets are created once and kept in named
|
||||
# variables so the "Reset" button (Group C / C4b) can refresh the
|
||||
# displayed values after resetting the underlying config.
|
||||
edit_calendar_app = BalLineEdit(self.CALENDAR_APP)
|
||||
# Note: the "Calendar App" field was removed (Group D follow-up) because
|
||||
# the calendar button now only SAVES the .ics file instead of opening it
|
||||
# with an external app, so the setting is no longer needed in the dialog.
|
||||
edit_event_summary = BalLineEdit(self.EVENT_SUMMARY)
|
||||
edit_event_description = BalTextEdit(self.EVENT_DESCRIPTION)
|
||||
|
||||
@@ -472,10 +479,17 @@ class Plugin(BalPlugin):
|
||||
)
|
||||
add_widget(
|
||||
grid,
|
||||
"Calendar App",
|
||||
edit_calendar_app,
|
||||
"Number of reminders",
|
||||
heir_num_reminders,
|
||||
5,
|
||||
"Default app used to open calendar",
|
||||
(
|
||||
"How many reminder alarms the exported calendar (.ics) event "
|
||||
"contains.\n"
|
||||
"The reminders are spread across the check-alive period and "
|
||||
"always fall before the delivery deadline.\n"
|
||||
"If the period is shorter than the requested number, at most "
|
||||
"one reminder per day is used. Range: 1 to 5 (default 3)."
|
||||
),
|
||||
)
|
||||
add_widget(
|
||||
grid,
|
||||
@@ -558,7 +572,7 @@ class Plugin(BalPlugin):
|
||||
(self.HIDE_INVALIDATED, heir_hide_invalidated, "check"),
|
||||
(self.AUTO_SIGN, heir_auto_sign, "check"),
|
||||
(self.EDITABLE_DATES, heir_editable_dates, "check"),
|
||||
(self.CALENDAR_APP, edit_calendar_app, "line"),
|
||||
(self.NUM_REMINDERS, heir_num_reminders, "spin"),
|
||||
(self.EVENT_SUMMARY, edit_event_summary, "line"),
|
||||
(self.EVENT_DESCRIPTION, edit_event_description, "text"),
|
||||
]
|
||||
@@ -570,6 +584,8 @@ class Plugin(BalPlugin):
|
||||
# value, which is harmless.
|
||||
if kind == "check":
|
||||
widget.setChecked(bool(cfg.default))
|
||||
elif kind == "spin":
|
||||
widget.setValue(int(cfg.default))
|
||||
elif kind == "line":
|
||||
widget.setText(cfg.default)
|
||||
elif kind == "text":
|
||||
|
||||
@@ -23,6 +23,58 @@ from .common import _, _logger # underscore names are not re-exported by "impor
|
||||
from .calendar import BalCalendar
|
||||
|
||||
|
||||
def compute_reminder_offsets(days, count):
|
||||
"""Return the reminder offsets (in days BEFORE the deadline) for an .ics event.
|
||||
|
||||
Group D / D1. The reminders are spread uniformly across the check-alive
|
||||
period and always fall *before* the delivery deadline, i.e. every returned
|
||||
offset is ``>= 1`` (a reminder exactly on the deadline would be useless).
|
||||
|
||||
Rules:
|
||||
* ``count`` is the requested number of reminders (the settings dialog
|
||||
caps it at 5, default 3).
|
||||
* at most ONE reminder per available day: the effective number is
|
||||
``min(count, days)``;
|
||||
* with ``days`` available days, offsets are chosen as evenly spaced
|
||||
points inside ``[1, days]`` (1 = the day before the deadline, ``days``
|
||||
= the first day of the period), de-duplicated and returned sorted
|
||||
descending (earliest reminder first).
|
||||
|
||||
Args:
|
||||
days: number of whole days between check-alive and the deadline.
|
||||
count: requested number of reminders.
|
||||
|
||||
Returns:
|
||||
A list of integer day-offsets (each ``>= 1``), e.g. ``[22, 15, 8]`` for
|
||||
``days=30, count=3``. Empty if there is no room for any reminder.
|
||||
"""
|
||||
# No room for any reminder (deadline today or already passed).
|
||||
if days < 1 or count < 1:
|
||||
return []
|
||||
|
||||
# Never more reminders than available days (one per day at most).
|
||||
effective = min(int(count), int(days))
|
||||
|
||||
# A single reminder: put it one day before the deadline.
|
||||
if effective == 1:
|
||||
return [1]
|
||||
|
||||
# Spread "effective" points evenly inside [1, days]. Using i/(effective-1)
|
||||
# for i in 0..effective-1 gives fractions 0..1; map them onto [1, days].
|
||||
# This places the first reminder at the start of the period (offset ~days)
|
||||
# and the last one one day before the deadline (offset 1).
|
||||
offsets = set()
|
||||
for i in range(effective):
|
||||
frac = i / (effective - 1) # 0.0 .. 1.0
|
||||
# offset = days at frac 0 (start), 1 at frac 1 (just before deadline).
|
||||
offset = round(days - frac * (days - 1))
|
||||
offset = max(1, min(days, offset))
|
||||
offsets.add(offset)
|
||||
|
||||
# Sorted descending: earliest reminder (largest offset) first.
|
||||
return sorted(offsets, reverse=True)
|
||||
|
||||
|
||||
class ClickableLabel(QLabel):
|
||||
doubleClicked = pyqtSignal()
|
||||
|
||||
@@ -683,15 +735,45 @@ class WillSettingsWidget(QWidget):
|
||||
self.widgets["baltx_fees"].set_read_only(True)
|
||||
|
||||
def create_alarms(self, alarm_start, alarm_end):
|
||||
days = (alarm_end - alarm_start).days+1
|
||||
"""Build the VALARM reminder blocks for the .ics event (Group D / D1).
|
||||
|
||||
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).
|
||||
|
||||
Args:
|
||||
alarm_start: the check-alive datetime (start of the period).
|
||||
alarm_end: the delivery-time / locktime datetime (the deadline).
|
||||
|
||||
Returns:
|
||||
A list of .ics text lines (possibly empty) describing the VALARMs.
|
||||
"""
|
||||
days = (alarm_end - alarm_start).days
|
||||
|
||||
# How many reminders the user asked for (default 3 if unreadable).
|
||||
try:
|
||||
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
|
||||
except Exception:
|
||||
count = 3
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
lines = []
|
||||
for i in range(1, days):
|
||||
for offset in offsets:
|
||||
lines.extend(
|
||||
[
|
||||
"BEGIN:VALARM",
|
||||
f"TRIGGER;RELATED=END:-P{i}D",
|
||||
# Fire "offset" days before the event end (the deadline).
|
||||
f"TRIGGER;RELATED=END:-P{offset}D",
|
||||
"ACTION:DISPLAY",
|
||||
# f"DESCRIPTION:{self.bal_window.bal_plugin.ALARM_DESCRIPTION.get()}",
|
||||
f"DESCRIPTION:{BalCalendar.ical_escape(description)}",
|
||||
"END:VALARM",
|
||||
]
|
||||
)
|
||||
@@ -704,7 +786,6 @@ class WillSettingsWidget(QWidget):
|
||||
threshold = self.widgets["threshold"].alarm
|
||||
alarm_end = BalCalendar.format_time(locktime)
|
||||
alarm_start = BalCalendar.format_time(threshold)
|
||||
days_difference = (locktime - threshold).days
|
||||
|
||||
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(
|
||||
@@ -735,23 +816,54 @@ class WillSettingsWidget(QWidget):
|
||||
|
||||
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
|
||||
# user picks below.
|
||||
self.temp_path = BalCalendar.write_temp_ics(ics_content)
|
||||
opened = BalCalendar.open_with_default_app(
|
||||
self.bal_window.bal_plugin.CALENDAR_APP.get(), self.temp_path
|
||||
|
||||
# Group D / D1b: always ask the user WHERE to save the .ics file (the
|
||||
# plugin no longer tries to open it with a calendar app). The save
|
||||
# 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")
|
||||
target = getSaveFileName(
|
||||
parent=self.bal_window.window,
|
||||
title=_("Save calendar reminder (.ics)"),
|
||||
# An absolute filename makes getSaveFileName ignore its remembered
|
||||
# IO_DIRECTORY and open on the Desktop instead.
|
||||
filename=default_path,
|
||||
filter="iCalendar (*.ics);;All files (*)",
|
||||
default_extension="ics",
|
||||
config=self.bal_window.window.config,
|
||||
)
|
||||
if opened:
|
||||
_logger.info(f"File opened with default app: {self.temp_path}")
|
||||
else:
|
||||
export_meta_gui(
|
||||
self.bal_window.window, f"will_event.ics",self.save_to_cwd
|
||||
|
||||
if not target:
|
||||
# User cancelled the save dialog: nothing to do.
|
||||
return
|
||||
try:
|
||||
self.save_ics_to(target)
|
||||
except Exception as save_err:
|
||||
_logger.error(f"saving .ics failed: {save_err}")
|
||||
self.bal_window.show_warning(
|
||||
_("Could not save the calendar file: {}").format(save_err)
|
||||
)
|
||||
return
|
||||
self.bal_window.show_message(
|
||||
_("Calendar file saved to:\n{}").format(target)
|
||||
)
|
||||
|
||||
def save_ics_to(self, target):
|
||||
"""Copy the generated .ics from the temp file to ``target`` (Group D / D1b).
|
||||
|
||||
def save_to_cwd(self,filename="event.ics"):
|
||||
target = os.path.abspath(filename)
|
||||
# se il file esiste, sovrascrive
|
||||
_logger.debug(f"save_to_cwd {self.temp_path},{filename}")
|
||||
Overwrites the destination if it already exists. Used by
|
||||
open_or_save_calendar after the user picks a save location.
|
||||
|
||||
Args:
|
||||
target: absolute destination path chosen by the user.
|
||||
|
||||
Returns:
|
||||
The destination path.
|
||||
"""
|
||||
_logger.debug(f"save_ics_to {self.temp_path} -> {target}")
|
||||
with open(self.temp_path, "rb") as src, open(target, "wb") as dst:
|
||||
dst.write(src.read())
|
||||
return target
|
||||
@@ -884,6 +996,43 @@ class BalCheckBox(QCheckBox):
|
||||
self.stateChanged.connect(on_check)
|
||||
|
||||
|
||||
class BalSpinBox(QSpinBox):
|
||||
"""Integer spin box bound to a BalConfig value (Group D / D1).
|
||||
|
||||
Mirrors BalCheckBox / BalLineEdit: it shows the persisted value on creation
|
||||
and writes the new value back to the config whenever the user changes it.
|
||||
|
||||
Args:
|
||||
variable: the BalConfig accessor to read from / write to.
|
||||
minimum: smallest selectable value (default 1).
|
||||
maximum: largest selectable value (default 5, used by "Number of
|
||||
reminders").
|
||||
on_change: optional callback invoked after the value is persisted.
|
||||
"""
|
||||
|
||||
def __init__(self, variable, minimum=1, maximum=5, on_change=None):
|
||||
QSpinBox.__init__(self)
|
||||
self.setMinimum(minimum)
|
||||
self.setMaximum(maximum)
|
||||
# Coerce the stored value to int and clamp it into the allowed range,
|
||||
# so a stale/invalid config value can never push the spin box out of
|
||||
# bounds.
|
||||
try:
|
||||
current = int(variable.get())
|
||||
except Exception:
|
||||
current = minimum
|
||||
current = max(minimum, min(maximum, current))
|
||||
self.setValue(current)
|
||||
self.on_change = on_change
|
||||
|
||||
def on_value_changed(v):
|
||||
variable.set(int(v))
|
||||
if self.on_change:
|
||||
self.on_change()
|
||||
|
||||
self.valueChanged.connect(on_value_changed)
|
||||
|
||||
|
||||
|
||||
class WillWidget(QWidget):
|
||||
def __init__(self, father=None, parent=None):
|
||||
|
||||
Reference in New Issue
Block a user