-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathccash.py
executable file
·446 lines (332 loc) · 14 KB
/
ccash.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
#!/usr/bin/env python
import sys
import os
import collections
import re
from PyQt4 import QtCore
from PyQt4 import QtGui
import cfile
import centry
import qfx
import charts
#TODO: allow columns to be reordered
#TODO: search bar to jump to an entry row
#TODO: when an entry changes, update charts
#TODO: ensure user-modified type names are valid and not duplicated
#TODO: allow all charts to show data from only a range of entries
#TODO: toolbar allowing user to open a chart w/ selected entries
#TODO: right-click context menu on entries to open charts
#TODO: use proper menu actions so ctrl-s style shortcuts work
#TODO: when a type is selected, highlight entries
#TODO: keep rows sorted by date
def typeStringForDescription(descr, comp_regexes):
for typename, comp_regex in comp_regexes.iteritems():
if comp_regex.match(descr):
return typename
return ""
class TypesDockWidget(QtGui.QWidget):
typesDeleted = QtCore.pyqtSignal(list)
typifyAll = QtCore.pyqtSignal()
def __init__(self):
super(TypesDockWidget, self).__init__()
# Types table
self.table = QtGui.QTableWidget(0, 2)
self.table.setHorizontalHeaderLabels(["Name", "Regex"])
self.setContextMenuPolicy(QtCore.Qt.ActionsContextMenu)
a = QtGui.QAction("&New", self)
self.addAction(a)
a.triggered.connect(self._addNewEmptyType)
a = QtGui.QAction("&Delete", self)
self.addAction(a)
a.triggered.connect(self._deleteSelectedRows)
# Buttons
hbox = QtGui.QHBoxLayout()
b = QtGui.QPushButton("New")
b.clicked.connect(self._addNewEmptyType)
b.setToolTip("Create a new entry type")
hbox.addWidget(b)
b = QtGui.QPushButton("Apply")
b.clicked.connect(self.typifyAll.emit)
b.setToolTip("Apply typing to all entries")
hbox.addWidget(b)
hbox.addStretch()
# Main layout
vbox = QtGui.QVBoxLayout()
vbox.addWidget(QtGui.QLabel("Types"))
vbox.addWidget(self.table)
vbox.addLayout(hbox)
self.setLayout(vbox)
def _appendRow(self, typename, regex):
row = self.table.rowCount()
self.table.insertRow(row)
self.table.setVerticalHeaderItem(row, QtGui.QTableWidgetItem())
self.table.setItem(row, 0, QtGui.QTableWidgetItem(typename))
self.table.setItem(row, 1, QtGui.QTableWidgetItem(regex))
def _addNewEmptyType(self):
self._appendRow("", "")
def _deleteSelectedRows(self):
selected_rows = [wi.row() for wi in self.table.selectedItems()]
removed_typenames = []
while selected_rows:
# Note that we take off the end of the list. If we removed
# indices from the beginning of the list, we'd have to
# decrement higher indices as those rows would shift up.
row = selected_rows.pop()
removed_typenames.append(str(self.table.item(row, 0).text()))
self.table.removeRow(row)
if removed_typenames:
self.typesDeleted.emit(removed_typenames)
@property
def types(self):
ret = collections.OrderedDict()
for row in xrange(self.table.rowCount()):
name = str(self.table.item(row, 0).text()).strip()
regex = str(self.table.item(row, 1).text()).strip()
if name:
ret[name] = regex
return ret
def clear(self):
while self.table.rowCount() > 0:
self.table.removeRow(0)
def addTypes(self, types):
for name, regex in types.iteritems():
self._appendRow(name, regex)
class TableController(QtCore.QObject):
selectionChanged = QtCore.pyqtSignal()
entryChanged = QtCore.pyqtSignal()
def __init__(self):
super(TableController, self).__init__()
self.table = QtGui.QTableWidget()
self.table.setAlternatingRowColors(True)
self.table.itemSelectionChanged.connect(self.selectionChanged.emit)
self._cached_entries = None
def _columnTitles(self):
return [str(self.table.horizontalHeaderItem(col).text()) for col in xrange(self.table.columnCount())]
def _columnIndexForLabel(self, label):
return self._columnTitles().index(label)
def clear(self):
while self.table.columnCount() > 0:
self.table.removeColumn(0)
while self.table.rowCount() > 0:
self.table.removeRow(0)
self._cached_entries = None # invalidate the cache
def typifyEntries(self, types):
descr_col_idx = self._columnIndexForLabel("description")
type_col_idx = self._columnIndexForLabel("type")
comp_regexes = { typename: re.compile(regex) for typename, regex in types.iteritems() }
for row in xrange(self.table.rowCount()):
descr = str(self.table.item(row, descr_col_idx).text())
new_type = typeStringForDescription(descr, comp_regexes)
self.table.item(row, type_col_idx).setText(new_type)
self._cached_entries = None # invalidate the cache
def _ensureColumnsExist(self, col_titles):
# note that we somewhat hard-code some columns to appear in a
# certain order here, but only add the columns if the attributes
# actually exist in the entries
for title in ["amount", "date", "type", "description", "uid"] + col_titles:
if title not in self._columnTitles() and title in col_titles:
col = len(self._columnTitles())
self.table.insertColumn(col)
self.table.setHorizontalHeaderItem(col, QtGui.QTableWidgetItem(title))
def addEntries(self, entries):
if not entries:
return
self._ensureColumnsExist(entries[0].ATTRIBUTES)
current_uids = [ce.uid for ce in self.entries]
entries_added = False
for e in entries:
if e.uid in current_uids:
# ensure we don't add duplicate entries
continue
current_uids.append(e.uid)
row = self.table.rowCount()
self.table.insertRow(row)
for attr in e.ATTRIBUTES:
wi = QtGui.QTableWidgetItem(str(getattr(e, attr)))
wi.setFlags(wi.flags() & ~QtCore.Qt.ItemIsEditable)
if e.amount > 0.0:
wi.setBackgroundColor(QtGui.QColor(160, 255, 160))
self.table.setItem(row, self._columnIndexForLabel(attr), wi)
entries_added = True
if entries_added:
if "description" in self._columnTitles():
self.table.resizeColumnToContents(self._columnTitles().index("description"))
self._cached_entries = None # invalidate the cache
@property
def entries(self):
# this method can be expensive; cache
if self._cached_entries is not None:
return self._cached_entries
ret = []
for row in xrange(self.table.rowCount()):
d = {}
for attr in centry.CEntry.ATTRIBUTES:
d[attr] = str(self.table.item(row, self._columnIndexForLabel(attr)).text())
ret.append(centry.CEntry(d))
self._cached_entries = ret
return ret
@property
def selected_entries(self):
rows = set()
for sr in self.table.selectedRanges():
rows.update(xrange(sr.topRow(), sr.bottomRow() + 1))
return [self.entries[row] for row in sorted(rows)]
class MainWin(QtGui.QMainWindow):
newFile = QtCore.pyqtSignal()
openFile = QtCore.pyqtSignal()
closeFile = QtCore.pyqtSignal()
saveFile = QtCore.pyqtSignal()
saveFileAs = QtCore.pyqtSignal()
addEntries = QtCore.pyqtSignal()
def __init__(self, table, dock):
super(MainWin, self).__init__()
self._sbar_total_entries_label = QtGui.QLabel()
self._sbar_untyped_entries_label = QtGui.QLabel()
self._sbar_selected_label = QtGui.QLabel()
# Main table
self.setCentralWidget(table)
# Dock
self.addDockWidget(QtCore.Qt.RightDockWidgetArea, dock)
# Menus
m = self.menuBar().addMenu("&File")
a = m.addAction("&New")
a.triggered.connect(self.newFile.emit)
a = m.addAction("&Open")
a.triggered.connect(self.openFile.emit)
a = m.addAction("&Close")
a.triggered.connect(self.closeFile.emit)
m.addSeparator()
a = m.addAction("&Save")
a.triggered.connect(self.saveFile.emit)
a = m.addAction("Save &As...")
a.triggered.connect(self.saveFileAs.emit)
m.addSeparator()
a = m.addAction("E&xit")
a.triggered.connect(self.close)
m = self.menuBar().addMenu("&Edit")
a = m.addAction("&Add Entries")
a.triggered.connect(self.addEntries.emit)
m = self.menuBar().addMenu("&View")
a = m.addAction("&Types")
a.triggered.connect(dock.show)
# Status bar
self.statusBar().addPermanentWidget(self._sbar_total_entries_label)
self.statusBar().addWidget(self._sbar_untyped_entries_label)
self.statusBar().addWidget(self._sbar_selected_label)
# Window stuff
self.setWindowTitle("CCash")
self.resize(1024, 600)
self.show()
def updateStatusBar(self, all_entries, selected):
num_ent = len(all_entries)
self._sbar_total_entries_label.setText("%d entries" % num_ent)
nu = len(filter(lambda e: not e.type, all_entries))
if all_entries:
nu_p = int((float(nu) / len(all_entries)) * 100.0)
else:
nu_p = 0
self._sbar_untyped_entries_label.setText("%d untyped entries (%d%%)" % (nu, nu_p))
num_sel = len(selected)
cost_sel = sum([e.amount for e in selected])
self._sbar_selected_label.setText("%d selected, $%.2f" % (num_sel, cost_sel))
class MainCont(object):
def __init__(self):
# Path to most recently opened or saved file
self._recent_path = ""
# Path to most recently *added* file
self._recent_qfx_path = ""
# Main table controller
self.table_cont = TableController()
self.table_cont.selectionChanged.connect(self.updateStatusBar)
# Types dock controller
self._dock = QtGui.QDockWidget()
self._dock.setWidget(TypesDockWidget())
self._dock.widget().typesDeleted.connect(self._typesDeleted)
self._dock.widget().typifyAll.connect(self._typifyAll)
# Main window signals
self._win = MainWin(self.table_cont.table, self._dock)
self._win.newFile.connect(self._newFile)
self._win.openFile.connect(self._loadFromPath)
self._win.closeFile.connect(self._closeFile)
self._win.saveFile.connect(self._saveFile)
self._win.saveFileAs.connect(self._saveFileAs)
self._win.addEntries.connect(self._addEntriesFromQFX)
self._resetUI()
def _resetUI(self):
self.table_cont.clear()
self._dock.widget().clear()
self.updateStatusBar()
def _typesDeleted(self, typenames):
self._typifyAll()
def _typifyAll(self):
self.table_cont.typifyEntries(self._dock.widget().types)
self.updateStatusBar()
def load(self, path):
types, entries = cfile.loadFromFile(path)
self._resetUI()
self.table_cont.addEntries(entries)
self._dock.widget().addTypes(types)
self.updateStatusBar()
self._recent_path = str(path)
def save(self, path):
cfile.writeToFile(path, self._dock.widget().types, self.table_cont.entries)
self._recent_path = str(path)
def _confirmIfChanges(self):
#TODO: prompt if the user has made changes
return True
def _newFile(self):
if not self._confirmIfChanges():
return
self._resetUI()
def _loadFromPath(self):
if not self._confirmIfChanges():
return
path = QtGui.QFileDialog.getOpenFileName(parent=self._win,
caption="Open File",
directory=os.path.dirname(self._recent_path),
filter="CCash (*.ccash)")
if not path:
return
self.load(path)
def _closeFile(self):
if not self._confirmIfChanges():
return
self._resetUI()
def _saveFile(self):
if not self._recent_path:
self._saveFileAs()
else:
self.save(self._recent_path)
def _saveFileAs(self):
path = QtGui.QFileDialog.getSaveFileName(parent=self._win,
caption="Save File",
directory=os.path.dirname(self._recent_path),
filter="CCash (*.ccash)")
if not path:
return
path = str(path)
if os.path.splitext(path)[1] != ".ccash":
path += ".ccash"
self.save(path)
def _addEntriesFromQFX(self):
path = QtGui.QFileDialog.getOpenFileName(parent=self._win,
caption="Open File",
directory=os.path.dirname(self._recent_qfx_path),
filter="QFX (*.qfx)")
if not path:
return
entries = [centry.CEntryFromQFX(qfx_stmttrn) for qfx_stmttrn in qfx.parseTransactionsFromFile(path)]
comp_regexes = { typename: re.compile(regex) for typename, regex in self._dock.widget().types.iteritems() }
for e in entries:
e.type = typeStringForDescription(e.description, comp_regexes)
self.table_cont.addEntries(entries)
self.updateStatusBar()
self._recent_qfx_path = str(path)
def updateStatusBar(self):
self._win.updateStatusBar(self.table_cont.entries, self.table_cont.selected_entries)
if __name__ == "__main__":
app = QtGui.QApplication(sys.argv)
cont = MainCont()
if len(sys.argv) > 1:
cont.load(sys.argv[1])
sys.exit(app.exec_())