doppia 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
doppia/__init__.py ADDED
File without changes
doppia/export.py ADDED
@@ -0,0 +1,79 @@
1
+ """Render a report (title, notes, headers, rows) as CSV, Markdown, plain text, or HTML (for PDF).
2
+
3
+ Row values are str, int, or Decimal; Decimals are monetary amounts.
4
+ """
5
+
6
+ import csv
7
+ import html
8
+ import io
9
+ from decimal import Decimal
10
+
11
+ NEG_HTML = "#be1e1e"
12
+ POS_HTML = "#149628"
13
+
14
+
15
+ def _money(value):
16
+ """Signed $ amount: exported files may be printed or viewed without color."""
17
+ return f"{'-' if value < 0 else ''}${abs(value):,.2f}"
18
+
19
+
20
+ def to_csv(headers, rows):
21
+ """Plain numbers (e.g. -709.30) so spreadsheets can total them."""
22
+ out = io.StringIO()
23
+ w = csv.writer(out, lineterminator="\n")
24
+ w.writerow(headers)
25
+ for row in rows:
26
+ w.writerow([f"{v:.2f}" if isinstance(v, Decimal) else v for v in row])
27
+ return out.getvalue()
28
+
29
+
30
+ def _md_cell(text):
31
+ return str(text).replace("|", "\\|")
32
+
33
+
34
+ def to_markdown(title, notes, headers, rows):
35
+ money_cols = {c for row in rows for c, v in enumerate(row) if isinstance(v, Decimal)}
36
+ lines = [f"# {title}", ""]
37
+ lines += [f"- {n}" for n in notes]
38
+ lines += ["", "| " + " | ".join(_md_cell(h) for h in headers) + " |"]
39
+ lines.append("|" + "|".join("---:" if c in money_cols else "---" for c in range(len(headers))) + "|")
40
+ for row in rows:
41
+ lines.append("| " + " | ".join(_md_cell(_money(v) if isinstance(v, Decimal) else v) for v in row) + " |")
42
+ return "\n".join(lines) + "\n"
43
+
44
+
45
+ def to_text(title, notes, headers, rows):
46
+ """Fixed-width columns: amounts and counts right-aligned, other text left-aligned."""
47
+ cells = [[_money(v) if isinstance(v, Decimal) else str(v) for v in row] for row in rows]
48
+ right = {c for row in rows for c, v in enumerate(row) if isinstance(v, (Decimal, int))}
49
+ widths = [max([len(h)] + [len(row[c]) for row in cells]) for c, h in enumerate(headers)]
50
+
51
+ def line(values):
52
+ return " ".join(v.rjust(w) if c in right else v.ljust(w) for c, (v, w) in enumerate(zip(values, widths))).rstrip()
53
+
54
+ lines = [title, "=" * len(title), ""] + notes + ["", line(headers), line(["-" * w for w in widths])]
55
+ lines += [line(row) for row in cells]
56
+ return "\n".join(lines) + "\n"
57
+
58
+
59
+ def to_html(title, notes, headers, rows, total_prefix=None, font_pt=9):
60
+ """Simple table document; QTextDocument renders it to PDF. Total rows are bold."""
61
+
62
+ def cell(v):
63
+ if isinstance(v, Decimal):
64
+ color = NEG_HTML if v < 0 else POS_HTML if v > 0 else None
65
+ style = f' style="color:{color}"' if color else ""
66
+ return f'<td align="right" nowrap{style}>{_money(v)}</td>'
67
+ align = ' align="right"' if isinstance(v, int) else ""
68
+ return f"<td nowrap{align}>{html.escape(str(v))}</td>"
69
+
70
+ parts = [f'<body style="font-size:{font_pt}pt">', f"<h2>{html.escape(title)}</h2>"]
71
+ parts += [f'<p style="margin:0">{html.escape(n)}</p>' for n in notes]
72
+ parts.append('<br><table border="1" cellspacing="0" cellpadding="3" style="border-collapse:collapse">')
73
+ parts.append("<tr>" + "".join(f"<th nowrap>{html.escape(h)}</th>" for h in headers) + "</tr>")
74
+ for row in rows:
75
+ bold = total_prefix and isinstance(row[0], str) and row[0].startswith(total_prefix)
76
+ tr = "".join(cell(v) for v in row)
77
+ parts.append(f'<tr style="font-weight:bold">{tr}</tr>' if bold else f"<tr>{tr}</tr>")
78
+ parts.append("</table></body>")
79
+ return "\n".join(parts)
doppia/gui/__init__.py ADDED
@@ -0,0 +1,568 @@
1
+ """Main window and entry point."""
2
+
3
+ import datetime as dt
4
+ import os
5
+ import sys
6
+ from decimal import Decimal
7
+
8
+ from PySide6.QtCore import QSettings, Qt, QTimer
9
+ from PySide6.QtGui import QAction, QFont, QFontMetrics, QKeySequence
10
+ from PySide6.QtWidgets import (
11
+ QAbstractItemView,
12
+ QApplication,
13
+ QCheckBox,
14
+ QFileDialog,
15
+ QFontDialog,
16
+ QHBoxLayout,
17
+ QHeaderView,
18
+ QInputDialog,
19
+ QLabel,
20
+ QLineEdit,
21
+ QMainWindow,
22
+ QMenu,
23
+ QMessageBox,
24
+ QPushButton,
25
+ QSplitter,
26
+ QTableView,
27
+ QTreeWidget,
28
+ QTreeWidgetItem,
29
+ QVBoxLayout,
30
+ QWidget,
31
+ )
32
+
33
+ from ..ledger import Ledger
34
+ from .dialogs import ClipboardImportDialog, ImportDialog, ReconcileDialog, ReplaceDialog, ReportDialog, TransactionDialog, parse_amount
35
+ from .models import PaddedDelegate, RegisterFilter, RegisterModel, keep_colors_when_selected, money, money_color
36
+
37
+ ALL_ACCOUNTS = "All accounts"
38
+ TITLE_MAX = 30 # longest account list shown over the register, including the "..."
39
+
40
+
41
+ class MainWindow(QMainWindow):
42
+ def __init__(self, path=None):
43
+ super().__init__()
44
+ self.settings = QSettings("doppia", "doppia")
45
+ self.ledger = None
46
+ self.balances = {} # {account: (balance as of today, cleared balance)}
47
+ self.accounts = [] # selected account names; empty means all accounts
48
+
49
+ # Account list: name, balance as of today, cleared balance.
50
+ self.tree = QTreeWidget()
51
+ keep_colors_when_selected(self.tree)
52
+ self.tree.setHeaderLabels(["Account", "Balance", "Cleared"])
53
+ for col in (1, 2): # right-aligned like the amounts below them
54
+ self.tree.headerItem().setTextAlignment(col, Qt.AlignRight | Qt.AlignVCenter)
55
+ self.tree.setRootIsDecorated(False)
56
+ # Ctrl/Shift-click selects several accounts; the register shows their combined transactions.
57
+ self.tree.setSelectionMode(QAbstractItemView.ExtendedSelection)
58
+ self.tree.itemSelectionChanged.connect(self.account_selected)
59
+ self.tree.header().setSectionResizeMode(0, QHeaderView.Stretch)
60
+ self.tree.header().setStretchLastSection(False)
61
+ for col in (1, 2):
62
+ self.tree.header().setSectionResizeMode(col, QHeaderView.ResizeToContents)
63
+ # Widen the right-aligned Cleared column by 2 characters to separate it from Balance.
64
+ self.tree.setItemDelegateForColumn(2, PaddedDelegate(2, self.tree))
65
+
66
+ # Register.
67
+ self.model = RegisterModel(None)
68
+ self.proxy = RegisterFilter()
69
+ self.proxy.setSourceModel(self.model)
70
+ self.view = QTableView()
71
+ keep_colors_when_selected(self.view)
72
+ self.view.setModel(self.proxy)
73
+ self.view.setSelectionBehavior(QAbstractItemView.SelectRows)
74
+ self.view.setEditTriggers(QAbstractItemView.NoEditTriggers)
75
+ self.view.verticalHeader().hide()
76
+ self.view.clicked.connect(self.on_click)
77
+ self.view.doubleClicked.connect(self.on_double_click)
78
+ self.view.setContextMenuPolicy(Qt.CustomContextMenu)
79
+ self.view.customContextMenuRequested.connect(self.context_menu)
80
+ hdr = self.view.horizontalHeader()
81
+ hdr.setSectionResizeMode(QHeaderView.Interactive)
82
+ # Header clicks sort; clicking the same header again reverses the order.
83
+ self.view.setSortingEnabled(True)
84
+ self.view.sortByColumn(RegisterModel.C_DATE, Qt.DescendingOrder)
85
+
86
+ self.search = QLineEdit()
87
+ self.search.setPlaceholderText("Filter (regular expression)…")
88
+ self.filter_help = (
89
+ "Case-insensitive Python regular expression, matched in the description, category,\n"
90
+ "memo, ref, date, or amount. Escape special characters, e.g. \\$3,000 or 12\\.50"
91
+ )
92
+ self.search.setToolTip(self.filter_help)
93
+ self.search.setClearButtonEnabled(True)
94
+ self.search.textChanged.connect(self.filter_changed)
95
+ self.uncleared = QCheckBox("Uncleared only")
96
+ self.uncleared.toggled.connect(self.proxy.set_uncleared_only)
97
+ self.title = QLabel()
98
+ btns = []
99
+ for text, slot in (("New", self.new_txn), ("Edit", self.edit_txn), ("Delete", self.delete_txn)):
100
+ b = QPushButton(text)
101
+ b.clicked.connect(slot)
102
+ btns.append(b)
103
+
104
+ bar = QHBoxLayout()
105
+ bar.addWidget(self.title)
106
+ bar.addStretch()
107
+ bar.addWidget(self.search, 1)
108
+ bar.addWidget(self.uncleared)
109
+ for b in btns:
110
+ bar.addWidget(b)
111
+ right = QWidget()
112
+ rl = QVBoxLayout(right)
113
+ rl.setContentsMargins(0, 0, 0, 0)
114
+ rl.addLayout(bar)
115
+ rl.addWidget(self.view)
116
+
117
+ self.split = QSplitter()
118
+ self.split.addWidget(self.tree)
119
+ self.split.addWidget(right)
120
+ self.split.setSizes([330, 1100])
121
+ self.setCentralWidget(self.split)
122
+ self.build_menus()
123
+ self.resize(1450, 850)
124
+
125
+ self.default_font = QFont(QApplication.font())
126
+ saved, desc = QFont(), self.settings.value("font")
127
+ self.apply_font(saved if desc and saved.fromString(desc) else self.default_font)
128
+
129
+ path = path or self.settings.value("last_file")
130
+ if path and os.path.exists(path):
131
+ self.open_path(path)
132
+ self.update_title()
133
+
134
+ # ------------------------------------------------------------------ menus
135
+
136
+ def build_menus(self):
137
+ def act(menu, text, slot, shortcut=None):
138
+ a = QAction(text, self)
139
+ a.triggered.connect(slot)
140
+ if shortcut:
141
+ a.setShortcut(QKeySequence(shortcut))
142
+ menu.addAction(a)
143
+ return a
144
+
145
+ m = self.menuBar().addMenu("&File")
146
+ act(m, "&Open…", self.open_file, QKeySequence.Open)
147
+ act(m, "&Save", self.save, QKeySequence.Save)
148
+ act(m, "Save &As…", self.save_as, QKeySequence.SaveAs)
149
+ m.addSeparator()
150
+ act(m, "&Import bank CSV…", self.import_csv, "Ctrl+I")
151
+ act(m, "Import from &clipboard (local LLM)…", self.import_clipboard, "Ctrl+Shift+V")
152
+ m.addSeparator()
153
+ act(m, "&Quit", self.close, QKeySequence.Quit)
154
+
155
+ m = self.menuBar().addMenu("&Transaction")
156
+ act(m, "&New", self.new_txn, QKeySequence.New)
157
+ act(m, "&Edit", self.edit_txn, "Ctrl+E")
158
+ act(m, "&Delete", self.delete_txn, "Ctrl+Del")
159
+ act(m, "Toggle &cleared", self.toggle_cleared, "Ctrl+K")
160
+ m.addSeparator()
161
+ act(m, "&Search and replace…", self.search_replace, "Ctrl+H")
162
+
163
+ m = self.menuBar().addMenu("&Account")
164
+ act(m, "&New account…", self.new_account)
165
+ act(m, "&Rename account…", self.rename_account)
166
+ act(m, "Re&concile…", self.reconcile, "Ctrl+R")
167
+
168
+ m = self.menuBar().addMenu("&Reports")
169
+ act(m, "&Reports…", self.reports, "Ctrl+P")
170
+ act(m, "Rename &category…", self.rename_category)
171
+
172
+ m = self.menuBar().addMenu("&View")
173
+ act(m, "&Font…", self.choose_font)
174
+ act(m, "&Reset font", self.reset_font)
175
+
176
+ # ------------------------------------------------------------------ font
177
+
178
+ # Register column widths in units of the font's average character width.
179
+ REGISTER_COL_CHARS = (13, 18, 8, 30, 23, 33, 13, 5, 14)
180
+
181
+ def choose_font(self):
182
+ ok, font = QFontDialog.getFont(QApplication.font(), self, "Font")
183
+ if ok:
184
+ self.apply_font(font)
185
+ self.settings.setValue("font", font.toString())
186
+
187
+ def reset_font(self):
188
+ self.apply_font(self.default_font)
189
+ self.settings.remove("font")
190
+
191
+ def apply_font(self, font):
192
+ """Set the font for the whole app (including dialogs opened later) and resize
193
+ everything that was sized in pixels."""
194
+ QApplication.setFont(font)
195
+ fm = QFontMetrics(font)
196
+ self.view.verticalHeader().setDefaultSectionSize(fm.height() + 6)
197
+ for col, chars in enumerate(self.REGISTER_COL_CHARS):
198
+ self.view.setColumnWidth(col, chars * fm.averageCharWidth())
199
+ title_font = QFont(font)
200
+ title_font.setBold(True)
201
+ if font.pointSizeF() > 0:
202
+ title_font.setPointSizeF(font.pointSizeF() * 1.25)
203
+ else: # font sized in pixels
204
+ title_font.setPixelSize(round(font.pixelSize() * 1.25))
205
+ self.title.setFont(title_font)
206
+ # Rebuild the account list: its bold/italic rows copied the old font when created.
207
+ self.refresh_accounts(reset_register=False)
208
+ # The new font reaches widgets via events; measure after they've been processed.
209
+ QTimer.singleShot(0, self.fit_account_list)
210
+
211
+ def fit_account_list(self):
212
+ """Widen the account pane so names and balances fit (only on font change or file open,
213
+ so a manually dragged splitter isn't undone by every edit)."""
214
+ t = self.tree
215
+ need = (
216
+ sum(t.sizeHintForColumn(c) for c in range(t.columnCount()))
217
+ + t.verticalScrollBar().sizeHint().width()
218
+ + 2 * t.frameWidth()
219
+ + 12
220
+ )
221
+ total = sum(self.split.sizes())
222
+ need = min(need, total // 2)
223
+ self.split.setSizes([need, total - need])
224
+
225
+ # ------------------------------------------------------------------ file
226
+
227
+ def open_path(self, path):
228
+ try:
229
+ ledger = Ledger.load(path)
230
+ except (OSError, ValueError) as e:
231
+ QMessageBox.critical(self, "Open", str(e))
232
+ return
233
+ self.ledger = ledger
234
+ self.settings.setValue("last_file", path)
235
+ self.model.set_ledger(ledger)
236
+ self.account = None
237
+ self.refresh_accounts()
238
+ self.fit_account_list()
239
+
240
+ def maybe_save(self):
241
+ if self.ledger is None or not self.ledger.dirty:
242
+ return True
243
+ r = QMessageBox.question(
244
+ self, "Unsaved changes", "Save changes?", QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel
245
+ )
246
+ if r == QMessageBox.Save:
247
+ return self.save()
248
+ return r == QMessageBox.Discard
249
+
250
+ def open_file(self):
251
+ if not self.maybe_save():
252
+ return
253
+ path, _ = QFileDialog.getOpenFileName(self, "Open ledger", "", "CSV files (*.csv);;All files (*)")
254
+ if path:
255
+ self.open_path(path)
256
+ self.update_title()
257
+
258
+ def save(self):
259
+ if self.ledger is None:
260
+ return False
261
+ if not self.ledger.path:
262
+ return self.save_as()
263
+ try:
264
+ self.ledger.save()
265
+ except OSError as e:
266
+ QMessageBox.critical(self, "Save", str(e))
267
+ return False
268
+ self.update_title()
269
+ return True
270
+
271
+ def save_as(self):
272
+ if self.ledger is None:
273
+ return False
274
+ path, _ = QFileDialog.getSaveFileName(self, "Save ledger as", self.ledger.path or "", "CSV files (*.csv)")
275
+ if not path:
276
+ return False
277
+ self.ledger.path = path
278
+ self.settings.setValue("last_file", path)
279
+ return self.save()
280
+
281
+ def closeEvent(self, ev):
282
+ if self.maybe_save():
283
+ ev.accept()
284
+ else:
285
+ ev.ignore()
286
+
287
+ # ------------------------------------------------------------------ view refresh
288
+
289
+ @property
290
+ def account(self):
291
+ """The selected account when exactly one is selected, else None."""
292
+ return self.accounts[0] if len(self.accounts) == 1 else None
293
+
294
+ @account.setter
295
+ def account(self, name):
296
+ self.accounts = [] if name is None else [name]
297
+
298
+ def show_accounts(self):
299
+ """Load the selected accounts into the register."""
300
+ self.update_totals()
301
+ self.model.set_account(self.accounts or None)
302
+ self.view.setColumnHidden(RegisterModel.C_ACCOUNT, self.account is not None)
303
+ self.update_title()
304
+
305
+ def update_title(self):
306
+ name = os.path.basename(self.ledger.path) if self.ledger and self.ledger.path else "(no file)"
307
+ dirty = "*" if self.ledger and self.ledger.dirty else ""
308
+ self.setWindowTitle(f"{dirty}{name} — doppia")
309
+ names = ", ".join(self.accounts) or ALL_ACCOUNTS
310
+ short = names if len(names) <= TITLE_MAX else names[: TITLE_MAX - 3] + "..."
311
+ self.title.setText(short)
312
+ self.title.setToolTip(names if short != names else "")
313
+
314
+ def filter_changed(self, text):
315
+ error = self.proxy.set_text(text)
316
+ # An invalid regex is ignored (all rows shown); tint the field and say why.
317
+ self.search.setStyleSheet("background: rgb(255, 225, 225);" if error else "")
318
+ self.search.setToolTip(f"Invalid regular expression: {error}" if error else self.filter_help)
319
+
320
+ def refresh_accounts(self, reset_register=True):
321
+ """Rebuild the account list, keeping the current selection."""
322
+ self.tree.blockSignals(True)
323
+ self.tree.clear()
324
+ balances = self.ledger.account_balances(as_of=dt.date.today()) if self.ledger else {}
325
+ self.balances = balances
326
+ sel = []
327
+ for name in [ALL_ACCOUNTS] + sorted(balances, key=str.lower):
328
+ if name == ALL_ACCOUNTS:
329
+ item = QTreeWidgetItem([name, "", ""])
330
+ f = item.font(0)
331
+ f.setBold(True)
332
+ item.setFont(0, f)
333
+ else:
334
+ bal, clr = balances[name]
335
+ item = QTreeWidgetItem([name, money(bal), money(clr)])
336
+ for c, v in ((1, bal), (2, clr)):
337
+ item.setTextAlignment(c, Qt.AlignRight | Qt.AlignVCenter)
338
+ if v:
339
+ item.setForeground(c, money_color(v))
340
+ self.tree.addTopLevelItem(item)
341
+ if name in (self.accounts or [ALL_ACCOUNTS]):
342
+ sel.append(item)
343
+ # Totals of the selected accounts; filled in by update_totals().
344
+ self.total_item = QTreeWidgetItem(["Total", "", ""])
345
+ self.total_item.setFlags(Qt.ItemIsEnabled)
346
+ for c in range(3):
347
+ f = self.total_item.font(c)
348
+ f.setBold(True)
349
+ self.total_item.setFont(c, f)
350
+ if c:
351
+ self.total_item.setTextAlignment(c, Qt.AlignRight | Qt.AlignVCenter)
352
+ self.tree.addTopLevelItem(self.total_item)
353
+ if sel:
354
+ self.tree.setCurrentItem(sel[0])
355
+ for item in sel:
356
+ item.setSelected(True)
357
+ self.tree.blockSignals(False)
358
+ if reset_register:
359
+ self.show_accounts()
360
+ else:
361
+ self.update_totals()
362
+ self.update_title()
363
+
364
+ def update_totals(self):
365
+ """Sum the balance and cleared columns over the selected accounts (all if none)."""
366
+ zero = Decimal("0")
367
+ names = self.accounts or self.balances
368
+ bal = sum((self.balances[n][0] for n in names if n in self.balances), zero)
369
+ clr = sum((self.balances[n][1] for n in names if n in self.balances), zero)
370
+ for c, v in ((1, bal), (2, clr)):
371
+ self.total_item.setText(c, money(v))
372
+ self.total_item.setForeground(c, money_color(v) or self.tree.palette().text())
373
+
374
+ def changed(self, select_txn=None):
375
+ """Call after any ledger mutation."""
376
+ self.refresh_accounts()
377
+ if select_txn is not None:
378
+ self.select_txn(select_txn)
379
+
380
+ def select_txn(self, txn):
381
+ for r, t in enumerate(self.model.rows):
382
+ if t is txn:
383
+ idx = self.proxy.mapFromSource(self.model.index(r, 0))
384
+ if idx.isValid():
385
+ self.view.selectRow(idx.row())
386
+ self.view.scrollTo(idx)
387
+ return
388
+
389
+ def account_selected(self):
390
+ if self.ledger is None:
391
+ return
392
+ names = {item.text(0) for item in self.tree.selectedItems()}
393
+ if not names:
394
+ return
395
+ # "All accounts" anywhere in the selection means all of them.
396
+ if ALL_ACCOUNTS in names:
397
+ self.accounts = []
398
+ else:
399
+ self.accounts = [a for a in self.ledger.accounts() if a in names]
400
+ self.show_accounts()
401
+
402
+ def current_txn(self):
403
+ idx = self.view.currentIndex()
404
+ if not idx.isValid():
405
+ return None
406
+ return self.model.txn(self.proxy.mapToSource(idx).row())
407
+
408
+ # ------------------------------------------------------------------ transactions
409
+
410
+ def need_ledger(self):
411
+ if self.ledger is None:
412
+ QMessageBox.information(self, "doppia", "Open a ledger CSV first (File → Open).")
413
+ return False
414
+ return True
415
+
416
+ def new_txn(self):
417
+ if not self.need_ledger():
418
+ return
419
+ # With several accounts selected, default the new transaction to the first of them.
420
+ dlg = TransactionDialog(self.ledger, account=self.accounts[0] if self.accounts else None, parent=self)
421
+ if dlg.exec():
422
+ t = dlg.result_txn()
423
+ self.ledger.add(t)
424
+ self.changed(t)
425
+
426
+ def edit_txn(self):
427
+ t = self.current_txn()
428
+ if t is None:
429
+ return
430
+ dlg = TransactionDialog(self.ledger, txn=t, parent=self)
431
+ if dlg.exec():
432
+ self.ledger.update(t, dlg.result_txn())
433
+ self.changed(t)
434
+
435
+ def on_click(self, idx):
436
+ if idx.column() == RegisterModel.C_CLR:
437
+ self.toggle_cleared()
438
+
439
+ def on_double_click(self, idx):
440
+ if idx.column() != RegisterModel.C_CLR:
441
+ self.edit_txn()
442
+
443
+ def context_menu(self, pos):
444
+ idx = self.view.indexAt(pos)
445
+ if not idx.isValid():
446
+ return
447
+ self.view.setCurrentIndex(idx)
448
+ menu = QMenu(self)
449
+ menu.addAction("Delete transaction", self.delete_txn)
450
+ menu.exec(self.view.viewport().mapToGlobal(pos))
451
+
452
+ def delete_txn(self):
453
+ t = self.current_txn()
454
+ if t is None:
455
+ return
456
+ partner = self.ledger.find_transfer_partner(t)
457
+ msg = f"Delete {t.date_str} {t.description} {money(t.amount, signed=True)}?"
458
+ if partner is not None:
459
+ msg += f"\n\nThe matching transfer entry in {partner.account} will also be deleted."
460
+ if QMessageBox.question(self, "Delete", msg) == QMessageBox.Yes:
461
+ row = self.view.currentIndex().row()
462
+ self.ledger.delete(t)
463
+ self.changed()
464
+ self.view.selectRow(min(row, self.proxy.rowCount() - 1))
465
+
466
+ def toggle_cleared(self):
467
+ t = self.current_txn()
468
+ if t is None:
469
+ return
470
+ self.ledger.set_cleared(t, not t.is_cleared)
471
+ # Clearing doesn't change row order or running balances, so update in place
472
+ # rather than resetting the register (which would lose the scroll position).
473
+ self.model.txn_changed(t)
474
+ self.refresh_accounts(reset_register=False)
475
+
476
+ def search_replace(self):
477
+ if not self.need_ledger():
478
+ return
479
+ dlg = ReplaceDialog(self.ledger, self.accounts, self)
480
+ dlg.exec()
481
+ if dlg.replaced:
482
+ self.changed()
483
+
484
+ # ------------------------------------------------------------------ accounts
485
+
486
+ def new_account(self):
487
+ if not self.need_ledger():
488
+ return
489
+ name, ok = QInputDialog.getText(self, "New account", "Account name:")
490
+ if not ok or not name.strip():
491
+ return
492
+ bal, ok = QInputDialog.getText(self, "New account", "Opening balance (negative for amount owed):", text="0.00")
493
+ if not ok:
494
+ return
495
+ try:
496
+ self.ledger.create_account(name.strip(), parse_amount(bal))
497
+ except ValueError as e:
498
+ QMessageBox.warning(self, "New account", str(e))
499
+ return
500
+ self.account = name.strip()
501
+ self.changed()
502
+
503
+ def rename_account(self):
504
+ if not self.need_ledger() or self.account is None:
505
+ return
506
+ new, ok = QInputDialog.getText(self, "Rename account", f"New name for {self.account}:", text=self.account)
507
+ if not ok or not new.strip() or new.strip() == self.account:
508
+ return
509
+ try:
510
+ self.ledger.rename_account(self.account, new.strip())
511
+ except ValueError as e:
512
+ QMessageBox.warning(self, "Rename account", str(e))
513
+ return
514
+ self.account = new.strip()
515
+ self.changed()
516
+
517
+ def rename_category(self):
518
+ if not self.need_ledger():
519
+ return
520
+ cats = self.ledger.categories()
521
+ old, ok = QInputDialog.getItem(self, "Rename category", "Category:", cats, 0, False)
522
+ if not ok:
523
+ return
524
+ new, ok = QInputDialog.getText(self, "Rename category", f"Rename {old!r} to (existing name merges):", text=old)
525
+ if ok and new.strip() and new.strip() != old:
526
+ self.ledger.rename_category(old, new.strip())
527
+ self.changed()
528
+
529
+ def reconcile(self):
530
+ if not self.need_ledger():
531
+ return
532
+ if self.account is None:
533
+ QMessageBox.information(self, "Reconcile", "Select a single account first.")
534
+ return
535
+ if ReconcileDialog(self.ledger, self.account, self).exec():
536
+ self.changed()
537
+
538
+ def import_csv(self):
539
+ if not self.need_ledger():
540
+ return
541
+ dlg = ImportDialog(self.ledger, self.account, self, self.settings)
542
+ if dlg.exec():
543
+ self.statusBar().showMessage(f"Imported {dlg.imported} transactions", 5000)
544
+ self.changed()
545
+
546
+ def import_clipboard(self):
547
+ if not self.need_ledger():
548
+ return
549
+ dlg = ClipboardImportDialog(self.ledger, self.account, self.settings, self)
550
+ if dlg.exec() and dlg.imported:
551
+ self.statusBar().showMessage(f"Imported {dlg.imported} transactions", 5000)
552
+ self.changed()
553
+
554
+ def reports(self):
555
+ if self.need_ledger():
556
+ ReportDialog(self.ledger, self).exec()
557
+
558
+
559
+ def main():
560
+ app = QApplication(sys.argv)
561
+ app.setApplicationName("doppia")
562
+ win = MainWindow(sys.argv[1] if len(sys.argv) > 1 else None)
563
+ win.show()
564
+ sys.exit(app.exec())
565
+
566
+
567
+ if __name__ == "__main__":
568
+ main()