/** * Records which dumps a dump's note and comments link to, and finds the dumps * related to one through those links. The dump and comment services keep the * links up to date, and the dump page lists the related dumps, which it * fetches again when `dump_related_changed` says they changed. * * @module */ import type { Dump } from "../../shared/dump.ts"; import { db, transaction } from "../db/connection.ts"; import { DUMP_SELECT_COLUMNS as SELECT_COLS, dumpRowToApi, isDumpRow, } from "../model/dump.ts"; import { parsePagePath } from "../../shared/paths.ts"; import { canonicalizeUrl } from "../lib/canonical-url.ts"; import { parseRows } from "../model/rows.ts"; import { broadcastDumpRelatedChanged } from "./ws-service.ts"; /** * Matches an http(s) URL in Markdown. It stops at the delimiters of * `[text](url)` and ``, which would otherwise end up in the URL. */ const URL_RE = /https?:\/\/[^\s)\]<>"']+/g; /** * Returns the id of the dump whose page, or its editor, `pathname` is, by its * owner's name and its slug, current or former; null when it is no dump's. * The host isn't checked, since the site's public URL differs between setups; * a query or a fragment (`#comment-x`) isn't in a pathname anyway. */ function resolveDumpPathId(pathname: string): string | null { const page = parsePagePath(pathname); if (page?.kind !== "dump") return null; const row = db.prepare( `SELECT d.id FROM dumps d JOIN users u ON u.id = d.user_id LEFT JOIN user_aliases ua ON ua.user_id = u.id WHERE (lower(u.username) = lower(:name) OR lower(ua.username) = lower(:name)) AND (d.slug = :slug OR d.id IN (SELECT dump_id FROM dump_slug_aliases WHERE user_id = u.id AND slug = :slug)) LIMIT 1;`, ).get({ name: page.username, slug: page.slug }) as | { id: string } | undefined; return row?.id ?? null; } /** * Tells clients that the links between `dumpId` and each of `otherIds` * changed, as `broadcastDumpRelatedChanged` says, after reading who may see * each of them. It sends nothing without `otherIds`. */ function announceRelatedChange(dumpId: string, otherIds: string[]): void { if (otherIds.length === 0) return; const ids = [dumpId, ...otherIds]; const rows = db.prepare( `SELECT id, user_id, is_private FROM dumps WHERE id IN (${ids.map(() => "?").join(", ")});`, ).all(...ids) as { id: string; user_id: string; is_private: number }[]; const dumps = rows.map((row) => ({ id: row.id, userId: row.user_id, isPrivate: Boolean(row.is_private), })); const dump = dumps.find((d) => d.id === dumpId); if (!dump) return; broadcastDumpRelatedChanged(dump, dumps.filter((d) => d.id !== dumpId)); } /** * Tells clients that dump `dumpId`, which just turned public, is now related * to the dumps linked to or from it, for the dump service. Its poster saw * those links already; to everyone else they are new. */ export function announceLinksNowPublic(dumpId: string): void { const rows = db.prepare( `SELECT to_dump_id AS id FROM dump_backlinks WHERE from_dump_id = ? UNION SELECT from_dump_id FROM dump_backlinks WHERE to_dump_id = ?;`, ).all(dumpId, dumpId) as { id: string }[]; announceRelatedChange(dumpId, rows.map((row) => row.id)); } /** * Replaces the backlinks from one text, a dump's note (`dump_comment`) or one * of its comments (`comment`), with the links its current `text` holds. A link * counts when it is a dump's page on this site (`/@owner/dumps/`), or * when it is the URL a dump was posted with, compared by `canonicalizeUrl` as * reposts are. The dump and * comment services call it on every save, and with an empty `text` when a * comment is deleted. Clients are told of the dumps it links or no longer * links, as `dump_related_changed`. */ export function relinkBacklinks( sourceType: "dump_comment" | "comment", sourceId: string, fromDumpId: string, text: string, ): void { const targetIds = new Set(); const externalUrls = new Set(); for (const match of text.matchAll(URL_RE)) { let parsed: URL; try { parsed = new URL(match[0]); } catch { continue; } const resolved = resolveDumpPathId(parsed.pathname); if (resolved) { targetIds.add(resolved); } else if (parsePagePath(parsed.pathname)?.kind !== "dump") { const canonical = canonicalizeUrl(match[0]); if (canonical) externalUrls.add(canonical); } } if (externalUrls.size > 0) { const placeholders = [...externalUrls].map(() => "?").join(", "); const rows = db.prepare( `SELECT id FROM dumps WHERE url_canonical IN (${placeholders});`, ).all(...externalUrls) as { id: string }[]; for (const row of rows) targetIds.add(row.id); } targetIds.delete(fromDumpId); const formerIds = transaction(() => { const former = db.prepare( `SELECT to_dump_id FROM dump_backlinks WHERE source_type = ? AND source_id = ?;`, ).all(sourceType, sourceId) as { to_dump_id: string }[]; db.prepare( `DELETE FROM dump_backlinks WHERE source_type = ? AND source_id = ?;`, ).run(sourceType, sourceId); const createdAt = new Date().toISOString(); const insert = db.prepare( `INSERT INTO dump_backlinks (source_type, source_id, from_dump_id, to_dump_id, created_at) VALUES (?, ?, ?, ?, ?);`, ); for (const toDumpId of targetIds) { insert.run(sourceType, sourceId, fromDumpId, toDumpId, createdAt); } return new Set(former.map((row) => row.to_dump_id)); }); // Most saves change no link, and send nothing. announceRelatedChange(fromDumpId, [ ...targetIds.symmetricDifference(formerIds), ]); } /** * Returns the dumps linked to or from `dumpId`, newest first and at most 20, * without the private ones the requesting user can't see. It doesn't check * `dumpId` itself: `GET /api/dumps/:dumpId/related` does that first. */ export function getRelatedDumps( dumpId: string, requestingUserId?: string, ): Dump[] { const rows = db.prepare( `SELECT ${SELECT_COLS} FROM dumps WHERE id IN ( SELECT to_dump_id FROM dump_backlinks WHERE from_dump_id = ? UNION SELECT from_dump_id FROM dump_backlinks WHERE to_dump_id = ? ) AND (is_private = 0 OR user_id = ?) ORDER BY created_at DESC LIMIT 20;`, ).all(dumpId, dumpId, requestingUserId ?? ""); return parseRows(rows, isDumpRow, dumpRowToApi); }