import { type Dump } from "../model/interfaces.ts"; import { db, dumpRowToApi, isDumpRow } from "../model/db.ts"; import { UUID_RE } from "../lib/slugify.ts"; // Mirrors dump-service SELECT_COLS — kept local to avoid a circular import // (dump-service.ts must import relinkBacklinks from this file). const SELECT_COLS = "id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private, custom_thumbnail_mime," + " (SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count"; // Matches http(s) URLs in free text — stops at whitespace or markdown-link // delimiters (so `[text](url)` and `` don't swallow the closing char). const URL_RE = /https?:\/\/[^\s)\]<>"']+/g; // A link to another dump's page on this same site (e.g. pasted from the // address bar) — host-agnostic, since the configured public URL can differ // between dev/prod/reverse-proxy setups. Matches `/dumps/{slug-or-id}`, // ignoring any trailing path/query/hash (so `/edit` or `#comment-x` links // still resolve to the same target dump). const DUMP_PATH_RE = /^\/dumps\/([^/?#]+)/; function canonicalize(url: string): string | null { try { return new URL(url).toString(); } catch { return null; } } /** Resolves a same-site `/dumps/{slug-or-id}` path segment to a dump id. */ 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; } /** * Re-derive the backlinks contributed by one text source (a dump's own * `comment` field, or a single comment's `body`) and persist them. Always * deletes the source's previous contribution first, so editing/clearing the * text correctly removes stale links — unlike `linkAttachments`/`notifyMentions`, * which are purely additive. */ 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); // no self-links db.exec("BEGIN;"); try { db.prepare( `DELETE FROM dump_backlinks WHERE source_type = ? AND source_id = ?;`, ).run(sourceType, sourceId); const createdAt = new Date().toISOString(); for (const toDumpId of targetIds) { db.prepare( `INSERT INTO dump_backlinks (source_type, source_id, from_dump_id, to_dump_id, created_at) VALUES (?, ?, ?, ?, ?);`, ).run(sourceType, sourceId, fromDumpId, toDumpId, createdAt); } db.exec("COMMIT;"); } catch (err) { db.exec("ROLLBACK;"); throw err; } } /** Bidirectional, privacy-filtered, capped at 20 — backlinks are a small, bounded list. */ 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); }