229 lines
8.8 KiB
Python
229 lines
8.8 KiB
Python
"""
|
|
bal.core.reminders
|
|
==================
|
|
|
|
Pure, GUI-free logic for the dead-man's-switch calendar reminders: choosing the
|
|
reminder offsets (BASIC vs ADVANCED modes) and rendering them as an RFC-5545
|
|
iCalendar (.ics) document.
|
|
|
|
Everything in this module is stdlib-only, so it can be imported and tested
|
|
without Electrum or Qt (e.g. in the lint venv).
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Optional
|
|
|
|
|
|
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)
|
|
|
|
|
|
# Fixed reminder offsets (in days BEFORE the delivery date) used in BASIC mode.
|
|
# In BASIC the check-alive parameter is hidden/unmanaged, so reminders cannot be
|
|
# spread over it; instead the owner asked for three fixed reminders: 30, 10 and
|
|
# 1 day before the inheritance delivery date.
|
|
BASIC_REMINDER_OFFSETS = (30, 10, 1)
|
|
|
|
|
|
def basic_reminder_offsets(days_to_deadline):
|
|
"""Return the BASIC-mode reminder offsets that still fall in the future.
|
|
|
|
BASIC mode uses the fixed offsets in ``BASIC_REMINDER_OFFSETS`` (30, 10 and
|
|
1 day before the delivery date). Any offset that would land in the past is
|
|
dropped, because a reminder before "today" is useless: if the delivery date
|
|
is only ``days_to_deadline`` days away, only the offsets that are ``<=
|
|
days_to_deadline`` are kept.
|
|
|
|
Args:
|
|
days_to_deadline: whole days from now until the delivery date.
|
|
|
|
Returns:
|
|
A list of integer day-offsets (each ``>= 1``), sorted as in
|
|
``BASIC_REMINDER_OFFSETS`` (descending: earliest reminder first). Empty
|
|
when the delivery date is less than one day away.
|
|
"""
|
|
horizon = max(int(days_to_deadline), 0)
|
|
return [off for off in BASIC_REMINDER_OFFSETS if 1 <= off <= horizon]
|
|
|
|
|
|
def format_time(time) -> str:
|
|
"""Render a datetime as an RFC-5545 UTC timestamp (``YYYYMMDDTHHMMSSZ``)."""
|
|
return time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
|
|
|
|
def fold_ical_line(line: str, limit: int = 75) -> str:
|
|
"""Fold a line to at most ``limit`` bytes per RFC-5545, without splitting
|
|
multi-byte UTF-8 characters. Continuation lines start with a space."""
|
|
encoded = line.encode("utf-8")
|
|
parts = []
|
|
while len(encoded) > limit:
|
|
# cut without splitting a UTF-8 continuation byte
|
|
cut = limit
|
|
while (encoded[cut] & 0xC0) == 0x80: # byte de continuazione UTF-8
|
|
cut -= 1
|
|
parts.append(encoded[:cut].decode("utf-8"))
|
|
encoded = encoded[cut:]
|
|
parts.append(encoded.decode("utf-8"))
|
|
return "\r\n ".join(parts)
|
|
|
|
|
|
def ical_escape(text: str) -> str:
|
|
"""Escape a string per RFC-5545: backslash, semicolon, comma, newlines."""
|
|
text = (
|
|
text.replace("\\", "\\\\")
|
|
.replace(";", "\\;")
|
|
.replace(",", "\\,")
|
|
)
|
|
return "\r\n".join(fold_ical_line(line) for line in text.split("\r\n"))
|
|
|
|
|
|
def write_temp_ics(content: str) -> str:
|
|
"""Write ``content`` to a temporary ``.ics`` file and return its path."""
|
|
fd, path = tempfile.mkstemp(prefix="event_", suffix=".ics")
|
|
with os.fdopen(fd, "wb") as f:
|
|
f.write(content.encode("utf-8"))
|
|
return path
|
|
|
|
|
|
def build_ics_reminders(
|
|
*,
|
|
locktime: datetime,
|
|
basic_mode: bool,
|
|
description: str,
|
|
summary: str,
|
|
wallet_name: str,
|
|
heirs_details: str,
|
|
version: str,
|
|
num_reminders: int = 3,
|
|
now: Optional[datetime] = None,
|
|
threshold: Optional[datetime] = None,
|
|
) -> Optional[str]:
|
|
"""Build the ``.ics`` content with one VEVENT per reminder date.
|
|
|
|
Group D / D1 (revised): N *separate* VEVENTs, one per reminder date, so the
|
|
user sees several distinct appointments in their calendar. The reminder
|
|
offsets come from :func:`basic_reminder_offsets` (BASIC mode) or
|
|
:func:`compute_reminder_offsets` (ADVANCED mode, spread over the check-alive
|
|
period).
|
|
|
|
Args:
|
|
locktime: the delivery deadline (datetime).
|
|
basic_mode: use the fixed BASIC offsets instead of spreading over the
|
|
check-alive period.
|
|
description: raw EVENT_DESCRIPTION template; ``$wallet_name`` and
|
|
``$heirs_complete`` placeholders are substituted and escaped.
|
|
summary: raw EVENT_SUMMARY template; ``$wallet_name`` is substituted.
|
|
wallet_name: label used in the UID and template substitutions.
|
|
heirs_details: pre-formatted heir list injected into ``description``.
|
|
version: plugin version, embedded in the PRODID line.
|
|
num_reminders: requested reminder count (ADVANCED mode only).
|
|
now: "today" reference; defaults to ``datetime.now()``.
|
|
threshold: check-alive date (ADVANCED mode only; required there).
|
|
|
|
Returns:
|
|
The ``.ics`` content string, or ``None`` when no reminder falls in the
|
|
future (the delivery date is too close or already passed) so the caller
|
|
can show a warning instead of producing an empty-looking file.
|
|
"""
|
|
now = now if now is not None else datetime.now()
|
|
|
|
if basic_mode:
|
|
days_to_deadline = (locktime - now).days
|
|
offsets = basic_reminder_offsets(days_to_deadline)
|
|
else:
|
|
if threshold is None:
|
|
raise ValueError("threshold is required in ADVANCED mode")
|
|
days = (locktime - threshold).days
|
|
offsets = compute_reminder_offsets(days, num_reminders)
|
|
|
|
# ToDo #2: no future reminder means there are no events to write. Return
|
|
# None so the caller shows a clear warning instead of an empty .ics file.
|
|
if not offsets:
|
|
return None
|
|
|
|
event_description = ical_escape(
|
|
f"{description}"
|
|
.replace("$wallet_name", str(wallet_name))
|
|
.replace("$heirs_complete", heirs_details)
|
|
)
|
|
summary_base = f"{summary}".replace("$wallet_name", str(wallet_name))
|
|
|
|
lines = [
|
|
"BEGIN:VCALENDAR",
|
|
"VERSION:2.0",
|
|
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{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 = format_time(locktime - timedelta(days=offset))
|
|
# Suffix the summary so the N events are easy to tell apart.
|
|
event_summary = 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(wallet_name)}-{offset}d",
|
|
f"DTSTAMP:{format_time(now)}",
|
|
f"DTSTART:{event_dt}",
|
|
f"DTEND:{event_dt}",
|
|
f"SUMMARY:{event_summary}",
|
|
f"DESCRIPTION:{event_description}",
|
|
"END:VEVENT",
|
|
])
|
|
|
|
lines.append("END:VCALENDAR")
|
|
lines = [s.rstrip("\r\n") for s in lines]
|
|
return "\r\n".join(lines) + "\r\n"
|