Replace the separate Export / Export V-NC toolbar actions with a cascading Export submenu: All exports every will item, Valid exports only valid ones, Valid NC exports valid items that are not yet fully signed.
1557 lines
56 KiB
Python
1557 lines
56 KiB
Python
"""
|
|
bal.gui.qt.lists
|
|
================
|
|
|
|
Tree/list views (subclasses of Electrum's ``MyTreeView``) and their toolbars.
|
|
|
|
* HeirListWidget - editable list of heirs (address / amount / locktime).
|
|
* PreviewList - preview of the will transactions before signing.
|
|
* WillExecutorListWidget- list of will-executor servers.
|
|
* WillExecutorWidget - container combining the list with add/import buttons.
|
|
|
|
These views call back into the :class:`BalWindow` controller (passed at
|
|
construction) for all business actions, so the heavy logic stays in ``window``
|
|
and ``dialogs``.
|
|
"""
|
|
|
|
from PyQt6.QtWidgets import QLineEdit as _QLineEdit
|
|
from PyQt6.QtWidgets import QMessageBox, QStyledItemDelegate
|
|
|
|
from .common import *
|
|
from .common import _, _logger # underscore names are not re-exported by "import *"
|
|
from .dialogs import BalBuildWillDialog, BalDialog
|
|
from .widgets import BalCheckBox, WillSettingsWidget
|
|
|
|
|
|
def _can_sign(will_item):
|
|
"""True if a will transaction may still be signed (not fully signed)."""
|
|
return bool(will_item) and not will_item.get_status("COMPLETE")
|
|
|
|
|
|
def _can_broadcast(will_item):
|
|
"""True if a will transaction is ready to broadcast (fully signed)."""
|
|
return bool(will_item) and will_item.get_status("COMPLETE")
|
|
|
|
|
|
def _can_delete(will_item):
|
|
"""True if a will transaction may be deleted (invalid or unsigned).
|
|
|
|
Valid AND fully-signed transactions are never deletable: removing them
|
|
would silently drop a committed inheritance.
|
|
"""
|
|
return bool(will_item) and (
|
|
not will_item.get_status("VALID") or not will_item.get_status("COMPLETE")
|
|
)
|
|
|
|
|
|
def _apply_select_all(willexecutors, select, valid=None):
|
|
"""Apply a bulk selection across a ``{url: we_dict}`` mapping.
|
|
|
|
``select`` is the target selection value. When ``valid`` is given (a
|
|
``{url: bool}`` validity map), the selection is restricted by validity:
|
|
selecting sets valid ones to True and invalid ones to False, while
|
|
deselecting only clears the invalid ones (valid ones keep their state).
|
|
Without ``valid`` every entry is simply set to ``select``. The mapping is
|
|
mutated in place and returned.
|
|
"""
|
|
for url, we in willexecutors.items():
|
|
if valid is not None:
|
|
if select:
|
|
we["selected"] = valid.get(url, False)
|
|
elif not valid.get(url, False):
|
|
we["selected"] = False
|
|
else:
|
|
we["selected"] = select
|
|
return willexecutors
|
|
|
|
|
|
class HeirListWidget(MyTreeView, MessageBoxMixin):
|
|
class Columns(MyTreeView.BaseColumnsEnum):
|
|
NAME = enum.auto()
|
|
ADDRESS = enum.auto()
|
|
AMOUNT = enum.auto()
|
|
|
|
headers = {
|
|
Columns.NAME: _("Name"),
|
|
Columns.ADDRESS: _("Address"),
|
|
Columns.AMOUNT: _("Amount"),
|
|
}
|
|
filter_columns = [Columns.NAME, Columns.ADDRESS]
|
|
|
|
ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 1000
|
|
|
|
ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 4000
|
|
key_role = ROLE_HEIR_KEY
|
|
|
|
def createEditor(self, parent, option, index):
|
|
return QLineEdit(parent)
|
|
|
|
def setEditorData(self, editor, index):
|
|
editor.setText(index.data())
|
|
|
|
def setModelData(self, editor, model, index):
|
|
model.setData(index, editor.text())
|
|
|
|
def __init__(self, bal_window: "BalWindow", parent):
|
|
super().__init__(
|
|
parent=parent,
|
|
main_window=bal_window.window,
|
|
stretch_column=self.Columns.NAME,
|
|
editable_columns=[
|
|
self.Columns.NAME,
|
|
self.Columns.ADDRESS,
|
|
self.Columns.AMOUNT,
|
|
],
|
|
)
|
|
self.decimal_point = bal_window.window.get_decimal_point()
|
|
self.bal_window = bal_window
|
|
|
|
try:
|
|
self.setModel(QStandardItemModel(self))
|
|
self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder)
|
|
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
|
except Exception:
|
|
pass
|
|
|
|
self.setSortingEnabled(True)
|
|
self.std_model = self.model()
|
|
|
|
self.update()
|
|
|
|
def on_activated(self, idx):
|
|
self.on_double_click(idx)
|
|
|
|
def on_double_click(self, idx):
|
|
edit_key = self.get_edit_key_from_coordinate(idx.row(), idx.column())
|
|
self.bal_window.heirs.get(edit_key)
|
|
self.bal_window.new_heir_dialog(edit_key)
|
|
|
|
def on_edited(self, idx, edit_key, *, text):
|
|
original = prior_name = self.bal_window.heirs.get(edit_key)
|
|
if not prior_name:
|
|
return
|
|
col = idx.column()
|
|
try:
|
|
if col == 2:
|
|
text = Util.encode_amount(text, self.decimal_point)
|
|
elif col == 0:
|
|
self.bal_window.delete_heirs([edit_key])
|
|
edit_key = text
|
|
prior_name[col - 1] = text
|
|
prior_name.insert(0, edit_key)
|
|
prior_name = tuple(prior_name)
|
|
except Exception:
|
|
prior_name = (
|
|
(edit_key,) + prior_name[: col - 1] + (text,) + prior_name[col:]
|
|
)
|
|
|
|
try:
|
|
self.bal_window.set_heir(prior_name)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
self.bal_window.set_heir((edit_key,) + original)
|
|
except Exception:
|
|
self.update()
|
|
|
|
def delete_heirs(self, selected_keys):
|
|
self.bal_window.delete_heirs(selected_keys)
|
|
self.update()
|
|
|
|
def create_menu(self, position):
|
|
menu = QMenu()
|
|
idx = self.indexAt(position)
|
|
column = idx.column() or self.Columns.NAME
|
|
selected_keys = []
|
|
for s_idx in self.selected_in_column(self.Columns.NAME):
|
|
sel_key = self.model().itemFromIndex(s_idx).data(0)
|
|
selected_keys.append(sel_key)
|
|
if selected_keys and idx.isValid():
|
|
column_title = self.model().horizontalHeaderItem(column).text()
|
|
# ok
|
|
column_data = "\n".join(
|
|
self.model().itemFromIndex(s_idx).text()
|
|
for s_idx in self.selected_in_column(column)
|
|
)
|
|
menu.addAction(
|
|
_("Copy {}").format(column_title),
|
|
lambda: self.place_text_on_clipboard(column_data, title=column_title),
|
|
)
|
|
if column in self.editable_columns:
|
|
item = self.model().itemFromIndex(idx)
|
|
if item.isEditable():
|
|
persistent = QPersistentModelIndex(idx)
|
|
menu.addAction(
|
|
_("Edit {}").format(column_title),
|
|
lambda p=persistent: self.edit(QModelIndex(p)),
|
|
)
|
|
menu.addAction(_("Delete"), lambda: self.delete_heirs(selected_keys))
|
|
menu.exec(self.viewport().mapToGlobal(position))
|
|
|
|
def update(self):
|
|
current_key = self.get_role_data_for_current_item(
|
|
col=self.Columns.NAME, role=self.ROLE_HEIR_KEY
|
|
)
|
|
self.model().clear()
|
|
self.update_headers(self.__class__.headers)
|
|
set_current = None
|
|
for key in sorted(self.bal_window.heirs.keys()):
|
|
heir = self.bal_window.heirs[key]
|
|
labels = [""] * len(self.Columns)
|
|
labels[self.Columns.NAME] = key
|
|
if is_op_return_address(heir[0]):
|
|
data_hex = heir[0][len(OP_RETURN_PREFIX):]
|
|
try:
|
|
decoded = bytes.fromhex(data_hex).decode("utf-8", errors="replace")
|
|
if len(decoded) > 40:
|
|
decoded = decoded[:40] + "\u2026"
|
|
labels[self.Columns.ADDRESS] = decoded
|
|
except Exception:
|
|
labels[self.Columns.ADDRESS] = "OP_RETURN"
|
|
else:
|
|
labels[self.Columns.ADDRESS] = heir[0]
|
|
labels[self.Columns.AMOUNT] = Util.decode_amount(
|
|
heir[1], self.decimal_point
|
|
)
|
|
|
|
items = [QStandardItem(x) for x in labels]
|
|
items[self.Columns.NAME].setEditable(True)
|
|
items[self.Columns.ADDRESS].setEditable(True)
|
|
items[self.Columns.AMOUNT].setEditable(True)
|
|
items[self.Columns.NAME].setData(
|
|
key, self.ROLE_HEIR_KEY + self.Columns.NAME
|
|
)
|
|
items[self.Columns.ADDRESS].setData(
|
|
key, self.ROLE_HEIR_KEY + self.Columns.ADDRESS
|
|
)
|
|
items[self.Columns.AMOUNT].setData(
|
|
key, self.ROLE_HEIR_KEY + self.Columns.AMOUNT
|
|
)
|
|
|
|
row_count = self.model().rowCount()
|
|
self.model().insertRow(row_count, items)
|
|
|
|
if key == current_key:
|
|
idx = self.model().index(row_count, self.Columns.NAME)
|
|
set_current = QPersistentModelIndex(idx)
|
|
try:
|
|
self.will_settings_widget.on_locktime_change()
|
|
except Exception:
|
|
pass
|
|
self.set_current_idx(set_current)
|
|
# FIXME refresh loses sort order; so set "default" here:
|
|
self.filter()
|
|
|
|
def refresh_row(self, key, row):
|
|
# nothing to update here
|
|
pass
|
|
|
|
def get_edit_key_from_coordinate(self, row, col):
|
|
a = self.get_role_data_from_coordinate(row, col, role=self.ROLE_HEIR_KEY + col)
|
|
return a
|
|
|
|
def create_toolbar(self, config):
|
|
toolbar, menu = self.create_toolbar_with_menu("")
|
|
menu.addAction(_("&New Heir"), self.bal_window.new_heir_dialog)
|
|
menu.addAction(_("Import"), self.bal_window.import_heirs)
|
|
menu.addAction(_("Export"), lambda: self.bal_window.export_heirs())
|
|
|
|
new_heir_button = QPushButton(_("New Heir"))
|
|
new_heir_button.clicked.connect(self.bal_window.new_heir_dialog)
|
|
|
|
widget = QWidget(self)
|
|
layout = QHBoxLayout(widget)
|
|
self.will_settings_widget = WillSettingsWidget(self.bal_window, self)
|
|
|
|
layout.addWidget(self.will_settings_widget)
|
|
layout.addWidget(new_heir_button)
|
|
|
|
toolbar.insertWidget(2, widget)
|
|
|
|
return toolbar
|
|
|
|
def build_transactions(self):
|
|
# will = self.bal_window.prepare_will()
|
|
self.bal_window.prepare_will()
|
|
|
|
|
|
|
|
class PreviewList(MyTreeView, MessageBoxMixin):
|
|
class Columns(MyTreeView.BaseColumnsEnum):
|
|
LOCKTIME = enum.auto()
|
|
TXID = enum.auto()
|
|
WILLEXECUTOR = enum.auto()
|
|
STATUS = enum.auto()
|
|
SERVER = enum.auto()
|
|
|
|
headers = {
|
|
Columns.LOCKTIME: _("Locktime"),
|
|
Columns.TXID: _("Txid"),
|
|
Columns.WILLEXECUTOR: _("Will-Executor"),
|
|
Columns.STATUS: _("Status"),
|
|
Columns.SERVER: _("Server"),
|
|
}
|
|
|
|
ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 2000
|
|
key_role = ROLE_HEIR_KEY
|
|
|
|
def createEditor(self, parent, option, index):
|
|
return QLineEdit(parent)
|
|
|
|
def setEditorData(self, editor, index):
|
|
editor.setText(index.data())
|
|
|
|
def setModelData(self, editor, model, index):
|
|
model.setData(index, editor.text())
|
|
|
|
def __init__(self, bal_window: "BalWindow", parent, will):
|
|
super().__init__(
|
|
parent=parent,
|
|
main_window=bal_window.window,
|
|
stretch_column=self.Columns.TXID,
|
|
)
|
|
# self._bal_parent = parent
|
|
self.bal_window = bal_window
|
|
self.decimal_point = bal_window.window.get_decimal_point
|
|
|
|
if will is not None:
|
|
self.will = will
|
|
else:
|
|
self.will = bal_window.willitems
|
|
|
|
try:
|
|
self.setModel(QStandardItemModel(self))
|
|
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
|
self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder)
|
|
except Exception:
|
|
pass
|
|
|
|
self.setSortingEnabled(True)
|
|
self.std_model = self.model()
|
|
|
|
self.update()
|
|
|
|
def on_activated(self, idx):
|
|
self.on_double_click(idx)
|
|
|
|
def on_double_click(self, idx):
|
|
idx = self.model().index(idx.row(), self.Columns.TXID)
|
|
sel_key = self.model().itemFromIndex(idx).data(0)
|
|
self.show_transaction([sel_key])
|
|
|
|
def create_menu(self, position):
|
|
menu = QMenu()
|
|
idx = self.indexAt(position)
|
|
column = idx.column() or self.Columns.TXID
|
|
selected_keys = []
|
|
for s_idx in self.selected_in_column(self.Columns.TXID):
|
|
sel_key = self.model().itemFromIndex(s_idx).data(0)
|
|
selected_keys.append(sel_key)
|
|
if selected_keys and idx.isValid():
|
|
column_title = self.model().horizontalHeaderItem(column).text()
|
|
|
|
menu.addAction(
|
|
_("details").format(column_title),
|
|
lambda: self.show_transaction(selected_keys),
|
|
).setEnabled(len(selected_keys) == 1)
|
|
menu.addAction(
|
|
_("sign").format(column_title),
|
|
lambda: self.sign_transactions(selected_keys),
|
|
).setEnabled(
|
|
any(_can_sign(self.will.get(k)) for k in selected_keys)
|
|
)
|
|
menu.addAction(
|
|
_("broadcast").format(column_title),
|
|
lambda: self.broadcast_transactions(selected_keys),
|
|
).setEnabled(
|
|
any(_can_broadcast(self.will.get(k)) for k in selected_keys)
|
|
)
|
|
menu.addAction(
|
|
_("check ").format(column_title),
|
|
lambda: self.check_transactions(selected_keys),
|
|
)
|
|
|
|
menu.addSeparator()
|
|
menu.addAction(
|
|
_("copy id").format(column_title),
|
|
lambda: self.copy_txids(selected_keys),
|
|
)
|
|
menu.addAction(
|
|
_("copy").format(column_title),
|
|
lambda: self.copy_tx_hexes(selected_keys),
|
|
)
|
|
menu.addAction(
|
|
_("merge from txn").format(column_title),
|
|
lambda: self.merge_from_txn(),
|
|
)
|
|
|
|
menu.addSeparator()
|
|
menu.addAction(
|
|
_("delete").format(column_title), lambda: self.delete(selected_keys)
|
|
).setEnabled(
|
|
any(_can_delete(self.will.get(k)) for k in selected_keys)
|
|
)
|
|
|
|
menu.exec(self.viewport().mapToGlobal(position))
|
|
|
|
def is_deletable(self, key):
|
|
"""True if the will transaction ``key`` may be deleted.
|
|
|
|
Deletion is only allowed for transactions that are NOT valid or NOT
|
|
fully signed (invalidated/replaced/mempool/confirmed items, or
|
|
unsigned/partially signed ones). Valid and complete transactions are
|
|
kept (deleting them would silently drop a committed inheritance).
|
|
"""
|
|
return _can_delete(self.will.get(key))
|
|
|
|
def delete(self, selected_keys):
|
|
keys = [k for k in selected_keys if self.is_deletable(k)]
|
|
for key in keys:
|
|
del self.will[key]
|
|
try:
|
|
del self.bal_window.willitems[key]
|
|
except Exception:
|
|
pass
|
|
try:
|
|
del self.bal_window.will[key]
|
|
except Exception:
|
|
pass
|
|
self.update()
|
|
|
|
def sign_transactions(self, selected_keys):
|
|
"""Sign all selected transactions that are not fully signed yet."""
|
|
keys = [
|
|
k for k in selected_keys
|
|
if _can_sign(self.will.get(k))
|
|
]
|
|
if keys:
|
|
self.bal_window.ask_password_and_sign_transactions(
|
|
callback=self.update, txids=keys
|
|
)
|
|
|
|
def broadcast_transactions(self, selected_keys):
|
|
"""Force-broadcast all selected fully-signed transactions.
|
|
|
|
``force=True`` makes the will-executors re-accept transactions that
|
|
were already pushed before.
|
|
"""
|
|
keys = [
|
|
k for k in selected_keys
|
|
if _can_broadcast(self.will.get(k))
|
|
]
|
|
if keys:
|
|
self.bal_window.broadcast_transactions(force=True, txids=keys)
|
|
self.update()
|
|
|
|
def copy_txids(self, selected_keys):
|
|
"""Copy the selected transaction IDs to the clipboard (one per line)."""
|
|
self.place_text_on_clipboard(
|
|
"\n".join(selected_keys), title=_("Transaction IDs")
|
|
)
|
|
|
|
def copy_tx_hexes(self, selected_keys):
|
|
"""Copy the selected transactions (serialised hex) to the clipboard."""
|
|
hexes = "\n".join(str(self.will[k].tx) for k in selected_keys)
|
|
self.place_text_on_clipboard(hexes, title=_("Transactions"))
|
|
|
|
def merge_from_txn(self):
|
|
"""Merge a transaction read from the clipboard, or from a file if the
|
|
clipboard does not contain a valid transaction.
|
|
"""
|
|
tx = None
|
|
try:
|
|
tx = tx_from_any(QApplication.clipboard().text())
|
|
except Exception:
|
|
tx = None
|
|
if tx is None:
|
|
filename = getOpenFileName(
|
|
parent=self.bal_window.window,
|
|
title=_("Open transaction file"),
|
|
filter="All files (*)",
|
|
config=self.bal_window.window.config,
|
|
)
|
|
if not filename:
|
|
return
|
|
try:
|
|
with open(filename, "r") as f:
|
|
data = f.read()
|
|
tx = tx_from_any(data)
|
|
except Exception as e:
|
|
self.bal_window.show_error(
|
|
_("Invalid transaction file: {}").format(e)
|
|
)
|
|
return
|
|
try:
|
|
self.bal_window.merge_single_transaction(tx)
|
|
except Exception as e:
|
|
self.bal_window.show_error(str(e))
|
|
self.update()
|
|
|
|
def check_transactions(self, selected_keys):
|
|
wout = {}
|
|
for k in selected_keys:
|
|
wout[k] = self.will[k]
|
|
if wout:
|
|
self.bal_window.check_transactions(wout)
|
|
self.update()
|
|
|
|
def show_transaction(self, selected_keys):
|
|
for key in selected_keys:
|
|
self.bal_window.show_transaction(self.will[key].tx)
|
|
|
|
self.update()
|
|
|
|
def select(self, selected_keys):
|
|
self.selected += selected_keys
|
|
self.update()
|
|
|
|
def deselect(self, selected_keys):
|
|
for key in selected_keys:
|
|
self.selected.remove(key)
|
|
self.update()
|
|
|
|
def update_will(self, will):
|
|
self.will.update(will)
|
|
self.update()
|
|
|
|
def replace(self, set_current, current_key, txid, bal_tx):
|
|
if self.bal_window.bal_plugin._hide_replaced and bal_tx.get_status("REPLACED"):
|
|
return False
|
|
if self.bal_window.bal_plugin._hide_invalidated and bal_tx.get_status(
|
|
"INVALIDATED"
|
|
):
|
|
return False
|
|
|
|
if not isinstance(bal_tx, WillItem):
|
|
bal_tx = WillItem(bal_tx)
|
|
|
|
tx = bal_tx.tx
|
|
|
|
labels = [""] * len(self.Columns)
|
|
labels[self.Columns.LOCKTIME] = str(BalTimestamp(tx.locktime))
|
|
labels[self.Columns.TXID] = txid
|
|
we = "None"
|
|
if bal_tx.we:
|
|
we = bal_tx.we["url"]
|
|
labels[self.Columns.WILLEXECUTOR] = we
|
|
status = bal_tx.status + signature_suffix(bal_tx)
|
|
if len(status) > 53:
|
|
status = "...{}".format(status[-50:])
|
|
labels[self.Columns.STATUS] = status
|
|
# Dedicated, always-readable label describing whether the inheritance
|
|
# transaction is actually stored on the will-executor servers.
|
|
labels[self.Columns.SERVER] = server_status_text(bal_tx)
|
|
|
|
items = []
|
|
for e in labels:
|
|
if isinstance(e, list):
|
|
try:
|
|
items.append(QStandardItem(*e))
|
|
except Exception as e:
|
|
pass
|
|
else:
|
|
items.append(QStandardItem(str(e)))
|
|
|
|
items[-1].setBackground(QColor(status_color(bal_tx)))
|
|
|
|
# Group C / C6: emphasise the Locktime column by rendering it in bold,
|
|
# so the delivery time stands out at a glance in the list.
|
|
try:
|
|
locktime_item = items[self.Columns.LOCKTIME]
|
|
bold_font = locktime_item.font()
|
|
bold_font.setBold(True)
|
|
locktime_item.setFont(bold_font)
|
|
except Exception as bold_err:
|
|
_logger.debug(f"locktime bold error: {bold_err}")
|
|
|
|
# Tooltip on the Server column: shows the will-executor URL (if any)
|
|
# plus the current server state, so the user can always inspect details.
|
|
try:
|
|
items[self.Columns.SERVER].setToolTip(server_status_tooltip(bal_tx))
|
|
except Exception as tip_err:
|
|
_logger.debug(f"server tooltip error: {tip_err}")
|
|
|
|
row_count = self.model().rowCount()
|
|
self.model().insertRow(row_count, items)
|
|
if txid == current_key:
|
|
idx = self.model().index(row_count, self.Columns.TXID)
|
|
set_current = QPersistentModelIndex(idx)
|
|
self.set_current_idx(set_current)
|
|
return set_current
|
|
|
|
def update(self):
|
|
try:
|
|
self.menu.removeAction(self.importaction)
|
|
except Exception:
|
|
pass
|
|
|
|
if self.will is None:
|
|
return
|
|
|
|
current_key = self.get_role_data_for_current_item(
|
|
col=self.Columns.TXID, role=self.ROLE_HEIR_KEY
|
|
)
|
|
self.model().clear()
|
|
self.update_headers(self.__class__.headers)
|
|
|
|
set_current = None
|
|
for txid, bal_tx in self.will.items():
|
|
tmp = self.replace(set_current, current_key, txid, bal_tx)
|
|
if tmp:
|
|
set_current = tmp
|
|
self.sortByColumn(self.Columns.LOCKTIME, Qt.SortOrder.AscendingOrder)
|
|
self.setSortingEnabled(True)
|
|
try:
|
|
self.will_settings_widget.on_locktime_change()
|
|
except Exception as _e:
|
|
pass
|
|
|
|
def create_toolbar(self, config):
|
|
toolbar, menu = self.create_toolbar_with_menu("")
|
|
menu.addAction(_("Prepare"), self.build_transactions)
|
|
menu.addAction(_("Display"), self.bal_window.preview_modal_dialog)
|
|
menu.addAction(_("Sign"), self.ask_password_and_sign_transactions)
|
|
export_menu = menu.addMenu(_("Export"))
|
|
export_menu.addAction(_("All"), self.export_will)
|
|
export_menu.addAction(_("Valid"), self.export_will_valid)
|
|
export_menu.addAction(_("Valid NC"), self.export_will_valid_incomplete)
|
|
menu.addAction(_("Import"), self.import_will_into_details)
|
|
menu.addAction(_("Merge"), self.merge_will)
|
|
menu.addAction(_("Broadcast"), self.broadcast)
|
|
menu.addAction(_("Check"), self.check)
|
|
menu.addAction(_("Invalidate"), self.invalidate_will)
|
|
|
|
# The Wizard is the main entry point to create an inheritance, so make
|
|
# it stand out: show a bold label next to a slightly larger icon (the
|
|
# plain icon-only button was too easy to overlook).
|
|
wizard = QPushButton(" " + _("Build Your Will"))
|
|
wizard.setIcon(
|
|
read_QIcon_from_bytes(
|
|
self.bal_window.bal_plugin.read_file("icons/wizard.png")
|
|
)
|
|
)
|
|
wizard.setIconSize(QSize(28, 28))
|
|
wizard.setMinimumHeight(40)
|
|
wizard.setStyleSheet("QPushButton{font-weight:bold;}")
|
|
# Tooltip so the button is self-explanatory when hovered.
|
|
wizard.setToolTip(_("Wizard - Build your will"))
|
|
wizard.clicked.connect(self.bal_window.init_wizard)
|
|
# display = QPushButton(_("Display"))
|
|
# display.clicked.connect(self.bal_window.preview_modal_dialog)
|
|
|
|
refresh = QPushButton()
|
|
refresh.setIcon(
|
|
read_QIcon_from_bytes(
|
|
self.bal_window.bal_plugin.read_file("icons/reload.png")
|
|
)
|
|
)
|
|
# Tooltip so the icon is self-explanatory when hovered. "Check
|
|
# Inheritance" makes it clear the button re-checks the inheritance/will
|
|
# state (not a generic refresh).
|
|
refresh.setToolTip(_("Check Inheritance"))
|
|
refresh.clicked.connect(self.check)
|
|
|
|
widget = QWidget(self)
|
|
hlayout = QHBoxLayout(widget)
|
|
hlayout.setContentsMargins(0, 0, 0, 0)
|
|
self.will_settings_widget = WillSettingsWidget(self.bal_window, self)
|
|
# Toolbar order (left -> right):
|
|
# Wizard | Delivery time | Check Alive | Calendar | Check (refresh)
|
|
# The Wizard button goes first (leftmost); the settings widget already
|
|
# lays out delivery/check-alive/calendar in that order internally.
|
|
hlayout.addWidget(wizard)
|
|
hlayout.addWidget(self.will_settings_widget)
|
|
hlayout.addWidget(refresh)
|
|
toolbar.insertWidget(2, widget)
|
|
|
|
self.menu = menu
|
|
self.toolbar = toolbar
|
|
return toolbar
|
|
|
|
def hide_replaced(self):
|
|
self.bal_window.bal_plugin.hide_replaced()
|
|
self.update()
|
|
|
|
def hide_invalidated(self):
|
|
self.bal_window.bal_plugin.hide_invalidated()
|
|
self.update()
|
|
|
|
def build_transactions(self):
|
|
will = self.bal_window.prepare_will()
|
|
if will:
|
|
self.update_will(will)
|
|
|
|
def export_json_file(self, path):
|
|
write_json_file(path, self.will)
|
|
|
|
def export_will(self):
|
|
self.bal_window.export_will()
|
|
self.update()
|
|
|
|
def export_will_valid(self):
|
|
"""Export only the will items that are valid."""
|
|
subset = {
|
|
wid: wi
|
|
for wid, wi in self.will.items()
|
|
if wi.get_status("VALID")
|
|
}
|
|
if not subset:
|
|
self.show_message(_("No valid will item to export"))
|
|
return
|
|
self.bal_window.export_will(will=subset)
|
|
self.update()
|
|
|
|
def export_will_valid_incomplete(self):
|
|
"""Export only the will items that are valid but not yet fully signed (V-NC)."""
|
|
subset = {
|
|
wid: wi
|
|
for wid, wi in self.will.items()
|
|
if wi.get_status("VALID") and not wi.get_status("COMPLETE")
|
|
}
|
|
if not subset:
|
|
self.show_message(_("No valid, incomplete will item to export"))
|
|
return
|
|
self.bal_window.export_will(will=subset)
|
|
self.update()
|
|
|
|
def import_will_into_details(self):
|
|
self.bal_window.import_will_into_details()
|
|
|
|
def merge_will(self):
|
|
self.bal_window.merge_will_ui()
|
|
|
|
def ask_password_and_sign_transactions(self):
|
|
self.bal_window.ask_password_and_sign_transactions(callback=self.update)
|
|
|
|
def broadcast(self):
|
|
self.bal_window.broadcast_transactions()
|
|
self.update()
|
|
|
|
def check(self):
|
|
close_window = BalBuildWillDialog(self.bal_window)
|
|
close_window.build_will_task()
|
|
|
|
will = {}
|
|
for wid, w in self.bal_window.willitems.items():
|
|
# Query the will-executor server for every valid will that HAS a
|
|
# will-executor assigned and is not yet CHECKED. Previously only
|
|
# transactions already marked PUSHED were checked, so a will that
|
|
# had actually been sent in the past but whose saved status still
|
|
# read "New" (not PUSHED) was skipped and the Check button reported
|
|
# "nothing to do". Will.needs_server_check now also includes such
|
|
# non-PUSHED wills, so the server can confirm the transaction is
|
|
# present and correct the status (see set_check_willexecutor).
|
|
if Will.needs_server_check(w):
|
|
will[wid] = w
|
|
if will:
|
|
self.bal_window.check_transactions(will)
|
|
self.update()
|
|
# NOTE (Group B / B2): signing + broadcasting is already performed
|
|
# automatically by BalBuildWillDialog.build_will_task() above (called at
|
|
# the start of check()). We must NOT trigger a second sign/broadcast
|
|
# cycle here, otherwise the will would be broadcast twice. Whether the
|
|
# automatic sign/broadcast runs silently or shows the manual "next step"
|
|
# hints is controlled by the AUTO_SIGN setting inside that dialog.
|
|
|
|
def invalidate_will(self):
|
|
self.bal_window.invalidate_will()
|
|
self.update()
|
|
|
|
|
|
# class PreviewDialog(BalDialog, MessageBoxMixin):
|
|
# def __init__(self, bal_window, will):
|
|
# self._bal_parent = bal_window.window
|
|
# BalDialog.__init__(
|
|
# self, bal_window=bal_window, bal_plugin=bal_window.bal_plugin
|
|
# )
|
|
# self.bal_plugin = bal_window.bal_plugin
|
|
# self.gui_object = self.bal_plugin.gui_object
|
|
# self.config = self.bal_plugin.config
|
|
# self.bal_window = bal_window
|
|
# self.wallet = bal_window.window.wallet
|
|
# self.format_amount = bal_window.window.format_amount
|
|
# self.base_unit = bal_window.window.base_unit
|
|
# self.format_fiat_and_units = bal_window.window.format_fiat_and_units
|
|
# self.fx = bal_window.window.fx
|
|
# self.format_fee_rate = bal_window.window.format_fee_rate
|
|
# self.show_address = bal_window.window.show_address
|
|
# if not will:
|
|
# self.will = bal_window.willitems
|
|
# else:
|
|
# self.will = will
|
|
# self.setWindowTitle(_("Transactions Preview"))
|
|
# self.setMinimumSize(1000, 200)
|
|
# self.size_label = QLabel()
|
|
# self.transactions_list = PreviewList(self.bal_window,self, self.will)
|
|
#
|
|
# try:
|
|
# self.bal_window.init_class_variables()
|
|
# except Exception as e:
|
|
# _logger.error(f"PreviewDialog Exception: {e}")
|
|
# self.check_will()
|
|
#
|
|
# vbox = QVBoxLayout(self)
|
|
# vbox.addWidget(self.size_label)
|
|
# vbox.addWidget(self.transactions_list)
|
|
# buttonbox = QHBoxLayout()
|
|
#
|
|
# b = QPushButton(_("Sign"))
|
|
# b.clicked.connect(self.transactions_list.ask_password_and_sign_transactions)
|
|
# buttonbox.addWidget(b)
|
|
#
|
|
# b = QPushButton(_("Export Will"))
|
|
# b.clicked.connect(self.transactions_list.export_will)
|
|
# buttonbox.addWidget(b)
|
|
#
|
|
# b = QPushButton(_("Broadcast"))
|
|
# b.clicked.connect(self.transactions_list.broadcast)
|
|
# buttonbox.addWidget(b)
|
|
#
|
|
# b = QPushButton(_("Invalidate will"))
|
|
# b.clicked.connect(self.transactions_list.invalidate_will)
|
|
# buttonbox.addWidget(b)
|
|
#
|
|
# vbox.addLayout(buttonbox)
|
|
#
|
|
# self.update()
|
|
#
|
|
# def update_will(self, will):
|
|
# self.will.update(will)
|
|
# self.transactions_list.update_will(will)
|
|
# self.update()
|
|
#
|
|
# def update(self):
|
|
# self.transactions_list.update()
|
|
#
|
|
# def is_hidden(self):
|
|
# return self.isMinimized() or self.isHidden()
|
|
#
|
|
# def show_or_hide(self):
|
|
# if self.is_hidden():
|
|
# self.bring_to_top()
|
|
# else:
|
|
# self.hide()
|
|
#
|
|
# def bring_to_top(self):
|
|
# self.show()
|
|
# self.raise_()
|
|
#
|
|
# def closeEvent(self, event):
|
|
# event.accept()
|
|
|
|
|
|
|
|
class _FullUrlEditDelegate(QStyledItemDelegate):
|
|
"""Item delegate for the will-executor URL column.
|
|
|
|
The URL column may display a SHORTENED form of long .onion addresses. This
|
|
delegate ensures editing operates on the FULL url: the editor is preloaded
|
|
from the real key role (which always holds the complete address) rather than
|
|
from the visible (possibly shortened) cell text.
|
|
"""
|
|
|
|
def __init__(self, url_role, parent=None):
|
|
super().__init__(parent)
|
|
self._url_role = url_role
|
|
|
|
def createEditor(self, parent, option, index):
|
|
return _QLineEdit(parent)
|
|
|
|
def setEditorData(self, editor, index):
|
|
# Prefer the full URL stored in the key role; fall back to the visible
|
|
# text if for some reason the role is missing.
|
|
full = index.data(self._url_role)
|
|
if not full:
|
|
full = index.data()
|
|
editor.setText(full or "")
|
|
|
|
def setModelData(self, editor, model, index):
|
|
model.setData(index, editor.text())
|
|
|
|
|
|
class WillExecutorListWidget(MyTreeView):
|
|
class Columns(MyTreeView.BaseColumnsEnum):
|
|
SELECTED = enum.auto()
|
|
URL = enum.auto()
|
|
STATUS = enum.auto()
|
|
BASE_FEE = enum.auto()
|
|
INFO = enum.auto()
|
|
ADDRESS = enum.auto()
|
|
|
|
headers = {
|
|
Columns.SELECTED: _(""),
|
|
Columns.URL: _("Url"),
|
|
Columns.STATUS: _("S"),
|
|
Columns.BASE_FEE: _("Base fee"),
|
|
Columns.INFO: _("Info"),
|
|
Columns.ADDRESS: _("Default Address"),
|
|
}
|
|
|
|
filter_columns = [Columns.URL]
|
|
|
|
ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 3000
|
|
ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 3001
|
|
key_role = ROLE_HEIR_KEY
|
|
|
|
def __init__(self, parent: "WillExecutorWidget"):
|
|
super().__init__(
|
|
parent=parent,
|
|
stretch_column=self.Columns.ADDRESS,
|
|
editable_columns=[
|
|
self.Columns.URL,
|
|
self.Columns.BASE_FEE,
|
|
self.Columns.ADDRESS,
|
|
self.Columns.INFO,
|
|
],
|
|
)
|
|
self._bal_parent = parent
|
|
try:
|
|
self.setModel(QStandardItemModel(self))
|
|
self.sortByColumn(self.Columns.SELECTED, Qt.SortOrder.AscendingOrder)
|
|
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
|
except Exception:
|
|
pass
|
|
self.setSortingEnabled(True)
|
|
self.std_model = self.model()
|
|
self.config = parent.bal_plugin.config
|
|
self.get_decimal_point = parent.bal_plugin.get_decimal_point
|
|
|
|
# The URL column may DISPLAY a shortened form of long .onion addresses
|
|
# (see update()). Without this, double-clicking to edit would load the
|
|
# shortened text (with the ellipsis) into the editor and saving it would
|
|
# corrupt the will-executor key. This delegate makes the editor load the
|
|
# FULL url from the real key role instead of the visible (shortened)
|
|
# text, so edits always operate on the complete address.
|
|
try:
|
|
self.setItemDelegateForColumn(
|
|
self.Columns.URL,
|
|
_FullUrlEditDelegate(self.ROLE_HEIR_KEY + self.Columns.URL, self),
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
self.update()
|
|
|
|
def create_menu(self, position):
|
|
menu = QMenu()
|
|
idx = self.indexAt(position)
|
|
column = idx.column() or self.Columns.URL
|
|
selected_keys = []
|
|
for s_idx in self.selected_in_column(self.Columns.URL):
|
|
item = self.model().itemFromIndex(s_idx)
|
|
# Use the FULL url stored in the key role, NOT item.data(0): the
|
|
# latter is the (possibly shortened) DISPLAY text, and using it as a
|
|
# dict key breaks delete/select/deselect/ping for long .onion URLs
|
|
# (KeyError). Fall back to the display text only if the role is
|
|
# unexpectedly missing.
|
|
sel_key = item.data(self.ROLE_HEIR_KEY + self.Columns.URL) or item.data(0)
|
|
selected_keys.append(sel_key)
|
|
if selected_keys and idx.isValid():
|
|
column_title = self.model().horizontalHeaderItem(column).text()
|
|
# column_data = "\n".join(
|
|
# self.model().itemFromIndex(s_idx).text()
|
|
# for s_idx in self.selected_in_column(column)
|
|
# )
|
|
# When exactly ONE cell is selected, offer "Copy" to copy the value
|
|
# of that cell to the clipboard (e.g. a single url / address / fee).
|
|
# This list has no ``main_window``, so use QApplication directly.
|
|
if len(self.selectionModel().selectedIndexes()) == 1:
|
|
cell_value = self.model().itemFromIndex(idx).text()
|
|
menu.addAction(
|
|
_("Copy"),
|
|
lambda: QApplication.clipboard().setText(cell_value),
|
|
)
|
|
if Willexecutors.is_selected(self._bal_parent.willexecutors_list[sel_key]):
|
|
menu.addAction(
|
|
_("deselect").format(column_title),
|
|
lambda: self.deselect(selected_keys),
|
|
)
|
|
else:
|
|
menu.addAction(
|
|
_("select").format(column_title), lambda: self.select(selected_keys)
|
|
)
|
|
if column in self.editable_columns:
|
|
item = self.model().itemFromIndex(idx)
|
|
if item.isEditable():
|
|
persistent = QPersistentModelIndex(idx)
|
|
menu.addAction(
|
|
_("Edit {}").format(column_title),
|
|
lambda p=persistent: self.edit(QModelIndex(p)),
|
|
)
|
|
|
|
menu.addAction(
|
|
_("Ping").format(column_title),
|
|
lambda: self.ping_willexecutors(selected_keys),
|
|
)
|
|
menu.addSeparator()
|
|
menu.addAction(
|
|
_("delete").format(column_title), lambda: self.delete(selected_keys)
|
|
)
|
|
|
|
menu.exec(self.viewport().mapToGlobal(position))
|
|
|
|
def ping_willexecutors(self, selected_keys):
|
|
wout = {}
|
|
for k in selected_keys:
|
|
wout[k] = self._bal_parent.willexecutors_list[k]
|
|
self._bal_parent.update_willexecutors(wout)
|
|
|
|
self._bal_parent.save_willexecutors()
|
|
self.update()
|
|
|
|
def on_activated(self, idx):
|
|
self.on_double_click(idx)
|
|
|
|
def on_double_click(self, idx):
|
|
edit_key = self.get_edit_key_from_coordinate(
|
|
idx.row(), self.Columns.URL
|
|
)
|
|
if edit_key and edit_key in self._bal_parent.willexecutors_list:
|
|
self._bal_parent.add(edit_key)
|
|
|
|
def get_edit_key_from_coordinate(self, row, col):
|
|
role = self.ROLE_HEIR_KEY + col
|
|
a = self.get_role_data_from_coordinate(row, col, role=role)
|
|
return a
|
|
|
|
def delete(self, selected_keys):
|
|
for key in selected_keys:
|
|
del self._bal_parent.willexecutors_list[key]
|
|
|
|
self._bal_parent.save_willexecutors()
|
|
self.update()
|
|
|
|
def select(self, selected_keys):
|
|
for wid, w in self._bal_parent.willexecutors_list.items():
|
|
if wid in selected_keys:
|
|
w["selected"] = True
|
|
self._bal_parent.save_willexecutors()
|
|
self.update()
|
|
|
|
def deselect(self, selected_keys):
|
|
for wid, w in self._bal_parent.willexecutors_list.items():
|
|
if wid in selected_keys:
|
|
w["selected"] = False
|
|
self._bal_parent.save_willexecutors()
|
|
self.update()
|
|
|
|
def on_edited(self, idx, edit_key, *, text):
|
|
# prior_name = self._bal_parent.willexecutors_list[edit_key]
|
|
col = idx.column()
|
|
try:
|
|
if col == self.Columns.URL:
|
|
self._bal_parent.willexecutors_list[text] = self._bal_parent.willexecutors_list[
|
|
edit_key
|
|
]
|
|
del self._bal_parent.willexecutors_list[edit_key]
|
|
if col == self.Columns.BASE_FEE:
|
|
self._bal_parent.willexecutors_list[edit_key]["base_fee"] = (
|
|
Util.encode_amount(text, self.get_decimal_point())
|
|
)
|
|
if col == self.Columns.ADDRESS:
|
|
self._bal_parent.willexecutors_list[edit_key]["address"] = text
|
|
if col == self.Columns.INFO:
|
|
self._bal_parent.willexecutors_list[edit_key]["info"] = text
|
|
self._bal_parent.save_willexecutors()
|
|
self.update()
|
|
except Exception:
|
|
pass
|
|
|
|
def update(self):
|
|
if self._bal_parent.willexecutors_list is None:
|
|
return
|
|
try:
|
|
current_key = self.get_role_data_for_current_item(
|
|
col=self.Columns.URL, role=self.ROLE_HEIR_KEY
|
|
)
|
|
self.model().clear()
|
|
self.update_headers(self.__class__.headers)
|
|
|
|
set_current = None
|
|
|
|
for url, value in self._bal_parent.willexecutors_list.items():
|
|
labels = [""] * len(self.Columns)
|
|
# Long Tor (.onion) URLs overflow the column; show a shortened
|
|
# form (first 37 chars + ellipsis, matching the welist site)
|
|
# while keeping the FULL url as the real key data (setData below)
|
|
# and in the tooltip, so nothing downstream breaks. Short URLs
|
|
# are shown unchanged.
|
|
display_url = url if len(url) <= 40 else url[:37] + "\u2026"
|
|
labels[self.Columns.URL] = display_url
|
|
if Willexecutors.is_selected(value, max_fee=float("inf")):
|
|
|
|
labels[self.Columns.SELECTED] = [
|
|
read_QIcon_from_bytes(
|
|
self._bal_parent.bal_plugin.read_file("icons/confirmed.png")
|
|
),
|
|
"",
|
|
]
|
|
else:
|
|
labels[self.Columns.SELECTED] = ""
|
|
labels[self.Columns.BASE_FEE] = Util.decode_amount(
|
|
value.get("base_fee", 0), self.get_decimal_point()
|
|
)
|
|
if str(value.get("status", 0)) == "200":
|
|
labels[self.Columns.STATUS] = [
|
|
read_QIcon_from_bytes(
|
|
self._bal_parent.bal_plugin.read_file(
|
|
"icons/status_connected.png"
|
|
)
|
|
),
|
|
"",
|
|
]
|
|
else:
|
|
labels[self.Columns.STATUS] = [
|
|
read_QIcon_from_bytes(
|
|
self._bal_parent.bal_plugin.read_file("icons/unconfirmed.png")
|
|
),
|
|
"",
|
|
]
|
|
labels[self.Columns.ADDRESS] = str(value.get("address", ""))
|
|
labels[self.Columns.INFO] = str(value.get("info", ""))
|
|
|
|
items = []
|
|
for e in labels:
|
|
if isinstance(e, list):
|
|
try:
|
|
items.append(QStandardItem(*e))
|
|
except Exception as e:
|
|
pass
|
|
else:
|
|
items.append(QStandardItem(e))
|
|
|
|
max_fee = self._bal_parent.bal_plugin.MAX_WILLEXECUTOR_FEE.get()
|
|
dust = self._bal_parent.bal_window.window.wallet.dust_threshold()
|
|
if not Willexecutors.is_valid(value, max_fee=max_fee, dust=dust):
|
|
grey = QColor("#808080")
|
|
for item in items:
|
|
font = item.font()
|
|
font.setItalic(True)
|
|
item.setFont(font)
|
|
item.setForeground(grey)
|
|
|
|
items[self.Columns.SELECTED].setEditable(False)
|
|
items[self.Columns.URL].setEditable(True)
|
|
items[self.Columns.ADDRESS].setEditable(True)
|
|
items[self.Columns.INFO].setEditable(True)
|
|
items[self.Columns.BASE_FEE].setEditable(True)
|
|
items[self.Columns.STATUS].setEditable(False)
|
|
|
|
items[self.Columns.URL].setData(
|
|
url, self.ROLE_HEIR_KEY + self.Columns.URL
|
|
)
|
|
# Full URL on hover (the visible text may be shortened above).
|
|
items[self.Columns.URL].setToolTip(url)
|
|
items[self.Columns.BASE_FEE].setData(
|
|
url, self.ROLE_HEIR_KEY + self.Columns.BASE_FEE
|
|
)
|
|
items[self.Columns.INFO].setData(
|
|
url, self.ROLE_HEIR_KEY + self.Columns.INFO
|
|
)
|
|
items[self.Columns.ADDRESS].setData(
|
|
url, self.ROLE_HEIR_KEY + self.Columns.ADDRESS
|
|
)
|
|
row_count = self.model().rowCount()
|
|
self.model().insertRow(row_count, items)
|
|
if url == current_key:
|
|
idx = self.model().index(row_count, self.Columns.URL)
|
|
set_current = QPersistentModelIndex(idx)
|
|
self.set_current_idx(set_current)
|
|
self.filter()
|
|
except Exception as e:
|
|
_logger.error(f"error updating willexcutor {e}")
|
|
raise e
|
|
|
|
|
|
|
|
class WillExecutorWidget(QWidget, MessageBoxMixin):
|
|
def __init__(self, parent, bal_window, willexecutors=None):
|
|
self.bal_window = bal_window
|
|
self.bal_plugin = bal_window.bal_plugin
|
|
self._bal_parent = parent
|
|
MessageBoxMixin.__init__(self)
|
|
QWidget.__init__(self, parent)
|
|
if willexecutors:
|
|
self.willexecutors_list = willexecutors
|
|
else:
|
|
self.willexecutors_list = Willexecutors.get_willexecutors(self.bal_plugin)
|
|
|
|
self.size_label = QLabel()
|
|
self.will_executor_list_widget = WillExecutorListWidget(self)
|
|
|
|
vbox = QVBoxLayout(self)
|
|
vbox.addWidget(self.size_label)
|
|
|
|
widget = QWidget()
|
|
hbox = QHBoxLayout(widget)
|
|
hbox.addWidget(QLabel(_("Add transactions without willexecutor")))
|
|
heir_no_willexecutor = BalCheckBox(self.bal_plugin.NO_WILLEXECUTOR)
|
|
hbox.addWidget(heir_no_willexecutor)
|
|
spacer_widget = QWidget()
|
|
spacer_widget.setSizePolicy(
|
|
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
|
|
)
|
|
hbox.addWidget(spacer_widget)
|
|
vbox.addWidget(widget)
|
|
|
|
vbox.addWidget(self.will_executor_list_widget)
|
|
buttonbox = QHBoxLayout()
|
|
|
|
b = QPushButton(_("Add"))
|
|
b.clicked.connect(self.add)
|
|
buttonbox.addWidget(b)
|
|
|
|
b = QPushButton(_("Download List"))
|
|
b.clicked.connect(self.download_list)
|
|
buttonbox.addWidget(b)
|
|
|
|
b = QPushButton(_("Import"))
|
|
b.clicked.connect(self.import_file)
|
|
buttonbox.addWidget(b)
|
|
|
|
def _menu_button(label):
|
|
btn = QToolButton()
|
|
btn.setText(_(label))
|
|
btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
|
buttonbox.addWidget(btn)
|
|
return btn
|
|
|
|
export_btn = _menu_button("Export")
|
|
export_menu = QMenu(export_btn)
|
|
export_menu.addAction(_("Export all"), lambda: self.export_file())
|
|
export_menu.addAction(
|
|
_("Export selected"), lambda: self.export_file(subset="selected")
|
|
)
|
|
export_menu.addAction(
|
|
_("Export only valid"), lambda: self.export_file(subset="valid")
|
|
)
|
|
export_btn.setMenu(export_menu)
|
|
|
|
ping_btn = _menu_button("Ping All")
|
|
ping_menu = QMenu(ping_btn)
|
|
ping_menu.addAction(_("Ping all"), lambda: self.update_willexecutors())
|
|
ping_menu.addAction(
|
|
_("Ping selected"), lambda: self.ping_selected_willexecutors()
|
|
)
|
|
ping_btn.setMenu(ping_menu)
|
|
|
|
select_btn = _menu_button("Select All")
|
|
select_menu = QMenu(select_btn)
|
|
select_menu.addAction(_("Select all"), lambda: self.set_select_all(True))
|
|
select_menu.addAction(
|
|
_("Select only valid"), lambda: self.set_select_all(True, only_valid=True)
|
|
)
|
|
select_menu.addAction(_("Deselect all"), lambda: self.set_select_all(False))
|
|
select_menu.addAction(
|
|
_("Deselect only invalid"),
|
|
lambda: self.set_select_all(False, only_valid=True),
|
|
)
|
|
select_btn.setMenu(select_menu)
|
|
|
|
vbox.addLayout(buttonbox)
|
|
# self.will_executor_list_widget.update()
|
|
|
|
def add(self, edit_key=None):
|
|
executor = None
|
|
if edit_key:
|
|
executor = self.willexecutors_list.get(edit_key)
|
|
title = _("Edit: {}").format(edit_key)
|
|
else:
|
|
title = _("New Will Executor")
|
|
d = BalDialog(
|
|
self.bal_window.window,
|
|
self.bal_plugin,
|
|
self.bal_plugin.get_window_title(title),
|
|
)
|
|
|
|
vbox = QVBoxLayout(d)
|
|
grid = QGridLayout()
|
|
|
|
url_edit = QLineEdit()
|
|
url_edit.setFixedWidth(32 * char_width_in_lineedit())
|
|
info_edit = QLineEdit("New Will Executor")
|
|
info_edit.setFixedWidth(32 * char_width_in_lineedit())
|
|
base_fee_spin = QSpinBox()
|
|
base_fee_spin.setRange(0, 1000000)
|
|
base_fee_spin.setValue(0)
|
|
address_edit = QLineEdit()
|
|
address_edit.setFixedWidth(32 * char_width_in_lineedit())
|
|
sync_btn = QPushButton("\u21BB")
|
|
sync_btn.setFixedWidth(32)
|
|
loading_label = QLabel()
|
|
d._sync_active = False
|
|
|
|
if executor:
|
|
url_edit.setText(edit_key)
|
|
info_edit.setText(str(executor.get("info", "New Will Executor")))
|
|
base_fee_spin.setValue(int(executor.get("base_fee", 0)))
|
|
address_edit.setText(str(executor.get("address", "")))
|
|
|
|
def on_sync():
|
|
url = url_edit.text().strip()
|
|
if not url:
|
|
self.show_error(_("URL is required"))
|
|
return
|
|
tmp = {}
|
|
sync_btn.setEnabled(False)
|
|
loading_label.setText("\u27F3")
|
|
d._sync_active = True
|
|
|
|
def task():
|
|
return Willexecutors.get_info_task(url, tmp)
|
|
|
|
def on_success(result):
|
|
if not getattr(d, "_sync_active", False):
|
|
return
|
|
if result.get("status") == 200:
|
|
info_edit.setText(str(result.get("info", "")))
|
|
base_fee_spin.setValue(int(result.get("base_fee", 0)))
|
|
address_edit.setText(str(result.get("address", "")))
|
|
if edit_key and edit_key in self.willexecutors_list:
|
|
self.willexecutors_list[edit_key].update({
|
|
"status": 200,
|
|
"info": result.get("info", ""),
|
|
"base_fee": result.get("base_fee", 0),
|
|
"address": result.get("address", ""),
|
|
"last_update": result.get(
|
|
"last_update", datetime.now().timestamp()
|
|
),
|
|
})
|
|
self.will_executor_list_widget.update()
|
|
else:
|
|
QMessageBox.warning(
|
|
d,
|
|
_("Error"),
|
|
_("Could not reach server at {}").format(url),
|
|
)
|
|
url_edit.setFocus()
|
|
url_edit.selectAll()
|
|
sync_btn.setEnabled(True)
|
|
loading_label.clear()
|
|
d._sync_active = False
|
|
|
|
def on_error(exc_info):
|
|
if not getattr(d, "_sync_active", False):
|
|
return
|
|
QMessageBox.warning(
|
|
d,
|
|
_("Error"),
|
|
_("Error contacting server: {}").format(
|
|
str(exc_info[1])
|
|
),
|
|
)
|
|
url_edit.setFocus()
|
|
url_edit.selectAll()
|
|
sync_btn.setEnabled(True)
|
|
loading_label.clear()
|
|
d._sync_active = False
|
|
|
|
def on_done():
|
|
pass
|
|
|
|
sync_thread = TaskThread(d)
|
|
sync_thread.add(
|
|
task, on_success=on_success, on_done=on_done, on_error=on_error
|
|
)
|
|
|
|
sync_btn.clicked.connect(on_sync)
|
|
|
|
if not edit_key:
|
|
add_another_btn = QPushButton(_("Add another"))
|
|
self._add_another = False
|
|
|
|
def add_another():
|
|
self._add_another = True
|
|
d.accept()
|
|
|
|
add_another_btn.clicked.connect(add_another)
|
|
else:
|
|
self._add_another = False
|
|
|
|
row = 0
|
|
grid.addWidget(QLabel(_("URL")), row, 0)
|
|
grid.addWidget(url_edit, row, 1)
|
|
grid.addWidget(sync_btn, row, 2)
|
|
grid.addWidget(loading_label, row, 3)
|
|
grid.addWidget(
|
|
HelpButton(_("Will executor server URL (e.g. http://192.168.1.100:8080)")),
|
|
row,
|
|
4,
|
|
)
|
|
|
|
row += 1
|
|
grid.addWidget(QLabel(_("Info")), row, 0)
|
|
grid.addWidget(info_edit, row, 1)
|
|
grid.addWidget(
|
|
HelpButton(_("A short description or name for this executor")),
|
|
row,
|
|
2,
|
|
)
|
|
|
|
row += 1
|
|
grid.addWidget(QLabel(_("Base Fee (sats)")), row, 0)
|
|
grid.addWidget(base_fee_spin, row, 1)
|
|
grid.addWidget(
|
|
HelpButton(_("Base fee in satoshis")),
|
|
row,
|
|
2,
|
|
)
|
|
|
|
row += 1
|
|
grid.addWidget(QLabel(_("Address")), row, 0)
|
|
grid.addWidget(address_edit, row, 1)
|
|
grid.addWidget(
|
|
HelpButton(_("Bitcoin address for fee payments (optional)")),
|
|
row,
|
|
2,
|
|
)
|
|
|
|
vbox.addLayout(grid)
|
|
buttons = [CancelButton(d), OkButton(d)]
|
|
if not edit_key:
|
|
buttons.append(add_another_btn)
|
|
vbox.addLayout(Buttons(*buttons))
|
|
|
|
while d.exec():
|
|
url = url_edit.text().strip()
|
|
if not url:
|
|
self.show_error(_("URL is required"))
|
|
continue
|
|
if edit_key:
|
|
old_url = edit_key
|
|
ex = self.willexecutors_list[old_url]
|
|
ex.update({
|
|
"info": info_edit.text().strip() or "New Will Executor",
|
|
"base_fee": base_fee_spin.value(),
|
|
"address": address_edit.text().strip(),
|
|
})
|
|
if url != old_url:
|
|
self.willexecutors_list[url] = ex
|
|
del self.willexecutors_list[old_url]
|
|
else:
|
|
self.willexecutors_list[url] = {
|
|
"info": info_edit.text().strip() or "New Will Executor",
|
|
"base_fee": base_fee_spin.value(),
|
|
"address": address_edit.text().strip(),
|
|
"selected": False,
|
|
"status": "-1",
|
|
}
|
|
self.will_executor_list_widget.update()
|
|
Willexecutors.save(self.bal_window.bal_plugin, self.willexecutors_list)
|
|
if not self._add_another:
|
|
break
|
|
self._add_another = False
|
|
url_edit.clear()
|
|
info_edit.setText("New Will Executor")
|
|
base_fee_spin.setValue(0)
|
|
address_edit.clear()
|
|
|
|
def download_list(self, wes=None):
|
|
# Both this button and the wizard go through the same code path on
|
|
# BalWindow, which shows a "Downloading..." dialog (non-blocking GUI),
|
|
# tries the configured + fallback servers, logs the technical details
|
|
# and shows a simple message on failure.
|
|
def on_success(result):
|
|
self.willexecutors_list.update(result)
|
|
self.will_executor_list_widget.update()
|
|
Willexecutors.save(self.bal_window.bal_plugin, self.willexecutors_list)
|
|
self.update()
|
|
|
|
self.bal_window.download_list(self.bal_window.willexecutors, on_success)
|
|
|
|
def export_file(self, subset=None):
|
|
data = self.export_data(subset)
|
|
if subset and not data:
|
|
self.show_message(_("No will-executor matches the selected filter"))
|
|
return
|
|
export_meta_gui(
|
|
self.bal_window.window,
|
|
"willexecutors.json",
|
|
partial(self.export_json_file, subset=subset),
|
|
)
|
|
|
|
def export_data(self, subset=None):
|
|
data = self.willexecutors_list
|
|
if subset == "selected":
|
|
data = {
|
|
url: we
|
|
for url, we in data.items()
|
|
if Willexecutors.is_selected(we)
|
|
}
|
|
elif subset == "valid":
|
|
valid = self._validity()
|
|
data = {
|
|
url: we for url, we in data.items() if valid.get(url, False)
|
|
}
|
|
return data
|
|
|
|
def export_json_file(self, path, subset=None):
|
|
write_json_file(path, self.export_data(subset))
|
|
|
|
def _validity(self):
|
|
max_fee = self.bal_plugin.MAX_WILLEXECUTOR_FEE.get()
|
|
dust = self.bal_window.window.wallet.dust_threshold()
|
|
return {
|
|
url: Willexecutors.is_valid(we, max_fee=max_fee, dust=dust)
|
|
for url, we in self.willexecutors_list.items()
|
|
}
|
|
|
|
def _selected_willexecutors(self):
|
|
return {
|
|
url: we
|
|
for url, we in self.willexecutors_list.items()
|
|
if Willexecutors.is_selected(we)
|
|
}
|
|
|
|
def set_select_all(self, select, only_valid=False):
|
|
"""Apply a bulk selection across all will-executors.
|
|
|
|
``select=True`` selects all (or only the valid ones when
|
|
``only_valid=True``, deselecting the invalid ones); ``select=False``
|
|
deselects all (or only the invalid ones when ``only_valid=True``,
|
|
leaving the valid ones selected).
|
|
"""
|
|
valid = self._validity() if only_valid else None
|
|
_apply_select_all(self.willexecutors_list, select, valid)
|
|
self.save_willexecutors()
|
|
|
|
def ping_selected_willexecutors(self):
|
|
wes = self._selected_willexecutors()
|
|
if not wes:
|
|
self.show_message(_("No will-executor is selected"))
|
|
return
|
|
self.update_willexecutors(wes)
|
|
|
|
def import_file(self):
|
|
import_meta_gui(
|
|
self.bal_window.window,
|
|
_("willexecutors"),
|
|
self.import_json_file,
|
|
self.willexecutors_list.update,
|
|
)
|
|
|
|
def update_willexecutors(self, wes=None):
|
|
if not wes:
|
|
wes = self.willexecutors_list
|
|
self.bal_window.ping_willexecutors(wes, self.save_willexecutors)
|
|
|
|
def import_json_file(self, path):
|
|
data = read_json_file(path)
|
|
data = self._validate(data)
|
|
self.willexecutors_list.update(data)
|
|
self.will_executor_list_widget.update()
|
|
|
|
# TODO validate willexecutor json import file
|
|
def _validate(self, data):
|
|
return data
|
|
|
|
def save_willexecutors(self, wes=None):
|
|
if not wes:
|
|
wes = self.willexecutors_list
|
|
self.willexecutors_list.update(wes)
|
|
self.will_executor_list_widget.update()
|
|
Willexecutors.save(self.bal_window.bal_plugin, self.willexecutors_list)
|
|
|
|
|