core: extract GUI-free logic into bal.core (reminders/checkalive/input_rules); fix save/sign tx RLock pickle by serializing tx; keep invalid heirs in wallet on build; tb1 testnet will-executor addresses; move tests to core modules
This commit is contained in:
71
bal/core/checkalive.py
Normal file
71
bal/core/checkalive.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
bal.core.checkalive
|
||||
===================
|
||||
|
||||
The "Check Alive" policy: the single reference timestamp (``date_to_check``)
|
||||
against which every will-validity check is evaluated, and the BASIC/ADVANCED
|
||||
mode rules that decide it.
|
||||
|
||||
Pure, GUI-free. The GUI raises :class:`CheckAliveError` to trigger the
|
||||
postpone/invalidate flow; the decision that it *should* be raised lives here.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from .plugin_base import BalTimestamp
|
||||
|
||||
|
||||
class CheckAliveError(Exception):
|
||||
"""Raised when the "check alive" date is in the past."""
|
||||
|
||||
def __init__(self, timestamp_to_check):
|
||||
self.timestamp_to_check = timestamp_to_check
|
||||
|
||||
def __str__(self):
|
||||
return "Check alive expired please update it: {}".format(
|
||||
datetime.fromtimestamp(self.timestamp_to_check).isoformat()
|
||||
)
|
||||
|
||||
|
||||
def resolve_date_to_check(
|
||||
is_basic_mode: bool, will_settings: Any, now: float | None = None
|
||||
) -> float:
|
||||
"""Return the reference timestamp for every will-validity check.
|
||||
|
||||
``date_to_check`` is the single reference timestamp that EVERY downstream
|
||||
check reads: the build filter, the heir count, ``check_will_expired``,
|
||||
``check_amounts`` and the locktime-vs-threshold guard.
|
||||
|
||||
* BASIC mode: the Check Alive is hidden and NOT editable, so it must never
|
||||
govern those checks. ``date_to_check`` is set to *now*: every check is
|
||||
evaluated against the current moment (the Check Alive effectively does not
|
||||
exist) while the delivery locktime is still fully enforced.
|
||||
* ADVANCED mode: the user-controlled stored threshold is used as-is.
|
||||
|
||||
Args:
|
||||
is_basic_mode: ``True`` for the SIMPLE / BASIC user type.
|
||||
will_settings: the per-wallet settings dict (``"threshold"`` key).
|
||||
now: overridable clock for tests; defaults to ``datetime.now()``.
|
||||
|
||||
Returns:
|
||||
The reference timestamp (float, UNIX seconds).
|
||||
"""
|
||||
if is_basic_mode:
|
||||
return (now if now is not None else datetime.now().timestamp())
|
||||
return BalTimestamp(will_settings["threshold"]).to_timestamp()
|
||||
|
||||
|
||||
def check_alive_expired(
|
||||
is_basic_mode: bool, date_to_check: float, now: float | None = None
|
||||
) -> bool:
|
||||
"""True when the Check Alive guard should fire (``CheckAliveError``).
|
||||
|
||||
Only ADVANCED mode can be "expired": in BASIC mode the Check Alive is inert
|
||||
by construction, so a passed check-alive date must never force a postpone or
|
||||
rewrite of the will.
|
||||
"""
|
||||
if is_basic_mode:
|
||||
return False
|
||||
current = now if now is not None else datetime.now().timestamp()
|
||||
return date_to_check < current
|
||||
@@ -630,7 +630,19 @@ class Heirs(dict, Logger):
|
||||
def buildTransactions(
|
||||
self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0
|
||||
):
|
||||
Heirs._validate(self)
|
||||
_before = list(self.keys())
|
||||
Heirs._validate(self, persist=False)
|
||||
_removed = [k for k in _before if k not in self]
|
||||
if _removed:
|
||||
# The build skips invalid heirs, but they are only dropped in memory
|
||||
# (persist=False): the wallet still keeps them, so the user can fix
|
||||
# or remove them deliberately instead of losing them silently.
|
||||
_logger.warning(
|
||||
"buildTransactions: skipped %d invalid heir(s) (kept in wallet, "
|
||||
"not removed): %s",
|
||||
len(_removed),
|
||||
", ".join(_removed),
|
||||
)
|
||||
if len(self) <= 0:
|
||||
_logger.info("while building transactions there was no heirs")
|
||||
return
|
||||
@@ -877,16 +889,22 @@ class Heirs(dict, Logger):
|
||||
return (address, amount, locktime)
|
||||
|
||||
@staticmethod
|
||||
def _validate(data, timestamp_to_check=False):
|
||||
def _validate(data, timestamp_to_check=False, persist=True):
|
||||
|
||||
for k, v in list(data.items()):
|
||||
if k == "heirs":
|
||||
return Heirs._validate(v, timestamp_to_check)
|
||||
return Heirs._validate(v, timestamp_to_check, persist)
|
||||
try:
|
||||
Heirs.validate_heir(k, v, timestamp_to_check)
|
||||
except Exception as e:
|
||||
_logger.info(f"exception heir removed {e}")
|
||||
data.pop(k)
|
||||
if persist:
|
||||
data.pop(k)
|
||||
else:
|
||||
# Drop the invalid heir in memory only, so the overridden
|
||||
# Heirs.pop (which calls save()) is not triggered: a build
|
||||
# must not silently delete heirs from the wallet.
|
||||
dict.pop(data, k)
|
||||
return data
|
||||
|
||||
|
||||
|
||||
193
bal/core/input_rules.py
Normal file
193
bal/core/input_rules.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
bal.core.input_rules
|
||||
====================
|
||||
|
||||
Pure, GUI-free rules for the plugin's text-input widgets: locktime bounds and
|
||||
acceptance, the RAW locktime sanitisation ("30d"/"1y"), and the percentage-or-
|
||||
amount field normalisation.
|
||||
|
||||
These used to live inside the Qt widget classes in ``bal.gui.qt.widgets``.
|
||||
Keeping them here makes them testable without Qt and lets any GUI front-end
|
||||
reuse the exact same parsing rules.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Optional, Tuple, Union
|
||||
|
||||
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
|
||||
|
||||
__all__ = [
|
||||
"NLOCKTIME_BLOCKHEIGHT_MAX",
|
||||
"NLOCKTIME_MAX",
|
||||
"NLOCKTIME_MIN",
|
||||
"LockTimeEditor",
|
||||
"normalize_locktime_raw_text",
|
||||
"normalize_perc_amount_text",
|
||||
"parse_perc_amount",
|
||||
"replace_dy_suffixes",
|
||||
]
|
||||
|
||||
|
||||
class LockTimeEditor:
|
||||
"""Acceptance bounds shared by the RAW and Date locktime editors.
|
||||
|
||||
A Qt widget mixin historically; the pure parts (bounds and acceptance test)
|
||||
live here so they can be tested and reused without Qt. Widget subclasses
|
||||
override ``min_allowed_value``/``max_allowed_value`` to tighten the bounds.
|
||||
"""
|
||||
|
||||
min_allowed_value = NLOCKTIME_MIN
|
||||
max_allowed_value = NLOCKTIME_MAX
|
||||
alarm = None
|
||||
|
||||
def get_value(self) -> Optional[int]:
|
||||
raise NotImplementedError()
|
||||
|
||||
def set_value(self, x: Any, force=True) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@classmethod
|
||||
def is_acceptable_locktime(cls, x: Any) -> bool:
|
||||
"""True when ``x`` (string or int) is within the allowed locktime bounds.
|
||||
|
||||
An empty/falsy value is accepted (the field is not yet filled in).
|
||||
"""
|
||||
if not x: # e.g. empty string
|
||||
return True
|
||||
try:
|
||||
x = int(x)
|
||||
except Exception as _e:
|
||||
return False
|
||||
return cls.min_allowed_value <= x <= cls.max_allowed_value
|
||||
|
||||
@staticmethod
|
||||
def get_max_allowed_timestamp() -> int:
|
||||
"""Highest locktime timestamp accepted on this platform.
|
||||
|
||||
On 32-bit ``time_t`` Windows builds ``datetime.fromtimestamp`` overflows
|
||||
past 2038, so the ceiling is clamped to INT32_MAX (see #6170).
|
||||
"""
|
||||
ts = NLOCKTIME_MAX
|
||||
# Test if this value is within the valid timestamp limits (which is
|
||||
# platform-dependent). see #6170
|
||||
try:
|
||||
datetime.fromtimestamp(ts)
|
||||
except (OSError, OverflowError):
|
||||
ts = 2**31 - 1 # INT32_MAX
|
||||
datetime.fromtimestamp(ts) # test if raises
|
||||
return ts
|
||||
|
||||
|
||||
def replace_dy_suffixes(text: str) -> str:
|
||||
"""Strip the relative-time suffixes (d/y) from ``text``.
|
||||
|
||||
Only days ("d") and years ("y") are supported. The block-height suffix
|
||||
("b") was removed (A1): locktimes are always timestamps now.
|
||||
"""
|
||||
return str(text).replace("d", "").replace("y", "")
|
||||
|
||||
|
||||
def _checkbdy(s: str, pos: int, appendix: str) -> Tuple[int, str]:
|
||||
"""Keep a ``d``/``y`` suffix typed right after an existing suffix.
|
||||
|
||||
When the character just before ``pos`` equals ``appendix``, the text is
|
||||
re-normalised so only one suffix remains.
|
||||
"""
|
||||
try:
|
||||
charpos = pos - 1
|
||||
charpos = max(0, charpos)
|
||||
charpos = min(len(s) - 1, charpos)
|
||||
if appendix == s[charpos]:
|
||||
s = replace_dy_suffixes(s) + appendix
|
||||
pos = charpos
|
||||
except Exception:
|
||||
pass
|
||||
return pos, s
|
||||
|
||||
|
||||
def normalize_locktime_raw_text(
|
||||
text: str, pos: int
|
||||
) -> Tuple[str, bool, bool, int]:
|
||||
"""Sanitise the RAW locktime field text.
|
||||
|
||||
Only digits plus the day ("d") and year ("y") suffixes are kept; the block
|
||||
suffix ("b") is removed (A1). Exactly one ``d``/``y`` suffix survives.
|
||||
|
||||
Args:
|
||||
text: the raw field text.
|
||||
pos: the cursor position within ``text``.
|
||||
|
||||
Returns:
|
||||
``(clean, isdays, isyears, new_pos)`` where ``clean`` is the sanitised
|
||||
text and ``new_pos`` the adjusted cursor position.
|
||||
"""
|
||||
text = text.strip()
|
||||
chars = "0123456789dy"
|
||||
pos = len("".join([i for i in text[:pos] if i in chars]))
|
||||
s = "".join([i for i in text if i in chars])
|
||||
isdays = False
|
||||
isyears = False
|
||||
|
||||
pos, s = _checkbdy(s, pos, "d")
|
||||
pos, s = _checkbdy(s, pos, "y")
|
||||
|
||||
if "d" in s:
|
||||
isdays = True
|
||||
if "y" in s:
|
||||
isyears = True
|
||||
|
||||
if isdays:
|
||||
s = replace_dy_suffixes(s) + "d"
|
||||
if isyears:
|
||||
s = replace_dy_suffixes(s) + "y"
|
||||
return s, isdays, isyears, pos
|
||||
|
||||
|
||||
def normalize_perc_amount_text(text: str, decimal_point: str) -> Tuple[str, bool]:
|
||||
"""Sanitise the amount-or-percentage field text.
|
||||
|
||||
Keeps digits, ``%`` and the decimal point; a trailing ``%`` marks the value
|
||||
as a percentage (``is_perc``). At most 8 decimal digits after the point.
|
||||
|
||||
Args:
|
||||
text: the raw field text.
|
||||
decimal_point: the decimal separator character (``electrum`` uses
|
||||
``DECIMAL_POINT``, locale dependent).
|
||||
|
||||
Returns:
|
||||
``(clean, is_perc)``.
|
||||
"""
|
||||
text = text.strip()
|
||||
chars = "0123456789%"
|
||||
chars += decimal_point
|
||||
|
||||
s = "".join([i for i in text if i in chars])
|
||||
|
||||
if "%" in s:
|
||||
is_perc = True
|
||||
s = s.replace("%", "")
|
||||
else:
|
||||
is_perc = False
|
||||
|
||||
if decimal_point in s:
|
||||
p = s.find(decimal_point)
|
||||
s = s.replace(decimal_point, "")
|
||||
s = s[:p] + decimal_point + s[p : p + 8]
|
||||
if is_perc:
|
||||
s += "%"
|
||||
|
||||
return s, is_perc
|
||||
|
||||
|
||||
def parse_perc_amount(text: str, decimal_point: str) -> Union[None, Decimal, int]:
|
||||
"""Parse an amount-or-percentage field text into a numeric value.
|
||||
|
||||
Returns ``None`` when the text cannot be parsed.
|
||||
"""
|
||||
try:
|
||||
text = text.replace(decimal_point, ".")
|
||||
text = text.replace("%", "")
|
||||
return Decimal(text)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -306,7 +306,10 @@ class BalPlugin(BasePlugin):
|
||||
config, "bal_event_summary", "BAL -Will execution of $wallet_name"
|
||||
)
|
||||
|
||||
# Default will-executor servers, keyed by network.
|
||||
# Default will-executor servers, keyed by network. These addresses are
|
||||
# the ones currently reported by each server's <chain>/info endpoint and
|
||||
# are refreshed again on ping; testnet/testnet4 must NOT be regtest
|
||||
# (bcrt1...) addresses, which are invalid on those networks.
|
||||
self.WILLEXECUTORS = BalConfig(
|
||||
config,
|
||||
"bal_willexecutors",
|
||||
@@ -325,7 +328,7 @@ class BalPlugin(BasePlugin):
|
||||
"base_fee": 100000,
|
||||
"status": "New",
|
||||
"info": "Bitcoin After Life Will Executor",
|
||||
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
|
||||
"address": "tb1qp5tmrvtm6dmz23mzkf55n5d53xh39wt0gwpp5m",
|
||||
"selected": True,
|
||||
}
|
||||
},
|
||||
@@ -334,7 +337,7 @@ class BalPlugin(BasePlugin):
|
||||
"base_fee": 100000,
|
||||
"status": "New",
|
||||
"info": "Bitcoin After Life Will Executor",
|
||||
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
|
||||
"address": "tb1qfj5ewmczg8ck2z0eeff6uysdrxd4qdy2ltrx5a",
|
||||
"selected": True,
|
||||
}
|
||||
},
|
||||
|
||||
228
bal/core/reminders.py
Normal file
228
bal/core/reminders.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
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"
|
||||
Reference in New Issue
Block a user