""" 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 *" from PyQt6.QtGui import QAction from PyQt6.QtWidgets import QToolButton class BalCalendarButton(QToolButton): """A QToolButton with a dropdown menu for .ics calendar file actions. Provides three actions: Open (with configured app), Open with..., and Save. Accepts an ``ics_provider`` callable that returns the .ics content string. """ def __init__(self, bal_window, ics_provider, parent=None): super().__init__(parent) self._bal_window = bal_window self._ics_provider = ics_provider self._calendar_temp_path = None calendar_menu = QMenu(self) open_action = QAction(_("Open"), self) open_action.triggered.connect(self._on_open) calendar_menu.addAction(open_action) open_with_action = QAction(_("Open with..."), self) open_with_action.triggered.connect(self._on_open_with) calendar_menu.addAction(open_with_action) save_action = QAction(_("Save"), self) save_action.triggered.connect(self._on_save) calendar_menu.addAction(save_action) self.setMenu(calendar_menu) self.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) # ------------------------------------------------------------------ # # ICS generation (lazy: generated on first action) # # ------------------------------------------------------------------ # def _ensure_ics(self): """Generate the .ics content and cache the temp file path.""" try: content = self._ics_provider() if content: self._calendar_temp_path = BalCalendar.write_temp_ics(content) else: self._calendar_temp_path = None except Exception as e: _logger.error(f"failed to generate .ics: {e}") self._calendar_temp_path = None return self._calendar_temp_path # ------------------------------------------------------------------ # # Menu action handlers # # ------------------------------------------------------------------ # def _on_open(self): """Open the .ics with the app configured in CALENDAR_APP.""" path = self._ensure_ics() if not path: return import shlex, subprocess if self._bal_window.bal_plugin.is_basic_mode(): app = self._bal_window.bal_plugin.CALENDAR_APP.default else: app = self._bal_window.bal_plugin.CALENDAR_APP.get() if not app: return try: args = shlex.split(app) + [path] subprocess.check_call(args) except Exception as e: _logger.error(f"opening calendar file failed: {e}") self._bal_window.show_warning( _("Could not open the calendar file: {}").format(e) ) def _on_open_with(self): """Let the user pick an application and open the .ics with it.""" path = self._ensure_ics() if not path: return app, ok = QInputDialog.getText( self, _("Open calendar with..."), _("Enter the application command:"), ) if ok and app: app = app.strip() if app: try: BalCalendar.open_with_default_app(app, path) except Exception as e: _logger.error(f"opening calendar with custom app failed: {e}") self._bal_window.show_warning( _("Could not open the calendar file: {}").format(e) ) def _on_save(self): """Show a "Save As" dialog and save the .ics file.""" path = self._ensure_ics() if not path: return desktop = BalCalendar.desktop_dir() default_path = os.path.join(desktop, "BAL_will_event.ics") target = getSaveFileName( parent=self._bal_window.window, title=_("Save calendar reminder (.ics)"), filename=default_path, filter="iCalendar (*.ics);;All files (*)", default_extension="ics", config=self._bal_window.window.config, ) if not target: return try: with open(path, "rb") as src, open(target, "wb") as dst: dst.write(src.read()) self._bal_window.show_message( _("Calendar file saved to:\n{}").format(target) ) except Exception as e: _logger.error(f"saving .ics failed: {e}") self._bal_window.show_warning( _("Could not save the calendar file: {}").format(e) ) 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\\\\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)