Files
bal-electrum-plugin/bal/gui/qt/calendar.py
2026-06-20 09:48:56 -04:00

161 lines
6.1 KiB
Python

"""
bal.gui.qt.calendar
===================
iCalendar (.ics) generation and "open with default calendar app" helper.
When a will is built, the plugin can create a calendar event reminding the user
to "check in" before the locktime expires. This module turns the event data
into an RFC-5545 .ics file and opens it with the OS default application.
"""
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
class BalCalendar:
@staticmethod
def write_temp_ics(content):
fd, path = tempfile.mkstemp(prefix="event_", suffix=".ics")
with os.fdopen(fd, "wb") as f:
f.write(content.encode("utf-8"))
return path
@staticmethod
def open_with_default_app(calendar_app, path):
_logger.debug("opening calendar app")
try:
subprocess.check_call([calendar_app, path])
return True
except Exception as e:
_logger.error(f"starting calendar app {e}")
return False
@staticmethod
def format_time(time):
return time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
#return time.astimezone(timezone.utc).strftime("%Y%m%d")
@staticmethod
def compute_event_timestamp(will_settings_locktime, min_valid_tx_locktime):
"""Return the UNIX timestamp the calendar event should start at.
The reminder must fire at the EARLIEST moment the inheritance could be
executed, i.e. the lower value between:
* ``will_settings_locktime`` - the deadline configured in the will
settings, and
* ``min_valid_tx_locktime`` - the locktime of the valid will
transaction that expires first (``None`` when there is no valid
transaction yet).
When there is no valid transaction the will-settings locktime is used.
"""
ws = int(will_settings_locktime)
if min_valid_tx_locktime is None:
return ws
return min(ws, int(min_valid_tx_locktime))
@staticmethod
def build_alarms(num_alarms, alarm_start, alarm_end):
"""Build RFC-5545 ``VALARM`` blocks spread before the deadline.
The window between ``alarm_start`` (the later of "now" and the
check-alive threshold) and ``alarm_end`` (the deadline / event time) is
divided into ``num_alarms + 1`` equal slices; an alarm is placed at each
of the first ``num_alarms`` division points. Each alarm uses a
``TRIGGER;RELATED=END`` negative duration so it fires *before* the event
end, which every major online calendar (Google, Apple, Outlook)
understands.
Args:
num_alarms: how many reminders to create (0 -> no alarms).
alarm_start: ``datetime`` when reminders should start being useful.
alarm_end: ``datetime`` of the deadline (the event end).
Returns:
A list of .ics lines (``BEGIN:VALARM`` ... ``END:VALARM`` blocks).
"""
if num_alarms < 1:
return []
total_seconds = max(1, int((alarm_end - alarm_start).total_seconds()))
# Divide the total time by (num_alarms + 1) and place alarms on the
# first num_alarms division points (measured back from the deadline).
interval = total_seconds // (num_alarms + 1)
lines = []
for i in range(1, num_alarms + 1):
# Offset measured from the END (deadline): the closest alarm to the
# deadline is the smallest offset.
offset_seconds = interval * (num_alarms + 1 - i)
trigger = BalCalendar._duration_trigger(offset_seconds)
lines.extend([
"BEGIN:VALARM",
f"TRIGGER;RELATED=END:-{trigger}",
"ACTION:DISPLAY",
"DESCRIPTION:Bitcoin After Life reminder",
"END:VALARM",
])
return lines
@staticmethod
def _duration_trigger(offset_seconds):
"""Format ``offset_seconds`` as an RFC-5545 duration (e.g. ``P3D``).
Uses the coarsest sensible unit so the value stays readable and within
what calendar clients expect: days (``P<n>D``), hours (``PT<n>H``),
minutes (``PT<n>M``) or seconds (``PT<n>S``).
"""
if offset_seconds <= 0:
return "PT0S"
if offset_seconds >= 86400:
return f"P{offset_seconds // 86400}D"
if offset_seconds >= 3600:
return f"PT{offset_seconds // 3600}H"
if offset_seconds >= 60:
return f"PT{offset_seconds // 60}M"
return f"PT{offset_seconds}S"
@staticmethod
def ical_escape(text: str) -> str:
# Escape special characters per RFC 5545: backslash, ";", ",", newlines.
text = text.encode("utf-8")
text = (
text.replace(b"\\", b"\\\\")
.replace(b";", b"\\;")
.replace(b",", b"\\,")
)
out =""
temp=text.split(b"\r\n")
for s in temp:
encoded= s
cut =0
while len(encoded) >75:
cut+=5
encoded=f"{s[:len(s)-cut]}"
if encoded[-1]==b"\\" and encoded[-2]!=b"\\\\":
cut += 1
encoded=f"{s[:len(s)-cut]}"
encoded=f"{encoded}...\r\n".encode("utf-8")
if cut>0:
out+=str(f"{s[:len(s)-cut].decode()}...\r\n")
else:
out+=str(f"{s.decode()}\r\n")
return out[:-2]
@staticmethod
def fold_ical_line(line: str, limit: int = 75) -> str:
# Return the line folded per RFC 5545: split into CRLF-separated chunks
# with a leading space on every continuation line.
encoded = line.encode("utf-8")
parts = []
while len(encoded) > limit:
# Cut without splitting a multi-byte UTF-8 character.
cut = limit
while (encoded[cut] & 0xC0) == 0x80: # UTF-8 continuation byte
cut -= 1
parts.append(encoded[:cut].decode("utf-8"))
encoded = encoded[cut:]
parts.append(encoded.decode("utf-8"))
return "\r\n ".join(parts)