forked from bitcoinafterlife/bal-electrum-plugin
161 lines
6.1 KiB
Python
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)
|