#!/usr/bin/python # Regdel, a ncurses inteface to ledger # # copyright (c) 2016 Guillaume Chereau # # Regdel is free software: you can redistribute it and/or modify it under the # terms of the GNU General Public License as published by the Free Software # Foundation, either version 3 of the License, or (at your option) any later # version. # # Regdel is distributed in the hope that it will be useful, but WITHOUT ANY # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR # A PARTICULAR PURPOSE. See the GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along with # regdel. If not, see . import itertools from collections import namedtuple import curses import datetime import subprocess import sys import csv LineInfo = namedtuple('LineInfo', ['color_id', 'color', 'bg', 'attr']) # XXX: how to clean this up? LINE_INFOS = { "bar": LineInfo(0, curses.COLOR_WHITE, curses.COLOR_BLUE, curses.A_BOLD), "cursor": LineInfo(0, curses.COLOR_WHITE, curses.COLOR_GREEN, curses.A_BOLD), "date": LineInfo(0, curses.COLOR_BLUE, -1, 0), "value_pos": LineInfo(0, curses.COLOR_GREEN, -1, 0), "value_neg": LineInfo(0, curses.COLOR_RED, -1, 0), } KEY_MAP = { ord('k'): "PREV_LINE", ord('j'): "NEXT_LINE", curses.KEY_UP: "PREV_LINE", curses.KEY_DOWN: "NEXT_LINE", ord('q'): "QUIT", ord('c'): "CURRENCY", ord('\n'): "SELECT", ord(' '): "NEXT_PAGE", ord('g'): "FIRST_LINE", curses.KEY_HOME: "FIRST_LINE", ord('G'): "LAST_LINE", curses.KEY_END: "LAST_LINE", } def clamp(x, a, b): return min(max(x, a), b) def ledger(path, cmd, query="", form=None, currency=None, options=None): args = ['ledger', '-f', path] args += [cmd] if options: args += options form_keys = None if isinstance(form, dict): form_keys = form.keys() form = form.values() if form is not None: form = ','.join('%(quoted({}))'.format(x) for x in form) form += '\n\n' args += ['--format', form] if currency is not None: args += ['-X', currency] args += [query] out = subprocess.check_output(args) out = out.split('\n\n' if form else '\n') if form is not None: out = [x for x in csv.reader(out)] if form_keys is not None: out = [{k:v for k, v in zip(form_keys, x)} for x in out] return out class View(object): def __init__(self, app, win): self.app = app self.lineno = 0 h, w = win.getmaxyx() self.full = win self.win = curses.newwin(h - 1, w, 1, 0) self.bar = curses.newwin(1, w, 0, 0) self.bar.bkgdset(' ', self.get_attr('bar')) self.w = w self.h = h - 1 self.offset = 0 def get_attr(self, line): if line in LINE_INFOS: inf = LINE_INFOS[line] return curses.color_pair(inf.color_id) | inf.attr else: return 0 def refresh(self): # XXX: why - 2 ?? self.lineno = clamp(self.lineno, 0, len(self.lines) - 2) # Make sure lineno is visible (ie: offset < lineno < offset + h) self.offset = min(self.offset, self.lineno) self.offset = max(self.offset, self.lineno - self.h + 1) self.win.clear() for i in range(self.win.getmaxyx()[0]): self.win.move(i, 0) self.render(self.win, i + self.offset) self.bar.clear() self.bar.move(0, 0) self.render_bar(self.bar) self.win.refresh() self.bar.refresh() def select(self, i): return None def toggle_currency(self): return def render_bar(self, win): return class AccountsView(View): def __init__(self, app, win): super(AccountsView, self).__init__(app, win) lines = ledger(app.path, 'accounts') accounts = [] for line in lines: for i in range(len(line.split(':'))): a = ':'.join(line.split(':')[:i+1]) if a not in accounts: accounts.append(a) self.lines = accounts def render(self, win, i): if i >= len(self.lines): return line = self.lines[i] cur = 'cursor' if i == self.lineno else None win.addstr(line, self.get_attr(cur or 'account')) def render_bar(self, win): win.addstr("Accounts") def select(self, i): return RegView(self.app, self.full, self.lines[i]) class RegView(View): def __init__(self, app, win, account): assert isinstance(account, str) super(RegView, self).__init__(app, win) self.account = account self.currency = None self.update() def update(self): form = dict(date='date', payee='payee', amount='scrub(display_amount)', commodity='commodity(scrub(display_amount))', total='scrub(display_total)', account='display_account') self.lines = ledger(self.app.path, 'reg', self.account, form=form, currency=self.currency, options=['--effective', '-S', 'date']) def render(self, win, i): if i >= len(self.lines): return line = self.lines[i] if not line: return cur = 'cursor' if i == self.lineno else None win.addstr("{} ".format(line['date']), self.get_attr(cur or 'date')) win.addstr("{:<20.20} ".format(line['payee']), self.get_attr(cur or 'payee')) win.addstr("{:<20.20} ".format(line['account']), self.get_attr(cur or 'payee')) win.addstr("{:>20} ".format(line['amount']), self.get_attr(cur or 'value_pos')) tot = "ERR" for t in line['total'].split('\n'): if line['commodity'] in t: tot = t win.addstr("{:>20} ".format(tot), self.get_attr(cur)) def render_bar(self, win): win.addstr(self.account) if self.currency: win.addstr(" ({})".format(self.currency)) def toggle_currency(self): if self.currency is None: self.currency = 'HKD' else: self.currency = None self.update() def select(self, i): line = self.lines[i] query = '{} @{}'.format(self.account, line['payee']) date = datetime.datetime.strptime(line['date'], '%Y/%m/%d').date() enddate = date + datetime.timedelta(1) out = ledger(self.app.path, 'print', query, options=['--raw', '--effective', '--begin', "{}".format(date), '--end', "{}".format(enddate)]) return TransactionView(self.app, self.full, out) class TransactionView(View): def __init__(self, app, win, lines): super(TransactionView, self).__init__(app, win) self.lines = lines def render(self, win, i): if i >= len(self.lines): return line = self.lines[i] cur = 'cursor' if i == self.lineno else None win.addstr(line, self.get_attr(cur)) def select(self, i): txt = self.lines[i].split()[0] return RegView(self.app, self.full, txt) class App: def __init__(self, path, scr): self.path = path self.scr = scr self.view = AccountsView(self, self.scr) self.views = [self.view] def process(self, req): #XXX: auto call refresh if lineno or offset changed. step = 0 if req == "QUIT": self.views.pop() if not self.views: return True self.view = self.views[-1] self.view.refresh() if req == "CURRENCY": self.view.toggle_currency() self.view.refresh() if req == "NEXT_LINE": step = +1 if req == "PREV_LINE": step = -1 if req == "NEXT_PAGE": step = self.view.win.getmaxyx()[0] self.view.offset += self.view.win.getmaxyx()[0] if req == "FIRST_LINE": self.view.lineno = 0 self.view.offset = 0 self.view.refresh() if req == "LAST_LINE": self.view.lineno = len(self.view.lines) self.view.refresh() if req == "SELECT": view = self.view.select(self.view.lineno) if view: self.views.append(view) self.view = view self.view.refresh() self.view.lineno += step if req == "MAIN" or step != 0: self.view.refresh() def run(self): self.process("MAIN") while True: c = self.scr.getch() req = KEY_MAP.get(c) if self.process(req): break if len(sys.argv) != 2: print "USAGE: regdel " sys.exit(-1) curses.initscr() curses.start_color() curses.use_default_colors() def start(scr): for i, key in enumerate(LINE_INFOS): inf = LINE_INFOS[key] color_id = i + 1 curses.init_pair(color_id, inf.color, inf.bg) LINE_INFOS[key] = inf._replace(color_id=color_id) app = App(sys.argv[1], scr) app.run() curses.wrapper(start)