daftarche 0.2.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.
- daftarche/__init__.py +8 -0
- daftarche/__main__.py +7 -0
- daftarche/app_info.py +35 -0
- daftarche/backup.py +41 -0
- daftarche/chart_utils.py +271 -0
- daftarche/data/icons/app_icon.png +0 -0
- daftarche/data/icons/splash.png +0 -0
- daftarche/db.py +1824 -0
- daftarche/main.py +135 -0
- daftarche/paths.py +46 -0
- daftarche/style.py +593 -0
- daftarche/tabs/__init__.py +0 -0
- daftarche/tabs/accounts.py +971 -0
- daftarche/tabs/charts.py +986 -0
- daftarche/tabs/dashboard.py +709 -0
- daftarche/tabs/import_tab.py +1069 -0
- daftarche/tabs/manage.py +1065 -0
- daftarche/tabs/transactions.py +1636 -0
- daftarche/widgets.py +1131 -0
- daftarche/window.py +349 -0
- daftarche-0.2.0.dist-info/METADATA +100 -0
- daftarche-0.2.0.dist-info/RECORD +26 -0
- daftarche-0.2.0.dist-info/WHEEL +5 -0
- daftarche-0.2.0.dist-info/entry_points.txt +2 -0
- daftarche-0.2.0.dist-info/licenses/LICENSE +21 -0
- daftarche-0.2.0.dist-info/top_level.txt +1 -0
daftarche/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Daftarche: personal bookkeeping desktop app (PySide6 + SQLite)."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("daftarche")
|
|
7
|
+
except PackageNotFoundError: # running from a checkout without `pip install -e .`
|
|
8
|
+
__version__ = "0.0.0"
|
daftarche/__main__.py
ADDED
daftarche/app_info.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Daftarche as an app: its name, and the description used by the launcher and the update check."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from sciwidgets.app import AppInfo
|
|
6
|
+
|
|
7
|
+
from daftarche import __version__
|
|
8
|
+
|
|
9
|
+
APP_NAME = "Daftarche"
|
|
10
|
+
"""Name shown to the user (window, splash, launcher)."""
|
|
11
|
+
|
|
12
|
+
APP_VERSION = __version__
|
|
13
|
+
|
|
14
|
+
ICONS = Path(__file__).resolve().parent / "data" / "icons"
|
|
15
|
+
APP_IMAGE = ICONS / "splash.png"
|
|
16
|
+
"""Background of the splash screen (drawn by tools/make_icon.py)."""
|
|
17
|
+
|
|
18
|
+
DIST_NAME = "daftarche"
|
|
19
|
+
"""Name of the package (PyPI, once public)."""
|
|
20
|
+
|
|
21
|
+
APP = AppInfo(
|
|
22
|
+
name=APP_NAME,
|
|
23
|
+
version=APP_VERSION,
|
|
24
|
+
dist_name=DIST_NAME,
|
|
25
|
+
module="daftarche",
|
|
26
|
+
command="daftarche",
|
|
27
|
+
github_repo="vahid-anari/daftarche",
|
|
28
|
+
icon=ICONS / "app_icon.png",
|
|
29
|
+
bundle_id="io.github.vahid-anari.daftarche",
|
|
30
|
+
linux_id="daftarche",
|
|
31
|
+
marker="Daftarche",
|
|
32
|
+
comment="Personal bookkeeping: bank transactions from QFX and Excel statements",
|
|
33
|
+
categories="Office;Finance;",
|
|
34
|
+
)
|
|
35
|
+
"""Daftarche for ``sciwidgets.app`` (launcher, update check)."""
|
daftarche/backup.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Automatic backups of the database, made at every start.
|
|
2
|
+
|
|
3
|
+
backups/daftarche-last.db overwritten at every start (undo the last session)
|
|
4
|
+
backups/daftarche-2026-10.db first start of each month; never overwritten
|
|
5
|
+
|
|
6
|
+
Only the newest KEEP_MONTHS monthly snapshots are kept. Copies are made with
|
|
7
|
+
SQLite's backup API, so they are consistent even while the database is open.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sqlite3
|
|
11
|
+
from datetime import date
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from daftarche.paths import BACKUP_DIR
|
|
15
|
+
|
|
16
|
+
KEEP_MONTHS = 12
|
|
17
|
+
LAST_BACKUP = BACKUP_DIR / "daftarche-last.db"
|
|
18
|
+
MONTHLY_GLOB = "daftarche-????-??.db"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def copy_db(conn: sqlite3.Connection, destination: Path | str) -> None:
|
|
22
|
+
"""Write a consistent copy of the open database to ``destination``."""
|
|
23
|
+
destination = Path(destination)
|
|
24
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
with sqlite3.connect(destination) as target:
|
|
26
|
+
conn.backup(target)
|
|
27
|
+
target.close()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def startup_backup(conn: sqlite3.Connection, today: date | None = None) -> list[Path]:
|
|
31
|
+
"""The backups made at start-up; returns the files written."""
|
|
32
|
+
today = today or date.today()
|
|
33
|
+
written = [LAST_BACKUP]
|
|
34
|
+
copy_db(conn, LAST_BACKUP)
|
|
35
|
+
monthly = BACKUP_DIR / f"daftarche-{today:%Y-%m}.db"
|
|
36
|
+
if not monthly.exists():
|
|
37
|
+
copy_db(conn, monthly)
|
|
38
|
+
written.append(monthly)
|
|
39
|
+
for old in sorted(BACKUP_DIR.glob(MONTHLY_GLOB))[:-KEEP_MONTHS]:
|
|
40
|
+
old.unlink()
|
|
41
|
+
return written
|
daftarche/chart_utils.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""
|
|
2
|
+
chart_utils.py — Shared chart styling helpers.
|
|
3
|
+
|
|
4
|
+
Key fixes:
|
|
5
|
+
- Qt QValueAxis uses C printf format: "$%.0f"
|
|
6
|
+
- Thousands separator: chart.setLocale(en_US) + chart.setLocalizeNumbers(True)
|
|
7
|
+
- X-axis label rotation: setLabelsAngle(-45) + setTruncateLabels(False)
|
|
8
|
+
"""
|
|
9
|
+
from PySide6.QtCharts import QValueAxis, QBarCategoryAxis, QChart, QLegend, QChartView
|
|
10
|
+
from PySide6.QtGui import QColor, QBrush, QFont, QPen, QPainter
|
|
11
|
+
from PySide6.QtCore import Qt, QLocale, QMargins, QEvent, Signal, QPointF, QRectF
|
|
12
|
+
from PySide6.QtWidgets import QLabel, QWidget
|
|
13
|
+
|
|
14
|
+
from daftarche.style import get_settings
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HoverChartView(QChartView):
|
|
18
|
+
mouseMoved = Signal(object)
|
|
19
|
+
mouseLeft = Signal()
|
|
20
|
+
|
|
21
|
+
def __init__(self, parent=None):
|
|
22
|
+
super().__init__(parent)
|
|
23
|
+
|
|
24
|
+
self.setRenderHint(QPainter.Antialiasing)
|
|
25
|
+
self.setMouseTracking(True)
|
|
26
|
+
self.viewport().setMouseTracking(True)
|
|
27
|
+
self.viewport().installEventFilter(self)
|
|
28
|
+
|
|
29
|
+
self._hover_label = QLabel("", self.viewport())
|
|
30
|
+
self._hover_label.setAlignment(Qt.AlignCenter)
|
|
31
|
+
self._hover_label.setWordWrap(True)
|
|
32
|
+
self._hover_label.setAttribute(Qt.WA_TransparentForMouseEvents, True)
|
|
33
|
+
self._hover_label.hide()
|
|
34
|
+
|
|
35
|
+
self._last_mouse_pos = None
|
|
36
|
+
self._style_hover_label()
|
|
37
|
+
|
|
38
|
+
def eventFilter(self, obj, event):
|
|
39
|
+
if obj is self.viewport():
|
|
40
|
+
if event.type() == QEvent.MouseMove:
|
|
41
|
+
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
|
42
|
+
self._last_mouse_pos = pos
|
|
43
|
+
self.mouseMoved.emit(pos)
|
|
44
|
+
|
|
45
|
+
elif event.type() == QEvent.Leave:
|
|
46
|
+
self._last_mouse_pos = None
|
|
47
|
+
self.hide_hover()
|
|
48
|
+
self.mouseLeft.emit()
|
|
49
|
+
|
|
50
|
+
return super().eventFilter(obj, event)
|
|
51
|
+
|
|
52
|
+
def last_mouse_pos(self):
|
|
53
|
+
return self._last_mouse_pos
|
|
54
|
+
|
|
55
|
+
def refresh_hover_style(self):
|
|
56
|
+
self._style_hover_label()
|
|
57
|
+
|
|
58
|
+
def _style_hover_label(self, border_color=None):
|
|
59
|
+
s = get_settings()
|
|
60
|
+
border = border_color or "rgba(255,255,255,40)"
|
|
61
|
+
|
|
62
|
+
self._hover_label.setStyleSheet(
|
|
63
|
+
f"color:{s.get('text')}; background: rgba(20,20,30,200);"
|
|
64
|
+
f" border: 1px solid {border};"
|
|
65
|
+
f" border-radius: 6px; padding: 4px 10px;"
|
|
66
|
+
f" font-size: {s.get('font_size')}pt;"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def show_hover(self, text, viewport_pos=None, border_color=None):
|
|
70
|
+
if not text:
|
|
71
|
+
self.hide_hover()
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
self._style_hover_label(border_color)
|
|
75
|
+
|
|
76
|
+
self._hover_label.setText(text)
|
|
77
|
+
self._hover_label.adjustSize()
|
|
78
|
+
self._hover_label.raise_()
|
|
79
|
+
|
|
80
|
+
if viewport_pos is None:
|
|
81
|
+
viewport_pos = self._last_mouse_pos
|
|
82
|
+
|
|
83
|
+
if viewport_pos is not None:
|
|
84
|
+
vp = self.viewport()
|
|
85
|
+
w = self._hover_label.width()
|
|
86
|
+
h = self._hover_label.height()
|
|
87
|
+
|
|
88
|
+
x = max(4, min(viewport_pos.x() + 12, vp.width() - w - 4))
|
|
89
|
+
y = max(4, min(viewport_pos.y() - h - 8, vp.height() - h - 4))
|
|
90
|
+
|
|
91
|
+
self._hover_label.move(x, y)
|
|
92
|
+
|
|
93
|
+
self._hover_label.show()
|
|
94
|
+
|
|
95
|
+
def hide_hover(self):
|
|
96
|
+
self._hover_label.hide()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# _StackedOverlay — paints per-month sorted stacked bars over QChartView
|
|
100
|
+
class StackedOverlay(QWidget):
|
|
101
|
+
|
|
102
|
+
def __init__(self, chart_view, paint_data, on_hover_cb=None):
|
|
103
|
+
super().__init__(chart_view.viewport())
|
|
104
|
+
self._chart_view = chart_view
|
|
105
|
+
self._paint_data = paint_data
|
|
106
|
+
self._on_hover_cb = on_hover_cb
|
|
107
|
+
self._hovered = None
|
|
108
|
+
self.setAttribute(Qt.WA_TransparentForMouseEvents, False)
|
|
109
|
+
self.setMouseTracking(True)
|
|
110
|
+
self.resize(chart_view.viewport().size())
|
|
111
|
+
chart_view.viewport().installEventFilter(self)
|
|
112
|
+
|
|
113
|
+
def eventFilter(self, obj, event):
|
|
114
|
+
if obj is self._chart_view.viewport() and event.type() == QEvent.Resize:
|
|
115
|
+
self.resize(obj.size())
|
|
116
|
+
self.update()
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
def _bar_rects(self):
|
|
120
|
+
chart = self._chart_view.chart()
|
|
121
|
+
pd = self._paint_data
|
|
122
|
+
periods = pd["all_periods"]
|
|
123
|
+
order = pd["per_month_order"]
|
|
124
|
+
data = pd["data"]
|
|
125
|
+
n = len(periods)
|
|
126
|
+
if n == 0 or not chart:
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
rects = []
|
|
130
|
+
for pi, period in enumerate(periods):
|
|
131
|
+
cats_sorted = order[pi]
|
|
132
|
+
baseline = 0.0
|
|
133
|
+
for cat in cats_sorted:
|
|
134
|
+
val = data[period].get(cat, 0)
|
|
135
|
+
if val <= 0:
|
|
136
|
+
continue
|
|
137
|
+
top_scene = chart.mapToScene(chart.mapToPosition(QPointF(pi, baseline + val)))
|
|
138
|
+
bottom_scene = chart.mapToScene(chart.mapToPosition(QPointF(pi, baseline)))
|
|
139
|
+
top_vp = self._chart_view.mapFromScene(top_scene)
|
|
140
|
+
bottom_vp = self._chart_view.mapFromScene(bottom_scene)
|
|
141
|
+
|
|
142
|
+
if n > 1:
|
|
143
|
+
left_scene = chart.mapToScene(chart.mapToPosition(QPointF(pi - 0.35, baseline)))
|
|
144
|
+
right_scene = chart.mapToScene(chart.mapToPosition(QPointF(pi + 0.35, baseline)))
|
|
145
|
+
left_vp = self._chart_view.mapFromScene(left_scene)
|
|
146
|
+
right_vp = self._chart_view.mapFromScene(right_scene)
|
|
147
|
+
bar_left = left_vp.x()
|
|
148
|
+
bar_right = right_vp.x()
|
|
149
|
+
else:
|
|
150
|
+
plot = chart.plotArea()
|
|
151
|
+
bar_left = plot.left() + plot.width() * 0.25
|
|
152
|
+
bar_right = plot.right() - plot.width() * 0.25
|
|
153
|
+
|
|
154
|
+
rect = QRectF(
|
|
155
|
+
bar_left,
|
|
156
|
+
top_vp.y(),
|
|
157
|
+
bar_right - bar_left,
|
|
158
|
+
abs(bottom_vp.y() - top_vp.y()),
|
|
159
|
+
)
|
|
160
|
+
rects.append((rect, pi, cat, val, baseline))
|
|
161
|
+
baseline += val
|
|
162
|
+
return rects
|
|
163
|
+
|
|
164
|
+
def paintEvent(self, event):
|
|
165
|
+
painter = QPainter(self)
|
|
166
|
+
painter.setRenderHint(QPainter.Antialiasing, False)
|
|
167
|
+
colors = self._paint_data.get("cat_colors", {})
|
|
168
|
+
|
|
169
|
+
for rect, pi, cat, val, baseline in self._bar_rects():
|
|
170
|
+
color = QColor(colors.get(cat, "#888888"))
|
|
171
|
+
painter.fillRect(rect, color)
|
|
172
|
+
pen = QPen(QColor(255, 255, 255, 255))
|
|
173
|
+
pen.setWidth(1)
|
|
174
|
+
painter.setPen(pen)
|
|
175
|
+
painter.drawRect(rect)
|
|
176
|
+
|
|
177
|
+
painter.end()
|
|
178
|
+
|
|
179
|
+
def mouseMoveEvent(self, event):
|
|
180
|
+
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
|
|
181
|
+
hit = None
|
|
182
|
+
for rect, pi, cat, val, baseline in self._bar_rects():
|
|
183
|
+
if rect.contains(pos):
|
|
184
|
+
hit = (pi, cat, val, baseline, pos)
|
|
185
|
+
break
|
|
186
|
+
if hit != self._hovered:
|
|
187
|
+
self._hovered = hit
|
|
188
|
+
if self._on_hover_cb:
|
|
189
|
+
self._on_hover_cb(hit)
|
|
190
|
+
|
|
191
|
+
def leaveEvent(self, event):
|
|
192
|
+
self._hovered = None
|
|
193
|
+
if self._on_hover_cb:
|
|
194
|
+
self._on_hover_cb(None)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def make_chart(title="", animated=True):
|
|
198
|
+
s = get_settings()
|
|
199
|
+
chart = QChart()
|
|
200
|
+
if title:
|
|
201
|
+
chart.setTitle(title)
|
|
202
|
+
chart.setTitleFont(QFont(s.get("font_family"), s.get("font_size") + 1, QFont.Bold))
|
|
203
|
+
chart.setTitleBrush(QBrush(QColor(s.get("text"))))
|
|
204
|
+
|
|
205
|
+
chart.setBackgroundBrush(QBrush(QColor(s.get("bg_surface"))))
|
|
206
|
+
|
|
207
|
+
if animated:
|
|
208
|
+
chart.setAnimationOptions(QChart.SeriesAnimations)
|
|
209
|
+
else:
|
|
210
|
+
chart.setAnimationOptions(QChart.NoAnimation)
|
|
211
|
+
|
|
212
|
+
chart.legend().setLabelColor(QColor(s.get("text")))
|
|
213
|
+
chart.legend().setAlignment(Qt.AlignRight)
|
|
214
|
+
chart.legend().setBackgroundVisible(False)
|
|
215
|
+
chart.legend().setFont(QFont(s.get("font_family"), s.get("font_size")))
|
|
216
|
+
chart.legend().setMarkerShape(QLegend.MarkerShapeRectangle)
|
|
217
|
+
chart.setMargins(QMargins(10, 10, 10, 10))
|
|
218
|
+
|
|
219
|
+
locale = QLocale(QLocale.Language.English, QLocale.Country.UnitedStates)
|
|
220
|
+
chart.setLocale(locale)
|
|
221
|
+
chart.setLocalizeNumbers(True)
|
|
222
|
+
|
|
223
|
+
return chart
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def make_y_axis(min_val=None, max_val=None):
|
|
227
|
+
"""Y axis with dollar values, proper Qt format string."""
|
|
228
|
+
s = get_settings()
|
|
229
|
+
ax = QValueAxis()
|
|
230
|
+
ax.setLabelFormat("$%.0f")
|
|
231
|
+
ax.setLabelsColor(QColor(s.get("text")))
|
|
232
|
+
ax.setGridLineColor(QColor(s.get("border")))
|
|
233
|
+
ax.setLinePen(QPen(QColor(s.get("border"))))
|
|
234
|
+
ax.setTitleBrush(QBrush(QColor(s.get("text_dim"))))
|
|
235
|
+
ax.setLabelsFont(QFont(s.get("font_family"), s.get("font_size") - 1))
|
|
236
|
+
if min_val is not None:
|
|
237
|
+
ax.setMin(min_val)
|
|
238
|
+
if max_val is not None:
|
|
239
|
+
ax.setMax(max_val)
|
|
240
|
+
return ax
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def make_x_axis(labels, rotate=None):
|
|
244
|
+
"""X axis with category labels, rotated for readability."""
|
|
245
|
+
s = get_settings()
|
|
246
|
+
ax = QBarCategoryAxis()
|
|
247
|
+
ax.append(labels)
|
|
248
|
+
ax.setLabelsColor(QColor(s.get("text")))
|
|
249
|
+
ax.setGridLineColor(QColor(s.get("border")))
|
|
250
|
+
ax.setLinePen(QPen(QColor(s.get("border"))))
|
|
251
|
+
ax.setLabelsFont(QFont(s.get("font_family"), s.get("font_size") - 1))
|
|
252
|
+
effective_rotate = rotate if rotate is not None else (-45 if len(labels) > 12 else 0)
|
|
253
|
+
if effective_rotate:
|
|
254
|
+
ax.setLabelsAngle(effective_rotate)
|
|
255
|
+
ax.setTruncateLabels(False)
|
|
256
|
+
return ax
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def style_chart_view(view, bordered=True):
|
|
260
|
+
s = get_settings()
|
|
261
|
+
if bordered:
|
|
262
|
+
view.setStyleSheet(
|
|
263
|
+
f"background:{s.get('bg_surface')}; "
|
|
264
|
+
f"border:1px solid {s.get('border')}; "
|
|
265
|
+
f"border-radius:8px;"
|
|
266
|
+
)
|
|
267
|
+
else:
|
|
268
|
+
view.setStyleSheet(
|
|
269
|
+
f"background: transparent; "
|
|
270
|
+
f"border: none;"
|
|
271
|
+
)
|
|
Binary file
|
|
Binary file
|