forked from bitcoinafterlife/bal-electrum-plugin
feat: add calendar button with dropdown menu to build-will dialog + calendar app setting (advanced mode)
This commit is contained in:
@@ -21,8 +21,11 @@ from .common import *
|
|||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||||
from .widgets import (BalCheckBox, BalLineEdit, BalTextEdit, BalTxFeesWidget,
|
from .widgets import (BalCheckBox, BalLineEdit, BalTextEdit, BalTxFeesWidget,
|
||||||
LockTimeWidget, PercAmountEdit, ThresholdTimeWidget,
|
LockTimeWidget, PercAmountEdit, ThresholdTimeWidget,
|
||||||
WillSettingsWidget, WillWidget)
|
WillSettingsWidget, WillWidget, basic_reminder_offsets,
|
||||||
|
compute_reminder_offsets)
|
||||||
from .calendar import BalCalendar
|
from .calendar import BalCalendar
|
||||||
|
from PyQt6.QtGui import QAction
|
||||||
|
from PyQt6.QtWidgets import QToolButton
|
||||||
# NOTE: list views (HeirListWidget, PreviewList, WillExecutorWidget) are
|
# NOTE: list views (HeirListWidget, PreviewList, WillExecutorWidget) are
|
||||||
# imported lazily where needed to avoid a dialogs<->lists import cycle.
|
# imported lazily where needed to avoid a dialogs<->lists import cycle.
|
||||||
|
|
||||||
@@ -1444,21 +1447,197 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
self._add_close_button()
|
self._add_close_button()
|
||||||
|
|
||||||
def _add_close_button(self):
|
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()).
|
Replaces the old automatic countdown (self.wait(5) + self.close()).
|
||||||
Guarded so it is only built once even if called again.
|
Guarded so it is only built once even if called again.
|
||||||
"""
|
"""
|
||||||
if getattr(self, "_close_button", None) is not None:
|
if getattr(self, "_close_button", None) is not None:
|
||||||
return
|
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 = QPushButton(_("Close"))
|
||||||
self._close_button.clicked.connect(self._on_close_clicked)
|
self._close_button.clicked.connect(self._on_close_clicked)
|
||||||
button_row = QHBoxLayout()
|
button_row = QHBoxLayout()
|
||||||
button_row.addStretch(1)
|
button_row.addStretch(1)
|
||||||
|
button_row.addWidget(calendar_button)
|
||||||
button_row.addWidget(self._close_button)
|
button_row.addWidget(self._close_button)
|
||||||
self.vbox.addLayout(button_row)
|
self.vbox.addLayout(button_row)
|
||||||
self._close_button.setFocus()
|
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):
|
def _on_close_clicked(self):
|
||||||
# Close the dialog first, then show the persistent popup guiding the
|
# Close the dialog first, then show the persistent popup guiding the
|
||||||
# user through any remaining MANUAL steps (Sign / Broadcast). Showing
|
# user through any remaining MANUAL steps (Sign / Broadcast). Showing
|
||||||
|
|||||||
@@ -468,7 +468,8 @@ class Plugin(BalPlugin):
|
|||||||
for w in (lbl_welist_server, edit_welist_server, help_welist_server,
|
for w in (lbl_welist_server, edit_welist_server, help_welist_server,
|
||||||
lbl_num_reminders, heir_num_reminders, help_num_reminders,
|
lbl_num_reminders, heir_num_reminders, help_num_reminders,
|
||||||
lbl_event_summary, edit_event_summary, help_event_summary,
|
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)
|
w.setVisible(not basic)
|
||||||
self.update_all()
|
self.update_all()
|
||||||
|
|
||||||
@@ -477,9 +478,6 @@ class Plugin(BalPlugin):
|
|||||||
# Editable line/text widgets are created once and kept in named
|
# Editable line/text widgets are created once and kept in named
|
||||||
# variables so the "Reset" button (Group C / C4b) can refresh the
|
# variables so the "Reset" button (Group C / C4b) can refresh the
|
||||||
# displayed values after resetting the underlying config.
|
# 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 = BalLineEdit(self.EVENT_SUMMARY)
|
||||||
edit_event_summary.setMinimumWidth(360)
|
edit_event_summary.setMinimumWidth(360)
|
||||||
edit_event_description = BalTextEdit(self.EVENT_DESCRIPTION)
|
edit_event_description = BalTextEdit(self.EVENT_DESCRIPTION)
|
||||||
@@ -489,6 +487,9 @@ class Plugin(BalPlugin):
|
|||||||
edit_welist_server = BalLineEdit(self.WELIST_SERVER)
|
edit_welist_server = BalLineEdit(self.WELIST_SERVER)
|
||||||
edit_welist_server.setMinimumWidth(360)
|
edit_welist_server.setMinimumWidth(360)
|
||||||
|
|
||||||
|
edit_calendar_app = BalLineEdit(self.CALENDAR_APP)
|
||||||
|
edit_calendar_app.setMinimumWidth(360)
|
||||||
|
|
||||||
heir_repush = QPushButton("Rebroadcast transactions")
|
heir_repush = QPushButton("Rebroadcast transactions")
|
||||||
heir_repush.clicked.connect(partial(self.broadcast_transactions, True))
|
heir_repush.clicked.connect(partial(self.broadcast_transactions, True))
|
||||||
bal_mode = QComboBox()
|
bal_mode = QComboBox()
|
||||||
@@ -634,20 +635,32 @@ class Plugin(BalPlugin):
|
|||||||
grid.addWidget(lbl_welist_server, 9, 0)
|
grid.addWidget(lbl_welist_server, 9, 0)
|
||||||
grid.addWidget(edit_welist_server, 9, 1)
|
grid.addWidget(edit_welist_server, 9, 1)
|
||||||
grid.addWidget(help_welist_server, 9, 2)
|
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.
|
# Initial visibility: hidden in basic, visible in advanced.
|
||||||
basic_init = str(self.USER_TYPE.get()).lower() != "advanced"
|
basic_init = str(self.USER_TYPE.get()).lower() != "advanced"
|
||||||
for w in (lbl_welist_server, edit_welist_server, help_welist_server,
|
for w in (lbl_welist_server, edit_welist_server, help_welist_server,
|
||||||
lbl_num_reminders, heir_num_reminders, help_num_reminders,
|
lbl_num_reminders, heir_num_reminders, help_num_reminders,
|
||||||
lbl_event_summary, edit_event_summary, help_event_summary,
|
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)
|
w.setVisible(not basic_init)
|
||||||
|
|
||||||
grid.addWidget(heir_repush, 10, 0)
|
grid.addWidget(heir_repush, 11, 0)
|
||||||
grid.addWidget(
|
grid.addWidget(
|
||||||
HelpButton(
|
HelpButton(
|
||||||
"Broadcast all transactions to willexecutors including those already pushed"
|
"Broadcast all transactions to willexecutors including those already pushed"
|
||||||
),
|
),
|
||||||
10,
|
11,
|
||||||
2,
|
2,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -679,6 +692,7 @@ class Plugin(BalPlugin):
|
|||||||
(self.EVENT_SUMMARY, edit_event_summary, "line"),
|
(self.EVENT_SUMMARY, edit_event_summary, "line"),
|
||||||
(self.EVENT_DESCRIPTION, edit_event_description, "text"),
|
(self.EVENT_DESCRIPTION, edit_event_description, "text"),
|
||||||
(self.WELIST_SERVER, edit_welist_server, "line"),
|
(self.WELIST_SERVER, edit_welist_server, "line"),
|
||||||
|
(self.CALENDAR_APP, edit_calendar_app, "line"),
|
||||||
]
|
]
|
||||||
for cfg, widget, kind in resets:
|
for cfg, widget, kind in resets:
|
||||||
# Persist the default value back into the Electrum config.
|
# Persist the default value back into the Electrum config.
|
||||||
|
|||||||
Reference in New Issue
Block a user