From 8f99f1650e4a7363af1d83190b4082fb72a9f73f Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 25 Aug 2026 19:02:35 +0200 Subject: ttym 3.0: fix terminal and config bugs, fold terminal-only patches into base Correctness fixes: - restore the terminal on SIGQUIT and SIGHUP. atexit never runs on an uncaught fatal signal, so Ctrl+\ left the cursor hidden and autowrap disabled. - drop the unconditional newline in restore_tty. Every run emitted a stray blank line, including piped output. - treat EINTR as an interruption rather than a poll() failure, which delayed Ctrl+C by up to one 100ms frame. config-file patch: - make comment stripping quote-aware so a value may contain '#', and strip one matched pair of quotes. bar_fill = "#" was silently dropped, despite '#' being the glyph of the hash style this build ships. - resolve bar_fill/bar_empty inside resolve_bar and clear them on --bar, so command-line flags beat the config file as documented. Previously config glyphs silently overrode --bar. - report unknown keys, unparseable booleans and malformed lines on stderr. flash = enabled silently meant off. - never write to disk. --dump-config prints a commented template on stdout instead of the binary creating ~/.config/ttym/config on first run. Patch set: - fold persist-alert, flash and bar-styles into the base. The base is now terminal-only: it opens /dev/tty, draws and exits, with no file writes and no subprocesses. Everything still in patches/ changes what the program touches. - seven patches become four; config-file's prerequisites drop from six patches to one (notify), so it is regenerable mechanically. - regenerate every patch. All apply with zero fuzz and compile. Source: - rewrite argv in place rather than into a malloc'd copy (C99 5.1.2.2.1), removing die(), four free() calls and . - fold the duplicated elapsed-time expression into elapsed(). - drop dead code: have_any, (void)cfg, and an unreachable branch in parse_duration. - replace hand-counted padding with \033[K. Docs and build: - timer.1: -c is the third hook argument, not the fourth. Add EXIT STATUS (a stopwatch always exits 130 -- it has no natural end) and ENVIRONMENT. Document config precedence and quoting. - wire VERSION into a dist target; it was defined and never used. - track .gitignore, which previously ignored itself, so a fresh clone had no ignore rules. Claude-Session: https://claude.ai/code/session_01APLBs8RB1FcUaC4viVkzbP --- patches/ttym-full-3.0.diff | 961 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 961 insertions(+) create mode 100644 patches/ttym-full-3.0.diff (limited to 'patches/ttym-full-3.0.diff') diff --git a/patches/ttym-full-3.0.diff b/patches/ttym-full-3.0.diff new file mode 100644 index 0000000..166c869 --- /dev/null +++ b/patches/ttym-full-3.0.diff @@ -0,0 +1,961 @@ +diff -ruN a/config.def.h b/config.def.h +--- a/config.def.h 2026-08-25 17:18:15.750123947 +0200 ++++ b/config.def.h 2026-08-25 17:18:15.750123947 +0200 +@@ -1,9 +1,32 @@ + /* See LICENSE file for copyright and license details. */ + ++/* Defaults below are compile-time. Many can be overridden at runtime by ++ * /etc/ttym.conf and ~/.config/ttym/config; command-line flags override ++ * both. Run `timer --dump-config` for a commented template. See timer(1). ++ */ ++ + /* progress bar glyphs (ASCII default) */ + static const char *const BAR_FILL = ">"; + static const char *const BAR_EMPTY = "-"; + ++/* desktop notification on countdown completion. ++ * Tokens are split on whitespace; placeholders {title} and {msg} are expanded. ++ * Set NULL or "" to disable. ++ */ ++static const char *const TITLE = "Timer"; ++static const char *const NOTIFY_CMD = "notify-send -u critical {title} {msg}"; ++ ++/* sound on countdown completion. If SOUND_CMD is "" the binary autodetects ++ * a player (paplay/aplay/mpv/ffplay) and plays a default freedesktop sound. ++ * SOUND_CMD is split on whitespace; same token rules as NOTIFY_CMD. ++ */ ++static const char *const SOUND_CMD = ""; ++ ++/* log location is ~/.config/ttym/ttym.log (honours $XDG_CONFIG_HOME). ++ * One-shot migration: ~/.config/timer/ -> ~/.config/ttym/ if the new ++ * directory doesn't already exist; legacy log file inside is renamed. ++ */ ++ + /* persistent alert: keep ringing the bell until any key is pressed. + * Foreground-only; backgrounded runs always fall through. + * Override per-run with --no-persist. +diff -ruN a/config.h b/config.h +--- a/config.h 2026-08-25 17:18:15.750123947 +0200 ++++ b/config.h 2026-08-25 17:18:15.750123947 +0200 +@@ -1,9 +1,32 @@ + /* See LICENSE file for copyright and license details. */ + ++/* Defaults below are compile-time. Many can be overridden at runtime by ++ * /etc/ttym.conf and ~/.config/ttym/config; command-line flags override ++ * both. Run `timer --dump-config` for a commented template. See timer(1). ++ */ ++ + /* progress bar glyphs (ASCII default) */ + static const char *const BAR_FILL = ">"; + static const char *const BAR_EMPTY = "-"; + ++/* desktop notification on countdown completion. ++ * Tokens are split on whitespace; placeholders {title} and {msg} are expanded. ++ * Set NULL or "" to disable. ++ */ ++static const char *const TITLE = "Timer"; ++static const char *const NOTIFY_CMD = "notify-send -u critical {title} {msg}"; ++ ++/* sound on countdown completion. If SOUND_CMD is "" the binary autodetects ++ * a player (paplay/aplay/mpv/ffplay) and plays a default freedesktop sound. ++ * SOUND_CMD is split on whitespace; same token rules as NOTIFY_CMD. ++ */ ++static const char *const SOUND_CMD = ""; ++ ++/* log location is ~/.config/ttym/ttym.log (honours $XDG_CONFIG_HOME). ++ * One-shot migration: ~/.config/timer/ -> ~/.config/ttym/ if the new ++ * directory doesn't already exist; legacy log file inside is renamed. ++ */ ++ + /* persistent alert: keep ringing the bell until any key is pressed. + * Foreground-only; backgrounded runs always fall through. + * Override per-run with --no-persist. +Binary files a/timer and b/timer differ +diff -ruN a/ttym.c b/ttym.c +--- a/ttym.c 2026-08-25 17:18:15.750123947 +0200 ++++ b/ttym.c 2026-08-25 17:18:15.750123947 +0200 +@@ -13,6 +13,10 @@ + #include + #include + #include ++#include ++#include ++#include ++#include + #include + #include + #include +@@ -24,9 +28,16 @@ + typedef struct { + int quiet; /* -q: suppress completion bell */ + int silent; /* -s: no bell, no extras */ ++ char title[256]; /* runtime config may override */ ++ char notify_cmd[512]; /* runtime config may override */ ++ char sound_cmd[512]; /* runtime config may override */ ++ char msg[1024]; ++ char comment[512]; /* -c TEXT: logged, and passed as 3rd arg to hooks */ + int alert_persist; /* alert loop until keypress; --no-persist disables */ + int flash; /* reverse-video flash during alert loop */ + char bar_style[32]; /* --bar STYLE */ ++ char bar_fill[16]; /* runtime config: explicit glyph, beats bar_style */ ++ char bar_empty[16]; /* runtime config: explicit glyph, beats bar_style */ + } Config; + + static struct termios oldtios; +@@ -236,6 +247,540 @@ + fflush(stdout); + } + ++/* ===== subprocess + placeholder expansion ===== ++ * Tokens split on whitespace; {title} and {msg} expanded within each token. ++ * No shell involved — argv stays argv. ++ */ ++ ++static int ++build_argv(const char *cmd, const Config *c, char ***out) ++{ ++ char buf[2048]; ++ snprintf(buf, sizeof(buf), "%s", cmd); ++ char **argv = calloc(64, sizeof(char *)); ++ if (!argv) return -1; ++ int argc = 0; ++ char *p = buf; ++ while (*p && argc < 63) { ++ while (*p && isspace((unsigned char)*p)) p++; ++ if (!*p) break; ++ char *start = p; ++ while (*p && !isspace((unsigned char)*p)) p++; ++ if (*p) *p++ = '\0'; ++ char tok[2048]; size_t oi = 0; ++ for (char *q = start; *q && oi < sizeof(tok) - 1; ) { ++ const char *sub = NULL; size_t skip = 0; ++ if (!strncmp(q, "{msg}", 5)) { sub = c->msg; skip = 5; } ++ else if (!strncmp(q, "{title}", 7)) { sub = c->title; skip = 7; } ++ if (sub) { ++ size_t sl = strlen(sub); ++ if (oi + sl >= sizeof(tok)) sl = sizeof(tok) - 1 - oi; ++ memcpy(tok + oi, sub, sl); ++ oi += sl; q += skip; ++ } else { tok[oi++] = *q++; } ++ } ++ tok[oi] = '\0'; ++ argv[argc++] = strdup(tok); ++ } ++ argv[argc] = NULL; ++ *out = argv; ++ return argc; ++} ++ ++static void ++free_argv(char **argv) ++{ ++ if (!argv) return; ++ for (int i = 0; argv[i]; i++) free(argv[i]); ++ free(argv); ++} ++ ++static void ++spawn_bg(char *const argv[]) ++{ ++ pid_t pid = fork(); ++ if (pid == 0) { ++ setsid(); ++ int devnull = open("/dev/null", O_RDWR); ++ if (devnull >= 0) { ++ dup2(devnull, 0); dup2(devnull, 1); dup2(devnull, 2); ++ if (devnull > 2) close(devnull); ++ } ++ execvp(argv[0], argv); ++ _exit(127); ++ } ++ (void)pid; ++} ++ ++static int ++command_exists(const char *name) ++{ ++ const char *path = getenv("PATH"); ++ if (!path) return 0; ++ char buf[512]; ++ const char *p = path; ++ while (*p) { ++ const char *e = strchr(p, ':'); ++ size_t len = e ? (size_t)(e - p) : strlen(p); ++ if (len > 0 && len + strlen(name) + 2 < sizeof(buf)) { ++ snprintf(buf, sizeof(buf), "%.*s/%s", (int)len, p, name); ++ if (access(buf, X_OK) == 0) return 1; ++ } ++ if (!e) break; ++ p = e + 1; ++ } ++ return 0; ++} ++ ++static const char * ++detect_sound_player(void) ++{ ++ static const char *cands[] = { "paplay", "aplay", "mpv", "ffplay", NULL }; ++ for (int i = 0; cands[i]; i++) ++ if (command_exists(cands[i])) return cands[i]; ++ return NULL; ++} ++ ++static const char * ++default_sound_path(void) ++{ ++ static const char *paths[] = { ++ "/usr/share/sounds/freedesktop/stereo/complete.oga", ++ "/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga", ++ "/usr/share/sounds/alsa/Front_Center.wav", ++ NULL ++ }; ++ for (int i = 0; paths[i]; i++) ++ if (access(paths[i], R_OK) == 0) return paths[i]; ++ return NULL; ++} ++ ++static void ++notify_and_sound(const Config *c) ++{ ++ if (c->notify_cmd[0] && command_exists("notify-send")) { ++ char **a; ++ if (build_argv(c->notify_cmd, c, &a) > 0) { ++ spawn_bg(a); ++ free_argv(a); ++ } ++ } ++ if (!c->quiet) { ++ if (c->sound_cmd[0]) { ++ char **a; ++ if (build_argv(c->sound_cmd, c, &a) > 0) { ++ spawn_bg(a); ++ free_argv(a); ++ } ++ } else { ++ const char *pl = detect_sound_player(); ++ const char *sp = default_sound_path(); ++ if (pl && sp) { ++ char *a[] = { (char *)pl, (char *)sp, NULL }; ++ spawn_bg(a); ++ } ++ } ++ } ++} ++ ++/* ===== log (TSV: tssecondscomment) ===== */ ++ ++static int ++build_config_path(char *buf, size_t bufsz, const char *suffix) ++{ ++ const char *home = getenv("HOME"); ++ const char *xdg = getenv("XDG_CONFIG_HOME"); ++ if (xdg && *xdg) snprintf(buf, bufsz, "%s/ttym/%s", xdg, suffix); ++ else if (home && *home) snprintf(buf, bufsz, "%s/.config/ttym/%s", home, suffix); ++ else return -1; ++ return 0; ++} ++ ++static void ++migrate_legacy_dir(void) ++{ ++ const char *home = getenv("HOME"); ++ const char *xdg = getenv("XDG_CONFIG_HOME"); ++ char base[512]; ++ if (xdg && *xdg) snprintf(base, sizeof(base), "%s", xdg); ++ else if (home && *home) snprintf(base, sizeof(base), "%s/.config", home); ++ else return; ++ char old_dir[600], new_dir[600]; ++ snprintf(old_dir, sizeof(old_dir), "%s/timer", base); ++ snprintf(new_dir, sizeof(new_dir), "%s/ttym", base); ++ struct stat sb; ++ if (stat(new_dir, &sb) == 0) return; ++ if (stat(old_dir, &sb) != 0 || !S_ISDIR(sb.st_mode)) return; ++ if (rename(old_dir, new_dir) != 0) return; ++ char old_log[700], new_log[700]; ++ snprintf(old_log, sizeof(old_log), "%s/timer.log", new_dir); ++ snprintf(new_log, sizeof(new_log), "%s/ttym.log", new_dir); ++ if (stat(old_log, &sb) == 0) rename(old_log, new_log); ++} ++ ++static int ++mkdir_p(const char *path, mode_t mode) ++{ ++ char buf[1024]; ++ snprintf(buf, sizeof(buf), "%s", path); ++ for (char *p = buf + 1; *p; p++) { ++ if (*p == '/') { ++ *p = '\0'; ++ if (mkdir(buf, mode) != 0 && errno != EEXIST) return -1; ++ *p = '/'; ++ } ++ } ++ if (mkdir(buf, mode) != 0 && errno != EEXIST) return -1; ++ return 0; ++} ++ ++static void ++iso_local(char *buf, size_t bufsz) ++{ ++ time_t t = time(NULL); ++ struct tm tm; ++ localtime_r(&t, &tm); ++ strftime(buf, bufsz, "%Y-%m-%d %H:%M:%S", &tm); ++} ++ ++static void ++sanitize_field(char *s) ++{ ++ for (; *s; s++) ++ if (*s == '\t' || *s == '\n' || *s == '\r') *s = ' '; ++} ++ ++static int ++is_old_format(const char *line) ++{ ++ return strlen(line) >= 11 && line[10] == 'T'; ++} ++ ++static void ++migrate_line(const char *line, char *out, size_t outsz) ++{ ++ if (strlen(line) < 19) { snprintf(out, outsz, "%s", line); return; } ++ char date[11], tpart[9]; ++ memcpy(date, line, 10); date[10] = '\0'; ++ memcpy(tpart, line + 11, 8); tpart[8] = '\0'; ++ const char *tab = strchr(line, '\t'); ++ if (!tab) { snprintf(out, outsz, "%s %s\n", date, tpart); return; } ++ snprintf(out, outsz, "%s %s%s", date, tpart, tab); ++} ++ ++static void ++migrate_log_if_needed(const char *path) ++{ ++ FILE *f = fopen(path, "r"); ++ if (!f) return; ++ char probe[2048]; ++ int needs = 0; ++ while (fgets(probe, sizeof(probe), f)) ++ if (is_old_format(probe)) { needs = 1; break; } ++ if (!needs) { fclose(f); return; } ++ rewind(f); ++ char tmp[1024]; ++ snprintf(tmp, sizeof(tmp), "%s.tmp", path); ++ FILE *out = fopen(tmp, "w"); ++ if (!out) { fclose(f); return; } ++ char line[2048]; ++ while (fgets(line, sizeof(line), f)) { ++ if (is_old_format(line)) { ++ char conv[2048]; ++ migrate_line(line, conv, sizeof(conv)); ++ fputs(conv, out); ++ } else fputs(line, out); ++ } ++ fclose(f); ++ fclose(out); ++ rename(tmp, path); ++} ++ ++static void ++write_log(long duration_s, const char *comment) ++{ ++ char path[512]; ++ if (build_config_path(path, sizeof(path), "ttym.log") < 0) return; ++ char dir[512]; ++ snprintf(dir, sizeof(dir), "%s", path); ++ char *slash = strrchr(dir, '/'); ++ if (slash) { *slash = '\0'; mkdir_p(dir, 0700); } ++ migrate_log_if_needed(path); ++ int fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0600); ++ if (fd < 0) return; ++ char ts[32]; iso_local(ts, sizeof(ts)); ++ char safe[512]; snprintf(safe, sizeof(safe), "%s", comment ? comment : ""); ++ sanitize_field(safe); ++ char line[1024]; ++ int n = snprintf(line, sizeof(line), "%s\t%ld\t%s\n", ts, duration_s, safe); ++ if (n > 0) { ssize_t w = write(fd, line, (size_t)n); (void)w; } ++ close(fd); ++} ++ ++static int ++read_log_lines(char ***out_lines, size_t *out_n) ++{ ++ char path[512]; ++ if (build_config_path(path, sizeof(path), "ttym.log") < 0) return -1; ++ migrate_log_if_needed(path); ++ FILE *f = fopen(path, "r"); ++ if (!f) { *out_lines = NULL; *out_n = 0; return 0; } ++ size_t cap = 64, n = 0; ++ char **lines = malloc(cap * sizeof(char *)); ++ if (!lines) { fclose(f); return -1; } ++ char buf[2048]; ++ while (fgets(buf, sizeof(buf), f)) { ++ size_t l = strlen(buf); ++ if (l && buf[l-1] == '\n') buf[l-1] = '\0'; ++ if (!*buf) continue; ++ if (n >= cap) { ++ cap *= 2; ++ char **nl = realloc(lines, cap * sizeof(char *)); ++ if (!nl) break; ++ lines = nl; ++ } ++ lines[n++] = strdup(buf); ++ } ++ fclose(f); ++ *out_lines = lines; ++ *out_n = n; ++ return 0; ++} ++ ++static void ++free_lines(char **lines, size_t n) ++{ ++ if (!lines) return; ++ for (size_t i = 0; i < n; i++) free(lines[i]); ++ free(lines); ++} ++ ++static int ++split_log_line(char *line, char **ts, long *secs, char **comment) ++{ ++ char *t1 = strchr(line, '\t'); if (!t1) return -1; ++ *t1 = '\0'; ++ char *t2 = strchr(t1 + 1, '\t'); if (!t2) return -1; ++ *t2 = '\0'; ++ char *end; ++ long v = strtol(t1 + 1, &end, 10); ++ if (end == t1 + 1) return -1; ++ *ts = line; *secs = v; *comment = t2 + 1; ++ return 0; ++} ++ ++static void ++fmt_long(long s, char *buf, size_t bufsz) ++{ ++ if (s < 0) s = 0; ++ long d = s / 86400; s %= 86400; ++ long h = s / 3600; s %= 3600; ++ long m = s / 60; ++ long sec = s % 60; ++ snprintf(buf, bufsz, "%02ld %02ld:%02ld:%02ld", d, h, m, sec); ++} ++ ++static int ++cmd_read(int argc, char **argv) ++{ ++ int n_tail = 0, raw = 0; ++ for (int i = 0; i < argc; i++) { ++ if (!strcmp(argv[i], "-n") && i + 1 < argc) { ++ n_tail = atoi(argv[++i]); ++ if (n_tail < 0) n_tail = 0; ++ } else if (!strcmp(argv[i], "--raw")) { ++ raw = 1; ++ } else { ++ fprintf(stderr, "timer -r: unknown arg: %s\n", argv[i]); ++ return 1; ++ } ++ } ++ char **lines = NULL; size_t n = 0; ++ if (read_log_lines(&lines, &n) < 0) return 1; ++ if (n == 0) { free_lines(lines, n); return 0; } ++ size_t start = 0; ++ if (n_tail > 0 && (size_t)n_tail < n) start = n - n_tail; ++ if (raw) { ++ for (size_t i = start; i < n; i++) puts(lines[i]); ++ } else { ++ printf("%-19s %-11s %s\n", "TIMESTAMP", "DURATION", "COMMENT"); ++ for (size_t i = start; i < n; i++) { ++ char *copy = strdup(lines[i]); ++ char *ts, *cmt; long secs; ++ if (split_log_line(copy, &ts, &secs, &cmt) == 0) { ++ char dur[32]; ++ fmt_long(secs, dur, sizeof(dur)); ++ printf("%-19s %-11s %s\n", ts, dur, cmt); ++ } else { ++ puts(lines[i]); ++ } ++ free(copy); ++ } ++ } ++ free_lines(lines, n); ++ return 0; ++} ++ ++static int ++parse_log_ts(const char *ts, struct tm *out) ++{ ++ memset(out, 0, sizeof(*out)); ++ if (sscanf(ts, "%d-%d-%d %d:%d:%d", ++ &out->tm_year, &out->tm_mon, &out->tm_mday, ++ &out->tm_hour, &out->tm_min, &out->tm_sec) != 6) return -1; ++ out->tm_year -= 1900; ++ out->tm_mon -= 1; ++ out->tm_isdst = -1; ++ return 0; ++} ++ ++typedef struct TagAcc { ++ char tag[128]; ++ long secs; ++ long count; ++ struct TagAcc *next; ++} TagAcc; ++ ++static void ++tag_add(TagAcc **head, const char *tag, long secs) ++{ ++ for (TagAcc *a = *head; a; a = a->next) ++ if (!strcmp(a->tag, tag)) { a->secs += secs; a->count++; return; } ++ TagAcc *n = calloc(1, sizeof(*n)); ++ if (!n) return; ++ snprintf(n->tag, sizeof(n->tag), "%s", tag); ++ n->secs = secs; ++ n->count = 1; ++ n->next = *head; ++ *head = n; ++} ++ ++static void ++extract_tags(const char *comment, long secs, TagAcc **head) ++{ ++ const char *p = comment; ++ int found = 0; ++ while (*p) { ++ int at_boundary = (p == comment) || isspace((unsigned char)*(p-1)); ++ if (*p == '+' && at_boundary) { ++ const char *s = p + 1, *e = s; ++ while (*e && !isspace((unsigned char)*e)) e++; ++ if (e > s) { ++ char buf[128]; ++ size_t len = (size_t)(e - s); ++ if (len >= sizeof(buf)) len = sizeof(buf) - 1; ++ memcpy(buf, s, len); buf[len] = '\0'; ++ tag_add(head, buf, secs); ++ found = 1; ++ } ++ p = e; ++ } else p++; ++ } ++ if (!found) tag_add(head, "(untagged)", secs); ++} ++ ++static int ++cmd_stats(int argc, char **argv) ++{ ++ const char *period = (argc > 0) ? argv[0] : "today"; ++ time_t now = time(NULL); ++ struct tm now_tm; localtime_r(&now, &now_tm); ++ time_t cutoff = 0; ++ if (!strcmp(period, "all")) cutoff = 0; ++ else if (!strcmp(period, "today")) { ++ struct tm t = now_tm; t.tm_hour = t.tm_min = t.tm_sec = 0; ++ cutoff = mktime(&t); ++ } else if (!strcmp(period, "week")) { ++ struct tm t = now_tm; t.tm_hour = t.tm_min = t.tm_sec = 0; ++ int back = (t.tm_wday == 0) ? 6 : t.tm_wday - 1; ++ t.tm_mday -= back; ++ cutoff = mktime(&t); ++ } else if (!strcmp(period, "month")) { ++ struct tm t = now_tm; t.tm_hour = t.tm_min = t.tm_sec = 0; t.tm_mday = 1; ++ cutoff = mktime(&t); ++ } else { ++ fprintf(stderr, "stats: unknown period '%s' (today|week|month|all)\n", period); ++ return 1; ++ } ++ char **lines = NULL; size_t n = 0; ++ if (read_log_lines(&lines, &n) < 0) return 1; ++ long total = 0, count = 0; ++ TagAcc *tags = NULL; ++ for (size_t i = 0; i < n; i++) { ++ char *copy = strdup(lines[i]); ++ char *ts, *cmt; long secs; ++ if (split_log_line(copy, &ts, &secs, &cmt) != 0) { free(copy); continue; } ++ struct tm tm; ++ if (parse_log_ts(ts, &tm) != 0) { free(copy); continue; } ++ time_t entry = mktime(&tm); ++ if (cutoff && entry < cutoff) { free(copy); continue; } ++ total += secs; count++; ++ extract_tags(cmt, secs, &tags); ++ free(copy); ++ } ++ free_lines(lines, n); ++ char tot[32]; fmt_long(total, tot, sizeof(tot)); ++ printf("\033[1m== timer stats: %s ==\033[0m\n", period); ++ printf("entries: %ld total: %s\n\n", count, tot); ++ int tag_n = 0; ++ for (TagAcc *a = tags; a; a = a->next) tag_n++; ++ if (tag_n > 0) { ++ TagAcc **arr = malloc(tag_n * sizeof(*arr)); ++ int i = 0; ++ for (TagAcc *a = tags; a; a = a->next) arr[i++] = a; ++ for (int x = 0; x < tag_n - 1; x++) ++ for (int y = x + 1; y < tag_n; y++) ++ if (arr[y]->secs > arr[x]->secs) { ++ TagAcc *t = arr[x]; arr[x] = arr[y]; arr[y] = t; ++ } ++ printf("%-24s %-11s %5s\n", "TAG", "DURATION", "COUNT"); ++ for (int z = 0; z < tag_n; z++) { ++ char d[32]; fmt_long(arr[z]->secs, d, sizeof(d)); ++ printf("%-24s %-11s %5ld\n", arr[z]->tag, d, arr[z]->count); ++ } ++ free(arr); ++ } ++ while (tags) { TagAcc *next = tags->next; free(tags); tags = next; } ++ return 0; ++} ++ ++/* ===== hooks ===== */ ++ ++static int ++build_hook_path(char *buf, size_t bufsz, const char *name) ++{ ++ const char *home = getenv("HOME"); ++ const char *xdg = getenv("XDG_CONFIG_HOME"); ++ if (xdg && *xdg) snprintf(buf, bufsz, "%s/ttym/hooks/%s", xdg, name); ++ else if (home && *home) snprintf(buf, bufsz, "%s/.config/ttym/hooks/%s", home, name); ++ else return -1; ++ return 0; ++} ++ ++static void ++run_hook(const char *name, const char *mode, long duration_s, const char *comment) ++{ ++ char path[512]; ++ if (build_hook_path(path, sizeof(path), name) < 0) return; ++ if (access(path, X_OK) != 0) return; ++ char dur[32]; ++ snprintf(dur, sizeof(dur), "%ld", duration_s); ++ pid_t pid = fork(); ++ if (pid == 0) { ++ int devnull = open("/dev/null", O_RDWR); ++ if (devnull >= 0) { ++ dup2(devnull, 0); dup2(devnull, 1); dup2(devnull, 2); ++ if (devnull > 2) close(devnull); ++ } ++ char *argv[] = { path, (char *)mode, dur, ++ (char *)(comment ? comment : ""), NULL }; ++ execvp(argv[0], argv); ++ _exit(127); ++ } else if (pid > 0) { ++ waitpid(pid, NULL, 0); ++ } ++} ++ + /* ===== alert loop ===== + * Bell + poll for keypress, ~1.5s per beat; 4-min safety cap. + */ +@@ -328,6 +873,178 @@ + } + *fill = s ? s->fill : BAR_FILL; + *empty = s ? s->empty : BAR_EMPTY; ++ /* explicit glyphs from the config file beat the named style; --bar ++ * clears them so a flag always wins over the file. */ ++ if (c->bar_fill[0]) *fill = c->bar_fill; ++ if (c->bar_empty[0]) *empty = c->bar_empty; ++} ++ ++/* ===== runtime config file ===== ++ * key = value lines. '#' starts a comment only at the start of a line or ++ * after whitespace, and never inside quotes. Quote a value to protect ++ * surrounding space or a leading '#'; one matched pair is stripped. Unknown keys and ++ * unparseable values are reported on stderr rather than silently dropped. ++ * Precedence: command-line flags > ~/.config/ttym/config > /etc/ttym.conf ++ * > the compile-time defaults in config.h. ++ */ ++ ++static int ++build_cfg_path(char *buf, size_t bufsz, const char *name) ++{ ++ const char *xdg = getenv("XDG_CONFIG_HOME"); ++ const char *home = getenv("HOME"); ++ if (xdg && *xdg) snprintf(buf, bufsz, "%s/ttym/%s", xdg, name); ++ else if (home && *home) snprintf(buf, bufsz, "%s/.config/ttym/%s", home, name); ++ else return -1; ++ return 0; ++} ++ ++static void ++trim(char *s) ++{ ++ char *p = s; ++ while (*p && isspace((unsigned char)*p)) p++; ++ if (p != s) memmove(s, p, strlen(p) + 1); ++ size_t n = strlen(s); ++ while (n > 0 && isspace((unsigned char)s[n - 1])) s[--n] = '\0'; ++} ++ ++static void ++strip_comment(char *s) ++{ ++ int q = 0; ++ for (char *p = s; *p; p++) { ++ if (q) { ++ if (*p == q) q = 0; ++ } else if (*p == '"' || *p == '\'') { ++ q = *p; ++ } else if (*p == '#' && (p == s || isspace((unsigned char)p[-1]))) { ++ *p = '\0'; ++ return; ++ } ++ } ++} ++ ++static void ++unquote(char *s) ++{ ++ size_t n = strlen(s); ++ if (n >= 2 && (s[0] == '"' || s[0] == '\'') && s[n - 1] == s[0]) { ++ memmove(s, s + 1, n - 2); ++ s[n - 2] = '\0'; ++ } ++} ++ ++static int ++cfg_bool(const char *path, int ln, const char *k, const char *v, int cur) ++{ ++ if (!strcasecmp(v, "true") || !strcasecmp(v, "yes") || ++ !strcasecmp(v, "on") || !strcmp(v, "1")) return 1; ++ if (!strcasecmp(v, "false") || !strcasecmp(v, "no") || ++ !strcasecmp(v, "off") || !strcmp(v, "0")) return 0; ++ fprintf(stderr, "%s:%d: %s: expected on or off, got: %s\n", path, ln, k, v); ++ return cur; ++} ++ ++static void ++load_config_file(Config *c, const char *path) ++{ ++ FILE *f = fopen(path, "r"); ++ if (!f) return; ++ char line[1024]; ++ int ln = 0; ++ while (fgets(line, sizeof(line), f)) { ++ ln++; ++ strip_comment(line); ++ char *eq = strchr(line, '='); ++ if (!eq) { ++ trim(line); ++ if (*line) ++ fprintf(stderr, "%s:%d: not key = value: %s\n", path, ln, line); ++ continue; ++ } ++ *eq = '\0'; ++ char *k = line, *v = eq + 1; ++ trim(k); trim(v); unquote(v); ++ if (!*k) { ++ fprintf(stderr, "%s:%d: missing key\n", path, ln); ++ continue; ++ } ++ if (!strcmp(k, "bar_fill")) snprintf(c->bar_fill, sizeof(c->bar_fill), "%s", v); ++ else if (!strcmp(k, "bar_empty")) snprintf(c->bar_empty, sizeof(c->bar_empty), "%s", v); ++ else if (!strcmp(k, "title")) snprintf(c->title, sizeof(c->title), "%s", v); ++ else if (!strcmp(k, "notify_cmd")) snprintf(c->notify_cmd, sizeof(c->notify_cmd), "%s", v); ++ else if (!strcmp(k, "sound_cmd")) snprintf(c->sound_cmd, sizeof(c->sound_cmd), "%s", v); ++ else if (!strcmp(k, "flash")) c->flash = cfg_bool(path, ln, k, v, c->flash); ++ else if (!strcmp(k, "alert_persist")) c->alert_persist = cfg_bool(path, ln, k, v, c->alert_persist); ++ else if (!strcmp(k, "bar_style")) { ++ if (find_bar_style(v)) ++ snprintf(c->bar_style, sizeof(c->bar_style), "%s", v); ++ else ++ fprintf(stderr, "%s:%d: unknown bar style: %s\n", path, ln, v); ++ } ++ else fprintf(stderr, "%s:%d: unknown key: %s\n", path, ln, k); ++ } ++ fclose(f); ++} ++ ++static void ++load_config(Config *c) ++{ ++ load_config_file(c, "/etc/ttym.conf"); ++ char path[512]; ++ if (build_cfg_path(path, sizeof(path), "config") == 0) ++ load_config_file(c, path); ++} ++ ++/* Written to stdout by --dump-config; ttym never creates this file itself. ++ * timer --dump-config > ~/.config/ttym/config ++ */ ++static void ++dump_config(FILE *f) ++{ ++ fputs( ++"# ttym configuration\n" ++"#\n" ++"# Read from /etc/ttym.conf first, then ~/.config/ttym/config\n" ++"# (honours $XDG_CONFIG_HOME). Command-line flags override both; an absent\n" ++"# or commented-out key keeps the compile-time default from config.h.\n" ++"#\n" ++"# Syntax: key = value. '#' begins a comment at the start of a line or after\n" ++"# whitespace. Quote a value to keep surrounding spaces or a leading '#';\n" ++"# one matched pair of quotes is stripped.\n" ++"# ---------------------------------------------------------------------------\n" ++"\n" ++"# Progress-bar glyphs. Set BOTH or NEITHER -- mismatched display widths\n" ++"# break bar alignment. Multi-byte UTF-8 is fine. These beat bar_style,\n" ++"# but --bar on the command line beats them.\n" ++"# bar_fill = > # a '#' glyph must be quoted: bar_fill = \"#\"\n" ++"# bar_empty = -\n" ++"\n" ++"# Named progress-bar style. One of:\n" ++"# unicode ascii hash dots line block arrow minimal\n" ++"# Leave unset to auto-detect (unicode on a UTF-8 locale, otherwise ascii).\n" ++"# bar_style = unicode\n" ++"\n" ++"# Reverse-video flash during the completion alert. on | off\n" ++"# flash = on\n" ++"\n" ++"# Persistent alert: keep ringing the bell until a key is pressed\n" ++"# (foreground runs only). on | off\n" ++"# alert_persist = on\n" ++"\n" ++"# Desktop-notification title.\n" ++"# title = Timer\n" ++"\n" ++"# Desktop-notification command run when a countdown finishes. Tokens are\n" ++"# split on whitespace; {title}/{msg} placeholders expand. Empty disables.\n" ++"# notify_cmd = notify-send -u critical {title} {msg}\n" ++"\n" ++"# Sound played when a countdown finishes. Leave unset to auto-detect a\n" ++"# player (paplay/aplay/mpv/ffplay) and play a default system sound.\n" ++"# Tokens are split on whitespace; {title}/{msg} placeholders expand.\n" ++"# sound_cmd = paplay /usr/share/sounds/freedesktop/stereo/complete.oga\n", ++ f); + } + + /* ===== usage ===== +@@ -340,20 +1057,26 @@ + { + fprintf(f, + "usage:\n" +-" timer [FLAGS] DURATION countdown\n" +-" timer [FLAGS] stopwatch\n" ++" timer [FLAGS] DURATION [MESSAGE...] countdown\n" ++" timer [FLAGS] stopwatch\n" ++" timer -r [-n N] [--raw] read log\n" ++" timer --stats [today|week|month|all] stats (default: today)\n" + "\n" + "DURATION: 25m | 90s | 2h | 1h30m | 01:30:00 | 25:00\n" + "FLAGS:\n" ++" -c TEXT comment for log (+tag tokens are extracted)\n" + " -q no sound\n" + " -s silent\n" + " --no-persist skip alert-until-keypress loop\n" + " --flash on|off terminal flash during alert loop\n" + " --bar STYLE bar style: unicode|ascii|hash|dots|line|block|arrow|minimal\n" ++" --dump-config print a commented config template on stdout and exit\n" + " -- end of options\n" + " -h help\n" + "\n" +-"controls: [space] pause/resume [q | Ctrl+C] quit\n"); ++"controls: [space] pause/resume [q | Ctrl+C] quit\n" ++"hooks: ~/.config/ttym/hooks/{on_start,on_done,on_quit} (executable)\n" ++"config: /etc/ttym.conf, then ~/.config/ttym/config (--dump-config)\n"); + } + + /* ===== run loop ===== */ +@@ -393,6 +1116,9 @@ + if (out_tty) + fputs("\033[?25l\033[?7l", stdout); + ++ run_hook("on_start", stopwatch ? "stopwatch" : "countdown", ++ stopwatch ? 0 : total, cfg->comment); ++ + long start_ms = now_ms(); + long pause_accum = 0; + long pause_start = 0; +@@ -457,6 +1183,7 @@ + printf("\r\033[32mSTOP %s\033[0m\033[K\n", el); + else + printf("STOP %s\n", el); ++ run_hook("on_quit", "stopwatch", elapsed_s, cfg->comment); + } else if (user_quit) { + char el[24]; + fmt_clock(elapsed_s, el, sizeof(el)); +@@ -464,6 +1191,7 @@ + printf("\r\033[33mQUIT at %s\033[0m\033[K\n", el); + else + printf("QUIT at %s\n", el); ++ run_hook("on_quit", "countdown", elapsed_s, cfg->comment); + } else { + char tb[24]; + fmt_clock(total, tb, sizeof(tb)); +@@ -471,6 +1199,8 @@ + printf("\r\033[32mDONE %s 100%%\033[0m\033[K\n", tb); + else + printf("DONE %s 100%%\n", tb); ++ if (!cfg->silent) notify_and_sound(cfg); ++ run_hook("on_done", "countdown", total, cfg->comment); + if (!cfg->silent && !cfg->quiet && out_tty) { + fputc('\a', stdout); + fflush(stdout); +@@ -482,6 +1212,7 @@ + } + } + ++ write_log(elapsed_s, cfg->comment); + return user_quit ? 130 : 0; + } + +@@ -490,9 +1221,21 @@ + int + main(int argc, char **argv) + { ++ migrate_legacy_dir(); ++ ++ if (argc >= 2 && !strcmp(argv[1], "-r")) ++ return cmd_read(argc - 2, argv + 2); ++ if (argc >= 2 && !strcmp(argv[1], "--stats")) ++ return cmd_stats(argc - 2, argv + 2); ++ + Config cfg = {0}; ++ snprintf(cfg.title, sizeof(cfg.title), "%s", TITLE ? TITLE : ""); ++ snprintf(cfg.notify_cmd, sizeof(cfg.notify_cmd), "%s", NOTIFY_CMD ? NOTIFY_CMD : ""); ++ snprintf(cfg.sound_cmd, sizeof(cfg.sound_cmd), "%s", SOUND_CMD ? SOUND_CMD : ""); ++ snprintf(cfg.msg, sizeof(cfg.msg), "%s", "Time is up"); + cfg.alert_persist = ALERT_PERSIST; + cfg.flash = FLASH; ++ load_config(&cfg); + + /* Pre-strip long-form flags before getopt. Stop at "--". */ + int nargc = 0, past_dd = 0; +@@ -513,17 +1256,23 @@ + } + if (!past_dd && !strcmp(argv[i], "--bar") && i + 1 < argc) { + snprintf(cfg.bar_style, sizeof(cfg.bar_style), "%s", argv[++i]); ++ cfg.bar_fill[0] = cfg.bar_empty[0] = '\0'; + continue; + } ++ if (!past_dd && !strcmp(argv[i], "--dump-config")) { ++ dump_config(stdout); ++ return 0; ++ } + argv[nargc++] = argv[i]; + } + argv[nargc] = NULL; + + int opt; +- while ((opt = getopt(nargc, argv, "qsh")) != -1) { ++ while ((opt = getopt(nargc, argv, "qsc:h")) != -1) { + switch (opt) { + case 'q': cfg.quiet = 1; break; + case 's': cfg.silent = 1; break; ++ case 'c': snprintf(cfg.comment, sizeof(cfg.comment), "%s", optarg); break; + case 'h': usage(stdout); return 0; + default: usage(stderr); return 1; + } +@@ -539,6 +1288,16 @@ + fprintf(stderr, "bad duration: %s\n", rem_argv[0]); + return 1; + } ++ if (rem_argc > 1) { ++ size_t off = 0; ++ cfg.msg[0] = '\0'; ++ for (int i = 1; i < rem_argc && off < sizeof(cfg.msg) - 2; i++) { ++ int n = snprintf(cfg.msg + off, sizeof(cfg.msg) - off, ++ "%s%s", (i > 1 ? " " : ""), rem_argv[i]); ++ if (n < 0) break; ++ off += (size_t)n; ++ } ++ } + } + + return run(total, stopwatch, &cfg); -- cgit v1.3