Phase 1: new bal/i18n.py, BAL's own gettext layer (domain "bal", catalogs read with plugin.read_file()). _() asks Electrum's catalog first, then BAL's, then returns the English source. The Qt plugin loads the catalog of Electrum's GUI language at start-up; the CLI stays English. Phase 2: every user-visible GUI text is now a whole, extractable sentence (Ruff INT rules enabled). Class-level texts are marked with N_() and translated when shown. Stored data stays language-neutral: the status history is written in English and translated for display, the calendar defaults follow the GUI language, and the history label and wallet labels are never translated because BAL uses them to recognise its transactions. No visible change apart from the double colon fixed in the will detail. See CHANGELOG entries 58 and 59 and PLAN_I18N.md.
236 lines
9.2 KiB
Python
236 lines
9.2 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. ``[30, 16, 1]`` 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,
|
|
reminder_suffix: str = "(reminder {idx}/{total})",
|
|
) -> 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).
|
|
reminder_suffix: text appended to each event summary, with the
|
|
``{idx}`` and ``{total}`` fields. This module stays free of
|
|
Electrum imports, so the GUI passes it already translated; the
|
|
English default keeps the CLI output unchanged.
|
|
|
|
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(
|
|
"{} {}".format(summary_base, reminder_suffix.format(idx=idx, total=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"
|