diff options
-rw-r--r-- | Makefile | 25 | ||||
-rw-r--r-- | README.7 | 17 | ||||
-rw-r--r-- | config.c | 22 | ||||
-rwxr-xr-x | configure | 24 | ||||
-rw-r--r-- | database.h | 17 | ||||
-rw-r--r-- | litterbox.1 | 86 | ||||
-rw-r--r-- | litterbox.c | 79 | ||||
-rw-r--r-- | scoop.1 | 76 | ||||
-rw-r--r-- | scoop.c | 96 | ||||
-rw-r--r-- | unscoop.1 | 101 | ||||
-rw-r--r-- | unscoop.c | 161 | ||||
-rw-r--r-- | xdg.c | 103 |
12 files changed, 531 insertions, 276 deletions
diff --git a/Makefile b/Makefile index f0f7e75..7c09875 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,25 @@ PREFIX ?= /usr/local -MANDIR ?= ${PREFIX}/share/man -ETCDIR ?= ${PREFIX}/etc +BINDIR ?= ${PREFIX}/bin +MANDIR ?= ${PREFIX}/man CFLAGS += -std=c11 -Wall -Wextra -Wpedantic -LDLIBS = -lsqlite3 -ltls +LDADD.sqlite3 = -lsqlite3 +LDADD.libtls = -ltls BINS = litterbox scoop unscoop MANS = ${BINS:=.1} -include config.mk +LDLIBS.litterbox = ${LDADD.sqlite3} ${LDADD.libtls} +LDLIBS.scoop = ${LDADD.sqlite3} +LDLIBS.unscoop = ${LDADD.sqlite3} + OBJS.litterbox = litterbox.o config.o xdg.o OBJS.scoop = scoop.o xdg.o OBJS.unscoop = unscoop.o xdg.o OBJS = ${OBJS.litterbox} ${OBJS.scoop} ${OBJS.unscoop} -FORMATS = generic catgirl irc textual - dev: tags all test all: ${BINS} @@ -27,15 +30,15 @@ scoop: ${OBJS.scoop} unscoop: ${OBJS.unscoop} -.o: - ${CC} ${LDFLAGS} ${OBJS.$@} ${LDLIBS} -o $@ +${BINS}: + ${CC} ${LDFLAGS} ${OBJS.$@} ${LDLIBS.$@} -o $@ ${OBJS}: database.h test: .test .test: unscoop - set -e; for format in ${FORMATS}; do ./unscoop -! -f $$format; done + for f in $$(./unscoop -f ?); do ./unscoop -! -f $$f || exit 1; done touch .test tags: *.[ch] @@ -45,10 +48,10 @@ clean: rm -f ${BINS} ${OBJS} .test tags install: ${BINS} ${MANS} - install -d ${DESTDIR}${PREFIX}/bin ${DESTDIR}${MANDIR}/man1 - install ${BINS} ${DESTDIR}${PREFIX}/bin + install -d ${DESTDIR}${BINDIR} ${DESTDIR}${MANDIR}/man1 + install ${BINS} ${DESTDIR}${BINDIR} install -m 644 ${MANS} ${DESTDIR}${MANDIR}/man1 uninstall: - rm -f ${BINS:%=${DESTDIR}${PREFIX}/bin/%} + rm -f ${BINS:%=${DESTDIR}${BINDIR}/%} rm -f ${MANS:%=${DESTDIR}${MANDIR}/man1/%} diff --git a/README.7 b/README.7 index ddff5e9..a33b34c 100644 --- a/README.7 +++ b/README.7 @@ -1,4 +1,4 @@ -.Dd August 27, 2020 +.Dd October 21, 2023 .Dt README 7 .Os "Causal Agency" . @@ -26,6 +26,11 @@ A web interface for .Nm is provided by .Lk https://git.causal.agency/scooper scooper . +Some formats of plain text logs +can be imported into the +.Nm +database with +.Xr unscoop 1 . . .Sh INSTALLING .Nm @@ -71,13 +76,11 @@ PKG_CONFIG_PATH=/usr/local/lib/pkgconfig ./configure . .Pp On -.Fx -and .Ox the recommended way to run .Nm is with the process supervisor -.Lk https://git.causal.agency/catsit catsit . +.Lk https://git.causal.agency/kitd kitd . . .Sh FILES .Bl -tag -width "litterbox.c" -compact @@ -104,6 +107,10 @@ Contributions in any form can be sent to For sending patches by email, see .Aq Lk https://git-send-email.io . . +.Pp +Monetary contributions can be +.Lk https://liberapay.com/june/donate "donated via Liberapay" . +. .Sh SEE ALSO .Xr litterbox 1 , .Xr scoop 1 , @@ -118,7 +125,7 @@ Web interface: .Lk https://git.causal.agency/scooper scooper .It .Rs -.%A June Bug +.%A June McEnroe .%T IRC Suite .%U https://text.causal.agency/010-irc-suite.txt .%D June 19, 2020 diff --git a/config.c b/config.c index b8b4efa..3448e0d 100644 --- a/config.c +++ b/config.c @@ -1,4 +1,4 @@ -/* Copyright (C) 2019 C. McEnroe <june@causal.agency> +/* Copyright (C) 2019 June McEnroe <june@causal.agency> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -56,21 +56,17 @@ int getopt_config( const char *optstring, const struct option *longopts, int *longindex ) { static int opt; - if (opt >= 0) { - opt = getopt_long(argc, argv, optstring, longopts, longindex); - } - if (opt >= 0) return opt; - for (;;) { if (!file) { - if (optind < argc) { - num = 0; - path = argv[optind++]; - file = configOpen(path, "r"); - if (!file) return clean('?'); - } else { - return clean(-1); + if (optind == argc) return clean(-1); + if (opt >= 0 && argv[optind][0] == '-') { + opt = getopt_long(argc, argv, optstring, longopts, longindex); + if (opt >= 0 || optind == argc) return clean(opt); } + num = 0; + path = argv[optind++]; + file = configOpen(path, "r"); + if (!file) return clean('?'); } for (;;) { diff --git a/configure b/configure index bf9d132..3314f75 100755 --- a/configure +++ b/configure @@ -4,27 +4,28 @@ set -eu cflags() { echo "CFLAGS += $*" } -ldlibs() { - echo "LDLIBS ${o:-}= $*" - o=+ -} -config() { - pkg-config --print-errors "$@" - cflags $(pkg-config --cflags "$@") - ldlibs $(pkg-config --libs "$@") -} defstr() { cflags "-D'$1=\"$2\"'" } defvar() { defstr "$1" "$(pkg-config --variable=$3 $2)${4:-}" } +ldadd() { + lib=$1; shift + echo "LDADD.${lib} = $*" +} +config() { + pkg-config --print-errors "$@" + cflags $(pkg-config --cflags "$@") + for lib; do ldadd $lib $(pkg-config --libs $lib); done +} exec >config.mk for opt; do case "${opt}" in (--prefix=*) echo "PREFIX = ${opt#*=}" ;; + (--bindir=*) echo "BINDIR = ${opt#*=}" ;; (--mandir=*) echo "MANDIR = ${opt#*=}" ;; (*) echo "warning: unsupported option ${opt}" >&2 ;; esac @@ -32,17 +33,14 @@ done case "$(uname)" in (OpenBSD) - ldlibs -ltls config sqlite3 - defvar SQLITE3_BIN sqlite3 exec_prefix /bin/sqlite3 ;; (Linux) cflags -D_GNU_SOURCE config sqlite3 libtls - defvar SQLITE3_BIN sqlite3 exec_prefix /bin/sqlite3 ;; (*) config sqlite3 libtls - defvar SQLITE3_BIN sqlite3 exec_prefix /bin/sqlite3 ;; esac +defvar SQLITE3_BIN sqlite3 exec_prefix /bin/sqlite3 diff --git a/database.h b/database.h index 15704f0..a6db904 100644 --- a/database.h +++ b/database.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2019 C. McEnroe <june@causal.agency> +/* Copyright (C) 2019 June McEnroe <june@causal.agency> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -41,11 +41,10 @@ #define SQL(...) #__VA_ARGS__ #define ARRAY_LEN(a) (sizeof(a) / sizeof((a)[0])) -const char *configPath(const char **dirs, const char *path); -const char *dataPath(const char **dirs, const char *path); +char *configPath(char *buf, size_t cap, const char *path, int i); +char *dataPath(char *buf, size_t cap, const char *path, int i); FILE *configOpen(const char *path, const char *mode); FILE *dataOpen(const char *path, const char *mode); -void dataMkdir(const char *path); int getopt_config( int argc, char *const *argv, const char *optstring, const struct option *longopts, int *longindex @@ -114,13 +113,14 @@ static inline void dbFind(const char *path, int flags) { errx(EX_NOINPUT, "%s: database not found", path); } + char buf[PATH_MAX]; if (flags & SQLITE_OPEN_CREATE) { - dataMkdir(""); + int error = mkdir(dataPath(buf, sizeof(buf), "", 0), S_IRWXU); + if (error && errno != EEXIST) err(EX_CANTCREAT, "%s", buf); } - const char *dirs = NULL; - while (NULL != (path = dataPath(&dirs, DatabasePath))) { - dbOpen(path, flags); + for (int i = 0; dataPath(buf, sizeof(buf), DatabasePath, i); ++i) { + dbOpen(buf, flags); if (db) return; } errx(EX_NOINPUT, "database not found; initialize it with litterbox -i"); @@ -152,7 +152,6 @@ static inline void dbClose(void) { free(persist); persist = prev; } - dbExec(SQL(PRAGMA optimize;)); sqlite3_close(db); } diff --git a/litterbox.1 b/litterbox.1 index b66d609..c54a576 100644 --- a/litterbox.1 +++ b/litterbox.1 @@ -1,4 +1,4 @@ -.Dd August 22, 2020 +.Dd April 3, 2021 .Dt LITTERBOX 1 .Os . @@ -19,17 +19,18 @@ .Op Fl l Ar limit .Op Fl n Ar nick .Op Fl p Ar port +.Op Fl t Ar trust .Op Fl u Ar user .Op Fl w Ar pass .Op Ar config ... . .Nm -.Op Fl d Ar path .Fl i | m +.Op Fl d Ar path . .Nm -.Op Fl d Ar path .Fl b Ar path +.Op Fl d Ar path . .Sh DESCRIPTION The @@ -39,7 +40,7 @@ which may be queried with .Xr scoop 1 . Messages, notices, joins, parts, quits, kicks, nick changes, -topic changes +topic changes, bans and unbans are recorded in the database. The contents of messages are indexed for full-text search. @@ -64,10 +65,13 @@ it will be migrated automatically when starts, or can be migrated explicitly with .Fl m . +Live backups of the database +can be made with +.Fl b . Instances of .Nm connected to different IRC networks -can share the same database. +will share the same database. . .Pp Options can be loaded from @@ -75,9 +79,10 @@ files listed on the command line. Files are searched for in .Pa $XDG_CONFIG_DIRS/litterbox unless the path starts with -.Ql / +.Ql / , +.Ql \&./ or -.Ql \&. . +.Ql \&../ . Each option is placed on a line, and lines beginning with .Ql # @@ -89,13 +94,13 @@ following their corresponding flags. The arguments are as follows: . .Bl -tag -width "-h host" -.It Fl N Ar name , Cm network = Ar name +.It Fl N Ar name | Cm network = Ar name Set the network name to be used if the server does not send .Sy RPL_ISUPPORT NETWORK . The default is the server hostname. . -.It Fl Q , Cm public-query +.It Fl Q | Cm public-query Enable the public search query interface. This allows anyone to perform searches in private messages to @@ -111,10 +116,8 @@ The searchable columns are .Li user , .Li target , .Li message . -For search query syntax, see -.Aq Lk https://www.sqlite.org/fts5.html#full_text_query_syntax . . -.It Fl U Ar url , Cm scooper-url = Ar url +.It Fl U Ar url | Cm scooper-url = Ar url Set the base URL of a .Xr scooper 1 instance @@ -130,7 +133,7 @@ Perform a live database backup to and exit. This operation requires SQLite version 3.27.0 or newer. . -.It Fl c Ar path , Cm cert = Ar path +.It Fl c Ar path | Cm cert = Ar path Load the TLS client certificate from .Ar path and authenticate with SASL EXTERNAL, @@ -143,23 +146,24 @@ If the private key is in a separate file, it is loaded with .Fl k . . -.It Fl d Ar path , Cm database = Ar path +.It Fl d Ar path | Cm database = Ar path Set the path to the database file. -The default paths are documented in -.Sx FILES . +See +.Sx FILES +for the default paths. . -.It Fl h Ar host , Cm host = Ar host +.It Fl h Ar host | Cm host = Ar host Connect to .Ar host . . .It Fl i Initialize the database and exit. . -.It Fl j Ar chan , Cm join = Ar chan +.It Fl j Ar chan | Cm join = Ar chan Join the comma-separated list of channels .Ar chan . . -.It Fl k Ar path , Cm priv = Ar path +.It Fl k Ar path | Cm priv = Ar path Load the TLS client private key from .Ar path . The @@ -167,7 +171,7 @@ The is searched for in the same manner as configuration files. . -.It Fl l Ar limit , Cm limit = Ar limit +.It Fl l Ar limit | Cm limit = Ar limit Limit the number of results in the search query interface enabled by @@ -180,18 +184,18 @@ The default limit is 10. Migrate the database to the latest format and exit. . -.It Fl n Ar nick , Cm nick = Ar nick +.It Fl n Ar nick | Cm nick = Ar nick Set the nickname to .Ar nick . The default nickname is .Dq litterbox . . -.It Fl p Ar port , Cm port = Ar port +.It Fl p Ar port | Cm port = Ar port Connect to .Ar port . The default port is 6697. . -.It Fl q , Cm private-query +.It Fl q | Cm private-query Enable the private search query interface. When connected to .Xr pounce 1 , @@ -209,20 +213,27 @@ The searchable columns are .Li user , .Li target , .Li message . -For search query syntax, see -.Aq Lk https://www.sqlite.org/fts5.html#full_text_query_syntax . . -.It Fl u Ar user , Cm user = Ar user +.It Fl t Ar path | Cm trust = Ar path +Trust the self-signed certificate loaded from +.Ar path +and disable server name verification. +The +.Ar path +is searched for in the same manner +as configuration files. +. +.It Fl u Ar user | Cm user = Ar user Set the username to .Ar user . The default username is the same as the nickname. . -.It Fl v , Cm verbose +.It Fl v | Cm verbose Write sent and received IRC messages as well as SQL INSERT statements to standard error. . -.It Fl w Ar pass , Cm pass = Ar pass +.It Fl w Ar pass | Cm pass = Ar pass Log in with the server password .Ar pass . .El @@ -235,9 +246,7 @@ Configuration files are searched for first in usually .Pa ~/.config , followed by the colon-separated list of paths -.Ev $XDG_CONFIG_DIRS , -usually -.Pa /etc/xdg . +.Ev $XDG_CONFIG_DIRS . . .It Pa $XDG_DATA_DIRS/litterbox/litterbox.sqlite The database file is searched for first in @@ -245,11 +254,10 @@ The database file is searched for first in usually .Pa ~/.local/share , followed by the colon-separated list of paths -.Ev $XDG_DATA_DIRS , -usually -.Pa /usr/local/share:/usr/share . +.Ev $XDG_DATA_DIRS . +. .It Pa ~/.local/share/litterbox/litterbox.sqlite -The most likely default path to the database file. +The most likely default path of the database file. .El . .Sh EXAMPLES @@ -269,6 +277,8 @@ public-query .Sh SEE ALSO .Xr scoop 1 , .Xr unscoop 1 +.Pp +.Lk https://www.sqlite.org/fts5.html#full_text_query_syntax "Full-text Query Syntax" . .Sh STANDARDS .Bl -item @@ -321,11 +331,11 @@ daemon implements the and .Sy causal.agency/passive vendor-specific IRCv3 capabilities -implemented by +offered by .Xr pounce 1 . . .Sh AUTHORS -.An June Bug Aq Mt june@causal.agency +.An June McEnroe Aq Mt june@causal.agency . .Sh BUGS Send mail to @@ -333,4 +343,4 @@ Send mail to or join .Li #ascii.town on -.Li chat.freenode.net . +.Li irc.tilde.chat . diff --git a/litterbox.c b/litterbox.c index c3bc46e..f33ed22 100644 --- a/litterbox.c +++ b/litterbox.c @@ -1,4 +1,4 @@ -/* Copyright (C) 2019 C. McEnroe <june@causal.agency> +/* Copyright (C) 2019 June McEnroe <june@causal.agency> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -128,6 +128,7 @@ static int searchLimit = 10; static char *self; static char *network; static char *chanTypes; +static char *statusmsg; static char *prefixes; static char *prefixModes; static char *listModes; @@ -182,6 +183,8 @@ static void handleReplyISupport(struct Message *msg) { set(&network, msg->params[i]); } else if (!strcmp(key, "CHANTYPES")) { set(&chanTypes, msg->params[i]); + } else if (!strcmp(key, "STATUSMSG")) { + set(&statusmsg, msg->params[i]); } else if (!strcmp(key, "PREFIX")) { strsep(&msg->params[i], "("); char *modes = strsep(&msg->params[i], ")"); @@ -214,8 +217,10 @@ static void handleReplyMOTD(struct Message *msg) { char *line = msg->params[1]; if (!strncmp(line, "- ", 2)) line += 2; size_t len = strlen(line); - if (motd.len + len + 1 > motd.cap) { - motd.cap = (motd.cap ? motd.cap * 2 : len + 1); + size_t req = motd.len + len + 1; + if (req > motd.cap) { + if (!motd.cap) motd.cap = 1024; + while (req > motd.cap) motd.cap *= 2; motd.buf = realloc(motd.buf, motd.cap); if (!motd.buf) err(EX_OSERR, "realloc"); } @@ -285,7 +290,7 @@ static void querySearch(struct Message *msg) { WHERE contexts.network = :network AND coalesce(contexts.query = :query, true) AND search MATCH :search - ORDER BY time DESC, event DESC + ORDER BY search.rowid DESC LIMIT :limit ) SELECT * FROM results @@ -315,7 +320,10 @@ static void querySearch(struct Message *msg) { if (!message) message = ""; if (!strcmp(user, "*")) user = nick; - format("NOTICE %s :(%s) [%s] ", msg->nick, context, time); + format( + "NOTICE %s :\3%02d%s\3: [%s] ", + msg->nick, color(context), context, time + ); switch (type) { break; case Privmsg: format("\3%d<%s>\3 %s\r\n", color(user), nick, message); @@ -424,21 +432,32 @@ static void insertEvent( dbRun(stmt); } +static enum Type messageType(struct Message *msg) { + if (msg->cmd[0] == 'N') return Notice; + if (strncmp(msg->params[1], "\1ACTION", 7)) return Privmsg; + if (msg->params[1][7] == ' ') { + msg->params[1] += 8; + } else if (msg->params[1][7] == '\1') { + msg->params[1] += 7; + } else { + return Privmsg; + } + size_t len = strlen(msg->params[1]); + if (msg->params[1][len - 1] == '\1') { + msg->params[1][len - 1] = '\0'; + } + return Action; +} + static void handlePrivmsg(struct Message *msg) { require(msg, true, 2); bool query = true; const char *context = msg->params[0]; + if (statusmsg) context += strspn(context, statusmsg); if (strchr(chanTypes, context[0])) query = false; if (!strcmp(context, self)) context = msg->nick; - - enum Type type = (!strcmp(msg->cmd, "NOTICE") ? Notice : Privmsg); - char *message = msg->params[1]; - if (!strncmp(message, "\1ACTION ", 8)) { - message += 8; - message[strcspn(message, "\1")] = '\0'; - type = Action; - } + enum Type type = messageType(msg); bool selfMessage = !strcmp(msg->nick, msg->params[0]); if (query && searchQuery && type == Privmsg) { @@ -451,7 +470,7 @@ static void handlePrivmsg(struct Message *msg) { insertContext(context, query); insertName(msg); - insertEvent(msg, type, context, NULL, message); + insertEvent(msg, type, context, NULL, msg->params[1]); } static void insertTopic( @@ -733,8 +752,9 @@ static void handle(struct Message *msg) { } static void atExit(void) { - if (client) tls_close(client); + dbExec(SQL(PRAGMA optimize;)); dbClose(); + if (client) tls_close(client); } static void quit(int sig) { @@ -747,12 +767,13 @@ static void quit(int sig) { int main(int argc, char *argv[]) { bool init = false; bool migrate = false; - const char *path = NULL; + const char *dbPath = NULL; const char *backup = NULL; bool insecure = false; const char *cert = NULL; const char *priv = NULL; + const char *trust = NULL; const char *defaultNetwork = NULL; const char *nick = "litterbox"; @@ -776,6 +797,7 @@ int main(int argc, char *argv[]) { { .val = 'n', .name = "nick", required_argument }, { .val = 'p', .name = "port", required_argument }, { .val = 'q', .name = "private-query", no_argument }, + { .val = 't', .name = "trust", required_argument }, { .val = 'u', .name = "user", required_argument }, { .val = 'v', .name = "verbose", no_argument }, { .val = 'w', .name = "pass", required_argument }, @@ -795,7 +817,7 @@ int main(int argc, char *argv[]) { break; case 'U': scooperURL = optarg; break; case 'b': backup = optarg; break; case 'c': cert = optarg; - break; case 'd': path = optarg; + break; case 'd': dbPath = optarg; break; case 'h': host = optarg; break; case 'i': init = true; break; case 'j': join = optarg; @@ -805,6 +827,7 @@ int main(int argc, char *argv[]) { break; case 'n': nick = optarg; break; case 'p': port = optarg; break; case 'q': searchQuery = Private; + break; case 't': trust = optarg; break; case 'u': user = optarg; break; case 'v': verbose = true; break; case 'w': pass = optarg; @@ -819,7 +842,7 @@ int main(int argc, char *argv[]) { int flags = SQLITE_OPEN_READWRITE; if (init) flags |= SQLITE_OPEN_CREATE; - dbFind(path, flags); + dbFind(dbPath, flags); atexit(atExit); if (init) { @@ -847,21 +870,26 @@ int main(int argc, char *argv[]) { client = tls_client(); if (!client) errx(EX_SOFTWARE, "tls_client"); + int error; + char path[PATH_MAX]; struct tls_config *config = tls_config_new(); if (!config) errx(EX_SOFTWARE, "tls_config_new"); - int error = tls_config_set_ciphers(config, "compat"); - if (error) { - errx(EX_SOFTWARE, "tls_config_set_ciphers: %s", tls_config_error(config)); - } if (insecure) { tls_config_insecure_noverifycert(config); tls_config_insecure_noverifyname(config); } + if (trust) { + tls_config_insecure_noverifyname(config); + for (int i = 0; configPath(path, sizeof(path), trust, i); ++i) { + error = tls_config_set_ca_file(config, path); + if (!error) break; + } + if (error) errx(EX_NOINPUT, "%s: %s", trust, tls_config_error(config)); + } if (cert) { - const char *dirs = NULL; - while (NULL != (path = configPath(&dirs, cert))) { + for (int i = 0; configPath(path, sizeof(path), cert, i); ++i) { if (priv) { error = tls_config_set_cert_file(config, path); } else { @@ -872,8 +900,7 @@ int main(int argc, char *argv[]) { if (error) errx(EX_NOINPUT, "%s: %s", cert, tls_config_error(config)); } if (priv) { - const char *dirs = NULL; - while (NULL != (path = configPath(&dirs, priv))) { + for (int i = 0; configPath(path, sizeof(path), priv, i); ++i) { error = tls_config_set_key_file(config, path); if (!error) break; } diff --git a/scoop.1 b/scoop.1 index 51ad6c7..8687e5d 100644 --- a/scoop.1 +++ b/scoop.1 @@ -1,4 +1,4 @@ -.Dd May 20, 2020 +.Dd May 18, 2021 .Dt SCOOP 1 .Os . @@ -8,7 +8,7 @@ . .Sh SYNOPSIS .Nm -.Op Fl Lgpqsv +.Op Fl Lgipqrsv .Op Fl D Ar date .Op Fl F Ar format .Op Fl N Ar network @@ -53,22 +53,25 @@ The arguments are as follows: Match events which occurred on .Ar date , of the format -.Ar YYYY-MM-DD -or the keyword -.Cm now . +.Ar YYYY-MM-DD . . .It Fl F Ar format Format timestamps using the .Ar format string, similar to -.Xr strftime 3 , -see -.Aq Lk https://www.sqlite.org/lang_datefunc.html . -The default format is ISO 8601. +.Xr strftime 3 . +The default format is +.Qq %Y-%m-%dT%H:%M:%S . . .It Fl L -Output timestamps in local time. +Output timestamps in local time +and interpret timestamps in +.Fl D , +.Fl a +and +.Fl b +as local time. . .It Fl N Ar network Match events from @@ -85,9 +88,10 @@ Match events .Po of type .Cm kick , -.Cm nick -or +.Cm nick , .Cm ban +or +.Cm unban .Pc with the target nickname or mask .Ar target . @@ -117,19 +121,20 @@ The default path is as in .It Fl f Ar format Set the output format to one of .Cm plain , -.Cm color , +.Cm color +or .Cm irc . +The default format is +.Cm color +if standard output is a terminal, +.Cm plain +otherwise. The .Cm irc format outputs events as IRC protocol with .Sy server-time tags. -The default format is -.Cm color -if standard output is a terminal, -.Cm plain -otherwise. . .It Fl g Group events by network and channel or query name. @@ -140,9 +145,14 @@ Implies Match events from users with the hostname .Ar host . . +.It Fl i +Use case-insensitive regular expression matching +with +.Fl m . +. .It Fl l Ar limit Limit the number of events matched, -ordered by most recent. +ordered by most recently inserted. Implies .Fl s . . @@ -152,6 +162,9 @@ matching the modern regular expression .Ar regexp . See .Xr re_format 7 . +To match case-insensitively, +use with +.Fl i . . .It Fl n Ar nick Match events from users with the nickname @@ -163,14 +176,18 @@ Match only events from channels. .It Fl q Match only events from queries. . +.It Fl r +Output results in reverse order. +. .It Fl s Sort the results from oldest to newest. By default events are output in the order they appear in the database. . -.It Fl t Ar type +.It Fl t Ar types Match events of -.Ar type . +the comma-separated list of +.Ar types . The types are .Cm privmsg , .Cm notice , @@ -208,8 +225,6 @@ The searchable columns are .Li user , .Li target , .Li message . -For search query syntax, see -.Aq Lk https://www.sqlite.org/fts5.html#full_text_query_syntax . .El . .Sh ENVIRONMENT @@ -218,18 +233,19 @@ For search query syntax, see The command to pipe terminal output to. The default is .Ev PAGER=less . -.It Ev SHELL -The shell used to run -.Ev PAGER . -The default is -.Pa /bin/sh . .El . .Sh SEE ALSO .Xr litterbox 1 +.Bl -item +.It +.Lk https://www.sqlite.org/lang_datefunc.html "Date and Time Functions" +.It +.Lk https://www.sqlite.org/fts5.html#full_text_query_syntax "Full-text Query Syntax" +.El . .Sh AUTHORS -.An June Bug Aq Mt june@causal.agency +.An June McEnroe Aq Mt june@causal.agency . .Sh BUGS Send mail to @@ -237,4 +253,4 @@ Send mail to or join .Li #ascii.town on -.Li chat.freenode.net . +.Li irc.tilde.chat . diff --git a/scoop.c b/scoop.c index 8c113d5..73aeb89 100644 --- a/scoop.c +++ b/scoop.c @@ -1,4 +1,4 @@ -/* Copyright (C) 2019 C. McEnroe <june@causal.agency> +/* Copyright (C) 2019 June McEnroe <june@causal.agency> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -245,6 +245,7 @@ static void regexpFree(void *_regex) { free(regex); } +static int regexpFlags = REG_EXTENDED | REG_NOSUB; static void regexp(sqlite3_context *ctx, int n, sqlite3_value *args[]) { assert(n == 2); if (sqlite3_value_type(args[0]) == SQLITE_NULL) { @@ -267,7 +268,7 @@ static void regexp(sqlite3_context *ctx, int n, sqlite3_value *args[]) { int error = regcomp( regex, (const char *)sqlite3_value_text(args[0]), - REG_EXTENDED | REG_NOSUB + regexpFlags ); if (error) { char msg[256]; @@ -283,7 +284,9 @@ static void regexp(sqlite3_context *ctx, int n, sqlite3_value *args[]) { sqlite3_result_int(ctx, !error); } -static char select[4096] = SQL( +enum { QueryCap = 4096 }; + +static char select[QueryCap] = SQL( SELECT events.event, contexts.network, @@ -306,8 +309,6 @@ static char select[4096] = SQL( events.target, ); -enum { QueryCap = 4096 }; - static char from[QueryCap] = SQL( FROM events JOIN contexts USING (context) @@ -344,6 +345,14 @@ static enum Type parseType(const char *input) { errx(EX_USAGE, "no such type %s", input); } +static int parseTypes(char *list) { + int mask = 0; + while (list) { + mask |= 1 << parseType(strsep(&list, ",")); + } + return mask; +} + int main(int argc, char *argv[]) { bool tty = isatty(STDOUT_FILENO); @@ -353,20 +362,33 @@ int main(int argc, char *argv[]) { bool sort = false; bool group = false; + bool reverse = false; const char *limit = NULL; int n = 0; - struct Bind binds[argc + 2]; - const char *Opts = "D:F:LN:ST:a:b:c:d:f:gh:l:m:n:pqst:u:vw:"; + struct Bind *binds = calloc(argc + 2, sizeof(*binds)); + if (!binds) err(EX_OSERR, "calloc"); + + const char *Opts = "D:F:LN:ST:a:b:c:d:f:gh:il:m:n:pqrst:u:vw:"; for (int opt; 0 < (opt = getopt(argc, argv, Opts));) { switch (opt) { break; case 'D': { append( where, SQL( - AND events.time >= strftime('%s', :date, 'start of day') - AND events.time - < strftime('%s', :date, 'start of day', '+1 day') + AND events.time >= + CASE WHEN :local THEN + strftime('%s', :date, 'start of day', 'utc') + ELSE + strftime('%s', :date, 'start of day') + END + AND events.time < + CASE WHEN :local THEN + strftime('%s', :date, 'start of day', '+1 day', + 'utc') + ELSE + strftime('%s', :date, 'start of day', '+1 day') + END ) ); binds[n++] = Bind(":date", optarg, 0); @@ -389,11 +411,29 @@ int main(int argc, char *argv[]) { binds[n++] = Bind(":target", optarg, 0); } break; case 'a': { - append(where, SQL(AND events.time >= strftime('%s', :after))); + append( + where, + SQL( + AND events.time >= + CASE WHEN :local + THEN strftime('%s', :after, 'utc') + ELSE strftime('%s', :after) + END + ) + ); binds[n++] = Bind(":after", optarg, 0); } break; case 'b': { - append(where, SQL(AND events.time < strftime('%s', :before))); + append( + where, + SQL( + AND events.time < + CASE WHEN :local + THEN strftime('%s', :before, 'utc') + ELSE strftime('%s', :before) + END + ) + ); binds[n++] = Bind(":before", optarg, 0); } break; case 'c': { @@ -414,6 +454,9 @@ int main(int argc, char *argv[]) { append(where, SQL(AND names.host = :host)); binds[n++] = Bind(":host", optarg, 0); } + break; case 'i': { + regexpFlags |= REG_ICASE; + } break; case 'l': { limit = optarg; sort = true; @@ -434,12 +477,15 @@ int main(int argc, char *argv[]) { append(where, SQL(AND contexts.query = :query)); binds[n++] = Bind(":query", NULL, 1); } + break; case 'r': { + reverse = true; + } break; case 's': { sort = true; } break; case 't': { - append(where, SQL(AND events.type = :type)); - binds[n++] = Bind(":type", NULL, parseType(optarg)); + append(where, SQL(AND (1 << events.type) & :types)); + binds[n++] = Bind(":types", NULL, parseTypes(optarg)); } break; case 'u': { append(where, SQL(AND names.user = :user)); @@ -456,7 +502,8 @@ int main(int argc, char *argv[]) { } } - if (optind < argc) { + bool search = (optind < argc); + if (search) { append(select, SQL(highlight(search, 6, :open, :close))); append(from, SQL(JOIN search ON search.rowid = events.event)); append(where, SQL(AND search MATCH :search)); @@ -474,11 +521,15 @@ int main(int argc, char *argv[]) { } if (limit) { - append(where, SQL(ORDER BY time DESC, event DESC LIMIT :limit)); + if (search) { + append(where, SQL(ORDER BY search.rowid DESC LIMIT :limit)); + } else { + append(where, SQL(ORDER BY event DESC LIMIT :limit)); + } binds[n++] = Bind(":limit", limit, 0); } - dbFind(path, SQLITE_OPEN_READWRITE); + dbFind(path, SQLITE_OPEN_READONLY); if (dbVersion() != DatabaseVersion) { errx(EX_CONFIG, "database out of date; migrate with litterbox -m"); } @@ -503,9 +554,15 @@ int main(int argc, char *argv[]) { SQL( WITH results AS (%s %s %s) SELECT * FROM results - ORDER BY %s time, event; + ORDER BY %s time %s, event %s; ), - select, from, where, (group ? "network, context," : "") + select, from, where, (group ? "network, context," : ""), + (reverse ? "DESC" : ""), (reverse ? "DESC" : "") + ); + } else if (reverse) { + len = asprintf( + &query, "%s %s %s ORDER BY %s DESC;", + select, from, where, (search ? "search.rowid" : "event") ); } else { len = asprintf(&query, "%s %s %s;", select, from, where); @@ -522,6 +579,7 @@ int main(int argc, char *argv[]) { dbBindInt(stmt, binds[i].param, binds[i].value); } } + free(binds); if (verbose) { char *expand = sqlite3_expanded_sql(stmt); diff --git a/unscoop.1 b/unscoop.1 index ed1c157..ddb0fb5 100644 --- a/unscoop.1 +++ b/unscoop.1 @@ -1,4 +1,4 @@ -.Dd September 30, 2020 +.Dd May 18, 2021 .Dt UNSCOOP 1 .Os . @@ -16,13 +16,14 @@ .Ar . .Nm -.Op Fl d Ar path .Fl D +.Op Fl d Ar path . .Sh DESCRIPTION The .Nm -utility imports IRC client logs into the +utility imports plain text IRC logs +into the .Xr litterbox 1 database. Network and channel or query names @@ -31,6 +32,12 @@ can be inferred from file paths, so should be run from the log directory root. . .Pp +The current path being imported +is printed to standard output. +Progress based on total file size +is printed to standard error. +. +.Pp The arguments are as follows: .Bl -tag -width Ds .It Fl D @@ -58,53 +65,95 @@ The default path is as in .Xr litterbox 1 . . .It Fl f Ar format -Set the input log format to one of: +Set the input log format. +The default is +.Cm generic . +. +.It Fl v +Print SQL +.Sy INSERT +statements on standard error. +.El +. +.Pp +The formats are as follows: .Bl -tag -width Ds .It Cm catgirl -The +Import logs from the .Xr catgirl 1 IRC client. +.Bd -literal -offset indent +find ~/.local/share/catgirl/log \e + -type f -name '*.log' -print0 | +xargs -0 unscoop -f catgirl +.Ed +. .It Cm generic -Matches paths of the form -.Pa network/context/* -and events of the following forms: +Import logs using generic matchers. +Network and context names are inferred +from paths of the form +.Pa network/context/* . +Events of the following formats are matched: .Bd -literal -offset indent [timestamp] <nick> privmsg [timestamp] -nick- notice [timestamp] * nick action .Ed +. .It Cm irc -Matches IRC protocol messages tagged with +Import logs formatted as +IRC protocol messages tagged with .Sy server-time . The network and context must be set with .Fl N and .Fl c . +. .It Cm textual -The Textual IRC client. -.El -.Pp -The default format is -.Cm generic . +Import logs from the Textual IRC client. +.Bd -literal -offset indent +find Textual -type f -name '*.txt' \e + -not -path '*/Console/*' -print0 | +xargs -0 unscoop -f textual +.Ed . -.It Fl v -Print SQL -.Sy INSERT -statements on standard error. -.El +.It Cm weechat +Import logs from the WeeChat IRC client. +.Bd -literal -offset indent +find ~/.weechat/logs -type f -name 'irc.*.weechatlog' \e + -not -name 'irc.server.*.weechatlog' -print0 | +xargs -0 unscoop -f weechat +.Ed +.Pp +.Nm +is not able to correctly infer +network and context names +for channel names containing dots. +Import these logs explicitly with +.Fl N +and +.Fl c . . -.Sh EXAMPLES -.Bd -literal -find catgirl -type f -name '*.log' -print0 | xargs -0 unscoop -f catgirl -find Textual -type f -name '*.txt' -not -path '*/Console/*' -print0 \e - | xargs -0 unscoop -f textual +.It Cm znc +Import logs from the +.Xr znc 1 +.Sy log +module. +.Bd -literal -offset indent +find ~/.znc/moddata/log \e + ~/.znc/users/*/moddata/log \e + ~/.znc/users/*/networks/*/moddata/log \e + -type f -name '*.log' \e + -not -path '*/status/*' -print0 | +xargs -0 unscoop -f znc .Ed +.El . .Sh SEE ALSO .Xr litterbox 1 . .Sh AUTHORS -.An June Bug Aq Mt june@causal.agency +.An June McEnroe Aq Mt june@causal.agency . .Sh BUGS Send mail to @@ -112,4 +161,4 @@ Send mail to or join .Li #ascii.town on -.Li chat.freenode.net . +.Li irc.tilde.chat . diff --git a/unscoop.c b/unscoop.c index 153841c..a290831 100644 --- a/unscoop.c +++ b/unscoop.c @@ -1,4 +1,4 @@ -/* Copyright (C) 2019 C. McEnroe <june@causal.agency> +/* Copyright (C) 2019 June McEnroe <june@causal.agency> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -49,6 +49,8 @@ struct Matcher { #define P0_MODE "[!~&@%+ ]?" #define P1_TIME "^[[]([^]]+)[]][ \t]" +#define P2_USERHOST "[(]([^@]+)@([^)]+)[)]" +#define P2_MESSAGE "( [(]([^)]+)[)])?" static const struct Matcher Catgirl[] = { { @@ -147,8 +149,6 @@ static const struct Matcher IRC[] = { #undef P2_TAGS #undef P3_ORIGIN -#define P2_USERHOST "[(]([^@]+)@([^)]+)[)]" -#define P2_MESSAGE "( [(]([^)]+)[)])?" static const struct Matcher Textual[] = { { P1_TIME "<" P0_MODE "([^>]+)> (.+)", @@ -185,8 +185,86 @@ static const struct Matcher Textual[] = { Unban, { ":time", ":nick", ":target" }, } }; -#undef P2_USERHOST -#undef P2_MESSAGE + +static const struct Matcher WeeChat[] = { + { + "([^\t]+)\t-->\t([^ ]+) " P2_USERHOST " has joined", + Join, { ":time", ":nick", ":user", ":host" }, + }, { + "([^\t]+)\t<--\t([^ ]+) " P2_USERHOST " has left [^ ]+" P2_MESSAGE, + Part, { ":time", ":nick", ":user", ":host", NULL, ":message" }, + }, { + "([^\t]+)\t<--\t([^ ]+) has kicked ([^ ]+)" P2_MESSAGE, + Kick, { ":time", ":nick", ":target", NULL, ":message" }, + }, { + "([^\t]+)\t<--\t([^ ]+) " P2_USERHOST " has quit" P2_MESSAGE, + Quit, { ":time", ":nick", ":user", ":host", NULL, ":message" }, + }, { + "([^\t]+)\t--\t([^ ]+) is now known as ([^ ]+)", + Nick, { ":time", ":nick", ":target" }, + }, { + "([^\t]+)\t--\t([^ ]+) has changed topic for [^ ]+ to \"(.+)\"", + Topic, { ":time", ":nick", ":message" }, + }, { + "([^\t]+)\t--\t([^ ]+) has unset topic", + Topic, { ":time", ":nick" }, + }, { + "([^\t]+)\t--\tMode [^ ]+ [[][+]b+ ([^]]+)[]] by ([^ ]+)", + Ban, { ":time", ":target", ":nick" }, + }, { + "([^\t]+)\t--\tMode [^ ]+ [[][-]b+ ([^]]+)[]] by ([^ ]+)", + Unban, { ":time", ":target", ":nick" }, + }, { + "([^\t]+)\t--\tNotice[(]([^)]+)[)]: (.+)", + Notice, { ":time", ":nick", ":message" }, + }, { + "([^\t]+)\t--\t([^ :]+): (.+)", + Notice, { ":time", ":nick", ":message" }, + }, { + "([^\t]+)\t [*]\t([^ ]+) (.+)", + Action, { ":time", ":nick", ":message" }, + }, { + "([^\t]+)\t" P0_MODE "([^-][^\t]*)\t(.+)", + Privmsg, { ":time", ":nick", ":message" }, + } +}; + +static const struct Matcher ZNC[] = { + { + P1_TIME "<([^>]+)> (.+)", + Privmsg, { ":time", ":nick", ":message" }, + }, { + P1_TIME "-([^-]+)- (.+)", + Notice, { ":time", ":nick", ":message" }, + }, { + P1_TIME "[*] ([^ ]+) (.+)", + Action, { ":time", ":nick", ":message" }, + }, { + P1_TIME "[*]{3} Joins: ([^ ]+) " P2_USERHOST, + Join, { ":time", ":nick", ":user", ":host" }, + }, { + P1_TIME "[*]{3} Parts: ([^ ]+) " P2_USERHOST " [(](.*)[)]", + Part, { ":time", ":nick", ":user", ":host", ":message" }, + }, { + P1_TIME "[*]{3} ([^ ]+) was kicked by ([^ ]+) [(](.*)[)]", + Kick, { ":time", ":target", ":nick", ":message" }, + }, { + P1_TIME "[*]{3} Quits: ([^ ]+) " P2_USERHOST " [(](.*)[)]", + Quit, { ":time", ":nick", ":user", ":host", ":message" }, + }, { + P1_TIME "[*]{3} ([^ ]+) is now known as ([^ ]+)", + Nick, { ":time", ":nick", ":target" }, + }, { + P1_TIME "[*]{3} ([^ ]+) changes topic to '(.*)'", + Topic, { ":time", ":nick", ":message" }, + }, { + P1_TIME "[*]{3} ([^ ]+) sets mode: [+]b+ (.+)", + Ban, { ":time", ":nick", ":target" }, + }, { + P1_TIME "[*]{3} ([^ ]+) sets mode: [-]b+ (.+)", + Unban, { ":time", ":nick", ":target" }, + } +}; static const struct Format { const char *name; @@ -195,18 +273,20 @@ static const struct Format { const char *pattern; size_t network; size_t context; + size_t date; + bool local; } Formats[] = { { "generic", Generic, ARRAY_LEN(Generic), - "([^/]+)/([^/]+)/[^/]+$", 1, 2, + "([^/]+)/([^/]+)/[^/]+$", 1, 2, 0, false, }, { "catgirl", Catgirl, ARRAY_LEN(Catgirl), - "([^/]+)/([^/]+)/[0-9-]+[.]log$", 1, 2, + "([^/]+)/([^/]+)/[0-9-]+[.]log$", 1, 2, 0, false, }, { "irc", IRC, ARRAY_LEN(IRC), - "^$", 0, 0, + "^$", 0, 0, 0, false, }, { "textual", Textual, ARRAY_LEN(Textual), @@ -216,14 +296,25 @@ static const struct Format { "([^/]+)/" "[0-9-]+[.]txt$" ), - 1, 4, + 1, 4, 0, false, + }, + { + "weechat", WeeChat, ARRAY_LEN(WeeChat), + "irc[.](.+)[.]([^.]+)[.]weechatlog$", 1, 2, 0, true, + }, + { + "znc", ZNC, ARRAY_LEN(ZNC), + "([^/]+)/(moddata/log/)?([^/]+)/([0-9-]+)[.]log$", 1, 3, 4, true, }, }; static const struct Format *formatParse(const char *name) { + bool list = !strcmp(name, "?"); for (size_t i = 0; i < ARRAY_LEN(Formats); ++i) { if (!strcmp(name, Formats[i].name)) return &Formats[i]; + if (list) printf("%s\n", Formats[i].name); } + if (list) exit(EX_OK); errx(EX_USAGE, "no such format %s", name); } @@ -250,6 +341,7 @@ static sqlite3_stmt *insertName; static sqlite3_stmt *insertEvent; static int paramNetwork; static int paramContext; +static int paramDate; static void prepareInsert(void) { const char *InsertName = SQL( @@ -262,9 +354,13 @@ static void prepareInsert(void) { INSERT INTO events (time, type, context, name, target, message) SELECT // SQLite expects a colon in the timezine, but ISO8601 does not. - CASE WHEN :time LIKE '%Z' - THEN strftime('%s', :time) - ELSE strftime('%s', substr(:time, 1, 22) || ':' || substr(:time, -2)) + CASE + WHEN :time LIKE '%+____' OR :time LIKE '%-____' THEN + strftime('%s', substr(:time, 1, 22) || ':' || substr(:time, -2)) + WHEN :local THEN + strftime('%s', coalesce(:date || ' ', "") || :time, 'utc') + ELSE + strftime('%s', coalesce(:date || ' ', "") || :time) END, :type, context, names.name, :target, :message FROM contexts, names @@ -277,21 +373,24 @@ static void prepareInsert(void) { dbPersist(&insertEvent, InsertEvent); paramNetwork = dbParam(insertEvent, ":network"); paramContext = dbParam(insertEvent, ":context"); + paramDate = dbParam(insertEvent, ":date"); } static void matchLine(const struct Format *format, const regex_t *regex, const char *line) { for (size_t i = 0; i < format->len; ++i) { const struct Matcher *matcher = &format->matchers[i]; - regmatch_t match[ParamCap]; - if (regexec(®ex[i], line, ParamCap, match, 0)) continue; + regmatch_t match[1 + ParamCap]; + if (regexec(®ex[i], line, 1 + ParamCap, match, 0)) continue; sqlite3_clear_bindings(insertName); for (int i = 1; i <= sqlite3_bind_parameter_count(insertEvent); ++i) { - if (i == paramNetwork || i == paramContext) continue; - sqlite3_bind_null(insertEvent, i); + if (i != paramNetwork && i != paramContext && i != paramDate) { + sqlite3_bind_null(insertEvent, i); + } } + dbBindInt(insertEvent, ":local", format->local); dbBindInt(insertEvent, ":type", matcher->type); for (size_t i = 0; i < ARRAY_LEN(matcher->params); ++i) { const char *param = matcher->params[i]; @@ -349,7 +448,8 @@ int main(int argc, char *argv[]) { } regex_t pathRegex = compile(format->pattern); - regex_t regex[format->len]; + regex_t *regex = calloc(format->len, sizeof(*regex)); + if (!regex) err(EX_OSERR, "calloc"); for (size_t i = 0; i < format->len; ++i) { regex[i] = compile(format->matchers[i].pattern); } @@ -384,11 +484,13 @@ int main(int argc, char *argv[]) { size_t sizeTotal = 0; size_t sizeRead = 0; - size_t sizePercent = -1; - regmatch_t match[argc][ParamCap]; + size_t sizePercent = 0; + struct { + regmatch_t match[ParamCap]; + } *paths = calloc(argc, sizeof(*paths)); for (int i = optind; i < argc; ++i) { - int error = regexec(&pathRegex, argv[i], ParamCap, match[i], 0); + int error = regexec(&pathRegex, argv[i], ParamCap, paths[i].match, 0); if (error && (!network || !context)) { warnx("skipping %s", argv[i]); argv[i] = NULL; @@ -405,13 +507,15 @@ int main(int argc, char *argv[]) { size_t cap = 0; for (int i = optind; i < argc; ++i) { if (!argv[i]) continue; + printf("%s\n", argv[i]); + fprintf(stderr, " %3zu%%\r", sizePercent); FILE *file = fopen(argv[i], "r"); if (!file) err(EX_NOINPUT, "%s", argv[i]); dbExec(SQL(BEGIN TRANSACTION;)); - regmatch_t pathNetwork = match[i][format->network]; - regmatch_t pathContext = match[i][format->context]; + regmatch_t pathNetwork = paths[i].match[format->network]; + regmatch_t pathContext = paths[i].match[format->context]; if (!network) { bindMatch(insertContext, ":network", argv[i], pathNetwork); bindMatch(insertEvent, ":network", argv[i], pathNetwork); @@ -422,13 +526,20 @@ int main(int argc, char *argv[]) { } dbRun(insertContext); + if (format->date) { + bindMatch( + insertEvent, ":date", argv[i], paths[i].match[format->date] + ); + } + for (ssize_t len; 0 < (len = getline(&line, &cap, file));) { + if (len >= 1 && line[len-1] == '\n') line[len-1] = '\0'; + if (len >= 2 && line[len-2] == '\r') line[len-2] = '\0'; matchLine(format, regex, line); sizeRead += len; if (100 * sizeRead / sizeTotal != sizePercent) { sizePercent = 100 * sizeRead / sizeTotal; - printf("\r%3zu%%", sizePercent); - fflush(stdout); + fprintf(stderr, " %3zu%%\r", sizePercent); } } if (ferror(file)) err(EX_IOERR, "%s", argv[i]); @@ -436,7 +547,7 @@ int main(int argc, char *argv[]) { fclose(file); dbExec(SQL(COMMIT TRANSACTION;)); } - printf("\n"); + fprintf(stderr, "\n"); dbClose(); } diff --git a/xdg.c b/xdg.c index 00cd021..e5d9232 100644 --- a/xdg.c +++ b/xdg.c @@ -1,4 +1,4 @@ -/* Copyright (C) 2019, 2020 C. McEnroe <june@causal.agency> +/* Copyright (C) 2019, 2020 June McEnroe <june@causal.agency> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -32,6 +32,7 @@ #include <stdlib.h> #include <string.h> #include <sys/stat.h> +#include <sysexits.h> #include "database.h" @@ -58,93 +59,73 @@ static const struct Base Data = { .defDirs = "/usr/local/share:/usr/share", }; -static const char * -basePath(struct Base base, const char **dirs, const char *path) { - static char buf[PATH_MAX]; - - if (*dirs) { - if (!**dirs) return NULL; - size_t len = strcspn(*dirs, ":"); - snprintf(buf, sizeof(buf), "%.*s/" SUBDIR "/%s", (int)len, *dirs, path); - *dirs += len; - if (**dirs) *dirs += 1; +static char *basePath( + struct Base base, char *buf, size_t cap, const char *path, int i +) { + if (path[strspn(path, ".")] == '/') { + if (i > 0) return NULL; + snprintf(buf, cap, "%s", path); return buf; } - if (path[0] == '/' || path[0] == '.') { - *dirs = ""; - return path; + if (i > 0) { + const char *dirs = getenv(base.envDirs); + if (!dirs) dirs = base.defDirs; + for (; i > 1; --i) { + dirs += strcspn(dirs, ":"); + dirs += (*dirs == ':'); + } + if (!*dirs) return NULL; + snprintf( + buf, cap, "%.*s/" SUBDIR "/%s", + (int)strcspn(dirs, ":"), dirs, path + ); + return buf; } - *dirs = getenv(base.envDirs); - if (!*dirs) *dirs = base.defDirs; - const char *home = getenv("HOME"); const char *baseHome = getenv(base.envHome); if (baseHome) { - snprintf(buf, sizeof(buf), "%s/" SUBDIR "/%s", baseHome, path); + snprintf(buf, cap, "%s/" SUBDIR "/%s", baseHome, path); } else if (home) { - snprintf( - buf, sizeof(buf), "%s/%s/" SUBDIR "/%s", - home, base.defHome, path - ); + snprintf(buf, cap, "%s/%s/" SUBDIR "/%s", home, base.defHome, path); } else { - return NULL; + errx(EX_USAGE, "HOME unset"); } return buf; } -const char *configPath(const char **dirs, const char *path) { - return basePath(Config, dirs, path); +char *configPath(char *buf, size_t cap, const char *path, int i) { + return basePath(Config, buf, cap, path, i); } -const char * -dataPath(const char **dirs, const char *path) { - return basePath(Data, dirs, path); +char *dataPath(char *buf, size_t cap, const char *path, int i) { + return basePath(Data, buf, cap, path, i); } FILE *configOpen(const char *path, const char *mode) { - const char *dirs = NULL; - for (const char *abs; NULL != (abs = configPath(&dirs, path));) { - FILE *file = fopen(abs, mode); + char buf[PATH_MAX]; + for (int i = 0; configPath(buf, sizeof(buf), path, i); ++i) { + FILE *file = fopen(buf, mode); if (file) return file; - if (errno != ENOENT) warn("%s", abs); + if (errno != ENOENT) warn("%s", buf); } - FILE *file = fopen(path, mode); - if (!file) warn("%s", path); - return file; -} - -void dataMkdir(const char *path) { - const char *dirs = NULL; - const char *abs = dataPath(&dirs, path); - if (!abs) return; - int error = mkdir(abs, S_IRWXU); - if (error && errno != EEXIST) warn("%s", abs); + warn("%s", configPath(buf, sizeof(buf), path, 0)); + return NULL; } FILE *dataOpen(const char *path, const char *mode) { - const char *dirs = NULL; - for (const char *abs; NULL != (abs = dataPath(&dirs, path));) { - FILE *file = fopen(abs, mode); + char buf[PATH_MAX]; + for (int i = 0; dataPath(buf, sizeof(buf), path, i); ++i) { + FILE *file = fopen(buf, mode); if (file) return file; - if (errno != ENOENT) warn("%s", abs); + if (errno != ENOENT) warn("%s", buf); } - if (mode[0] != 'r') { - dataMkdir(""); - dirs = NULL; - path = dataPath(&dirs, path); - if (!path) { - warn("HOME unset"); - return NULL; - } - FILE *file = fopen(path, mode); - if (!file) warn("%s", path); - return file; + int error = mkdir(dataPath(buf, sizeof(buf), "", 0), S_IRWXU); + if (error && errno != EEXIST) warn("%s", buf); } - - FILE *file = fopen(path, mode); - if (!file) warn("%s", path); + FILE *file = fopen(dataPath(buf, sizeof(buf), path, 0), mode); + if (!file) warn("%s", buf); return file; } |