summary refs log tree commit diff
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--contexts.c16
-rw-r--r--events.c136
-rw-r--r--html.c559
-rw-r--r--networks.c4
-rw-r--r--search.c39
-rw-r--r--server.h26
6 files changed, 360 insertions, 420 deletions
diff --git a/contexts.c b/contexts.c
index 5eba403..5c5e5cc 100644
--- a/contexts.c
+++ b/contexts.c
@@ -58,8 +58,8 @@ const char *ContextsQuery = SQL(
 );
 
 enum kcgi_err contextsPage(struct kreq *req) {
-	struct Scope scope = pageScope(req);
-	if (!scope.network) return httpFail(req, KHTTP_400);
+	if (!req->fieldmap[Network]) return httpFail(req, KHTTP_400);
+	const char *network = req->fieldmap[Network]->parsed.s;
 
 	enum kcgi_err error = 0
 		|| httpHead(req, KHTTP_200, KMIME_TEXT_HTML)
@@ -69,12 +69,12 @@ enum kcgi_err contextsPage(struct kreq *req) {
 	struct khtmlreq html;
 	error = error
 		|| khtml_open(&html, req, 0)
-		|| htmlHead(&html, scope.network)
-		|| htmlNav(&html, scope);
+		|| htmlHead(&html, network)
+		|| htmlNav(&html, req);
 	if (error) return error;
 
 	sqlite3_reset(stmt.contexts);
-	dbBindText(stmt.contexts, ":network", scope.network);
+	dbBindText(stmt.contexts, ":network", network);
 	dbBindInt(stmt.contexts, ":recent", contextsRecent);
 	dbBindInt(stmt.contexts, ":public", contextsPublic);
 
@@ -94,7 +94,7 @@ enum kcgi_err contextsPage(struct kreq *req) {
 		bool active = sqlite3_column_int(stmt.contexts, i++);
 
 		enum State prev = state;
-		state = (active ? Active : (query ? Queries : Channels));
+		state = (active ? Active : query ? Queries : Channels);
 		if (state != prev) {
 			error = 0
 				|| khtml_closeelem(&html, 1)
@@ -107,7 +107,7 @@ enum kcgi_err contextsPage(struct kreq *req) {
 
 		char *href = khttp_urlpart(
 			NULL, NULL, Pages[Events],
-			Keys[Network].name, scope.network,
+			Keys[Network].name, network,
 			Keys[Context].name, context,
 			NULL
 		);
@@ -126,7 +126,7 @@ enum kcgi_err contextsPage(struct kreq *req) {
 	if (error) return error;
 
 	sqlite3_reset(stmt.motd);
-	dbBindText(stmt.motd, ":network", scope.network);
+	dbBindText(stmt.motd, ":network", network);
 
 	result = sqlite3_step(stmt.motd);
 	if (result == SQLITE_ROW) {
diff --git a/events.c b/events.c
index e5b0777..932f535 100644
--- a/events.c
+++ b/events.c
@@ -34,23 +34,10 @@ static const char *timestamp(time_t time) {
 	return stamp;
 }
 
-static enum kcgi_err tidyField(struct kreq *req, struct khtmlreq *html) {
-	if (!req->fieldmap[Tidy]) return KCGI_OK;
-	return khtml_attr(
-		html, KELEM_INPUT,
-		KATTR_TYPE, "hidden",
-		KATTR_NAME, Keys[Tidy].name,
-		KATTR_VALUE, req->fieldmap[Tidy]->parsed.s,
-		KATTR__MAX
-	);
-}
-
 static enum kcgi_err
-dateForm(struct kreq *req, struct khtmlreq *html, struct Scope scope) {
-	struct kpair *field = req->fieldmap[After];
-	if (!field) field = req->fieldmap[Before];
+dateForm(struct khtmlreq *html, struct kreq *req, const char *current) {
 	struct tm tm = {0};
-	if (!strptime(field->parsed.s, "%F", &tm)) {
+	if (!strptime(current, "%F", &tm)) {
 		tm = *gmtime(&(time_t) { time(NULL) });
 	}
 	char date[sizeof("0000-00-00")];
@@ -62,7 +49,8 @@ dateForm(struct kreq *req, struct khtmlreq *html, struct Scope scope) {
 			KATTR_ACTION, Pages[Events],
 			KATTR__MAX
 		)
-		|| htmlScopeFields(html, scope)
+		|| htmlHidden(html, req, Network)
+		|| htmlHidden(html, req, Context)
 		|| khtml_attr(
 			html, KELEM_INPUT,
 			KATTR_TYPE, "date",
@@ -70,20 +58,14 @@ dateForm(struct kreq *req, struct khtmlreq *html, struct Scope scope) {
 			KATTR_VALUE, date,
 			KATTR__MAX
 		)
-		|| tidyField(req, html)
-		|| khtml_attr(
-			html, KELEM_INPUT,
-			KATTR_TYPE, "submit",
-			KATTR_VALUE, "Jump",
-			KATTR__MAX
-		)
-		|| khtml_closeelem(html, 1);
+		|| htmlHidden(html, req, Tidy)
+		|| khtml_elem(html, KELEM_BUTTON)
+		|| khtml_puts(html, "Jump")
+		|| khtml_closeelem(html, 2);
 }
 
 static enum kcgi_err
-displayForm(struct kreq *req, struct khtmlreq *html, struct Scope scope) {
-	struct kpair *time = req->fieldmap[After];
-	if (!time) time = req->fieldmap[Before];
+displayForm(struct khtmlreq *html, struct kreq *req, bool tidy) {
 	return 0
 		|| khtml_attr(
 			html, KELEM_FORM,
@@ -91,32 +73,40 @@ displayForm(struct kreq *req, struct khtmlreq *html, struct Scope scope) {
 			KATTR_ACTION, Pages[Events],
 			KATTR__MAX
 		)
-		|| htmlScopeFields(html, scope)
-		|| khtml_attr(
-			html, KELEM_INPUT,
-			KATTR_TYPE, "hidden",
-			KATTR_NAME, time->key,
-			KATTR_VALUE, time->parsed.s,
-			KATTR__MAX
-		)
+		|| htmlHidden(html, req, Network)
+		|| htmlHidden(html, req, Context)
+		|| htmlHidden(html, req, After)
+		|| htmlHidden(html, req, Before)
 		|| khtml_elem(html, KELEM_LABEL)
 		|| khtml_attr(
 			html, KELEM_INPUT,
 			KATTR_TYPE, "checkbox",
 			KATTR_NAME, Keys[Tidy].name,
 			KATTR_VALUE, "1",
-			(req->fieldmap[Tidy] ? KATTR_CHECKED : KATTR__MAX), "checked",
+			(tidy ? KATTR_CHECKED : KATTR__MAX), "checked",
 			KATTR__MAX
 		)
 		|| khtml_puts(html, "Hide general events")
 		|| khtml_closeelem(html, 1)
-		|| khtml_attr(
-			html, KELEM_INPUT,
-			KATTR_TYPE, "submit",
-			KATTR_VALUE, "Apply",
-			KATTR__MAX
-		)
-		|| khtml_closeelem(html, 1);
+		|| khtml_elem(html, KELEM_BUTTON)
+		|| khtml_puts(html, "Apply")
+		|| khtml_closeelem(html, 2);
+}
+
+static char *pageURL(
+	const char *network, const char *context,
+	enum Key key, time_t time, bool tidy
+) {
+	char *url = khttp_urlpart(
+		NULL, NULL, Pages[Events],
+		Keys[Network].name, network,
+		Keys[Context].name, context,
+		Keys[key].name, timestamp(time),
+		(tidy ? Keys[Tidy].name : NULL), "1",
+		NULL
+	);
+	if (!url) err(EX_OSERR, "khttp_urlpart");
+	return url;
 }
 
 const char *EventsTopicQuery = SQL(
@@ -180,14 +170,17 @@ const char *EventsBeforeQuery = SQL(
 );
 
 enum kcgi_err eventsPage(struct kreq *req) {
-	struct Scope scope = pageScope(req);
-	if (!scope.network || !scope.context) return httpFail(req, KHTTP_400);
+	if (!req->fieldmap[Network] || !req->fieldmap[Context]) {
+		return httpFail(req, KHTTP_400);
+	}
+	const char *network = req->fieldmap[Network]->parsed.s;
+	const char *context = req->fieldmap[Context]->parsed.s;
 
 	if (!req->fieldmap[After] && !req->fieldmap[Before]) {
 		char *url = khttp_urlpart(
 			NULL, NULL, Pages[Events],
-			Keys[Network].name, scope.network,
-			Keys[Context].name, scope.context,
+			Keys[Network].name, network,
+			Keys[Context].name, context,
 			Keys[Before].name, timestamp(time(NULL)),
 			NULL
 		);
@@ -200,6 +193,7 @@ enum kcgi_err eventsPage(struct kreq *req) {
 	const char *time = req->fieldmap[Before]
 		? req->fieldmap[Before]->parsed.s
 		: req->fieldmap[After]->parsed.s;
+	bool tidy = (req->fieldmap[Tidy] ? req->fieldmap[Tidy]->parsed.i : false);
 
 	enum kcgi_err error = 0
 		|| httpHead(req, KHTTP_200, KMIME_TEXT_HTML)
@@ -209,18 +203,18 @@ enum kcgi_err eventsPage(struct kreq *req) {
 	struct khtmlreq html;
 	error = error
 		|| khtml_open(&html, req, 0)
-		|| htmlHead(&html, scope.context)
-		|| htmlNav(&html, scope)
+		|| htmlHead(&html, context)
+		|| htmlNav(&html, req)
 		|| khtml_elem(&html, KELEM_DIV)
-		|| dateForm(req, &html, scope)
-		|| displayForm(req, &html, scope)
+		|| dateForm(&html, req, time)
+		|| displayForm(&html, req, tidy)
 		|| khtml_closeelem(&html, 1)
 		|| khtml_elem(&html, KELEM_TABLE);
 	if (error) return error;
 
 	sqlite3_reset(stmt.topic);
-	dbBindText(stmt.topic, ":network", scope.network);
-	dbBindText(stmt.topic, ":context", scope.context);
+	dbBindText(stmt.topic, ":network", network);
+	dbBindText(stmt.topic, ":context", context);
 	dbBindText(stmt.topic, ":time", time);
 	dbBindInt(stmt.topic, ":public", contextsPublic);
 
@@ -241,10 +235,10 @@ enum kcgi_err eventsPage(struct kreq *req) {
 	if (req->fieldmap[Before]) events = stmt.eventsBefore;
 
 	sqlite3_reset(events);
-	dbBindText(events, ":network", scope.network);
-	dbBindText(events, ":context", scope.context);
+	dbBindText(events, ":network", network);
+	dbBindText(events, ":context", context);
 	dbBindText(events, ":time", time);
-	dbBindInt(events, ":tidy", (req->fieldmap[Tidy] ? Join : TypesLen));
+	dbBindInt(events, ":tidy", (tidy ? Join : TypesLen));
 	dbBindInt(events, ":public", contextsPublic);
 	dbBindInt(events, ":limit", eventsLimit);
 
@@ -264,21 +258,12 @@ enum kcgi_err eventsPage(struct kreq *req) {
 		event.message = sqlite3_column_text(events, i++);
 
 		if (!rows) {
-			char *base = khttp_urlpart(
-				NULL, NULL, Pages[Events],
-				Keys[Network].name, scope.network,
-				Keys[Context].name, scope.context,
-				Keys[Before].name, timestamp(event.time + eventsOverlap),
-				(req->fieldmap[Tidy] ? Keys[Tidy].name : NULL), "1",
-				NULL
-			);
-			if (!base) err(EX_OSERR, "khttp_urlpart");
-
 			char *href = NULL;
-			asprintf(&href, "%s#%" PRId64, base, event.event);
+			char *page = pageURL(
+				network, context, Before, event.time + eventsOverlap, tidy
+			);
+			asprintf(&href, "%s#%" PRId64, page, event.event);
 			if (!href) err(EX_OSERR, "asprintf");
-			free(base);
-
 			error = 0
 				|| khtml_attr(&html, KELEM_TR, KATTR_CLASS, "page", KATTR__MAX)
 				|| khtml_attr(&html, KELEM_TH, KATTR_COLSPAN, "3", KATTR__MAX)
@@ -286,6 +271,7 @@ enum kcgi_err eventsPage(struct kreq *req) {
 				|| khtml_puts(&html, "Earlier messages")
 				|| khtml_closeelem(&html, 3);
 			free(href);
+			free(page);
 			if (error) return error;
 		}
 
@@ -300,21 +286,15 @@ enum kcgi_err eventsPage(struct kreq *req) {
 		prevEvent = event.event;
 		prevTime = event.time;
 
-		error = htmlEvent(&html, scope, event);
+		error = htmlEvent(&html, req, &event);
 		if (error) return error;
 	}
 	if (result != SQLITE_DONE) errx(EX_SOFTWARE, "%s", sqlite3_errmsg(db));
 
 	if (rows && (rows == eventsLimit || req->fieldmap[Before])) {
-		char *href = khttp_urlpart(
-			NULL, NULL, Pages[Events],
-			Keys[Network].name, scope.network,
-			Keys[Context].name, scope.context,
-			Keys[After].name, timestamp(prevTime - eventsOverlap),
-			(req->fieldmap[Tidy] ? Keys[Tidy].name : NULL), "1",
-			NULL
+		char *href = pageURL(
+			network, context, After, prevTime - eventsOverlap, tidy
 		);
-		if (!href) err(EX_OSERR, "khttp_urlpart");
 		error = 0
 			|| khtml_attr(&html, KELEM_TR, KATTR_CLASS, "page", KATTR__MAX)
 			|| khtml_attr(&html, KELEM_TH, KATTR_COLSPAN, "3", KATTR__MAX)
diff --git a/html.c b/html.c
index 2d3ce2f..a28d84b 100644
--- a/html.c
+++ b/html.c
@@ -47,32 +47,19 @@ enum kcgi_err htmlHead(struct khtmlreq *html, const char *title) {
 		|| khtml_closeelem(html, 1);
 }
 
-enum kcgi_err htmlScopeFields(struct khtmlreq *html, struct Scope scope) {
-	if (scope.network) {
-		enum kcgi_err error = khtml_attr(
-			html, KELEM_INPUT,
-			KATTR_TYPE, "hidden",
-			KATTR_NAME, Keys[Network].name,
-			KATTR_VALUE, scope.network,
-			KATTR__MAX
-		);
-		if (error) return error;
-	}
-	if (scope.context) {
-		enum kcgi_err error = khtml_attr(
-			html, KELEM_INPUT,
-			KATTR_TYPE, "hidden",
-			KATTR_NAME, Keys[Context].name,
-			KATTR_VALUE, scope.context,
-			KATTR__MAX
-		);
-		if (error) return error;
-	}
-	return KCGI_OK;
+enum kcgi_err
+htmlHidden(struct khtmlreq *html, struct kreq *req, enum Key key) {
+	if (!req->fieldmap[key]) return KCGI_OK;
+	return khtml_attr(
+		html, KELEM_INPUT,
+		KATTR_TYPE, "hidden",
+		KATTR_NAME, Keys[key].name,
+		KATTR_VALUE, req->fieldmap[key]->val,
+		KATTR__MAX
+	);
 }
 
-enum kcgi_err
-htmlNav(struct khtmlreq *html, struct Scope scope) {
+enum kcgi_err htmlNav(struct khtmlreq *html, struct kreq *req) {
 	enum kcgi_err error = 0
 		|| khtml_elem(html, KELEM_NAV)
 		|| khtml_elem(html, KELEM_OL)
@@ -82,44 +69,53 @@ htmlNav(struct khtmlreq *html, struct Scope scope) {
 		|| khtml_closeelem(html, 2);
 	if (error) return error;
 
-	if (scope.network) {
+	const char *network = NULL;
+	const char *context = NULL;
+	if (req->fieldmap[Network]) {
+		network = req->fieldmap[Network]->parsed.s;
+		if (req->fieldmap[Context]) {
+			context = req->fieldmap[Context]->parsed.s;
+		}
+	}
+
+	if (network) {
 		char *href = khttp_urlpart(
 			NULL, NULL, Pages[Contexts],
-			Keys[Network].name, scope.network,
+			Keys[Network].name, network,
 			NULL
 		);
 		if (!href) err(EX_OSERR, "khttp_urlpart");
 		error = 0
 			|| khtml_elem(html, KELEM_LI)
 			|| khtml_attr(html, KELEM_A, KATTR_HREF, href, KATTR__MAX)
-			|| khtml_puts(html, scope.network)
+			|| khtml_puts(html, network)
 			|| khtml_closeelem(html, 2);
+		free(href);
 		if (error) return error;
 	}
 
-	if (scope.network && scope.context) {
+	if (context) {
+		const char *context = req->fieldmap[Context]->parsed.s;
 		char *href = khttp_urlpart(
 			NULL, NULL, Pages[Events],
-			Keys[Network].name, scope.network,
-			Keys[Context].name, scope.context,
+			Keys[Network].name, network,
+			Keys[Context].name, context,
 			NULL
 		);
 		if (!href) err(EX_OSERR, "khttp_urlpart");
 		error = 0
 			|| khtml_elem(html, KELEM_LI)
 			|| khtml_attr(html, KELEM_A, KATTR_HREF, href, KATTR__MAX)
-			|| khtml_puts(html, scope.context)
+			|| khtml_puts(html, context)
 			|| khtml_closeelem(html, 2);
+		free(href);
 		if (error) return error;
 	}
 
-	char label[256];
-	snprintf(
-		label, sizeof(label), "Search%s%s",
-		(scope.network ? " " : ""),
-		(scope.context ? scope.context : scope.network ? scope.network : "")
-	);
-
+	const char *query = NULL;
+	if (req->fieldmap[Query]) {
+		query = req->fieldmap[Query]->parsed.s;
+	}
 	return 0
 		|| khtml_closeelem(html, 1)
 		|| khtml_attr(
@@ -132,44 +128,124 @@ htmlNav(struct khtmlreq *html, struct Scope scope) {
 			html, KELEM_INPUT,
 			KATTR_TYPE, "search",
 			KATTR_NAME, Keys[Query].name,
-			KATTR_VALUE, (scope.query ? scope.query : ""),
+			KATTR_VALUE, (query ? query : ""),
 			KATTR__MAX
 		)
-		|| htmlScopeFields(html, scope)
-		|| khtml_attr(
-			html, KELEM_INPUT,
-			KATTR_TYPE, "submit",
-			KATTR_VALUE, label,
-			KATTR__MAX
+		|| htmlHidden(html, req, Network)
+		|| htmlHidden(html, req, Context)
+		|| khtml_elem(html, KELEM_BUTTON)
+		|| khtml_printf(
+			html, "Search %s",
+			(context ? context : network ? network : "")
 		)
-		|| khtml_closeelem(html, 2);
+		|| khtml_closeelem(html, 3);
 }
 
-static const char *SourceURL = "https://git.causal.agency/scooper";
-static const char *SyntaxURL = {
-	"https://www.sqlite.org/fts5.html#full_text_query_syntax"
-};
-static const char *Columns = {
-	"network, channel, query, nick, user, target, message"
-};
+enum kcgi_err linkify(struct khtmlreq *html, const char *str, size_t len) {
+	static const char *Pattern = "https?://([^[:space:]>\"()]|[(][^)]*[)])+";
+	static regex_t regex;
+	if (!regex.re_nsub) {
+		int error = regcomp(&regex, Pattern, REG_EXTENDED);
+		assert(!error);
+	}
 
-enum kcgi_err htmlFooter(struct khtmlreq *html) {
-	return 0
-		|| khtml_closeto(html, 0)
-		|| khtml_elem(html, KELEM_FOOTER)
-		|| khtml_elem(html, KELEM_SPAN)
-		|| khtml_attr(html, KELEM_A, KATTR_HREF, SourceURL, KATTR__MAX)
-		|| khtml_puts(html, "scooper is AGPLv3+")
-		|| khtml_closeelem(html, 2)
-		|| khtml_putc(html, ' ')
-		|| khtml_elem(html, KELEM_SPAN)
-		|| khtml_attr(html, KELEM_A, KATTR_HREF, SyntaxURL, KATTR__MAX)
-		|| khtml_puts(html, "Search syntax")
-		|| khtml_closeelem(html, 1)
-		|| khtml_putc(html, ' ')
-		|| khtml_attr(html, KELEM_SPAN, KATTR_TITLE, Columns, KATTR__MAX)
-		|| khtml_puts(html, "Columns")
-		|| khtml_closeto(html, 0);
+	regmatch_t match = {0};
+	while (!regexec(&regex, str, 1, &match, 0)) {
+		if ((size_t)match.rm_eo > len) break;
+
+		char *href = strndup(&str[match.rm_so], match.rm_eo - match.rm_so);
+		if (!href) err(EX_OSERR, "strndup");
+
+		enum kcgi_err error = 0
+			|| khtml_write(str, match.rm_so, html)
+			|| khtml_attr(html, KELEM_A, KATTR_HREF, href, KATTR__MAX)
+			|| khtml_write(&str[match.rm_so], match.rm_eo - match.rm_so, html)
+			|| khtml_closeelem(html, 1);
+		free(href);
+		if (error) return error;
+
+		str += match.rm_eo;
+		len -= match.rm_eo;
+	}
+	if (len) return khtml_write(str, len, html);
+	return KCGI_OK;
+}
+
+static const struct Style {
+	int fg, bg;
+	bool b, r, i, u;
+} Default = { .fg = 99, .bg = 99 };
+
+enum kcgi_err htmlStyle(struct khtmlreq *html, struct Style style) {
+	enum kcgi_err error = KCGI_OK;
+	char class[sizeof("fg99")];
+	if (style.fg != Default.fg) {
+		snprintf(class, sizeof(class), "fg%02d", style.fg);
+		error = error || khtml_attr(
+			html, KELEM_SPAN,
+			KATTR_CLASS, class,
+			KATTR__MAX
+		);
+	}
+	if (style.bg != Default.bg) {
+		snprintf(class, sizeof(class), "bg%02d", style.bg);
+		error = error || khtml_attr(
+			html, KELEM_SPAN,
+			KATTR_CLASS, class,
+			KATTR__MAX
+		);
+	}
+	if (style.b) error = error || khtml_elem(html, KELEM_B);
+	if (style.r) error = error || khtml_elem(html, KELEM_MARK);
+	if (style.i) error = error || khtml_elem(html, KELEM_I);
+	if (style.u) error = error || khtml_elem(html, KELEM_U);
+	return error;
+}
+
+enum kcgi_err htmlIRC(struct khtmlreq *html, const char *str) {
+	enum kcgi_err error = khtml_attr(
+		html, KELEM_SPAN,
+		KATTR_CLASS, "irc",
+		KATTR__MAX
+	);
+	if (error) return error;
+
+	size_t top = khtml_elemat(html);
+	struct Style style = Default;
+	for (;;) {
+		size_t len = strcspn(str, "\2\3\17\26\35\37");
+		if (len) {
+			error = 0
+				|| khtml_closeto(html, top)
+				|| htmlStyle(html, style)
+				|| linkify(html, str, len);
+			if (error) return error;
+		}
+
+		str += len;
+		if (!*str) break;
+		switch (*str++) {
+			break; case '\2':  style.b ^= true;
+			break; case '\17': style = Default;
+			break; case '\26': style.r ^= true;
+			break; case '\35': style.i ^= true;
+			break; case '\37': style.u ^= true;
+			break; case '\3': {
+				if (!isdigit(*str)) {
+					style.fg = Default.fg;
+					style.bg = Default.bg;
+					break;
+				}
+				style.fg = *str++ - '0';
+				if (isdigit(*str)) style.fg = style.fg * 10 + *str++ - '0';
+				if (str[0] != ',' || !isdigit(str[1])) break;
+				str++;
+				style.bg = *str++ - '0';
+				if (isdigit(*str)) style.bg = style.bg * 10 + *str++ - '0';
+			}
+		}
+	}
+	return khtml_closeto(html, top) || khtml_closeelem(html, 1);
 }
 
 static const char *timestamp(time_t time) {
@@ -178,74 +254,73 @@ static const char *timestamp(time_t time) {
 	return stamp;
 }
 
-static enum kcgi_err eventTime(struct khtmlreq *html, struct Event event) {
-	char *base = NULL;
-	if (event.network && event.context) {
-		base = khttp_urlpart(
+static enum kcgi_err
+eventTime(struct khtmlreq *html, const struct Event *event) {
+	char *page = NULL;
+	char *href = NULL;
+	if (event->network && event->context) {
+		page = khttp_urlpart(
 			NULL, NULL, Pages[Events],
-			Keys[Network].name, event.network,
-			Keys[Context].name, event.context,
-			Keys[After].name, timestamp(event.time - eventsOverlap),
+			Keys[Network].name, event->network,
+			Keys[Context].name, event->context,
+			Keys[After].name, timestamp(event->time - eventsOverlap),
 			NULL
 		);
-		if (!base) err(EX_OSERR, "khttp_urlpart");
+		if (!page) err(EX_OSERR, "khttp_urlpart");
 	}
-	char *href = NULL;
-	asprintf(&href, "%s#%" PRId64, (base ? base : ""), event.event);
+	asprintf(&href, "%s#%" PRId64, (page ? page : ""), event->event);
 	if (!href) err(EX_OSERR, "asprintf");
-	if (base) free(base);
+	if (page) free(page);
 
+	const char *stamp = timestamp(event->time);
 	enum kcgi_err error = 0
 		|| khtml_attr(html, KELEM_TD, KATTR_CLASS, "time", KATTR__MAX)
 		|| khtml_attr(html, KELEM_A, KATTR_HREF, href, KATTR__MAX)
 		|| khtml_attr(
 			html, KELEM_TIME,
-			KATTR_DATETIME, timestamp(event.time),
+			KATTR_DATETIME, stamp,
 			KATTR__MAX
 		)
-		|| khtml_puts(html, timestamp(event.time))
+		|| khtml_puts(html, stamp)
 		|| khtml_closeelem(html, 3);
-
 	free(href);
 	return error;
 }
 
 static enum kcgi_err
-eventNetwork(struct khtmlreq *html, struct Scope scope, struct Event event) {
-	if (scope.network || !scope.query || !event.network) return KCGI_OK;
+eventNetwork(struct khtmlreq *html, struct kreq *req, struct Event *event) {
+	if (!req->fieldmap[Query] || req->fieldmap[Network]) return KCGI_OK;
 	char *href = khttp_urlpart(
 		NULL, NULL, Pages[Search],
-		Keys[Network].name, event.network,
-		Keys[Query].name, scope.query,
+		Keys[Query].name, req->fieldmap[Query]->parsed.s,
+		Keys[Network].name, event->network,
 		NULL
 	);
 	if (!href) err(EX_OSERR, "khttp_urlpart");
 	enum kcgi_err error = 0
 		|| khtml_attr(html, KELEM_TD, KATTR_CLASS, "network", KATTR__MAX)
 		|| khtml_attr(html, KELEM_A, KATTR_HREF, href, KATTR__MAX)
-		|| khtml_puts(html, event.network)
+		|| khtml_puts(html, event->network)
 		|| khtml_closeelem(html, 2);
 	free(href);
 	return error;
 }
 
 static enum kcgi_err
-eventContext(struct khtmlreq *html, struct Scope scope, struct Event event) {
-	if (scope.context || !scope.query || !event.network || !event.context) {
-		return KCGI_OK;
-	}
+eventContext(struct khtmlreq *html, struct kreq *req, struct Event *event) {
+	if (!req->fieldmap[Query] || req->fieldmap[Context]) return KCGI_OK;
 	char *href = khttp_urlpart(
 		NULL, NULL, Pages[Search],
-		Keys[Network].name, event.network,
-		Keys[Context].name, event.context,
-		Keys[Query].name, scope.query,
+		Keys[Query].name, req->fieldmap[Query]->parsed.s,
+		Keys[Network].name, event->network,
+		Keys[Context].name, event->context,
 		NULL
 	);
 	if (!href) err(EX_OSERR, "khttp_urlpart");
 	enum kcgi_err error = 0
 		|| khtml_attr(html, KELEM_TD, KATTR_CLASS, "context", KATTR__MAX)
 		|| khtml_attr(html, KELEM_A, KATTR_HREF, href, KATTR__MAX)
-		|| khtml_puts(html, event.context)
+		|| khtml_puts(html, event->context)
 		|| khtml_closeelem(html, 2);
 	free(href);
 	return error;
@@ -262,115 +337,93 @@ static int hash(const char *str) {
 	return 2 + hash % 74;
 }
 
-static const char *colorClass(int color) {
-	static char class[sizeof("fg99")];
-	snprintf(class, sizeof(class), "fg%02d", color);
-	return class;
-}
-
-static enum kcgi_err eventNick(struct khtmlreq *html, struct Event event) {
+static enum kcgi_err
+eventNick(struct khtmlreq *html, const struct Event *event) {
 	char *mask = NULL;
-	asprintf(&mask, "%s!%s@%s", event.nick, event.user, event.host);
+	char class[sizeof("fg99")];
+	snprintf(class, sizeof(class), "fg%02d", hash(event->user));
+	asprintf(&mask, "%s!%s@%s", event->nick, event->user, event->host);
 	if (!mask) err(EX_OSERR, "asprintf");
-
+	const char *format = "%s";
+	switch (event->type) {
+		break; case Privmsg: format = "<%s>";
+		break; case Notice:  format = "-%s-";
+		break; case Action:  format = "* %s";
+		break; default:;
+	}
 	enum kcgi_err error = 0
 		|| khtml_attr(html, KELEM_TD, KATTR_CLASS, "nick", KATTR__MAX)
 		|| khtml_attr(
 			html, KELEM_SPAN,
-			KATTR_CLASS, colorClass(hash(event.user)),
+			KATTR_CLASS, class,
 			KATTR_TITLE, mask,
 			KATTR__MAX
-		);
+		)
+		|| khtml_printf(html, format, event->nick)
+		|| khtml_closeelem(html, 2);
 	free(mask);
-	if (error) return error;
-
-	switch (event.type) {
-		break; case Privmsg: error = khtml_printf(html, "<%s>", event.nick);
-		break; case Action:  error = khtml_printf(html, "* %s", event.nick);
-		break; case Notice:  error = khtml_printf(html, "-%s-", event.nick);
-		break; default: error = khtml_puts(html, event.nick);
-	}
-	return error || khtml_closeelem(html, 2);
-}
-
-static enum kcgi_err typeJoin(struct khtmlreq *html, struct Event event) {
-	(void)event;
-	return khtml_puts(html, "joined");
-}
-
-static enum kcgi_err typePart(struct khtmlreq *html, struct Event event) {
-	if (!event.message) return khtml_puts(html, "left");
-	return 0
-		|| khtml_puts(html, "left (")
-		|| htmlIRC(html, event.message)
-		|| khtml_puts(html, ")");
-}
-
-static enum kcgi_err typeQuit(struct khtmlreq *html, struct Event event) {
-	if (!event.message) return khtml_puts(html, "quit");
-	return 0
-		|| khtml_puts(html, "quit (")
-		|| htmlIRC(html, event.message)
-		|| khtml_puts(html, ")");
+	return error;
 }
 
-static enum kcgi_err typeKick(struct khtmlreq *html, struct Event event) {
-	if (!event.target) return KCGI_OK;
-	if (!event.message) return khtml_printf(html, "kicked %s", event.target);
+static enum kcgi_err
+maybeMessage(struct khtmlreq *html, const struct Event *event) {
+	if (!event->message) return KCGI_OK;
 	return 0
-		|| khtml_printf(html, "kicked %s (", event.target)
-		|| htmlIRC(html, event.message)
+		|| khtml_puts(html, " (")
+		|| htmlIRC(html, event->message)
 		|| khtml_puts(html, ")");
 }
 
-static enum kcgi_err typeNick(struct khtmlreq *html, struct Event event) {
-	if (!event.target) return KCGI_OK;
-	return 0
-		|| khtml_puts(html, "changed nick to ")
-		|| khtml_attr(
-			html, KELEM_SPAN,
-			KATTR_CLASS, colorClass(hash(event.user)),
-			KATTR__MAX
-		)
-		|| khtml_puts(html, event.target)
-		|| khtml_closeelem(html, 1);
-}
-
-static enum kcgi_err typeTopic(struct khtmlreq *html, struct Event event) {
-	if (!event.message) return KCGI_OK;
-	return 0
-		|| khtml_puts(html, "set the topic: ")
-		|| htmlIRC(html, event.message);
-}
-
-static enum kcgi_err typeBan(struct khtmlreq *html, struct Event event) {
-	if (!event.target) return KCGI_OK;
-	return khtml_printf(html, "banned %s", event.target);
-}
-
-static enum kcgi_err typeUnban(struct khtmlreq *html, struct Event event) {
-	if (!event.target) return KCGI_OK;
-	return khtml_printf(html, "unbanned %s", event.target);
-}
-
-static enum kcgi_err eventMessage(struct khtmlreq *html, struct Event event) {
+static enum kcgi_err
+eventMessage(struct khtmlreq *html, const struct Event *event) {
 	enum kcgi_err error = khtml_attr(
 		html, KELEM_TD,
 		KATTR_CLASS, "message",
 		KATTR__MAX
 	);
 	if (error) return error;
-	switch (event.type) {
-		break; case Join: error = typeJoin(html, event);
-		break; case Part: error = typePart(html, event);
-		break; case Quit: error = typeQuit(html, event);
-		break; case Kick: error = typeKick(html, event);
-		break; case Nick: error = typeNick(html, event);
-		break; case Topic: error = typeTopic(html, event);
-		break; case Ban: error = typeBan(html, event);
-		break; case Unban: error = typeUnban(html, event);
+	switch (event->type) {
+		break; case Join: {
+			error = khtml_puts(html, "joined");
+		}
+		break; case Part: {
+			error = khtml_puts(html, "left") || maybeMessage(html, event);
+		}
+		break; case Quit: {
+			error = khtml_puts(html, "quit") || maybeMessage(html, event);
+		}
+		break; case Kick: {
+			if (!event->target) break;
+			error = 0
+				|| khtml_printf(html, "kicked %s", event->target)
+				|| maybeMessage(html, event);
+		}
+		break; case Nick: {
+			if (!event->target) break;
+			char class[sizeof("fg99")];
+			snprintf(class, sizeof(class), "fg%02d", hash(event->user));
+			error = 0
+				|| khtml_puts(html, "changed nick to ")
+				|| khtml_attr(html, KELEM_SPAN, KATTR_CLASS, class, KATTR__MAX)
+				|| khtml_puts(html, event->target)
+				|| khtml_closeelem(html, 1);
+		}
+		break; case Topic: {
+			if (!event->message) break;
+			error = 0
+				|| khtml_puts(html, "set the topic: ")
+				|| htmlIRC(html, event->message);
+		}
+		break; case Ban: {
+			if (!event->target) break;
+			error = khtml_printf(html, "banned %s", event->target);
+		}
+		break; case Unban: {
+			if (!event->target) break;
+			error = khtml_printf(html, "unbanned %s", event->target);
+		}
 		break; default: {
-			if (event.message) error = htmlIRC(html, event.message);
+			if (event->message) error = htmlIRC(html, event->message);
 		}
 	}
 	return error || khtml_closeelem(html, 1);
@@ -383,126 +436,46 @@ static const char *Types[TypesLen] = {
 };
 
 enum kcgi_err
-htmlEvent(struct khtmlreq *html, struct Scope scope, struct Event event) {
-	const char *type = (event.type < TypesLen ? Types[event.type] : "unknown");
+htmlEvent(struct khtmlreq *html, struct kreq *req, struct Event *event) {
+	const char *type = (event->type < TypesLen ? Types[event->type] : "unknown");
 	return 0
 		|| khtml_attrx(
 			html, KELEM_TR,
-			KATTR_ID, KATTRX_INT, event.event,
+			KATTR_ID, KATTRX_INT, event->event,
 			KATTR_CLASS, KATTRX_STRING, type,
 			KATTR__MAX
 		)
 		|| eventTime(html, event)
-		|| eventNetwork(html, scope, event)
-		|| eventContext(html, scope, event)
+		|| eventNetwork(html, req, event)
+		|| eventContext(html, req, event)
 		|| eventNick(html, event)
 		|| eventMessage(html, event)
 		|| khtml_closeelem(html, 1);
 }
 
-enum kcgi_err linkify(struct khtmlreq *html, const char *str, size_t len) {
-	static const char *Pattern = "https?://([^[:space:]>\"()]|[(][^)]*[)])+";
-	static regex_t regex;
-	if (!regex.re_nsub) {
-		int error = regcomp(&regex, Pattern, REG_EXTENDED);
-		assert(!error);
-	}
-
-	regmatch_t match = {0};
-	while (!regexec(&regex, str, 1, &match, 0)) {
-		if ((size_t)match.rm_eo > len) break;
-
-		char *href = strndup(&str[match.rm_so], match.rm_eo - match.rm_so);
-		if (!href) err(EX_OSERR, "strndup");
-
-		enum kcgi_err error = 0
-			|| khtml_write(str, match.rm_so, html)
-			|| khtml_attr(html, KELEM_A, KATTR_HREF, href, KATTR__MAX)
-			|| khtml_write(&str[match.rm_so], match.rm_eo - match.rm_so, html)
-			|| khtml_closeelem(html, 1);
-		free(href);
-		if (error) return error;
-
-		str += match.rm_eo;
-		len -= match.rm_eo;
-	}
-	if (len) return khtml_write(str, len, html);
-	return KCGI_OK;
-}
-
-static const struct Style {
-	int fg, bg;
-	bool b, r, i, u;
-} Default = { .fg = 99, .bg = 99 };
-
-enum kcgi_err htmlStyle(struct khtmlreq *html, struct Style style) {
-	enum kcgi_err error = KCGI_OK;
-	char class[sizeof("fg99")];
-	if (style.fg != Default.fg) {
-		snprintf(class, sizeof(class), "fg%02d", style.fg);
-		error = error || khtml_attr(
-			html, KELEM_SPAN,
-			KATTR_CLASS, class,
-			KATTR__MAX
-		);
-	}
-	if (style.bg != Default.bg) {
-		snprintf(class, sizeof(class), "bg%02d", style.bg);
-		error = error || khtml_attr(
-			html, KELEM_SPAN,
-			KATTR_CLASS, class,
-			KATTR__MAX
-		);
-	}
-	if (style.b) error = error || khtml_elem(html, KELEM_B);
-	if (style.r) error = error || khtml_elem(html, KELEM_MARK);
-	if (style.i) error = error || khtml_elem(html, KELEM_I);
-	if (style.u) error = error || khtml_elem(html, KELEM_U);
-	return error;
-}
-
-enum kcgi_err htmlIRC(struct khtmlreq *html, const char *str) {
-	enum kcgi_err error = khtml_attr(
-		html, KELEM_SPAN,
-		KATTR_CLASS, "irc",
-		KATTR__MAX
-	);
-	if (error) return error;
-
-	size_t top = khtml_elemat(html);
-	struct Style style = Default;
-	for (;;) {
-		size_t len = strcspn(str, "\2\3\17\26\35\37");
-		if (len) {
-			error = 0
-				|| khtml_closeto(html, top)
-				|| htmlStyle(html, style)
-				|| linkify(html, str, len);
-			if (error) return error;
-		}
+static const char *SourceURL = "https://git.causal.agency/scooper";
+static const char *SyntaxURL = {
+	"https://www.sqlite.org/fts5.html#full_text_query_syntax"
+};
+static const char *Columns = {
+	"network, channel, query, nick, user, target, message"
+};
 
-		str += len;
-		if (!*str) break;
-		switch (*str++) {
-			break; case '\2':  style.b ^= true;
-			break; case '\17': style = Default;
-			break; case '\26': style.r ^= true;
-			break; case '\35': style.i ^= true;
-			break; case '\37': style.u ^= true;
-			break; case '\3': {
-				if (!isdigit(*str)) {
-					style.fg = Default.fg;
-					style.bg = Default.bg;
-					break;
-				}
-				style.fg = *str++ - '0';
-				if (isdigit(*str)) style.fg = style.fg * 10 + *str++ - '0';
-				if (str[0] != ',' || !isdigit(str[1])) break;
-				str++;
-				style.bg = *str++ - '0';
-				if (isdigit(*str)) style.bg = style.bg * 10 + *str++ - '0';
-			}
-		}
-	}
-	return khtml_closeto(html, top) || khtml_closeelem(html, 1);
+enum kcgi_err htmlFooter(struct khtmlreq *html) {
+	return 0
+		|| khtml_closeto(html, 0)
+		|| khtml_elem(html, KELEM_FOOTER)
+		|| khtml_elem(html, KELEM_SPAN)
+		|| khtml_attr(html, KELEM_A, KATTR_HREF, SourceURL, KATTR__MAX)
+		|| khtml_puts(html, "scooper is AGPLv3+")
+		|| khtml_closeelem(html, 2)
+		|| khtml_putc(html, ' ')
+		|| khtml_elem(html, KELEM_SPAN)
+		|| khtml_attr(html, KELEM_A, KATTR_HREF, SyntaxURL, KATTR__MAX)
+		|| khtml_puts(html, "Search syntax")
+		|| khtml_closeelem(html, 1)
+		|| khtml_putc(html, ' ')
+		|| khtml_attr(html, KELEM_SPAN, KATTR_TITLE, Columns, KATTR__MAX)
+		|| khtml_puts(html, "Columns")
+		|| khtml_closeto(html, 0);
 }
diff --git a/networks.c b/networks.c
index c92da21..af3ad80 100644
--- a/networks.c
+++ b/networks.c
@@ -45,8 +45,6 @@ const char *NetworksQuery = SQL(
 );
 
 enum kcgi_err networksPage(struct kreq *req) {
-	struct Scope scope = {0};
-
 	enum kcgi_err error = 0
 		|| httpHead(req, KHTTP_200, KMIME_TEXT_HTML)
 		|| khttp_body(req);
@@ -56,7 +54,7 @@ enum kcgi_err networksPage(struct kreq *req) {
 	error = error
 		|| khtml_open(&html, req, 0)
 		|| htmlHead(&html, "Litterbox")
-		|| htmlNav(&html, scope);
+		|| htmlNav(&html, req);
 	if (error) return error;
 
 	sqlite3_reset(stmt.networks);
diff --git a/search.c b/search.c
index c284732..67ce79c 100644
--- a/search.c
+++ b/search.c
@@ -44,15 +44,15 @@ const char *SearchQuery = SQL(
 	LIMIT :offset, :limit;
 );
 
-static char *offsetURL(struct Scope scope, int64_t offset) {
-	const char *networkKey = (scope.network ? Keys[Network].name : NULL);
-	const char *contextKey = (scope.context ? Keys[Context].name : NULL);
+static char *offsetURL(
+	const char *network, const char *context, const char *query, int64_t offset
+) {
 	char *url = khttp_urlpartx(
 		NULL, NULL, Pages[Search],
-		Keys[Query].name, KATTRX_STRING, scope.query,
+		Keys[Query].name, KATTRX_STRING, query,
 		Keys[Offset].name, KATTRX_INT, offset,
-		networkKey, KATTRX_STRING, scope.network,
-		contextKey, KATTRX_STRING, scope.context,
+		(network ? Keys[Network].name : NULL), KATTRX_STRING, network,
+		(context ? Keys[Context].name : NULL), KATTRX_STRING, context,
 		NULL
 	);
 	if (!url) err(EX_OSERR, "khttp_urlpartx");
@@ -60,12 +60,15 @@ static char *offsetURL(struct Scope scope, int64_t offset) {
 }
 
 enum kcgi_err searchPage(struct kreq *req) {
-	struct Scope scope = pageScope(req);
-	if (!scope.query) return httpFail(req, KHTTP_400);
-	if (scope.context && !scope.network) return httpFail(req, KHTTP_400);
-
+	if (!req->fieldmap[Query]) return httpFail(req, KHTTP_400);
+	const char *query = req->fieldmap[Query]->parsed.s;
+	const char *network = NULL;
+	const char *context = NULL;
 	int64_t offset = 0;
+	if (req->fieldmap[Network]) network = req->fieldmap[Network]->parsed.s;
+	if (req->fieldmap[Context]) context = req->fieldmap[Context]->parsed.s;
 	if (req->fieldmap[Offset]) offset = req->fieldmap[Offset]->parsed.i;
+	if (context && !network) return httpFail(req, KHTTP_400);
 
 	enum kcgi_err error = 0
 		|| httpHead(req, KHTTP_200, KMIME_TEXT_HTML)
@@ -75,14 +78,14 @@ enum kcgi_err searchPage(struct kreq *req) {
 	struct khtmlreq html;
 	error = error
 		|| khtml_open(&html, req, 0)
-		|| htmlHead(&html, scope.query)
-		|| htmlNav(&html, scope)
+		|| htmlHead(&html, query)
+		|| htmlNav(&html, req)
 		|| khtml_elem(&html, KELEM_TABLE);
 	if (error) return error;
 
 	if (offset) {
 		int64_t prev = offset - eventsLimit;
-		char *href = offsetURL(scope, (prev > 0 ? prev : 0));
+		char *href = offsetURL(network, context, query, (prev > 0 ? prev : 0));
 		error = 0
 			|| khtml_attr(&html, KELEM_TR, KATTR_CLASS, "page", KATTR__MAX)
 			|| khtml_attr(&html, KELEM_TH, KATTR_COLSPAN, "5", KATTR__MAX)
@@ -95,9 +98,9 @@ enum kcgi_err searchPage(struct kreq *req) {
 
 	sqlite3_reset(stmt.search);
 	dbBindText(stmt.search, ":highlight", "\26");
-	dbBindText(stmt.search, ":network", scope.network);
-	dbBindText(stmt.search, ":context", scope.context);
-	dbBindText(stmt.search, ":query", scope.query);
+	dbBindText(stmt.search, ":network", network);
+	dbBindText(stmt.search, ":context", context);
+	dbBindText(stmt.search, ":query", query);
 	dbBindInt(stmt.search, ":public", contextsPublic);
 	dbBindInt(stmt.search, ":limit", eventsLimit);
 	dbBindInt(stmt.search, ":offset", offset);
@@ -117,7 +120,7 @@ enum kcgi_err searchPage(struct kreq *req) {
 		event.host = sqlite3_column_text(stmt.search, i++);
 		event.target = sqlite3_column_text(stmt.search, i++);
 		event.message = sqlite3_column_text(stmt.search, i++);
-		error = htmlEvent(&html, scope, event);
+		error = htmlEvent(&html, req, &event);
 		if (error) return error;
 	}
 	if (result != SQLITE_DONE) {
@@ -130,7 +133,7 @@ enum kcgi_err searchPage(struct kreq *req) {
 	}
 
 	if (rows == eventsLimit) {
-		char *href = offsetURL(scope, offset + eventsLimit);
+		char *href = offsetURL(network, context, query, offset + eventsLimit);
 		error = 0
 			|| khtml_attr(&html, KELEM_TR, KATTR_CLASS, "page", KATTR__MAX)
 			|| khtml_attr(&html, KELEM_TH, KATTR_COLSPAN, "5", KATTR__MAX)
diff --git a/server.h b/server.h
index 1177871..20d1803 100644
--- a/server.h
+++ b/server.h
@@ -55,11 +55,11 @@ extern const char *Pages[PagesLen];
 	X(Context, "context", kvalid_stringne) \
 	X(After, "after", kvalid_stringne) \
 	X(Before, "before", kvalid_stringne) \
-	X(Tidy, "tidy", kvalid_stringne) \
+	X(Tidy, "tidy", kvalid_int) \
 	X(Query, "query", kvalid_stringne) \
 	X(Offset, "offset", kvalid_int)
 
-enum {
+enum Key {
 #define X(key, name, valid) key,
 	ENUM_KEYS
 #undef X
@@ -67,20 +67,6 @@ enum {
 };
 extern const struct kvalid Keys[KeysLen];
 
-struct Scope {
-	const char *network;
-	const char *context;
-	const char *query;
-};
-
-static inline struct Scope pageScope(struct kreq *req) {
-	struct Scope s = {0};
-	if (req->fieldmap[Network]) s.network = req->fieldmap[Network]->parsed.s;
-	if (req->fieldmap[Context]) s.context = req->fieldmap[Context]->parsed.s;
-	if (req->fieldmap[Query]) s.query = req->fieldmap[Query]->parsed.s;
-	return s;
-}
-
 extern int contextsRecent;
 extern bool contextsPublic;
 extern const char *NetworksQuery;
@@ -181,13 +167,13 @@ struct Event {
 
 extern const char *htmlStylesheet;
 enum kcgi_err htmlHead(struct khtmlreq *html, const char *title);
-enum kcgi_err htmlScopeFields(struct khtmlreq *html, struct Scope scope);
-enum kcgi_err htmlNav(struct khtmlreq *html, struct Scope scope);
-enum kcgi_err htmlFooter(struct khtmlreq *html);
+enum kcgi_err htmlHidden(struct khtmlreq *html, struct kreq *req, enum Key key);
+enum kcgi_err htmlNav(struct khtmlreq *html, struct kreq *req);
 enum kcgi_err htmlIRC(struct khtmlreq *html, const char *str);
 enum kcgi_err htmlEvent(
-	struct khtmlreq *html, struct Scope scope, struct Event event
+	struct khtmlreq *html, struct kreq *req, struct Event *event
 );
+enum kcgi_err htmlFooter(struct khtmlreq *html);
 
 static inline enum kcgi_err
 httpHead(struct kreq *req, enum khttp http, enum kmime mime) {