#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "client.h" #include "tui.h" #include "formula.h" #include "util.h" #include "version.h" #include "yyjson.h" #include "ui.h" /* ^C quits the application; Esc is navigation only */ int g_quit = 0; /* ^R re-execs the binary, keeping session, org, year and screen */ int g_reload = 0; char g_scene[32] = "dashboard"; char g_reload_scene[32]; int ui_getch(void) { int ch = getch(); if (ch == 3) { /* ^C */ g_quit = 1; return 27; /* behave like "back" so screens unwind */ } if (ch == 18) { /* ^R */ g_reload = 1; snprintf(g_reload_scene, sizeof g_reload_scene, "%s", g_scene); return 27; } return ch; } void request_quit(void) { g_quit = 1; } /* ------------------------------------------------------------------ */ /* json helpers */ /* ------------------------------------------------------------------ */ yyjson_val *jget(yyjson_val *root, const char *path) { char tmp[128]; snprintf(tmp, sizeof tmp, "%s", path); yyjson_val *v = root; char *save = NULL; for (char *tok = strtok_r(tmp, ".", &save); tok; tok = strtok_r(NULL, ".", &save)) { if (!v) return NULL; if (yyjson_is_obj(v)) v = yyjson_obj_get(v, tok); else if (yyjson_is_arr(v)) { char *end = NULL; long idx = strtol(tok, &end, 10); if (!end || *end) return NULL; v = yyjson_arr_get(v, (size_t)idx); } else { return NULL; } } return v; } yyjson_doc *parse(const char *resp) { return resp ? yyjson_read(resp, strlen(resp), 0) : NULL; } char *jstr_dup(const char *resp, const char *path) { yyjson_doc *d = parse(resp); yyjson_val *v = d ? jget(yyjson_doc_get_root(d), path) : NULL; char *out = v && yyjson_is_str(v) ? xstrdup(yyjson_get_str(v)) : NULL; yyjson_doc_free(d); return out; } int64_t jint_val(const char *resp, const char *path, int64_t def) { yyjson_doc *d = parse(resp); yyjson_val *v = d ? jget(yyjson_doc_get_root(d), path) : NULL; int64_t out = v && yyjson_is_int(v) ? yyjson_get_int(v) : def; yyjson_doc_free(d); return out; } size_t jarr_size(const char *resp, const char *path) { yyjson_doc *d = parse(resp); yyjson_val *v = d ? jget(yyjson_doc_get_root(d), path) : NULL; size_t n = v && yyjson_is_arr(v) ? yyjson_arr_size(v) : 0; yyjson_doc_free(d); return n; } int jbool_val(const char *resp, const char *path, int def) { yyjson_doc *d = parse(resp); yyjson_val *v = d ? jget(yyjson_doc_get_root(d), path) : NULL; int out = v && yyjson_is_bool(v) ? yyjson_get_bool(v) : def; yyjson_doc_free(d); return out; } /* ------------------------------------------------------------------ */ /* ui primitives */ /* ------------------------------------------------------------------ */ char g_server_version[32]; /* Whole kronor with space thousands, öre truncated like the blankett. */ void kr_whole(int64_t ore, char *buf, size_t n) { char tmp[48]; tui_kr_format((ore / 100) * 100, tmp, sizeof tmp); char *comma = strchr(tmp, ','); if (comma) *comma = '\0'; snprintf(buf, n, "%s", tmp); } int parse_x_double(const char *s, double *out) { if (!s) return 0; char buf[64]; size_t j = 0; for (const char *p = s; *p && j < sizeof buf - 1; p++) { if (*p == ' ' || *p == '\t') continue; buf[j++] = (*p == ',') ? '.' : *p; } buf[j] = '\0'; if (!j) return 0; char *end = NULL; double v = strtod(buf, &end); if (!end || *end) return 0; *out = v; return 1; } void replace_x_into(const char *src, double x, char *dst, size_t cap) { size_t o = 0; for (const char *p = src; *p && o + 1 < cap;) { if (p[0] == '{' && p[1] == 'x' && p[2] == '}') { char tmp[64]; snprintf(tmp, sizeof tmp, "%.2f", x); for (size_t k = 0; tmp[k] && o + 1 < cap; k++) dst[o++] = tmp[k]; p += 3; } else { dst[o++] = *p++; } } dst[o] = '\0'; } /* Display width in terminal columns, counting UTF-8 lead bytes. Good enough for Swedish text; combining marks are ignored. */ /* ------------------------------------------------------------------ */ /* app helpers */ /* ------------------------------------------------------------------ */ void app_refresh_context(struct app *a) { char args[64]; snprintf(args, sizeof args, "{\"org\":%lld}", (long long)a->org); char *resp = client_rpc(&a->conn, "session.use_org", a->session, 0, args); free(resp); resp = client_rpc(&a->conn, "org.get", a->session, a->org, "{}"); if (resp && client_ok(resp)) { char *name = jstr_dup(resp, "result.name"); if (name) { snprintf(a->org_name, sizeof a->org_name, "%s", name); free(name); } } free(resp); resp = client_rpc(&a->conn, "org.list", a->session, 0, "{}"); if (resp) { size_t n = jarr_size(resp, "result.items"); for (size_t i = 0; i < n; i++) { char path[64]; snprintf(path, sizeof path, "result.items.%zu.id", i); if (jint_val(resp, path, -1) == a->org) { snprintf(path, sizeof path, "result.items.%zu.role", i); char *role = jstr_dup(resp, path); if (role) { snprintf(a->role, sizeof a->role, "%s", role); free(role); } } } } free(resp); snprintf(a->default_series, sizeof a->default_series, "%s", "A"); a->attachment_dir[0] = '\0'; resp = client_rpc(&a->conn, "settings.get", a->session, a->org, "{}"); if (resp && client_ok(resp)) { char *ser = jstr_dup(resp, "result.default_series"); if (ser && *ser) snprintf(a->default_series, sizeof a->default_series, "%s", ser); free(ser); char *dir = jstr_dup(resp, "result.attachment_dir"); if (dir && *dir) snprintf(a->attachment_dir, sizeof a->attachment_dir, "%s", dir); free(dir); } free(resp); a->max_attachment_bytes = 10 * 1024 * 1024; resp = client_rpc(&a->conn, "meta", NULL, 0, NULL); if (resp && client_ok(resp)) { char *ver = jstr_dup(resp, "result.version"); if (ver && *ver) snprintf(g_server_version, sizeof g_server_version, "%s", ver); free(ver); int64_t v = jint_val(resp, "result.limits.max_attachment_bytes", 0); if (v > 0) a->max_attachment_bytes = (long)v; } free(resp); resp = client_rpc(&a->conn, "fiscal_year.get", a->session, a->org, "{}"); if (resp && client_ok(resp)) { a->fy = jint_val(resp, "result.id", 0); char *label = jstr_dup(resp, "result.label"); char *start = jstr_dup(resp, "result.start_date"); char *end = jstr_dup(resp, "result.end_date"); if (label) snprintf(a->fy_label, sizeof a->fy_label, "%s", label); if (start) snprintf(a->fy_start, sizeof a->fy_start, "%s", start); if (end) snprintf(a->fy_end, sizeof a->fy_end, "%s", end); free(label); free(start); free(end); } free(resp); } void show_error(const char *title, const char *resp) { char *code = jstr_dup(resp, "error.code"); char *msg = jstr_dup(resp, "error.message"); if ((!code || !*code) && (!msg || !*msg)) { tui_message(title, "%s", resp && *resp ? resp : "Inget svar från servern (kör daemonen?)"); } else { tui_message(title, "%s: %s", code && *code ? code : "fel", msg && *msg ? msg : "okänt fel"); } free(code); free(msg); } char *read_file_b64(const char *path) { FILE *f = fopen(path, "rb"); if (!f) return NULL; struct buf b; buf_init(&b); unsigned char chunk[65536]; size_t rn; while ((rn = fread(chunk, 1, sizeof chunk, f)) > 0) buf_append(&b, chunk, rn); int bad = ferror(f); fclose(f); if (bad) { buf_free(&b); return NULL; } char *b64 = util_b64(b.p ? b.p : (const unsigned char *)"", b.len); buf_free(&b); return b64; } struct fentry { char name[300]; int isdir; }; static int fentry_cmp(const void *a, const void *b) { const struct fentry *x = a; const struct fentry *y = b; if (x->isdir != y->isdir) return y->isdir - x->isdir; return strcasecmp(x->name, y->name); } /* Expands a leading ~ to $HOME (no ~user support). */ static void expand_path(const char *in, char *out, size_t n) { if (in && in[0] == '~' && (in[1] == '\0' || in[1] == '/')) { const char *home = getenv("HOME"); if (home) snprintf(out, n, "%s%s", home, in + 1); else snprintf(out, n, "%s", in[1] ? in + 2 : "."); } else { snprintf(out, n, "%s", in ? in : "."); } } /* File browser for choosing underlag. Starts in start_dir or $HOME. Returns a malloc'd path, or NULL on cancel. */ char *file_browser(struct app *a, const char *start_dir) { (void)a; if (g_quit) return NULL; char dir[1024]; const char *start = (start_dir && *start_dir) ? start_dir : getenv("HOME"); expand_path(start, dir, sizeof dir); if (!dir[0]) snprintf(dir, sizeof dir, "."); for (;;) { DIR *d = opendir(dir); if (!d) { tui_message("Filväljare", "Kan inte öppna %s", dir); return NULL; } struct fentry *ents = NULL; size_t n = 0, cap = 0; struct dirent *de; while ((de = readdir(d)) != NULL) { if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) continue; /* ".." is the first item in the list */ if (de->d_name[0] == '.') continue; /* hidden files */ char full[1600]; snprintf(full, sizeof full, "%.1200s/%.299s", dir, de->d_name); struct stat st; if (stat(full, &st) != 0) continue; if (n == cap) { cap = cap ? cap * 2 : 64; ents = xrealloc(ents, cap * sizeof *ents); } snprintf(ents[n].name, sizeof ents[n].name, "%s", de->d_name); ents[n].isdir = S_ISDIR(st.st_mode); n++; if (n >= 2000) break; } closedir(d); qsort(ents, n, sizeof *ents, fentry_cmp); char **items = xcalloc(n + 1, sizeof(char *)); items[0] = xstrdup(".. (uppåt)"); for (size_t i = 0; i < n; i++) { char line[340]; snprintf(line, sizeof line, "%s%s", ents[i].name, ents[i].isdir ? "/" : ""); items[i + 1] = xstrdup(line); } char title[1200]; snprintf(title, sizeof title, "Välj fil — %.200s", dir); int sel = tui_select_list(title, items, (int)n + 1, 0, 1, NULL, 0, NULL, 0); for (size_t i = 0; i <= n; i++) free(items[i]); free(items); if (sel < 0) { free(ents); return NULL; } if (sel == 0) { char *slash = strrchr(dir, '/'); if (slash && slash != dir) *slash = '\0'; else if (slash == dir) dir[1] = '\0'; free(ents); continue; } size_t idx = (size_t)sel - 1; char full[1600]; snprintf(full, sizeof full, "%.1200s/%.299s", dir, ents[idx].name); if (ents[idx].isdir) { snprintf(dir, sizeof dir, "%.1023s", full); free(ents); continue; } free(ents); return xstrdup(full); } } /* Valid UTF-8 without NUL bytes: good enough to call a receipt text. */ static int bytes_look_text(const unsigned char *p, size_t n) { for (size_t i = 0; i < n;) { unsigned char c = p[i]; if (c == 0) return 0; if (c < 0x80) { i++; continue; } int len; if ((c & 0xE0) == 0xC0) len = 2; else if ((c & 0xF0) == 0xE0) len = 3; else if ((c & 0xF8) == 0xF0) len = 4; else return 0; if (i + (size_t)len > n) return 0; for (int k = 1; k < len; k++) if ((p[i + k] & 0xC0) != 0x80) return 0; i += (size_t)len; } return 1; } /* Directory for generated PDFs. */ static void pdf_cache_dir(char *buf, size_t n) { const char *cache = getenv("XDG_CACHE_HOME"); if (cache && *cache) { snprintf(buf, n, "%s/bokf", cache); return; } snprintf(buf, n, "/tmp"); } /* Keep a user-supplied name from escaping the directory. */ static void safe_fname(const char *in, char *out, size_t n) { size_t o = 0; for (const char *p = in ? in : ""; *p && o + 1 < n; p++) { unsigned char c = (unsigned char)*p; if (c < 32 || c == '/') c = '-'; out[o++] = (char)c; } out[o] = '\0'; } static int write_pdf_b64(const char *b64, const char *path) { unsigned char *data = NULL; size_t len = 0; if (!b64 || util_b64_decode(b64, strlen(b64), &data, &len) != 0) return -1; FILE *fp = fopen(path, "wb"); if (!fp) { free(data); return -1; } size_t wrote = fwrite(data, 1, len, fp); fclose(fp); free(data); return wrote == len ? 0 : -1; } /* Open a saved PDF with xdg-open when a desktop session is present; otherwise (or on fork failure) show where it was saved. */ static void open_pdf(const char *path, const char *title) { const char *display = getenv("DISPLAY"); const char *wayland = getenv("WAYLAND_DISPLAY"); if ((!display || !*display) && (!wayland || !*wayland)) { tui_message(title, "Sparad: %s", path); return; } pid_t pid = fork(); if (pid == 0) { pid_t gc = fork(); if (gc == 0) { setsid(); execlp("xdg-open", "xdg-open", path, (char *)NULL); _exit(127); } _exit(gc < 0 ? 1 : 0); } if (pid > 0) waitpid(pid, NULL, 0); else tui_message(title, "Sparad: %s", path); } void pdf_save_and_open(const char *b64, const char *filename, const char *title) { char dir[512], path[700], safe[600]; pdf_cache_dir(dir, sizeof dir); safe_fname(filename, safe, sizeof safe); if (snprintf(path, sizeof path, "%s/%s", dir, safe) >= (int)sizeof path) { tui_message(title, "Sökvägen blir för lång."); return; } config_mkdirs(path); if (write_pdf_b64(b64, path) != 0) { tui_message(title, "Kunde inte spara %s", path); return; } open_pdf(path, title); } /* Fetch one attachment, save it to a prompted path and verify its hash. Text attachments are shown inline after saving. */ void attachment_download(struct app *a, int64_t att_id) { char args[64]; snprintf(args, sizeof args, "{\"id\":%lld}", (long long)att_id); char *resp = client_rpc(&a->conn, "attachment.get", a->session, a->org, args); if (!resp || !client_ok(resp)) { show_error("Kunde inte hämta underlaget", resp); free(resp); return; } char *fn = jstr_dup(resp, "result.filename"); char *mime = jstr_dup(resp, "result.mime"); char *sha = jstr_dup(resp, "result.sha256"); char *b64 = jstr_dup(resp, "result.content_base64"); unsigned char *data = NULL; size_t n = 0; if (!b64 || util_b64_decode(b64, strlen(b64), &data, &n) != 0) { tui_message("Underlag", "Kunde inte avkoda innehållet."); goto done; } const char *home = getenv("HOME"); char def[600], dl[512]; snprintf(dl, sizeof dl, "%s/Downloads", home && *home ? home : "."); struct stat sb; if (stat(dl, &sb) != 0 || !S_ISDIR(sb.st_mode)) snprintf(dl, sizeof dl, "%s", home && *home ? home : "."); snprintf(def, sizeof def, "%s/%s", dl, fn && *fn ? fn : "underlag"); char pathbuf[512]; char *path = tui_prompt_into(pathbuf, sizeof pathbuf, "Spara underlag: ", def, 0) ? pathbuf : NULL; if (!path || !*path) goto done; char full[600]; if (path[0] == '~' && (path[1] == '/' || path[1] == '\0')) snprintf(full, sizeof full, "%s%s", home ? home : "", path + 1); else snprintf(full, sizeof full, "%s", path); if (stat(full, &sb) == 0) { char ansbuf[512]; char *ans = tui_prompt_into(ansbuf, sizeof ansbuf, "Filen finns, skriv över? (j/n): ", "n", 0) ? ansbuf : NULL; if (!ans || (ans[0] != 'j' && ans[0] != 'J')) goto done; } FILE *f = fopen(full, "wb"); if (!f || fwrite(data, 1, n, f) != n) { tui_message("Underlag", "Kunde inte skriva %s: %s", full, strerror(errno)); if (f) fclose(f); goto done; } fclose(f); unsigned char raw[32]; char hex[65]; util_sha256(data, n, raw); util_hex(raw, sizeof raw, hex); if (sha && *sha && strcmp(hex, sha) != 0) tui_message("Underlag", "VARNING: kontrollsumman stämmer inte.\nSparat: %s", full); else tui_message("Underlag", "Sparat: %s\n(%zu byte)", full, n); int is_text = (mime && strncmp(mime, "text/", 5) == 0) || (n > 0 && bytes_look_text(data, n)); if (is_text && n <= 256 * 1024) { char *copy = xmalloc(n + 1); memcpy(copy, data, n); copy[n] = '\0'; tui_pager("Underlag", copy, NULL); free(copy); } done: free(data); free(b64); free(sha); free(mime); free(fn); free(resp); } const char *vstr(yyjson_val *o, const char *k) { yyjson_val *v = o ? yyjson_obj_get(o, k) : NULL; return v && yyjson_is_str(v) ? yyjson_get_str(v) : ""; } int64_t vint(yyjson_val *o, const char *k) { yyjson_val *v = o ? yyjson_obj_get(o, k) : NULL; return v && yyjson_is_int(v) ? (int64_t)yyjson_get_int(v) : 0; } void buf_line(struct buf *b, const char *fmt, ...) { char line[1024]; va_list ap; va_start(ap, fmt); int n = vsnprintf(line, sizeof line, fmt, ap); va_end(ap); if (n < 0) return; buf_append(b, line, (size_t)n < sizeof line ? (size_t)n : sizeof line - 1); } void buf_rule(struct buf *b, int width) { char line[128]; if (width > (int)sizeof line - 3) width = (int)sizeof line - 3; if (width < 1) return; line[0] = MK_RULE[0]; memset(line + 1, '-', (size_t)width); line[width + 1] = '\n'; buf_append(b, line, (size_t)width + 2); } int acct_in(const char *number, int lo, int hi) { int n = atoi(number); return n >= lo && n <= hi; } const char *const BS_KEYS[3] = { "assets", "equity", "liabilities" }; const struct is_group IS_GROUPS[] = { { "Rörelseintäkter, lagerförändringar m.m.", "Nettoomsättning", 3000, 3799, 1 }, { "Rörelseintäkter, lagerförändringar m.m.", "Övriga rörelseintäkter", 3800, 3999, 1 }, { "Rörelsekostnader", "Råvaror och förnödenheter", 4000, 4999, 0 }, { "Rörelsekostnader", "Övriga externa kostnader", 5000, 6999, 0 }, { "Rörelsekostnader", "Personalkostnader", 7000, 7699, 0 }, { "Rörelsekostnader", "Avskrivningar och nedskrivningar", 7700, 7899, 0 }, { "Rörelsekostnader", "Övriga rörelsekostnader", 7900, 7999, 0 }, }; /* Pick a voucher in the active fiscal year; returns its id or 0. */ int64_t pick_voucher(struct app *a) { char args[96]; snprintf(args, sizeof args, "{\"fiscal_year\":%lld,\"limit\":200}", (long long)a->fy); char *resp = client_rpc(&a->conn, "voucher.list", a->session, a->org, args); if (!resp || !client_ok(resp)) { show_error("Verifikat", resp); free(resp); return 0; } size_t n = jarr_size(resp, "result.items"); if (n == 0) { tui_message("Verifikat", "Inga verifikat i räkenskapsåret."); free(resp); return 0; } char **items = xcalloc(n, sizeof(char *)); int64_t *ids = xcalloc(n, sizeof(int64_t)); for (size_t i = 0; i < n; i++) { char path[64], ver[32], line[512]; snprintf(path, sizeof path, "result.items.%zu.id", i); ids[i] = jint_val(resp, path, 0); snprintf(path, sizeof path, "result.items.%zu.series", i); char *series = jstr_dup(resp, path); snprintf(path, sizeof path, "result.items.%zu.number", i); int64_t number = jint_val(resp, path, 0); snprintf(path, sizeof path, "result.items.%zu.date", i); char *date = jstr_dup(resp, path); snprintf(path, sizeof path, "result.items.%zu.description", i); char *desc = jstr_dup(resp, path); snprintf(ver, sizeof ver, "%s%lld", series ? series : "", (long long)number); snprintf(line, sizeof line, "%-8s %-10s %s", ver, date ? date : "", desc ? desc : ""); items[i] = xstrdup(line); free(series); free(date); free(desc); } int sel = tui_select_list("Välj verifikat", items, (int)n, 0, 1, NULL, 0, NULL, 0); int64_t out = sel >= 0 ? ids[sel] : 0; for (size_t i = 0; i < n; i++) free(items[i]); free(items); free(ids); free(resp); return out; } /* client_rpc() cannot carry the protocol's top-level dry_run, so build the request by hand. */ char *rpc_dry(struct app *a, const char *cmd, const char *args) { size_t n = strlen(args) + strlen(cmd) + strlen(a->session) + 160; char *raw = xmalloc(n); snprintf(raw, n, "{\"v\":1,\"id\":\"tui\",\"cmd\":\"%s\",\"session\":\"%s\"," "\"org\":%lld,\"dry_run\":true,\"args\":%s}", cmd, a->session, (long long)a->org, args); char *r = NULL; if (client_send_line(&a->conn, raw) == 0) r = client_read_line(&a->conn); free(raw); return r; } /* Small append-only log for connection/reload problems. */ void tui_log(const char *fmt, ...) { char path[600], dir[512]; const char *cache = getenv("XDG_CACHE_HOME"); if (cache && *cache) snprintf(dir, sizeof dir, "%s/bokf", cache); else { const char *home = getenv("HOME"); snprintf(dir, sizeof dir, "%s/.cache/bokf", home && *home ? home : "."); } if (snprintf(path, sizeof path, "%s/tui.log", dir) >= (int)sizeof path) return; config_mkdirs(path); FILE *f = fopen(path, "a"); if (!f) return; time_t now = time(NULL); struct tm tm; gmtime_r(&now, &tm); fprintf(f, "%04d-%02d-%02dT%02d:%02d:%02dZ ", tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec); va_list ap; va_start(ap, fmt); vfprintf(f, fmt, ap); va_end(ap); fputc('\n', f); fclose(f); }