From 07f5b140922d13519ad7a0d1dad67a9b403be9f1 Mon Sep 17 00:00:00 2001 From: Anders Betts Date: Tue, 22 Sep 2026 12:43:52 +0200 Subject: tui: kundutkast, , explicit Spara och F2-åtgärder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 5 +- clients/drafts.c | 249 ++++++++++++++++++++++++ clients/drafts.h | 49 +++++ clients/screens_invoices.c | 465 ++++++++++++++++++++++++++++++++++++--------- clients/tui.c | 189 ++++++++++++++++++ clients/tui.h | 23 +++ docs/DECISIONS.md | 5 +- docs/STATE.md | 27 +-- docs/TUI-GUIDELINES.md | 37 ++-- scripts/tui-golden.py | 59 ++++++ tests/test_tui.c | 95 +++++++++ 11 files changed, 1078 insertions(+), 125 deletions(-) create mode 100644 clients/drafts.c create mode 100644 clients/drafts.h diff --git a/Makefile b/Makefile index 6b246a3..d27a270 100644 --- a/Makefile +++ b/Makefile @@ -58,7 +58,8 @@ TUI_SCREEN_OBJ = $(patsubst %.c,$(BUILD)/%.o,$(TUI_SCREENS)) TUI_SCREEN_DEP = $(TUI_SCREEN_OBJ:.o=.d) $(BUILD)/bokftui: $(BUILD)/clients/bokftui.o $(BUILD)/clients/ui.o \ - $(TUI_SCREEN_OBJ) $(BUILD)/clients/tui.o \ + $(BUILD)/clients/drafts.o $(TUI_SCREEN_OBJ) \ + $(BUILD)/clients/tui.o \ $(BUILD)/clients/client.o \ $(BUILD)/src/util.o $(BUILD)/src/log.o $(BUILD)/src/formula.o \ $(BUILD)/vendor/yyjson.o $(BUILD)/vendor/sha256.o @@ -69,6 +70,7 @@ $(BUILD)/test_core: $(BUILD)/tests/test_core.o $(BUILD)/clients/client.o \ $(CC) $(CFLAGS) -o $@ $^ -lm $(SSL_LIBS) $(BUILD)/test_tui: $(BUILD)/tests/test_tui.o $(BUILD)/clients/tui.o \ + $(BUILD)/clients/drafts.o \ $(BUILD)/src/util.o $(BUILD)/src/log.o $(BUILD)/vendor/yyjson.o \ $(BUILD)/vendor/sha256.o $(CC) $(CFLAGS) -o $@ $^ -lm -lncursesw @@ -156,6 +158,7 @@ $(BUILD)/tests/%.o: tests/%.c $(BUILD)/clients/bokfctl.d $(BUILD)/clients/bokftui.d \ $(BUILD)/clients/ui.d $(TUI_SCREEN_DEP) \ $(BUILD)/clients/tui.d $(BUILD)/clients/client.d \ + $(BUILD)/clients/drafts.d \ $(BUILD)/tests/test_core.d $(BUILD)/tests/test_tui.d \ $(BUILD)/tests/pdf_check.d $(BUILD)/tests/invoice_check.d \ $(BUILD)/tests/smtp_check.d \ diff --git a/clients/drafts.c b/clients/drafts.c new file mode 100644 index 0000000..f71e85c --- /dev/null +++ b/clients/drafts.c @@ -0,0 +1,249 @@ +#include "drafts.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "util.h" +#include "yyjson.h" + +static void mkdirs_for(const char *path) +{ + char tmp[640]; + + if (snprintf(tmp, sizeof tmp, "%s", path) >= (int)sizeof tmp) + return; + for (char *p = tmp + 1; *p; p++) { + if (*p != '/') + continue; + *p = '\0'; + mkdir(tmp, 0700); + *p = '/'; + } +} + +static void cache_path(char *buf, size_t n) +{ + const char *cache = getenv("XDG_CACHE_HOME"); + + if (cache && *cache) { + snprintf(buf, n, "%s/bokf/drafts.json", cache); + return; + } + const char *home = getenv("HOME"); + snprintf(buf, n, "%s/.cache/bokf/drafts.json", + home && *home ? home : "."); +} + +static void rec_clear(struct draft *r) +{ + free(r->entity); + free(r->id); + free(r->fields); +} + +void drafts_free(struct drafts *d) +{ + if (!d) + return; + for (size_t i = 0; i < d->n; i++) + rec_clear(&d->v[i]); + free(d->v); + d->v = NULL; + d->n = d->cap = 0; +} + +static struct draft *rec_find(const struct drafts *d, int64_t org, + const char *entity, const char *id) +{ + for (size_t i = 0; i < d->n; i++) { + struct draft *r = &d->v[i]; + + if (r->org == org && strcmp(r->entity, entity) == 0 && + strcmp(r->id, id) == 0) + return r; + } + return NULL; +} + +static void rec_put(struct drafts *d, int64_t org, const char *entity, + const char *id, const char *fields) +{ + struct draft *r = rec_find(d, org, entity, id); + + if (!r) { + if (d->n == d->cap) { + d->cap = d->cap ? d->cap * 2 : 8; + d->v = xrealloc(d->v, d->cap * sizeof *d->v); + } + r = &d->v[d->n++]; + memset(r, 0, sizeof *r); + r->org = org; + r->entity = xstrdup(entity); + r->id = xstrdup(id); + r->fields = xstrdup(fields && *fields ? fields : "{}"); + return; + } + free(r->fields); + r->fields = xstrdup(fields && *fields ? fields : "{}"); +} + +static void drafts_write(const struct drafts *d) +{ + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = yyjson_mut_obj(doc); + + yyjson_mut_doc_set_root(doc, root); + yyjson_mut_obj_add_int(doc, root, "version", 1); + yyjson_mut_val *arr = yyjson_mut_arr(doc); + for (size_t i = 0; i < d->n; i++) { + const struct draft *r = &d->v[i]; + yyjson_mut_val *o = yyjson_mut_arr_add_obj(doc, arr); + yyjson_mut_obj_add_int(doc, o, "org", r->org); + yyjson_mut_obj_add_strcpy(doc, o, "entity", r->entity); + yyjson_mut_obj_add_strcpy(doc, o, "id", r->id); + yyjson_mut_val *f = yyjson_mut_rawcpy(doc, r->fields); + yyjson_mut_obj_add_val(doc, o, "fields", f ? f : yyjson_mut_obj(doc)); + } + yyjson_mut_obj_add_val(doc, root, "drafts", arr); + char *json = yyjson_mut_write(doc, YYJSON_WRITE_PRETTY, NULL); + yyjson_mut_doc_free(doc); + if (!json) + return; + + mkdirs_for(d->path); + char tmp[640]; + if (snprintf(tmp, sizeof tmp, "%s.tmp", d->path) >= (int)sizeof tmp) { + free(json); + return; + } + int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd >= 0) { + FILE *f = fdopen(fd, "w"); + if (f) { + fputs(json, f); + fclose(f); + rename(tmp, d->path); + } else { + close(fd); + } + } + free(json); +} + +static void drafts_load(struct drafts *d) +{ + FILE *f = fopen(d->path, "rb"); + if (!f) + return; + struct buf b; + char tmp[8192]; + size_t k; + buf_init(&b); + while ((k = fread(tmp, 1, sizeof tmp, f)) > 0) + buf_append(&b, tmp, k); + fclose(f); + yyjson_doc *doc = yyjson_read((const char *)b.p, b.len, 0); + buf_free(&b); + if (!doc) + return; + yyjson_val *arr = yyjson_obj_get(yyjson_doc_get_root(doc), "drafts"); + if (yyjson_is_arr(arr)) { + size_t idx, max; + yyjson_val *it; + yyjson_arr_foreach(arr, idx, max, it) { + yyjson_val *org = yyjson_obj_get(it, "org"); + yyjson_val *entity = yyjson_obj_get(it, "entity"); + yyjson_val *id = yyjson_obj_get(it, "id"); + yyjson_val *fields = yyjson_obj_get(it, "fields"); + if (!yyjson_is_int(org) || !yyjson_is_str(entity) || + !yyjson_is_str(id) || !yyjson_is_obj(fields)) + continue; + char *ft = yyjson_val_write(fields, 0, NULL); + rec_put(d, yyjson_get_sint(org), yyjson_get_str(entity), + yyjson_get_str(id), ft ? ft : "{}"); + free(ft); + } + } + yyjson_doc_free(doc); +} + +void drafts_init_path(struct drafts *d, const char *path) +{ + memset(d, 0, sizeof *d); + snprintf(d->path, sizeof d->path, "%s", path); + drafts_load(d); +} + +void drafts_init(struct drafts *d) +{ + char path[600]; + cache_path(path, sizeof path); + drafts_init_path(d, path); +} + +const char *drafts_get(const struct drafts *d, int64_t org, + const char *entity, const char *id) +{ + const struct draft *r = rec_find(d, org, entity, id); + return r ? r->fields : NULL; +} + +int drafts_have(const struct drafts *d, int64_t org, const char *entity, + const char *id) +{ + return rec_find(d, org, entity, id) != NULL; +} + +void drafts_put(struct drafts *d, int64_t org, const char *entity, + const char *id, const char *fields) +{ + rec_put(d, org, entity, id, fields); + drafts_write(d); +} + +void drafts_del(struct drafts *d, int64_t org, const char *entity, + const char *id) +{ + struct draft *r = rec_find(d, org, entity, id); + + if (!r) + return; + size_t i = (size_t)(r - d->v); + rec_clear(r); + memmove(&d->v[i], &d->v[i + 1], (d->n - i - 1) * sizeof *d->v); + d->n--; + drafts_write(d); +} + +size_t drafts_count(const struct drafts *d, int64_t org, const char *entity) +{ + size_t n = 0; + + for (size_t i = 0; i < d->n; i++) + if (d->v[i].org == org && strcmp(d->v[i].entity, entity) == 0) + n++; + return n; +} + +void drafts_foreach(const struct drafts *d, int64_t org, const char *entity, + void (*cb)(void *ud, const char *id, const char *fields), + void *ud) +{ + for (size_t i = 0; i < d->n; i++) + if (d->v[i].org == org && strcmp(d->v[i].entity, entity) == 0) + cb(ud, d->v[i].id, d->v[i].fields); +} + +char *drafts_new_id(void) +{ + static unsigned seq; + char buf[64]; + + snprintf(buf, sizeof buf, "ny-%lld-%u", (long long)time(NULL), ++seq); + return xstrdup(buf); +} diff --git a/clients/drafts.h b/clients/drafts.h new file mode 100644 index 0000000..2cc67db --- /dev/null +++ b/clients/drafts.h @@ -0,0 +1,49 @@ +#ifndef BOKF_DRAFTS_H +#define BOKF_DRAFTS_H + +#include +#include + +/* Client-local edit drafts: every non-committed edit is mirrored to + $XDG_CACHE_HOME/bokf/drafts.json (mode 0600, atomic replace) so a reload, + a crash or Ctrl+C never loses work. A record is keyed by + (org, entity, id); a new entity uses a temporary id from + drafts_new_id(). See docs/TUI-GUIDELINES.md "Interaction model". */ + +struct draft { + int64_t org; + char *entity; + char *id; + char *fields; /* JSON object text */ +}; + +struct drafts { + struct draft *v; + size_t n, cap; + char path[600]; +}; + +void drafts_init(struct drafts *d); +/* Explicit path, for tests. */ +void drafts_init_path(struct drafts *d, const char *path); +void drafts_free(struct drafts *d); + +/* The draft's fields JSON, or NULL; the store owns the string. */ +const char *drafts_get(const struct drafts *d, int64_t org, + const char *entity, const char *id); +int drafts_have(const struct drafts *d, int64_t org, const char *entity, + const char *id); +/* Inserts or replaces the draft and persists the file. */ +void drafts_put(struct drafts *d, int64_t org, const char *entity, + const char *id, const char *fields); +void drafts_del(struct drafts *d, int64_t org, const char *entity, + const char *id); +size_t drafts_count(const struct drafts *d, int64_t org, const char *entity); +void drafts_foreach(const struct drafts *d, int64_t org, const char *entity, + void (*cb)(void *ud, const char *id, const char *fields), + void *ud); + +/* "ny-