diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index 582d264..567e09f 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -21,8 +21,11 @@ from .common import * from .common import _, _logger # underscore names are not re-exported by "import *" from .widgets import (BalCheckBox, BalLineEdit, BalTextEdit, BalTxFeesWidget, LockTimeWidget, PercAmountEdit, ThresholdTimeWidget, - WillSettingsWidget, WillWidget) + WillSettingsWidget, WillWidget, basic_reminder_offsets, + compute_reminder_offsets) from .calendar import BalCalendar +from PyQt6.QtGui import QAction +from PyQt6.QtWidgets import QToolButton # NOTE: list views (HeirListWidget, PreviewList, WillExecutorWidget) are # imported lazily where needed to avoid a dialogs<->lists import cycle. @@ -1444,21 +1447,197 @@ class BalBuildWillDialog(BalDialog): self._add_close_button() def _add_close_button(self): - """Add a right-aligned "Close" button to dismiss the dialog manually. + """Add a right-aligned "Close" button and a "Calendar" dropdown button. Replaces the old automatic countdown (self.wait(5) + self.close()). Guarded so it is only built once even if called again. """ if getattr(self, "_close_button", None) is not None: return + + # Generate .ics content once for all calendar actions. + self._generate_calendar_ics() + + calendar_menu = QMenu() + open_action = QAction(_("Open"), self) + open_action.triggered.connect(self._on_calendar_open) + calendar_menu.addAction(open_action) + open_with_action = QAction(_("Open with..."), self) + open_with_action.triggered.connect(self._on_calendar_open_with) + calendar_menu.addAction(open_with_action) + save_action = QAction(_("Save"), self) + save_action.triggered.connect(self._on_calendar_save) + calendar_menu.addAction(save_action) + + calendar_button = QToolButton() + calendar_button.setText(_("Calendar")) + calendar_button.setMenu(calendar_menu) + calendar_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + self._close_button = QPushButton(_("Close")) self._close_button.clicked.connect(self._on_close_clicked) button_row = QHBoxLayout() button_row.addStretch(1) + button_row.addWidget(calendar_button) button_row.addWidget(self._close_button) self.vbox.addLayout(button_row) self._close_button.setFocus() + def _generate_calendar_ics(self): + """Build the .ics content and write it to a temp file. + + Stores the temp path in ``self._calendar_temp_path`` (or ``None`` + on failure) so all three calendar actions reuse the same data. + """ + from datetime import datetime, timedelta + + self._calendar_temp_path = None + try: + locktime_ts = Util.parse_locktime_string( + self.bal_window.will_settings["locktime"] + ) + locktime = datetime.fromtimestamp(locktime_ts) + + if self.bal_window.bal_plugin.is_basic_mode(): + days_to_deadline = (locktime - datetime.now()).days + offsets = basic_reminder_offsets(days_to_deadline) + else: + threshold_ts = BalTimestamp( + self.bal_window.will_settings["threshold"] + ).to_timestamp() + threshold = datetime.fromtimestamp(threshold_ts) + days = (locktime - threshold).days + try: + count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get()) + except Exception: + count = 3 + offsets = compute_reminder_offsets(days, count) + + now = BalCalendar.format_time(datetime.now()) + + heirs_details = "\r\n".join( + f" {heir} - {self.bal_window.heirs[heir][0]}, " + f"{self.bal_window.heirs[heir][1]}" + for heir in self.bal_window.heirs + ) + + if self.bal_window.bal_plugin.is_basic_mode(): + raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default + raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default + else: + raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get() + raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get() + + event_description = BalCalendar.ical_escape( + f"{raw_description}" + .replace("$wallet_name", str(self.bal_window.wallet)) + .replace("$heirs_complete", heirs_details) + ) + summary_base = ( + f"{raw_summary}" + .replace("$wallet_name", str(self.bal_window.wallet)) + ) + + lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//Bitcoin After Life//Electrum Plugin/" + f"{BalPlugin.__version__}", + ] + + total = len(offsets) + for idx, offset in enumerate(offsets, start=1): + event_dt = BalCalendar.format_time(locktime - timedelta(days=offset)) + summary = BalCalendar.ical_escape( + f"{summary_base} (reminder {idx}/{total})" + ) + lines.extend([ + "BEGIN:VEVENT", + f"UID:bal-{str(self.bal_window.wallet)}-{offset}d", + f"DTSTAMP:{now}", + f"DTSTART:{event_dt}", + f"DTEND:{event_dt}", + f"SUMMARY:{summary}", + f"DESCRIPTION:{event_description}", + "END:VEVENT", + ]) + + lines.append("END:VCALENDAR") + lines = [s.rstrip("\r\n") for s in lines] + ics_content = "\r\n".join(lines) + "\r\n" + self._calendar_temp_path = BalCalendar.write_temp_ics(ics_content) + except Exception as e: + _logger.error(f"failed to generate .ics: {e}") + + def _on_calendar_open(self): + """Open the .ics calendar file with the configured app.""" + path = getattr(self, "_calendar_temp_path", None) + if not path: + return + import shlex, subprocess + 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_calendar_open_with(self): + """Let the user pick an application and open the .ics file with it.""" + path = getattr(self, "_calendar_temp_path", None) + if not path: + return + from PyQt6.QtWidgets import QInputDialog + 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_calendar_save(self): + """Show a "Save As" dialog and save the .ics calendar file.""" + path = getattr(self, "_calendar_temp_path", None) + 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) + ) + def _on_close_clicked(self): # Close the dialog first, then show the persistent popup guiding the # user through any remaining MANUAL steps (Sign / Broadcast). Showing diff --git a/bal/gui/qt/plugin.py b/bal/gui/qt/plugin.py index e927a34..b73b6f8 100644 --- a/bal/gui/qt/plugin.py +++ b/bal/gui/qt/plugin.py @@ -468,7 +468,8 @@ class Plugin(BalPlugin): for w in (lbl_welist_server, edit_welist_server, help_welist_server, lbl_num_reminders, heir_num_reminders, help_num_reminders, lbl_event_summary, edit_event_summary, help_event_summary, - lbl_event_description, edit_event_description, help_event_description): + lbl_event_description, edit_event_description, help_event_description, + lbl_calendar_app, edit_calendar_app, help_calendar_app): w.setVisible(not basic) self.update_all() @@ -477,9 +478,6 @@ class Plugin(BalPlugin): # Editable line/text widgets are created once and kept in named # variables so the "Reset" button (Group C / C4b) can refresh the # displayed values after resetting the underlying config. - # Note: the "Calendar App" field was removed (Group D follow-up) because - # the calendar button now only SAVES the .ics file instead of opening it - # with an external app, so the setting is no longer needed in the dialog. edit_event_summary = BalLineEdit(self.EVENT_SUMMARY) edit_event_summary.setMinimumWidth(360) edit_event_description = BalTextEdit(self.EVENT_DESCRIPTION) @@ -489,6 +487,9 @@ class Plugin(BalPlugin): edit_welist_server = BalLineEdit(self.WELIST_SERVER) edit_welist_server.setMinimumWidth(360) + edit_calendar_app = BalLineEdit(self.CALENDAR_APP) + edit_calendar_app.setMinimumWidth(360) + heir_repush = QPushButton("Rebroadcast transactions") heir_repush.clicked.connect(partial(self.broadcast_transactions, True)) bal_mode = QComboBox() @@ -634,20 +635,32 @@ class Plugin(BalPlugin): grid.addWidget(lbl_welist_server, 9, 0) grid.addWidget(edit_welist_server, 9, 1) grid.addWidget(help_welist_server, 9, 2) + + lbl_calendar_app = QLabel(_("Calendar app command")) + help_calendar_app = HelpButton( + "Command used to open .ics calendar files.\n" + "Leave empty to use the system default (xdg-open/open/start).\n" + "Only used in ADVANCED mode." + ) + grid.addWidget(lbl_calendar_app, 10, 0) + grid.addWidget(edit_calendar_app, 10, 1) + grid.addWidget(help_calendar_app, 10, 2) + # Initial visibility: hidden in basic, visible in advanced. basic_init = str(self.USER_TYPE.get()).lower() != "advanced" for w in (lbl_welist_server, edit_welist_server, help_welist_server, lbl_num_reminders, heir_num_reminders, help_num_reminders, lbl_event_summary, edit_event_summary, help_event_summary, - lbl_event_description, edit_event_description, help_event_description): + lbl_event_description, edit_event_description, help_event_description, + lbl_calendar_app, edit_calendar_app, help_calendar_app): w.setVisible(not basic_init) - grid.addWidget(heir_repush, 10, 0) + grid.addWidget(heir_repush, 11, 0) grid.addWidget( HelpButton( "Broadcast all transactions to willexecutors including those already pushed" ), - 10, + 11, 2, ) @@ -679,6 +692,7 @@ class Plugin(BalPlugin): (self.EVENT_SUMMARY, edit_event_summary, "line"), (self.EVENT_DESCRIPTION, edit_event_description, "text"), (self.WELIST_SERVER, edit_welist_server, "line"), + (self.CALENDAR_APP, edit_calendar_app, "line"), ] for cfg, widget, kind in resets: # Persist the default value back into the Electrum config.