""" 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 (``PD``), hours (``PTH``), minutes (``PTM``) or seconds (``PTS``). """ 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)