aboutsummaryrefslogtreecommitdiffstats
path: root/firefly_cli/output.py
blob: 166e1bfd51a840b84077867c1fd6f2c003601041 (plain)
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
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>  GPL-2.0-only
import json
import sys

def unwrap(resp):
    """Flatten Firefly's JSON:API envelope to plain id+attributes objects."""
    def flat(item):
        out = {"id": item.get("id")}
        out.update(item.get("attributes", {}))
        return out
    data = resp.get("data", resp)
    if isinstance(data, list):
        return [flat(i) for i in data]
    if isinstance(data, dict) and "attributes" in data:
        return flat(data)
    return data

# Columns shown for transactions in --human mode, in order. The useful data
# lives in each tx's nested `transactions` split list, not the top level.
_TX_COLS = ("id", "date", "type", "amount", "currency",
            "description", "from", "to", "category")

def _it_date(d):
    """Firefly date 'YYYY-MM-DD...' -> Italian 'DD/MM/YYYY'. Raw on mismatch."""
    s = (d or "")[:10]
    parts = s.split("-")
    if len(parts) == 3 and all(parts):
        y, m, day = parts
        return f"{day}/{m}/{y}"
    return s

def _trim_amount(a):
    """Firefly sends amounts as 12-decimal strings; show a tidy 2-dp value.
    Falls back to the raw value if it is not a plain number."""
    try:
        return f"{float(a):.2f}"
    except (TypeError, ValueError):
        return a

def _tx_rows(rows):
    """Explode Firefly transaction objects into flat per-split display rows.

    Each object has a `transactions` list (one entry per split). We surface the
    fields a human actually reads; the raw JSON output is unaffected.
    """
    out = []
    for r in rows:
        for s in r.get("transactions", [{}]):
            out.append({
                "id": r.get("id"),
                "date": _it_date(s.get("date")),
                "type": s.get("type"),
                "amount": _trim_amount(s.get("amount")),
                "currency": s.get("currency_code"),
                "description": s.get("description"),
                "from": s.get("source_name"),
                "to": s.get("destination_name"),
                "category": s.get("category_name"),
            })
    return out

def _is_tx(rows):
    return bool(rows) and isinstance(rows[0], dict) and "transactions" in rows[0]

# Per-resource column whitelists for --human. Firefly returns ~50 fields per
# row; only a handful are worth a table. A row is matched by a signature key.
# (signature, columns) -- first match wins; unmatched rows use a generic table.
_VIEWS = [
    ("account_role", ("id", "name", "type", "current_balance",
                      "currency_code", "active")),
    ("tag",          ("id", "tag", "description")),
    ("current_balance", ("id", "name", "current_balance")),  # `account balance`
    ("name",         ("id", "name")),                       # category, etc.
]

def _cols_for(row):
    for sig, cols in _VIEWS:
        if sig in row:
            return list(cols)
    return None

def _cell(v):
    # Tables are one row per line; collapse any embedded newlines (e.g. notes).
    return str(v if v is not None else "").replace("\n", " ").replace("\r", "")

_RESET = "\033[0m"
_TYPE_COLOR = {            # by transaction type
    "withdrawal": "\033[31m",  # red   (money out)
    "deposit":    "\033[32m",  # green (money in)
    "transfer":   "\033[36m",  # cyan  (internal)
}

def emit(data, human=False, stream=None):
    stream = stream or sys.stdout
    if not human:
        json.dump(data, stream, indent=2, default=str)
        stream.write("\n")
        return
    rows = data if isinstance(data, list) else [data]
    if not rows:
        stream.write("(no results)\n")
        return
    if _is_tx(rows):
        rows = _tx_rows(rows)
        cols = list(_TX_COLS)
    else:
        cols = _cols_for(rows[0])
        if cols is None:
            # Generic fallback: drop nested (dict/list) columns that would dump
            # unreadable blobs; show the scalar fields only.
            cols = [c for c in rows[0]
                    if not any(isinstance(r.get(c), (dict, list)) for r in rows)]
    widths = {c: max(len(c), *(len(_cell(r.get(c))) for r in rows)) for c in cols}
    color = getattr(stream, "isatty", lambda: False)()
    sep = "   "
    stream.write(sep.join(c.ljust(widths[c]) for c in cols) + "\n")
    stream.write(sep.join("─" * widths[c] for c in cols) + "\n")  # header rule
    for r in rows:
        line = sep.join(_cell(r.get(c)).ljust(widths[c]) for c in cols)
        if color:
            tint = _TYPE_COLOR.get(str(r.get("type", "")).lower())
            if tint:
                line = tint + line + _RESET
        stream.write(line + "\n")

def emit_error(message, stream=None):
    stream = stream or sys.stderr
    json.dump({"error": message}, stream)
    stream.write("\n")