v4: playlists have categories of their own — a set apart from the dumps', which admins manage on /manage under "Playlist categories", next to "Dump categories". Each category now has a kind, dump or playlist, its slug and name unique within its kind only; migration 0025 rebuilds categories with the column, every existing one becoming a dump category with its dumps, and adds playlist_categories. A playlist's owner files it under any of them in the create and edit forms, and it shows them as chips on its card and its page, each leading to /playlists/<slug>.
/playlists is the new page of every public playlist, newest first (GET /api/playlists), and /playlists/<slug> a playlist category's, with a menu of the categories, linked from the user menu as "All playlists"; `playlists` is reserved as a dump category's slug, and one that had it is renamed. A profile's Playlists tab narrows to a playlist category with the same menu as its Dumps tab (?category=), and so do the Dumps and Playlists tabs of /search, each with its own kind (category and playlistCategory for GET /api/search), another tab dropping it. The category service takes the kind throughout, so a dump can't be filed under a playlist category nor the other way round, and categoryJoin is the one filter join the dump, follow and playlist services share. useCategories(kind) gives one kind's set from the one fetch, CategoryChips replaces the chips the dump card and page each drew, useCategoryFilter keeps a list to its category as the socket brings more, and useCategorySelection holds a form's picked categories. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* Stores the categories and which dumps belong to them. The category routes
|
||||
* manage and list them, the dump service files each dump it writes under its
|
||||
* categories, and every service that lists dumps fills in their `categoryIds`.
|
||||
* Stores the categories and which dumps and playlists belong to them, a set
|
||||
* of categories for each {@link CategoryKind}. The category routes manage and
|
||||
* list them, the dump and playlist services file each dump or playlist they
|
||||
* write under its categories, and every service that lists dumps or playlists
|
||||
* fills in their `categoryIds` and filters them by category with
|
||||
* {@link categoryJoin}.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -9,6 +12,7 @@
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type {
|
||||
Category,
|
||||
CategoryKind,
|
||||
CreateCategoryRequest,
|
||||
UpdateCategoryRequest,
|
||||
} from "../../shared/category.ts";
|
||||
@@ -27,13 +31,23 @@ import { VALIDATION } from "../../shared/validation.ts";
|
||||
import { parseRows } from "../model/rows.ts";
|
||||
|
||||
/**
|
||||
* Throws a 400 unless `slug` is a valid category slug: a slug within a
|
||||
* category's length, and none of the top-level paths other pages hold
|
||||
* (reserved-slugs.ts), since a category's feed lives at the URL root.
|
||||
* Names, for each kind, the table that files things under its categories and
|
||||
* the column there of the thing filed.
|
||||
*/
|
||||
function validateCategorySlug(slug: string): void {
|
||||
const LINKS: Record<CategoryKind, { table: string; column: string }> = {
|
||||
dump: { table: "dump_categories", column: "dump_id" },
|
||||
playlist: { table: "playlist_categories", column: "playlist_id" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Throws a 400 unless `slug` is a valid slug for a category of `kind`: a slug
|
||||
* within a category's length, and for a dump category none of the top-level
|
||||
* paths other pages hold (reserved-slugs.ts), since its feed lives at the URL
|
||||
* root. A playlist category's lives under `/playlists/`.
|
||||
*/
|
||||
function validateCategorySlug(slug: string, kind: CategoryKind): void {
|
||||
validateSlug(slug, VALIDATION.CATEGORY_SLUG_MAX);
|
||||
if (isReservedSlug(slug)) {
|
||||
if (kind === "dump" && isReservedSlug(slug)) {
|
||||
throw new APIException(400, `Slug "${slug}" is reserved`);
|
||||
}
|
||||
}
|
||||
@@ -82,12 +96,15 @@ export function getCategoryById(id: string): Category {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the category at `slug`, for {@link listDumpsByCategory}.
|
||||
* Returns the category of `kind` at `slug`, for {@link listDumpsByCategory}
|
||||
* and the list of every public playlist.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such category.
|
||||
*/
|
||||
export function getCategoryBySlug(slug: string): Category {
|
||||
const row = db.prepare(`SELECT * FROM categories WHERE slug = ?;`).get(slug);
|
||||
export function getCategoryBySlug(kind: CategoryKind, slug: string): Category {
|
||||
const row = db.prepare(
|
||||
`SELECT * FROM categories WHERE kind = ? AND slug = ?;`,
|
||||
).get(kind, slug);
|
||||
if (!row || !isCategoryRow(row)) {
|
||||
throw new APIException(404, "Category not found");
|
||||
}
|
||||
@@ -95,35 +112,64 @@ export function getCategoryBySlug(slug: string): Category {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the id of the category at `slug`, or undefined when there is none.
|
||||
* Returns the id of the category of `kind` at `slug`, or undefined when there
|
||||
* is none.
|
||||
*/
|
||||
export function getCategoryIdBySlug(slug: string): string | undefined {
|
||||
const row = db.prepare(`SELECT id FROM categories WHERE slug = ?;`).get(slug);
|
||||
export function getCategoryIdBySlug(
|
||||
kind: CategoryKind,
|
||||
slug: string,
|
||||
): string | undefined {
|
||||
const row = db.prepare(
|
||||
`SELECT id FROM categories WHERE kind = ? AND slug = ?;`,
|
||||
).get(kind, slug);
|
||||
return row && typeof (row as { id?: unknown }).id === "string"
|
||||
? (row as { id: string }).id
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the id of the category the `?category=` slug of `params` names,
|
||||
* undefined without one, or "missing" when it names none, so that the list
|
||||
* comes back empty rather than unfiltered. The follows routes filter the
|
||||
* index's "followed" tab with it, and the users routes a profile's lists.
|
||||
* Returns the id of the category of `kind` whose slug the `name` parameter of
|
||||
* `params`, `?category=` by default, gives, undefined without one, or
|
||||
* "missing" when it names none, so that the list comes back empty rather than
|
||||
* unfiltered. The follows routes filter the index's "followed" tab with it,
|
||||
* the users routes a profile's lists, and the search route its results.
|
||||
*/
|
||||
export function resolveCategoryFilter(
|
||||
kind: CategoryKind,
|
||||
params: URLSearchParams,
|
||||
name = "category",
|
||||
): string | "missing" | undefined {
|
||||
const slug = params.get("category");
|
||||
const slug = params.get(name);
|
||||
if (!slug) return undefined;
|
||||
return getCategoryIdBySlug(slug) ?? "missing";
|
||||
return getCategoryIdBySlug(kind, slug) ?? "missing";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every category, by position then slug, for `GET /api/categories`.
|
||||
* Returns the join that keeps only the rows of `alias`, a dump or playlist as
|
||||
* `kind` says, filed under `categoryId`, with its parameters, or nothing
|
||||
* without one. A query puts the join after its `FROM` and the parameters
|
||||
* before its own.
|
||||
*/
|
||||
export function categoryJoin(
|
||||
kind: CategoryKind,
|
||||
alias: string,
|
||||
categoryId?: string,
|
||||
): [string, string[]] {
|
||||
if (!categoryId) return ["", []];
|
||||
const { table, column } = LINKS[kind];
|
||||
return [
|
||||
`INNER JOIN ${table} cat ON cat.${column} = ${alias}.id AND cat.category_id = ?`,
|
||||
[categoryId],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every category, of both kinds, by kind, position then slug, for
|
||||
* `GET /api/categories`.
|
||||
*/
|
||||
export function listCategories(): Category[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT * FROM categories ORDER BY position ASC, slug ASC;`,
|
||||
`SELECT * FROM categories ORDER BY kind ASC, position ASC, slug ASC;`,
|
||||
).all();
|
||||
return parseRows(rows, isCategoryRow, categoryRowToApi);
|
||||
}
|
||||
@@ -133,20 +179,21 @@ export function listCategories(): Category[] {
|
||||
* and the position defaults to 0.
|
||||
*
|
||||
* @throws {APIException} 400 when the slug or name is invalid, 409 when
|
||||
* either is taken.
|
||||
* either is taken within its kind.
|
||||
*/
|
||||
export function createCategory(req: CreateCategoryRequest): Category {
|
||||
validateCategorySlug(req.slug);
|
||||
validateCategorySlug(req.slug, req.kind);
|
||||
const name = requireName(req.name);
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const createdAt = new Date();
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO categories (id, slug, name, position, created_at)
|
||||
VALUES (?, ?, ?, ?, ?);`,
|
||||
`INSERT INTO categories (id, kind, slug, name, position, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?);`,
|
||||
).run(
|
||||
id,
|
||||
req.kind,
|
||||
req.slug,
|
||||
name,
|
||||
req.position ?? 0,
|
||||
@@ -158,6 +205,7 @@ export function createCategory(req: CreateCategoryRequest): Category {
|
||||
|
||||
return {
|
||||
id,
|
||||
kind: req.kind,
|
||||
slug: req.slug,
|
||||
name,
|
||||
position: req.position ?? 0,
|
||||
@@ -170,7 +218,7 @@ export function createCategory(req: CreateCategoryRequest): Category {
|
||||
* the category as it now is. Fields `req` leaves out keep their value.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such category, 400 when the new
|
||||
* slug or name is invalid, 409 when either is taken.
|
||||
* slug or name is invalid, 409 when either is taken within its kind.
|
||||
*/
|
||||
export function updateCategory(
|
||||
id: string,
|
||||
@@ -180,7 +228,7 @@ export function updateCategory(
|
||||
|
||||
const newSlug = req.slug ?? existing.slug;
|
||||
if (req.slug !== undefined && req.slug !== existing.slug) {
|
||||
validateCategorySlug(req.slug);
|
||||
validateCategorySlug(req.slug, existing.kind);
|
||||
}
|
||||
const newName = req.name !== undefined
|
||||
? requireName(req.name)
|
||||
@@ -212,8 +260,8 @@ export function updateCategory(
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes category `id`, for `DELETE /api/categories/:id`. Its dumps stay, and
|
||||
* lose it from their `categoryIds`.
|
||||
* Deletes category `id`, for `DELETE /api/categories/:id`. Its dumps or
|
||||
* playlists stay, and lose it from their `categoryIds`.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such category.
|
||||
*/
|
||||
@@ -225,20 +273,25 @@ export function deleteCategory(id: string): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that every id in `categoryIds` names a category. The dump service
|
||||
* runs it before it writes a dump row or stores an uploaded file, so a bad id
|
||||
* fails the request before anything is written.
|
||||
* Checks that every id in `categoryIds` names a category of `kind`, so that a
|
||||
* dump is never filed under a playlist category, nor the other way round. The
|
||||
* dump and playlist services run it before they write a row or store an
|
||||
* uploaded file, so a bad id fails the request before anything is written.
|
||||
*
|
||||
* @throws {APIException} 400 when an id names no category.
|
||||
* @throws {APIException} 400 when an id names no category of `kind`.
|
||||
*/
|
||||
export function assertCategoriesExist(categoryIds: string[]): void {
|
||||
export function assertCategoriesExist(
|
||||
kind: CategoryKind,
|
||||
categoryIds: string[],
|
||||
): void {
|
||||
const unique = [...new Set(categoryIds)];
|
||||
|
||||
if (unique.length > 0) {
|
||||
const placeholders = unique.map(() => "?").join(", ");
|
||||
const found = db.prepare(
|
||||
`SELECT COUNT(*) as count FROM categories WHERE id IN (${placeholders});`,
|
||||
).get(...unique) as { count: number } | undefined;
|
||||
`SELECT COUNT(*) as count FROM categories
|
||||
WHERE kind = ? AND id IN (${placeholders});`,
|
||||
).get(kind, ...unique) as { count: number } | undefined;
|
||||
if ((found?.count ?? 0) !== unique.length) {
|
||||
throw new APIException(400, "One or more categories do not exist");
|
||||
}
|
||||
@@ -246,56 +299,64 @@ export function assertCategoriesExist(categoryIds: string[]): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the categories of dump `dumpId` with `categoryIds`, duplicates
|
||||
* dropped. An empty list takes the dump out of every category. Called inside
|
||||
* the dump service's transaction, it joins that one.
|
||||
* Replaces the categories of the dump or playlist `id`, as `kind` says, with
|
||||
* `categoryIds`, duplicates dropped. An empty list takes it out of every
|
||||
* category. Called inside the dump or playlist service's transaction, it
|
||||
* joins that one.
|
||||
*
|
||||
* @throws {APIException} 400 when an id names no category.
|
||||
* @throws {APIException} 400 when an id names no category of `kind`.
|
||||
*/
|
||||
export function setDumpCategories(
|
||||
dumpId: string,
|
||||
export function setCategories(
|
||||
kind: CategoryKind,
|
||||
id: string,
|
||||
categoryIds: string[],
|
||||
): void {
|
||||
assertCategoriesExist(categoryIds);
|
||||
assertCategoriesExist(kind, categoryIds);
|
||||
const unique = [...new Set(categoryIds)];
|
||||
const { table, column } = LINKS[kind];
|
||||
|
||||
transaction(() => {
|
||||
db.prepare(`DELETE FROM dump_categories WHERE dump_id = ?;`).run(dumpId);
|
||||
db.prepare(`DELETE FROM ${table} WHERE ${column} = ?;`).run(id);
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`,
|
||||
`INSERT INTO ${table} (${column}, category_id) VALUES (?, ?);`,
|
||||
);
|
||||
for (const categoryId of unique) {
|
||||
insert.run(dumpId, categoryId);
|
||||
insert.run(id, categoryId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets `categoryIds` on each of `dumps` in place, in one query. The dump row
|
||||
* doesn't carry them, so every service that returns dumps calls this first.
|
||||
* Sets `categoryIds` on each of `items`, dumps or playlists as `kind` says, in
|
||||
* place, in one query. Their rows don't carry them, so every service that
|
||||
* returns dumps or playlists calls this first.
|
||||
*/
|
||||
export function attachCategoryIds(dumps: Dump[]): void {
|
||||
if (dumps.length === 0) return;
|
||||
const ids = dumps.map((d) => d.id);
|
||||
export function attachCategoryIds(
|
||||
kind: CategoryKind,
|
||||
items: { id: string; categoryIds?: string[] }[],
|
||||
): void {
|
||||
if (items.length === 0) return;
|
||||
const { table, column } = LINKS[kind];
|
||||
const ids = items.map((d) => d.id);
|
||||
const placeholders = ids.map(() => "?").join(", ");
|
||||
const rows = db.prepare(
|
||||
`SELECT dump_id, category_id FROM dump_categories WHERE dump_id IN (${placeholders});`,
|
||||
).all(...ids) as Array<{ dump_id: string; category_id: string }>;
|
||||
`SELECT ${column} as id, category_id FROM ${table} WHERE ${column} IN (${placeholders});`,
|
||||
).all(...ids) as Array<{ id: string; category_id: string }>;
|
||||
|
||||
const byDump = new Map<string, string[]>();
|
||||
for (const { dump_id, category_id } of rows) {
|
||||
const list = byDump.get(dump_id) ?? [];
|
||||
const byItem = new Map<string, string[]>();
|
||||
for (const { id, category_id } of rows) {
|
||||
const list = byItem.get(id) ?? [];
|
||||
list.push(category_id);
|
||||
byDump.set(dump_id, list);
|
||||
byItem.set(id, list);
|
||||
}
|
||||
for (const dump of dumps) {
|
||||
dump.categoryIds = byDump.get(dump.id) ?? [];
|
||||
for (const item of items) {
|
||||
item.categoryIds = byItem.get(item.id) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of the dumps in the category at `slug`, newest first, for
|
||||
* `GET /api/dumps?category=`. Private dumps are left out, except
|
||||
* Returns a page of the dumps in the dump category at `slug`, newest first,
|
||||
* for `GET /api/dumps?category=`. Private dumps are left out, except
|
||||
* `requestingUserId`'s own.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such category.
|
||||
@@ -306,7 +367,7 @@ export function listDumpsByCategory(
|
||||
limit: number,
|
||||
requestingUserId?: string,
|
||||
): Page<Dump> {
|
||||
const category = getCategoryBySlug(slug);
|
||||
const category = getCategoryBySlug("dump", slug);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const privacyClause = requestingUserId
|
||||
@@ -314,15 +375,15 @@ export function listDumpsByCategory(
|
||||
: "d.is_private = 0";
|
||||
const privacyParams = requestingUserId ? [requestingUserId] : [];
|
||||
|
||||
const [catJoin, catParams] = categoryJoin("dump", "d", category.id);
|
||||
const rows = db.prepare(
|
||||
`SELECT ${SELECT_COLS_ALIASED}
|
||||
FROM dumps d
|
||||
INNER JOIN dump_categories dc ON dc.dump_id = d.id
|
||||
WHERE dc.category_id = ? AND ${privacyClause}
|
||||
FROM dumps d ${catJoin}
|
||||
WHERE ${privacyClause}
|
||||
ORDER BY d.created_at DESC LIMIT ? OFFSET ?;`,
|
||||
).all(category.id, ...privacyParams, limit + 1, offset);
|
||||
).all(...catParams, ...privacyParams, limit + 1, offset);
|
||||
|
||||
const items = parseRows(rows, isDumpRow, dumpRowToApi);
|
||||
attachCategoryIds(items);
|
||||
attachCategoryIds("dump", items);
|
||||
return pageOf(items, limit);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user