aboutsummaryrefslogtreecommitdiff
path: root/clients/bokftui.c
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-18 22:16:32 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-18 22:16:32 +0200
commite9fc91dbc8314a25a0d71b7ee4344d3fc4ccfd5e (patch)
treed5c14087df9db17c63b5be5cada4f11592c493b3 /clients/bokftui.c
parent251eb8c54c77029f0b43aa987ea50c44016147f3 (diff)
downloadbokf-0.1.29.tar.gz
bokf-0.1.29.zip
attachments: link/unlink from the TUI, multi-voucher linksv0.1.29
The voucher detail can attach a file to an existing voucher (^F) and the underlag picker (f) can remove a link (d, confirmed). The Underlag inbox links a highlighted item to a voucher picked from a list (k). attachment.link and attachment.unlink are new write commands (dry-run, audited); unlinked files return to the inbox. The same content may now link to several vouchers — only the same voucher/attachment pair is a CONFLICT, matching the table's primary key.
Diffstat (limited to 'clients/bokftui.c')
-rw-r--r--clients/bokftui.c346
1 files changed, 319 insertions, 27 deletions
diff --git a/clients/bokftui.c b/clients/bokftui.c
index ddff00b..d446974 100644
--- a/clients/bokftui.c
+++ b/clients/bokftui.c
@@ -718,7 +718,7 @@ static int menu(const char *title, const char *const *items, int n,
static int select_list(const char *title, char **items, int n, int start,
int allow_refresh, int *cursor, int allow_new,
- int allow_archive, int allow_toggle)
+ const char *archive_action, int allow_toggle)
{
int sel = start;
if (sel < 0)
@@ -763,13 +763,14 @@ static int select_list(const char *title, char **items, int n, int start,
clrtoeol();
}
{
- char hint[256];
+ char hint[256], dbuf[48] = "";
+ if (archive_action)
+ snprintf(dbuf, sizeof dbuf, " d = %s", archive_action);
snprintf(hint, sizeof hint,
"upp/ned, 1-9 = hoppa, g = gå till, PgUp/PgDn, Home/End,"
" Enter%s%s%s%s Esc/q = tillbaka",
allow_refresh ? " F5 = uppdatera" : "",
- allow_new ? " ^N = ny" : "",
- allow_archive ? " d = arkivera" : "",
+ allow_new ? " ^N = ny" : "", dbuf,
allow_toggle ? " ^A = öppna/stäng" : "");
snprintf(hint + strlen(hint), sizeof hint - strlen(hint),
" ^C = avsluta");
@@ -826,7 +827,7 @@ static int select_list(const char *title, char **items, int n, int start,
return -2;
else if (allow_new && ch == KEY_CTRL_N)
return -4;
- else if (allow_archive && (ch == 'd' || ch == 'D') && n > 0)
+ else if (archive_action && (ch == 'd' || ch == 'D') && n > 0)
return -6;
else if (allow_toggle && ch == 1 && n > 0)
return -7;
@@ -1278,7 +1279,7 @@ static void select_fiscal_year(struct app *a)
if (start >= (int)n)
start = (int)n - 1;
int sel = select_list("Räkenskapsår", lines, (int)n, start, 0,
- &cursor, 1, 0, 1);
+ &cursor, 1, NULL, 1);
for (size_t i = 0; i < n; i++)
free(lines[i]);
free(lines);
@@ -1471,7 +1472,7 @@ static char *file_browser(struct app *a, const char *start_dir)
}
char title[1200];
snprintf(title, sizeof title, "Välj fil — %.200s", dir);
- int sel = select_list(title, items, (int)n + 1, 0, 1, NULL, 0, 0, 0);
+ int sel = 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);
@@ -1501,6 +1502,114 @@ static char *file_browser(struct app *a, const char *start_dir)
}
}
+/* 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. */
+static 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) {
+ 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 *path = prompt("Spara underlag: ", def, 0);
+ 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 *ans = prompt("Filen finns, skriv över? (j/n): ", "n", 0);
+ if (!ans || (ans[0] != 'j' && ans[0] != 'J'))
+ goto done;
+ }
+ FILE *f = fopen(full, "wb");
+ if (!f || fwrite(data, 1, n, f) != n) {
+ 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)
+ message("Underlag", "VARNING: kontrollsumman stämmer inte.\nSparat: %s",
+ full);
+ else
+ 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';
+ text_view("Underlag", copy, NULL);
+ free(copy);
+ }
+done:
+ free(data);
+ free(b64);
+ free(sha);
+ free(mime);
+ free(fn);
+ free(resp);
+}
+
static int voucher_detail(struct app *a, int64_t id, int64_t *out_new)
{
if (out_new)
@@ -1597,11 +1706,15 @@ static int voucher_detail(struct app *a, int64_t id, int64_t *out_new)
int want_refresh = 0, corrected = 0, stop = 0, posted_new = 0;
int64_t posted_id = 0;
while (!stop) {
+ char hint[256];
+ snprintf(hint, sizeof hint,
+ "upp/ned F5 = uppdatera ^N = nytt verifikat c ="
+ " rätta ^F = bifoga%s Esc/q = tillbaka",
+ natts ? " f = underlag" : "");
frame("Verifikat");
for (int i = 0; i < view && top + i < n; i++)
mvaddnstr(2 + i, 2, lines[top + i], COLS - 4);
- hints("upp/ned F5 = uppdatera ^N = nytt verifikat c ="
- " rätta Esc/q = tillbaka");
+ hints(hint);
refresh();
int ch = ui_getch();
if (ch == 'q' || ch == 27) {
@@ -1643,6 +1756,95 @@ static int voucher_detail(struct app *a, int64_t id, int64_t *out_new)
free(ref);
}
stop = 1;
+ } else if (ch == 6) { /* ^F: attach a file to this voucher */
+ char *path = file_browser(a, a->attachment_dir);
+ if (path) {
+ struct stat sb;
+ if (stat(path, &sb) == 0 &&
+ (long)sb.st_size > a->max_attachment_bytes) {
+ message("Bilaga", "Filen är %.1f MB, max %ld MB.",
+ (double)sb.st_size / (1024 * 1024),
+ a->max_attachment_bytes / (1024 * 1024));
+ free(path);
+ } else {
+ char *b64 = read_file_b64(path);
+ if (!b64) {
+ message("Fel", "Kunde inte läsa %s", path);
+ free(path);
+ } else {
+ const char *base = strrchr(path, '/');
+ base = base ? base + 1 : path;
+ yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *o = yyjson_mut_obj(d);
+ yyjson_mut_doc_set_root(d, o);
+ yyjson_mut_obj_add_strcpy(d, o, "filename", base);
+ yyjson_mut_obj_add_strcpy(d, o, "content_base64",
+ b64);
+ yyjson_mut_obj_add_int(d, o, "voucher_id", id);
+ char *fargs = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ free(b64);
+ free(path);
+ char *r = client_rpc(&a->conn, "attachment.put",
+ a->session, a->org, fargs);
+ if (r && client_ok(r))
+ message("Underlag", "Bifogat.");
+ else
+ show_error("Kunde inte bifoga", r);
+ free(r);
+ free(fargs);
+ want_refresh = 1;
+ stop = 1;
+ }
+ }
+ }
+ } else if (ch == 'f' && natts > 0) {
+ char **alines = xcalloc(natts, sizeof(char *));
+ int64_t *aids = xcalloc(natts, sizeof(int64_t));
+ for (size_t i = 0; i < natts; i++) {
+ char path[64];
+ snprintf(path, sizeof path, "result.attachments.%zu.id",
+ i);
+ aids[i] = jint_val(resp, path, 0);
+ snprintf(path, sizeof path,
+ "result.attachments.%zu.filename", i);
+ char *fn = jstr_dup(resp, path);
+ alines[i] = xstrdup(fn ? fn : "(namnlös)");
+ free(fn);
+ }
+ int acursor = 0;
+ int asel = select_list("Underlag", alines, (int)natts, 0, 0,
+ &acursor, 0, "ta bort", 0);
+ if (asel >= 0)
+ attachment_download(a, aids[asel]);
+ else if (asel == -6 && acursor >= 0 &&
+ (size_t)acursor < natts) {
+ char q[320];
+ snprintf(q, sizeof q,
+ "Ta bort '%s' från verifikatet? (j/n): ",
+ alines[acursor]);
+ char *ans = prompt(q, "n", 0);
+ if (ans && (ans[0] == 'j' || ans[0] == 'J')) {
+ char uargs[64];
+ snprintf(uargs, sizeof uargs,
+ "{\"id\":%lld,\"voucher_id\":%lld}",
+ (long long)aids[acursor], (long long)id);
+ char *r = client_rpc(&a->conn, "attachment.unlink",
+ a->session, a->org, uargs);
+ if (r && client_ok(r)) {
+ message("Underlag", "Länken borttagen.");
+ want_refresh = 1;
+ stop = 1;
+ } else {
+ show_error("Kunde inte ta bort", r);
+ }
+ free(r);
+ }
+ }
+ for (size_t i = 0; i < natts; i++)
+ free(alines[i]);
+ free(alines);
+ free(aids);
} else if (ch == KEY_DOWN && top + view < n) {
top++;
} else if (ch == KEY_UP && top > 0) {
@@ -2021,7 +2223,7 @@ static int64_t vouchers_new(struct app *a)
free(ser);
free(ds);
}
- int tsel = select_list("Välj mall", lines, (int)ln, 0, 1, NULL, 0, 0, 0);
+ int tsel = select_list("Välj mall", lines, (int)ln, 0, 1, NULL, 0, NULL, 0);
if (tsel >= 0)
snprintf(tname, sizeof tname, "%s", names[tsel]);
for (size_t i = 0; i < ln; i++) {
@@ -2419,7 +2621,7 @@ static void vouchers_screen(struct app *a)
}
int cur = start;
int sel = select_list("Verifikat", items, (int)total, start, 1, &cur,
- 1, 0, 0);
+ 1, NULL, 0);
if (cur >= 0 && (size_t)cur < n)
a->voucher_sel = ids[cur];
if (sel == -2) {
@@ -3547,9 +3749,62 @@ static void reports_screen(struct app *a)
}
}
+/* Pick a voucher in the active fiscal year; returns its id or 0. */
+static 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) {
+ 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 = 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;
+}
+
static void inbox_screen(struct app *a)
{
- int top = 0;
+ int top = 0, sel = 0;
for (;;) {
if (g_quit)
return;
@@ -3562,8 +3817,11 @@ static void inbox_screen(struct app *a)
}
size_t n = jarr_size(resp, "result.items");
char **items = xcalloc(n ? n : 1, sizeof(char *));
+ int64_t *ids = xcalloc(n ? n : 1, sizeof(int64_t));
for (size_t i = 0; i < n; i++) {
char path[64], 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.filename", i);
char *fn = jstr_dup(resp, path);
snprintf(path, sizeof path, "result.items.%zu.size_bytes", i);
@@ -3577,39 +3835,73 @@ static void inbox_screen(struct app *a)
free(fn);
}
int view = LINES - 4;
+ if (n == 0)
+ sel = 0;
+ else if (sel >= (int)n)
+ sel = (int)n - 1;
+ if (sel < top)
+ top = sel;
+ if (sel >= top + view)
+ top = sel - view + 1;
if (top > (int)n - view)
top = (int)n - view;
if (top < 0)
top = 0;
frame("Underlag (inkorg)");
for (size_t i = (size_t)top; i < n && (int)(i - (size_t)top) < view;
- i++)
- mvaddnstr(2 + (int)(i - (size_t)top), 2, items[i], COLS - 4);
+ i++) {
+ int y = 2 + (int)(i - (size_t)top);
+ if ((int)i == sel)
+ attron(A_REVERSE);
+ mvaddnstr(y, 2, items[i], COLS - 4);
+ if ((int)i == sel)
+ attroff(A_REVERSE);
+ }
if (n == 0)
mvaddstr(2, 2, "Inga obokförda underlag.");
- hints("a/^N = lägg till fil PgUp/PgDn, Home/End rullar F5 ="
- " uppdatera Esc/q = tillbaka");
+ hints("Enter = hämta k = koppla till verifikat a/^N = lägg till"
+ " fil PgUp/PgDn, Home/End rullar F5 = uppdatera Esc/q ="
+ " tillbaka");
refresh();
int ch = ui_getch();
+ int64_t sel_id = (sel >= 0 && (size_t)sel < n) ? ids[sel] : 0;
for (size_t i = 0; i < n; i++)
free(items[i]);
free(items);
+ free(ids);
free(resp);
if (ch == 27 || ch == 'q')
return;
- if (ch == KEY_DOWN && top + view < (int)n)
- top++;
- else if (ch == KEY_UP && top > 0)
- top--;
+ if ((ch == '\n' || ch == '\r' || ch == KEY_ENTER) && sel_id)
+ attachment_download(a, sel_id);
+ else if (ch == KEY_DOWN && sel + 1 < (int)n)
+ sel++;
+ else if (ch == KEY_UP && sel > 0)
+ sel--;
else if (ch == KEY_NPAGE)
- top += view;
+ sel = sel + view < (int)n ? sel + view : (int)n - 1;
else if (ch == KEY_PPAGE)
- top -= view;
+ sel = sel - view > 0 ? sel - view : 0;
else if (ch == KEY_HOME)
- top = 0;
+ sel = 0;
else if (ch == KEY_END)
- top = (int)n > view ? (int)n - view : 0;
- else if (ch == 'a' || ch == KEY_CTRL_N) {
+ sel = n > 0 ? (int)n - 1 : 0;
+ else if (ch == 'k' && sel_id) {
+ int64_t vid = pick_voucher(a);
+ if (vid) {
+ char kargs[64];
+ snprintf(kargs, sizeof kargs,
+ "{\"id\":%lld,\"voucher_id\":%lld}",
+ (long long)sel_id, (long long)vid);
+ char *r = client_rpc(&a->conn, "attachment.link", a->session,
+ a->org, kargs);
+ if (r && client_ok(r))
+ message("Underlag", "Kopplat till verifikatet.");
+ else
+ show_error("Kunde inte koppla", r);
+ free(r);
+ }
+ } else if (ch == 'a' || ch == KEY_CTRL_N) {
char *path = file_browser(a, a->attachment_dir);
if (!path)
continue;
@@ -4894,7 +5186,7 @@ static void templates_screen(struct app *a)
names[n] = xstrdup("");
lines[n] = xstrdup("+ Ny mall (^N)");
int s = select_list("Mallar", lines, (int)n + 1, cursor, 1, &cursor, 1,
- n > 0, 0);
+ n > 0 ? "arkivera" : NULL, 0);
if (s == -1) {
for (size_t i = 0; i <= n; i++) {
free(names[i]);
@@ -5348,7 +5640,7 @@ static int select_org(struct app *a)
free(role);
}
int sel = select_list("Välj organisation att representera", items, (int)n,
- 0, 0, NULL, 0, 0, 0);
+ 0, 0, NULL, 0, NULL, 0);
for (size_t i = 0; i < n; i++)
free(items[i]);
free(items);