aboutsummaryrefslogtreecommitdiff
path: root/clients/ui.c
diff options
context:
space:
mode:
Diffstat (limited to 'clients/ui.c')
-rw-r--r--clients/ui.c674
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);
+}