128 lines
4.4 KiB
TypeScript
128 lines
4.4 KiB
TypeScript
/**
|
|
* 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 { parsePagePath } from "../../shared/paths.ts";
|
|
import { canonicalizeUrl } from "../lib/canonical-url.ts";
|
|
import { parseRows } from "../model/rows.ts";
|
|
|
|
/**
|
|
* Matches an http(s) URL in Markdown. It stops at the delimiters of
|
|
* `[text](url)` and `<url>`, 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;
|
|
}
|
|
|
|
/**
|
|
* 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/<slug>`), 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.
|
|
*/
|
|
export function relinkBacklinks(
|
|
sourceType: "dump_comment" | "comment",
|
|
sourceId: string,
|
|
fromDumpId: string,
|
|
text: string,
|
|
): void {
|
|
const targetIds = new Set<string>();
|
|
const externalUrls = new Set<string>();
|
|
|
|
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);
|
|
|
|
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 parseRows(rows, isDumpRow, dumpRowToApi);
|
|
}
|