D1 - number of reminders (default 3, max 5):
- new NUM_REMINDERS config in plugin_base.py
- new BalSpinBox widget bound to a BalConfig
- new pure helper compute_reminder_offsets(days, count): reminders spread
uniformly across the check-alive period, always before the deadline
(offset >= 1), at most one per available day, de-duplicated, earliest
first (e.g. (30,3)->[30,16,1], (2,3)->[2,1], (1,3)->[1], (0,3)->[])
- create_alarms() rewritten to use it and emit one VALARM per offset with a
DESCRIPTION reminder text
- 'Number of reminders' spin box (range 1..5) added to the settings dialog
and to the Reset list
D1b - save-only .ics (ask where, default to Desktop):
- BalCalendar.desktop_dir() helper (~/Desktop with home fallback)
- open_or_save_calendar() no longer opens the file with a calendar app; it
always shows a 'save as' dialog (.ics filter, default will_event.ics,
starting on the Desktop) and copies the file there, then confirms.
Identical behaviour on Windows/Linux/macOS. save_to_cwd -> save_ics_to.
Follow-up: removed the now-unused 'Calendar App' field from the settings
dialog (and from the Reset list). CALENDAR_APP config and
open_with_default_app left in place (unused) to avoid unrelated changes.
D2 intentionally skipped (per user request).
tests/test_group_d_alarms.py: 7 new tests (NUM_REMINDERS default/change and
all distribution rules). Full suite: 217 passed.
100 lines
3.3 KiB
Python
100 lines
3.3 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 desktop_dir():
|
|
"""Return the user's Desktop directory (Group D / D1b).
|
|
|
|
Used as the initial folder of the "save .ics" dialog. On Windows this is
|
|
normally ``C:\\Users\\<name>\\Desktop``; on Linux/macOS ``~/Desktop`` is
|
|
used when it exists. If the Desktop cannot be located the home directory
|
|
is returned as a safe fallback, so the save dialog always opens
|
|
somewhere sensible.
|
|
|
|
Returns:
|
|
An absolute directory path (string).
|
|
"""
|
|
home = os.path.expanduser("~")
|
|
desktop = os.path.join(home, "Desktop")
|
|
if os.path.isdir(desktop):
|
|
return desktop
|
|
return home
|
|
|
|
|
|
@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 ical_escape(text: str) -> str:
|
|
# escape per RFC5545: 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:
|
|
# ritorna linee separate da CRLF e folding con spazio iniziale sulle righe successive
|
|
encoded = line.encode("utf-8")
|
|
parts = []
|
|
while len(encoded) > limit:
|
|
# taglia senza spezzare byte UTF-8
|
|
cut = limit
|
|
while (encoded[cut] & 0xC0) == 0x80: # byte di 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)
|