regdel 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. #!/usr/bin/python
  2. # Regdel, a ncurses inteface to ledger
  3. #
  4. # copyright (c) 2016 Guillaume Chereau <guillaume@noctua-software.com>
  5. #
  6. # Regdel is free software: you can redistribute it and/or modify it under the
  7. # terms of the GNU General Public License as published by the Free Software
  8. # Foundation, either version 3 of the License, or (at your option) any later
  9. # version.
  10. #
  11. # Regdel is distributed in the hope that it will be useful, but WITHOUT ANY
  12. # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
  13. # A PARTICULAR PURPOSE. See the GNU General Public License for more details.
  14. #
  15. # You should have received a copy of the GNU General Public License along with
  16. # regdel. If not, see <http://www.gnu.org/licenses/>.
  17. import itertools
  18. from collections import namedtuple
  19. import curses
  20. import datetime
  21. import subprocess
  22. import sys
  23. import csv
  24. LineInfo = namedtuple('LineInfo', ['color_id', 'color', 'bg', 'attr'])
  25. # XXX: how to clean this up?
  26. LINE_INFOS = {
  27. "bar": LineInfo(0, curses.COLOR_WHITE, curses.COLOR_BLUE, curses.A_BOLD),
  28. "cursor": LineInfo(0, curses.COLOR_WHITE, curses.COLOR_GREEN, curses.A_BOLD),
  29. "date": LineInfo(0, curses.COLOR_BLUE, -1, 0),
  30. "value_pos": LineInfo(0, curses.COLOR_GREEN, -1, 0),
  31. "value_neg": LineInfo(0, curses.COLOR_RED, -1, 0),
  32. }
  33. KEY_MAP = {
  34. ord('k'): "PREV_LINE",
  35. ord('j'): "NEXT_LINE",
  36. curses.KEY_UP: "PREV_LINE",
  37. curses.KEY_DOWN: "NEXT_LINE",
  38. ord('q'): "QUIT",
  39. ord('c'): "CURRENCY",
  40. ord('\n'): "SELECT",
  41. ord(' '): "NEXT_PAGE",
  42. ord('g'): "FIRST_LINE",
  43. curses.KEY_HOME: "FIRST_LINE",
  44. ord('G'): "LAST_LINE",
  45. curses.KEY_END: "LAST_LINE",
  46. }
  47. def clamp(x, a, b): return min(max(x, a), b)
  48. def ledger(path, cmd, query="", form=None, currency=None, options=None):
  49. args = ['ledger', '-f', path]
  50. args += [cmd]
  51. if options: args += options
  52. form_keys = None
  53. if isinstance(form, dict):
  54. form_keys = form.keys()
  55. form = form.values()
  56. if form is not None:
  57. form = ','.join('%(quoted({}))'.format(x) for x in form)
  58. form += '\n\n'
  59. args += ['--format', form]
  60. if currency is not None:
  61. args += ['-X', currency]
  62. args += [query]
  63. out = subprocess.check_output(args)
  64. out = out.split('\n\n' if form else '\n')
  65. if form is not None:
  66. out = [x for x in csv.reader(out)]
  67. if form_keys is not None:
  68. out = [{k:v for k, v in zip(form_keys, x)} for x in out]
  69. return out
  70. class View(object):
  71. def __init__(self, app, win):
  72. self.app = app
  73. self.lineno = 0
  74. h, w = win.getmaxyx()
  75. self.full = win
  76. self.win = curses.newwin(h - 1, w, 1, 0)
  77. self.bar = curses.newwin(1, w, 0, 0)
  78. self.bar.bkgdset(' ', self.get_attr('bar'))
  79. self.w = w
  80. self.h = h - 1
  81. self.offset = 0
  82. def get_attr(self, line):
  83. if line in LINE_INFOS:
  84. inf = LINE_INFOS[line]
  85. return curses.color_pair(inf.color_id) | inf.attr
  86. else:
  87. return 0
  88. def refresh(self):
  89. # XXX: why - 2 ??
  90. self.lineno = clamp(self.lineno, 0, len(self.lines) - 2)
  91. # Make sure lineno is visible (ie: offset < lineno < offset + h)
  92. self.offset = min(self.offset, self.lineno)
  93. self.offset = max(self.offset, self.lineno - self.h + 1)
  94. self.win.clear()
  95. for i in range(self.win.getmaxyx()[0]):
  96. self.win.move(i, 0)
  97. self.render(self.win, i + self.offset)
  98. self.bar.clear()
  99. self.bar.move(0, 0)
  100. self.render_bar(self.bar)
  101. self.win.refresh()
  102. self.bar.refresh()
  103. def select(self, i):
  104. return None
  105. def toggle_currency(self):
  106. return
  107. def render_bar(self, win):
  108. return
  109. class AccountsView(View):
  110. def __init__(self, app, win):
  111. super(AccountsView, self).__init__(app, win)
  112. lines = ledger(app.path, 'accounts')
  113. accounts = []
  114. for line in lines:
  115. for i in range(len(line.split(':'))):
  116. a = ':'.join(line.split(':')[:i+1])
  117. if a not in accounts: accounts.append(a)
  118. self.lines = accounts
  119. def render(self, win, i):
  120. if i >= len(self.lines): return
  121. line = self.lines[i]
  122. cur = 'cursor' if i == self.lineno else None
  123. win.addstr(line, self.get_attr(cur or 'account'))
  124. def render_bar(self, win):
  125. win.addstr("Accounts")
  126. def select(self, i):
  127. return RegView(self.app, self.full, self.lines[i])
  128. class RegView(View):
  129. def __init__(self, app, win, account):
  130. assert isinstance(account, str)
  131. super(RegView, self).__init__(app, win)
  132. self.account = account
  133. self.currency = None
  134. self.update()
  135. def update(self):
  136. form = dict(date='date', payee='payee',
  137. amount='scrub(display_amount)',
  138. commodity='commodity(scrub(display_amount))',
  139. total='scrub(display_total)',
  140. account='display_account')
  141. self.lines = ledger(self.app.path, 'reg', self.account,
  142. form=form, currency=self.currency,
  143. options=['--effective', '-S', 'date'])
  144. def render(self, win, i):
  145. if i >= len(self.lines): return
  146. line = self.lines[i]
  147. if not line: return
  148. cur = 'cursor' if i == self.lineno else None
  149. win.addstr("{} ".format(line['date']), self.get_attr(cur or 'date'))
  150. win.addstr("{:<20.20} ".format(line['payee']), self.get_attr(cur or 'payee'))
  151. win.addstr("{:<20.20} ".format(line['account']), self.get_attr(cur or 'payee'))
  152. win.addstr("{:>20} ".format(line['amount']), self.get_attr(cur or 'value_pos'))
  153. tot = "ERR"
  154. for t in line['total'].split('\n'):
  155. if line['commodity'] in t:
  156. tot = t
  157. win.addstr("{:>20} ".format(tot), self.get_attr(cur))
  158. def render_bar(self, win):
  159. win.addstr(self.account)
  160. if self.currency:
  161. win.addstr(" ({})".format(self.currency))
  162. def toggle_currency(self):
  163. if self.currency is None:
  164. self.currency = 'HKD'
  165. else:
  166. self.currency = None
  167. self.update()
  168. def select(self, i):
  169. line = self.lines[i]
  170. query = '{} @{}'.format(self.account, line['payee'])
  171. date = datetime.datetime.strptime(line['date'], '%Y/%m/%d').date()
  172. enddate = date + datetime.timedelta(1)
  173. out = ledger(self.app.path, 'print', query,
  174. options=['--raw',
  175. '--effective',
  176. '--begin', "{}".format(date),
  177. '--end', "{}".format(enddate)])
  178. return TransactionView(self.app, self.full, out)
  179. class TransactionView(View):
  180. def __init__(self, app, win, lines):
  181. super(TransactionView, self).__init__(app, win)
  182. self.lines = lines
  183. def render(self, win, i):
  184. if i >= len(self.lines): return
  185. line = self.lines[i]
  186. cur = 'cursor' if i == self.lineno else None
  187. win.addstr(line, self.get_attr(cur))
  188. def select(self, i):
  189. txt = self.lines[i].split()[0]
  190. return RegView(self.app, self.full, txt)
  191. class App:
  192. def __init__(self, path, scr):
  193. self.path = path
  194. self.scr = scr
  195. self.view = AccountsView(self, self.scr)
  196. self.views = [self.view]
  197. def process(self, req):
  198. #XXX: auto call refresh if lineno or offset changed.
  199. step = 0
  200. if req == "QUIT":
  201. self.views.pop()
  202. if not self.views: return True
  203. self.view = self.views[-1]
  204. self.view.refresh()
  205. if req == "CURRENCY":
  206. self.view.toggle_currency()
  207. self.view.refresh()
  208. if req == "NEXT_LINE": step = +1
  209. if req == "PREV_LINE": step = -1
  210. if req == "NEXT_PAGE":
  211. step = self.view.win.getmaxyx()[0]
  212. self.view.offset += self.view.win.getmaxyx()[0]
  213. if req == "FIRST_LINE":
  214. self.view.lineno = 0
  215. self.view.offset = 0
  216. self.view.refresh()
  217. if req == "LAST_LINE":
  218. self.view.lineno = len(self.view.lines)
  219. self.view.refresh()
  220. if req == "SELECT":
  221. view = self.view.select(self.view.lineno)
  222. if view:
  223. self.views.append(view)
  224. self.view = view
  225. self.view.refresh()
  226. self.view.lineno += step
  227. if req == "MAIN" or step != 0:
  228. self.view.refresh()
  229. def run(self):
  230. self.process("MAIN")
  231. while True:
  232. c = self.scr.getch()
  233. req = KEY_MAP.get(c)
  234. if self.process(req): break
  235. if len(sys.argv) != 2:
  236. print "USAGE: regdel <ledger-file>"
  237. sys.exit(-1)
  238. curses.initscr()
  239. curses.start_color()
  240. curses.use_default_colors()
  241. def start(scr):
  242. for i, key in enumerate(LINE_INFOS):
  243. inf = LINE_INFOS[key]
  244. color_id = i + 1
  245. curses.init_pair(color_id, inf.color, inf.bg)
  246. LINE_INFOS[key] = inf._replace(color_id=color_id)
  247. app = App(sys.argv[1], scr)
  248. app.run()
  249. curses.wrapper(start)