/** * Picks and checks the slugs of dumps and playlists, which name them in their * page URLs under their owner: `/@bob/dumps/`, `/@bob/playlists/`. * Each kind has its own slug space per owner, shared by the items' current * slugs and their aliases (the slugs they had before). `services/slug-service.ts` * changes and resolves slugs with these, and migration 0021 gave every item * one; both pass the database, since this module doesn't open it. * * @module */ import type { DatabaseSync } from "node:sqlite"; import { APIException } from "./api-exception.ts"; import { isValidSlug, SLUG_MAX } from "../../shared/slug.ts"; /** A kind of item that has a slug under its owner. */ export type SlugKind = "dump" | "playlist"; /** Where each {@link SlugKind}'s slugs live. */ export const SLUG_TABLES: Record< SlugKind, { items: string; aliases: string; itemId: string } > = { dump: { items: "dumps", aliases: "dump_slug_aliases", itemId: "dump_id" }, playlist: { items: "playlists", aliases: "playlist_slug_aliases", itemId: "playlist_id", }, }; /** * Returns the id of the item whose current slug or alias is `slug` in * `userId`'s space for `kind`, or undefined when it is free. */ export function slugHolder( db: DatabaseSync, kind: SlugKind, userId: string, slug: string, ): string | undefined { const { items, aliases, itemId } = SLUG_TABLES[kind]; const row = db.prepare( `SELECT id FROM ${items} WHERE user_id = ? AND slug = ? UNION ALL SELECT ${itemId} FROM ${aliases} WHERE user_id = ? AND slug = ? LIMIT 1;`, ).get(userId, slug, userId, slug) as { id: string } | undefined; return row?.id; } /** * Returns `base`, or `base-2`, `base-3`… cut to fit, whichever is free in * `userId`'s space for `kind`. `base` must be a valid slug, as `slugify` * makes. The services take it on creating an item, the migration on * re-slugging one, inside the transaction that writes it. */ export function uniqueSlug( db: DatabaseSync, kind: SlugKind, userId: string, base: string, ): string { for (let n = 1;; n++) { const suffix = n === 1 ? "" : `-${n}`; const candidate = `${ base.substring(0, SLUG_MAX - suffix.length).replace(/-+$/, "") }${suffix}`; if (!slugHolder(db, kind, userId, candidate)) return candidate; } } /** * Throws a 400 unless `slug` is a valid slug, lowercase words of letters and * digits joined by hyphens, at most `max` long. slug-service checks a dump's * or a playlist's with it, and category-service a category's, with that * kind's own limit. */ export function validateSlug(slug: string, max: number = SLUG_MAX): void { if (!isValidSlug(slug, max)) { throw new APIException( 400, `Slug must be lowercase letters, numbers and hyphens, at most ${max} characters`, ); } }