diff options
| author | Anders Betts <anders.betts@gmail.com> | 2026-09-20 22:00:01 +0200 |
|---|---|---|
| committer | Anders Betts <anders.betts@gmail.com> | 2026-09-20 22:00:01 +0200 |
| commit | 10a535ace098144980d0ffd1593f6384c39cc221 (patch) | |
| tree | 53a7c6c5026d479c4b6d5fd4e5b341474cd190fa /clients/ui.c | |
| parent | 3a5a6d3b04dc4e17a42225afb243eecb3502ead6 (diff) | |
| download | bokf-10a535ace098144980d0ffd1593f6384c39cc221.tar.gz bokf-10a535ace098144980d0ffd1593f6384c39cc221.zip | |
tui: split bokftui into screen files
Diffstat (limited to 'clients/ui.c')
| -rw-r--r-- | clients/ui.c | 674 |
1 files changed, 674 insertions, 0 deletions
diff --git a/clients/ui.c b/clients/ui.c new file mode 100644 index 0000000..ff573c9 --- /dev/null +++ b/clients/ui.c @@ -0,0 +1,674 @@ +#include <dirent.h> +#include <errno.h> +#include <locale.h> +#include <math.h> +#include <ncursesw/ncurses.h> +#include <stdarg.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <strings.h> +#include <sys/stat.h> +#include <sys/wait.h> +#include <time.h> +#include <unistd.h> + +#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; +} + +/* 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); +} |
