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 +0 -0
- doppia/export.py +79 -0
- doppia/gui/__init__.py +568 -0
- doppia/gui/dialogs.py +1221 -0
- doppia/gui/models.py +274 -0
- doppia/importer.py +223 -0
- doppia/ledger.py +352 -0
- doppia/llm.py +255 -0
- doppia-0.1.0.dist-info/METADATA +151 -0
- doppia-0.1.0.dist-info/RECORD +14 -0
- doppia-0.1.0.dist-info/WHEEL +5 -0
- doppia-0.1.0.dist-info/entry_points.txt +2 -0
- doppia-0.1.0.dist-info/licenses/LICENSE +21 -0
- doppia-0.1.0.dist-info/top_level.txt +1 -0
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()
|