86 lines
2.7 KiB
TypeScript
86 lines
2.7 KiB
TypeScript
/**
|
|
* Picks and checks the slugs of dumps and playlists, which name them in their
|
|
* page URLs under their owner: `/@bob/dumps/<slug>`, `/@bob/playlists/<slug>`.
|
|
* 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 `SLUG_MAX` long. Dump and playlist slugs
|
|
* reserve no word: nothing else a user names lives beside them.
|
|
*/
|
|
export function validateSlug(slug: string): void {
|
|
if (!isValidSlug(slug)) {
|
|
throw new APIException(
|
|
400,
|
|
`Slug must be lowercase letters, numbers and hyphens, at most ${SLUG_MAX} characters`,
|
|
);
|
|
}
|
|
}
|