Files
gerbeur/api/db/migrations/0021_page_urls.ts
khannurien 708d113013 v4: a /community page shows the site's week and month, from the same figures as the digests, and every recurring server task is a job of one scheduler — the digests' top dumps and community section (who joined, the new public playlists, the chat and the totals) existed only in emails. community-service.ts now gathers them for a period from listTopDumps, listNewUsers and listNewPublicPlaylists, each in its own service and returning the API's Dump, PublicUser and Playlist; the digest builds its sections from the same call, its email-only DumpRow and DUMP_COLS replaced by one Dump mapper that networkOf uses too, and links its community section to the page. The page, /community for the last week and /community/monthly for the month, signed in only and linked from the avatar menu, reads GET /api/community?period=, which serves figures kept in memory and recomputed every GERBEUR_COMMUNITY_REFRESH_MINUTES (15 by default). It shows the four figures, the top dumps and the new playlists as live cards, and the new members as UserCards, the follow tab's user row made shared. Its words are the emails' own: the messages both use are renamed from email.digest.* to digest.*, as digest.weekly.off was. api/lib/scheduler.ts's startJobs runs the digests, the community refresh, the WebSocket ping, the typing expiry and the rate-limit sweep, which main.ts starts, so none of them runs on import any more. PERIOD_MS moves to shared/settings.ts. community is reserved as a category slug, and migration 0022 renames a category that had it, with renameReservedCategories moved out of 0021 into reserved-slugs.ts. A group's dump or playlist cards now fill its column. 262 backend tests and 678 frontend tests pass, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked in screenshots in Smooth light at 1280 and 390px, Smooth dark with mocked playlists, Brutalist dark at 390px and Kanagawa dark at 1280px.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:42:01 +00:00

240 lines
8.3 KiB
TypeScript

