/** * 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. * * @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 { UUID_RE } from "../lib/slugify.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; /** * Matches the path of a dump's page, `/dumps/{slug-or-id}`, and ignores what * follows it, so an `/edit` or `#comment-x` link counts too. The host isn't * checked, since the site's public URL differs between setups. */ const DUMP_PATH_RE = /^\/dumps\/([^/?#]+)/; /** Returns `url` as the URL parser normalizes it, or null when it isn't one. */ function canonicalize(url: string): string | null { try { return new URL(url).toString(); } catch { return null; } } /** Returns the id of the dump with id or slug `idOrSlug`, or null. */ function resolveDumpPathId(idOrSlug: string): string | null { const row = UUID_RE.test(idOrSlug) ? db.prepare(`SELECT id FROM dumps WHERE id = ?;`).get(idOrSlug) : db.prepare(`SELECT id FROM dumps WHERE slug = ?;`).get(idOrSlug); return (row as { id: string } | undefined)?.id ?? null; } /** * 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 or the exact URL a dump was * posted with. The dump and comment services call it on every save, and with * an empty `text` when a comment is deleted. */ 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)) { const canonical = canonicalize(match[0]); if (!canonical) continue; let parsed: URL; try { parsed = new URL(canonical); } catch { continue; } const pathMatch = DUMP_PATH_RE.exec(parsed.pathname); if (pathMatch) { const resolved = resolveDumpPathId(decodeURIComponent(pathMatch[1])); if (resolved) targetIds.add(resolved); } else { externalUrls.add(canonical); } } if (externalUrls.size > 0) { const placeholders = [...externalUrls].map(() => "?").join(", "); const rows = db.prepare( `SELECT id FROM dumps WHERE url IN (${placeholders});`, ).all(...externalUrls) as { id: string }[]; for (const row of rows) targetIds.add(row.id); } targetIds.delete(fromDumpId); transaction(() => { 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); } }); } /** * 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 rows.filter(isDumpRow).map(dumpRowToApi); }