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 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
@@ -0,0 +1,7 @@
1
+ """Run with ``python -m daftarche`` (used by the app launcher)."""
2
+
3
+ import sys
4
+
5
+ from daftarche.main import main
6
+
7
+ sys.exit(main())
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
@@ -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