/**
* Moves the site to v4's page URLs, where dumps and playlists live under their
* owner (`/@bob/dumps/<slug>`) and are named by an editable slug alone. A
* slug was `slugify(title)-<8 hex of the id>`, unique site-wide; it is now the
* bare `slugify(title)`, unique in its owner's space for its kind, and every
* slug an item or a user leaves behind stays an alias that redirects
* (`dump_slug_aliases`, `playlist_slug_aliases`, `user_aliases`).
*
* v3 URLs aren't redirected. Instead this rewrites the links to this site's
* pages in what users wrote: dump notes, comments, chat messages, and
* playlist and profile descriptions.
*
* SQLite can't make `slug` NOT NULL without rebuilding `dumps` and
* `playlists`, which other tables reference, so the column stays nullable
* and every writer sets it; the unique index is what the services rely on.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
import { slugify } from "../../../shared/slug.ts";
import {
dumpPath,
feedPath,
playlistPath,
type ProfileTab,
searchPath,
userPath,
} from "../../../shared/paths.ts";
import { type SlugKind, uniqueSlug } from "../../lib/slugify.ts";
import { renameReservedCategories } from "../../lib/reserved-slugs.ts";
import { isSelfHost } from "../../lib/self-url.ts";
/** An item's new path, by its v3 slug and by its id. */
type PathsByKey = Map<string, string>;
/**
* Gives every dump or playlist of `kind` the bare slug of its title, unique
* per owner, the oldest first so it keeps the bare one. Returns each one's new
* path under both keys a v3 link may have used: its old slug and its id.
*/
function reslug(db: DatabaseSync, kind: SlugKind): PathsByKey {
const table = kind === "dump" ? "dumps" : "playlists";
const rows = db.prepare(
`SELECT t.id, t.user_id, t.title, t.slug, u.username
FROM ${table} t JOIN users u ON u.id = t.user_id
ORDER BY t.created_at, t.id;`,
).all() as {
id: string;
user_id: string;
title: string;
slug: string | null;
username: string;
}[];
// Cleared first, so that an old slug doesn't take a new one's place.
db.exec(`UPDATE ${table} SET slug = NULL;`);
const set = db.prepare(`UPDATE ${table} SET slug = ? WHERE id = ?;`);
const paths: PathsByKey = new Map();
const build = kind === "dump" ? dumpPath : playlistPath;
for (const row of rows) {
const slug = uniqueSlug(db, kind, row.user_id, slugify(row.title));
set.run(slug, row.id);
const path = build({ ownerUsername: row.username, slug });
paths.set(row.id, path);
if (row.slug) paths.set(row.slug, path);
}
return paths;
}
/** Where each v3 profile tab went: a tab, the overview, or a page of its own. */
const PROFILE_TAB_V4: Record<string, ProfileTab | "" | "/settings"> = {
dumps: "",
playlists: "playlists",
followed: "follow",
invitees: "invitees",
settings: "/settings",
manage: "manage",
};
/** The v3 full lists under a profile, which are v4 tabs. */
const PROFILE_LISTS = new Set<string>(["dumps", "upvoted", "playlists"]);
/**
* Matches a link to a v3 page, its origin being optional: an absolute one is
* this site's only when its host is, a relative one only as a Markdown link's
* target. The path stops before a query or a fragment, which stay as they are.
*/
const V3_LINK_RE =
/(https?:\/\/([^/\s)\]<>"']+))?(\/(?:dumps|playlists|users|~|search\/~)\/[^\s)\]<>"'?#]+)/g;
/**
* Returns the v4 path of the v3 page at `path`, or null when it names nothing
* known, which then stays as written: a deleted dump's link is dead either way.
*/
function v4PathOf(
path: string,
dumps: PathsByKey,
playlists: PathsByKey,
username: (name: string) => string | undefined,
): string | null {
let segments: string[];
try {
segments = path.split("/").slice(1).filter(Boolean).map(
decodeURIComponent,
);
} catch {
return null;
}
const [root, key, ...rest] = segments;
switch (root) {
case "dumps": {
const target = dumps.get(key);
if (!target) return null;
if (rest.length === 0) return target;
return rest.length === 1 && rest[0] === "edit" ? `${target}/edit` : null;
}
case "playlists":
return rest.length === 0 ? playlists.get(key) ?? null : null;
case "users": {
const name = username(key) ?? key;
if (rest.length === 0) return userPath(name);
if (rest.length === 1 && PROFILE_LISTS.has(rest[0])) {
return userPath(name, rest[0] as ProfileTab);
}
if (rest.length === 2 && rest[0] === "~" && rest[1] in PROFILE_TAB_V4) {
const tab = PROFILE_TAB_V4[rest[1]];
if (tab === "/settings") return tab;
return tab ? userPath(name, tab) : userPath(name);
}
return null;
}
case "~":
if (rest.length > 0) return null;
return key === "followed"
? feedPath(undefined, "follow")
: ["hot", "new", "journal"].includes(key)
? feedPath(undefined, key as "hot")
: null;
case "search":
// `/search/~/users`, split as "search", "~", "users".
return key === "~" && (rest[0] === "users" || rest[0] === "playlists") &&
rest.length === 1
? searchPath(rest[0])
: null;
}
return null;
}
/**
* Rewrites the links to v3 pages of this site in the text column `column` of
* `table`, in every row that has one.
*/
function rewriteLinks(
db: DatabaseSync,
table: string,
column: string,
toV4: (path: string) => string | null,
): void {
const rows = db.prepare(
`SELECT id, ${column} AS text FROM ${table} WHERE ${column} IS NOT NULL;`,
).all() as { id: string; text: string }[];
const update = db.prepare(`UPDATE ${table} SET ${column} = ? WHERE id = ?;`);
for (const { id, text } of rows) {
const rewritten = text.replace(
V3_LINK_RE,
(match, origin: string | undefined, host: string, path: string, at) => {
if (origin ? !isSelfHost(host) : text[at - 1] !== "(") return match;
const v4 = toV4(path);
return v4 ? `${origin ?? ""}${v4}` : match;
},
);
if (rewritten !== text) update.run(rewritten, id);
}
}
/**
* Run once by `runMigrations`. On a database made from schema.sql the tables
* and indexes exist and there is nothing to re-slug or rewrite.
*/
export function up(db: DatabaseSync): void {
db.exec(`
CREATE TABLE IF NOT EXISTS dump_slug_aliases (
user_id TEXT NOT NULL,
slug TEXT NOT NULL,
dump_id TEXT NOT NULL REFERENCES dumps(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, slug)
);
CREATE INDEX IF NOT EXISTS idx_dump_slug_aliases_dump
ON dump_slug_aliases(dump_id, created_at);
CREATE TABLE IF NOT EXISTS playlist_slug_aliases (
user_id TEXT NOT NULL,
slug TEXT NOT NULL,
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, slug)
);
CREATE INDEX IF NOT EXISTS idx_playlist_slug_aliases_playlist
ON playlist_slug_aliases(playlist_id, created_at);
CREATE TABLE IF NOT EXISTS user_aliases (
username TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
PRIMARY KEY (username)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_aliases_lower
ON user_aliases(lower(username));
CREATE INDEX IF NOT EXISTS idx_user_aliases_user
ON user_aliases(user_id, created_at);
DROP INDEX IF EXISTS idx_dumps_slug;
DROP INDEX IF EXISTS idx_playlists_slug;
`);
const dumps = reslug(db, "dump");
const playlists = reslug(db, "playlist");
db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_dumps_user_slug
ON dumps(user_id, slug);
CREATE UNIQUE INDEX IF NOT EXISTS idx_playlists_user_slug
ON playlists(user_id, slug);
`);
renameReservedCategories(db);
const findUser = db.prepare(
`SELECT username FROM users WHERE lower(username) = lower(?);`,
);
const username = (name: string) =>
(findUser.get(name) as { username: string } | undefined)?.username;
const toV4 = (path: string) => v4PathOf(path, dumps, playlists, username);
for (
const [table, column] of [
["dumps", "comment"],
["comments", "body"],
["chat_messages", "body"],
["playlists", "description"],
["users", "description"],
]
) rewriteLinks(db, table, column, toV4);
}