From aba68d2752a2ca3954ef127176ef750387bea915 Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:28:48 +0100 Subject: [PATCH 1/3] feat(core): add flat media folder storage --- .../database/migrations/071_media_folders.ts | 72 +++++ .../core/src/database/migrations/runner.ts | 2 + .../database/repositories/media-folders.ts | 100 +++++++ .../core/src/database/repositories/media.ts | 25 +- packages/core/src/database/types.ts | 9 + .../database/media-folders.test.ts | 245 ++++++++++++++++++ .../database/media-page-pagination.test.ts | 12 + .../integration/database/migrations.test.ts | 1 + 8 files changed, 462 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/database/migrations/071_media_folders.ts create mode 100644 packages/core/src/database/repositories/media-folders.ts create mode 100644 packages/core/tests/integration/database/media-folders.test.ts diff --git a/packages/core/src/database/migrations/071_media_folders.ts b/packages/core/src/database/migrations/071_media_folders.ts new file mode 100644 index 0000000000..db0003387a --- /dev/null +++ b/packages/core/src/database/migrations/071_media_folders.ts @@ -0,0 +1,72 @@ +import type { Kysely } from "kysely"; + +import { columnExists } from "../dialect-helpers.js"; + +const DUPLICATE_COLUMN_RE = /(?:duplicate column|column .* already exists|already exists.*column)/i; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable("media_folders") + .ifNotExists() + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("name", "text", (col) => col.notNull()) + .addColumn("name_key", "text", (col) => col.notNull().unique()) + .execute(); + + await addFolderColumnIfMissing(db, () => + db.schema + .alterTable("media") + .addColumn("folder_id", "text", (col) => + col.references("media_folders.id").onDelete("set null"), + ) + .execute(), + ); + + await db.schema + .createIndex("idx_media_folder_id") + .ifNotExists() + .on("media") + .column("folder_id") + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropIndex("idx_media_folder_id").ifExists().execute(); + if (await columnExists(db, "media", "folder_id")) { + await db.schema.alterTable("media").dropColumn("folder_id").execute(); + } + await db.schema.dropTable("media_folders").ifExists().execute(); +} + +async function addFolderColumnIfMissing( + db: Kysely, + addColumn: () => Promise, +): Promise { + if (await columnExists(db, "media", "folder_id")) return; + + try { + await addColumn(); + } catch (error) { + if (DUPLICATE_COLUMN_RE.test(deepErrorMessage(error))) { + if (await columnExists(db, "media", "folder_id")) return; + } + throw error; + } +} + +function deepErrorMessage(error: unknown): string { + if (error instanceof Error) { + const own = error.message ?? ""; + if (error.cause) { + const causeMessage = deepErrorMessage(error.cause); + return own ? `${own}: ${causeMessage}` : causeMessage; + } + return own; + } + if (typeof error === "string") return error; + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts index 84d1cda67e..46ef71517d 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -73,6 +73,7 @@ import * as m067 from "./067_indexed_content_fields.js"; import * as m068 from "./068_content_taxonomy_entry_groups.js"; import * as m069 from "./069_collection_title_date_fields.js"; import * as m070 from "./070_collection_routable.js"; +import * as m071 from "./071_media_folders.js"; const MIGRATIONS: Readonly> = Object.freeze({ "001_initial": m001, @@ -144,6 +145,7 @@ const MIGRATIONS: Readonly> = Object.freeze({ "068_content_taxonomy_entry_groups": m068, "069_collection_title_date_fields": m069, "070_collection_routable": m070, + "071_media_folders": m071, }); /** Ordered names from the statically registered migration set. */ diff --git a/packages/core/src/database/repositories/media-folders.ts b/packages/core/src/database/repositories/media-folders.ts new file mode 100644 index 0000000000..47861fd9a6 --- /dev/null +++ b/packages/core/src/database/repositories/media-folders.ts @@ -0,0 +1,100 @@ +import type { Kysely } from "kysely"; +import { ulid } from "ulidx"; + +import type { Database } from "../types.js"; +import { decodeCursor, EmDashValidationError, encodeCursor, type FindManyResult } from "./types.js"; + +export interface MediaFolder { + id: string; + name: string; +} + +export interface FindManyMediaFoldersOptions { + limit?: number; + cursor?: string; +} + +function normalizeFolderName(name: string): { name: string; nameKey: string } { + if (typeof name !== "string") { + throw new EmDashValidationError("Folder name must be a string"); + } + const trimmed = name.trim(); + if (trimmed.length < 1 || trimmed.length > 200) { + throw new EmDashValidationError("Folder name must be between 1 and 200 characters"); + } + return { name: trimmed, nameKey: trimmed.normalize("NFKC").toLowerCase() }; +} + +export class MediaFolderRepository { + constructor(private db: Kysely) {} + + async findMany(options: FindManyMediaFoldersOptions = {}): Promise> { + const limit = Math.max(1, Math.min(options.limit ?? 50, 100)); + let query = this.db + .selectFrom("media_folders") + .select(["id", "name", "name_key"]) + .orderBy("name_key", "asc") + .orderBy("id", "asc") + .limit(limit + 1); + + if (options.cursor) { + const { orderValue: nameKey, id } = decodeCursor(options.cursor); + query = query.where((eb) => + eb.or([ + eb("name_key", ">", nameKey), + eb.and([eb("name_key", "=", nameKey), eb("id", ">", id)]), + ]), + ); + } + + const rows = await query.execute(); + const hasMore = rows.length > limit; + const visible = rows.slice(0, limit); + return { + items: visible.map(({ id, name }) => ({ id, name })), + nextCursor: + hasMore && visible.length > 0 + ? encodeCursor(visible.at(-1)!.name_key, visible.at(-1)!.id) + : undefined, + }; + } + + async findById(id: string): Promise { + const row = await this.db + .selectFrom("media_folders") + .select(["id", "name"]) + .where("id", "=", id) + .executeTakeFirst(); + return row ?? null; + } + + async create(inputName: string): Promise { + const { name, nameKey } = normalizeFolderName(inputName); + const row = await this.db + .insertInto("media_folders") + .values({ id: ulid(), name, name_key: nameKey }) + .returning(["id", "name"]) + .executeTakeFirstOrThrow(); + return row; + } + + async update(id: string, inputName: string): Promise { + const { name, nameKey } = normalizeFolderName(inputName); + const row = await this.db + .updateTable("media_folders") + .set({ name, name_key: nameKey }) + .where("id", "=", id) + .returning(["id", "name"]) + .executeTakeFirst(); + return row ?? null; + } + + async delete(id: string): Promise { + const row = await this.db + .deleteFrom("media_folders") + .where("id", "=", id) + .returning("id") + .executeTakeFirst(); + return row !== undefined; + } +} diff --git a/packages/core/src/database/repositories/media.ts b/packages/core/src/database/repositories/media.ts index ca47d3a8c7..ced23f159b 100644 --- a/packages/core/src/database/repositories/media.ts +++ b/packages/core/src/database/repositories/media.ts @@ -63,6 +63,7 @@ export interface MediaItem { dominantColor: string | null; createdAt: string; authorId: string | null; + folderId?: string | null; } export interface CreateMediaInput { @@ -89,6 +90,16 @@ export interface FindManyMediaOptions { status?: MediaStatus | "all"; // Filter by status, defaults to "ready" /** Case-insensitive substring matched against the filename (covers filename and extension). */ q?: string; + /** Omit for all media, pass null for the Main library, or pass a folder ID. */ + folderId?: string | null; +} + +export interface UpdateMediaInput { + alt?: string; + caption?: string; + width?: number; + height?: number; + folderId?: string | null; } export interface FindMediaPageOptions extends Omit { @@ -132,6 +143,7 @@ export class MediaRepository { status: input.status ?? "ready", created_at: now, author_id: input.authorId ?? null, + folder_id: null, }; await this.db.insertInto("media").values(row).execute(); @@ -439,10 +451,7 @@ export class MediaRepository { /** * Update media metadata */ - async update( - id: string, - input: Partial>, - ): Promise { + async update(id: string, input: UpdateMediaInput): Promise { const existing = await this.findById(id); if (!existing) { return null; @@ -453,6 +462,7 @@ export class MediaRepository { if (input.caption !== undefined) updates.caption = input.caption; if (input.width !== undefined) updates.width = input.width; if (input.height !== undefined) updates.height = input.height; + if (input.folderId !== undefined) updates.folder_id = input.folderId; if (Object.keys(updates).length > 0) { await this.db.updateTable("media").set(updates).where("id", "=", id).execute(); @@ -516,6 +526,12 @@ export class MediaRepository { query = query.where("status", "=", options.status ?? "ready"); } + if (options.folderId === null) { + query = query.where("folder_id", "is", null); + } else if (options.folderId !== undefined) { + query = query.where("folder_id", "=", options.folderId); + } + return query; } @@ -560,6 +576,7 @@ export class MediaRepository { status: row.status as MediaStatus, createdAt: row.created_at, authorId: row.author_id, + folderId: row.folder_id, }; } } diff --git a/packages/core/src/database/types.ts b/packages/core/src/database/types.ts index 4116485375..ce1937876e 100644 --- a/packages/core/src/database/types.ts +++ b/packages/core/src/database/types.ts @@ -76,6 +76,13 @@ export interface MediaTable { dominant_color: string | null; created_at: Generated; author_id: string | null; + folder_id: Generated; +} + +export interface MediaFolderTable { + id: string; + name: string; + name_key: string; } export interface MediaUploadAttemptTable { @@ -627,6 +634,7 @@ export interface Database { content_taxonomies: ContentTaxonomyTable; _emdash_taxonomy_defs: TaxonomyDefTable; media: MediaTable; + media_folders: MediaFolderTable; _emdash_media_upload_attempts: MediaUploadAttemptTable; _emdash_media_usage_sources: MediaUsageSourceTable; _emdash_media_usage: MediaUsageTable; @@ -694,6 +702,7 @@ export type MediaRow = { dominant_color: string | null; created_at: string; author_id: string | null; + folder_id: string | null; }; export interface RedirectTable { diff --git a/packages/core/tests/integration/database/media-folders.test.ts b/packages/core/tests/integration/database/media-folders.test.ts new file mode 100644 index 0000000000..966ed270f6 --- /dev/null +++ b/packages/core/tests/integration/database/media-folders.test.ts @@ -0,0 +1,245 @@ +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { columnExists, indexExists, tableExists } from "../../../src/database/dialect-helpers.js"; +import * as migration071 from "../../../src/database/migrations/071_media_folders.js"; +import { MediaFolderRepository } from "../../../src/database/repositories/media-folders.js"; +import { MediaRepository } from "../../../src/database/repositories/media.js"; +import { EmDashValidationError } from "../../../src/database/repositories/types.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +describeEachDialect("media folder storage and repositories", (dialect) => { + let ctx: DialectTestContext; + let folders: MediaFolderRepository; + let media: MediaRepository; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + folders = new MediaFolderRepository(ctx.db); + media = new MediaRepository(ctx.db); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("upgrades existing media into the Main library and supports rollback", async () => { + await migration071.down(ctx.db); + await ctx.db + .insertInto("media") + .values({ + id: "existing-media", + filename: "existing.jpg", + mime_type: "image/jpeg", + size: 123, + width: 640, + height: 480, + alt: "Existing alt", + caption: "Existing caption", + storage_key: "uploads/existing.jpg", + status: "ready", + content_hash: "existing-hash", + blurhash: "existing-blurhash", + dominant_color: "#112233", + author_id: "author-1", + }) + .execute(); + + await migration071.up(ctx.db); + + expect(await tableExists(ctx.db, "media_folders")).toBe(true); + expect(await columnExists(ctx.db, "media", "folder_id")).toBe(true); + expect(await indexExists(ctx.db, "idx_media_folder_id")).toBe(true); + const upgraded = await ctx.db + .selectFrom("media") + .select(["id", "storage_key", "alt", "caption", "content_hash", "folder_id"]) + .where("id", "=", "existing-media") + .executeTakeFirstOrThrow(); + expect(upgraded).toEqual({ + id: "existing-media", + storage_key: "uploads/existing.jpg", + alt: "Existing alt", + caption: "Existing caption", + content_hash: "existing-hash", + folder_id: null, + }); + + await migration071.down(ctx.db); + + expect(await tableExists(ctx.db, "media_folders")).toBe(false); + expect(await columnExists(ctx.db, "media", "folder_id")).toBe(false); + expect(await indexExists(ctx.db, "idx_media_folder_id")).toBe(false); + expect( + await ctx.db + .selectFrom("media") + .select(["id", "storage_key", "alt", "caption", "content_hash"]) + .where("id", "=", "existing-media") + .executeTakeFirst(), + ).toEqual({ + id: "existing-media", + storage_key: "uploads/existing.jpg", + alt: "Existing alt", + caption: "Existing caption", + content_hash: "existing-hash", + }); + }); + + it("normalizes names and traverses folders with a stable cursor", async () => { + const resume = await folders.create(" Résumé "); + const archive = await folders.create("Archive"); + const drafts = await folders.create("Drafts"); + + expect(resume.name).toBe("Résumé"); + expect(await folders.findById(archive.id)).toEqual(archive); + await expect(folders.create(" re\u0301sume\u0301 ")).rejects.toThrow(); + await expect(folders.update(drafts.id, "ARCHIVE")).rejects.toThrow(); + expect(await folders.findById(drafts.id)).toEqual(drafts); + + const first = await folders.findMany({ limit: 2 }); + expect(first.items.map((folder) => folder.name)).toEqual(["Archive", "Drafts"]); + expect(first.nextCursor).toBeDefined(); + const second = await folders.findMany({ limit: 2, cursor: first.nextCursor }); + expect(second).toEqual({ items: [resume], nextCursor: undefined }); + + const renamed = await folders.update(drafts.id, " Published "); + expect(renamed).toEqual({ id: drafts.id, name: "Published" }); + expect(await folders.update("missing-folder", "Missing")).toBeNull(); + expect(await folders.delete(archive.id)).toBe(true); + expect(await folders.delete(archive.id)).toBe(false); + expect(await folders.findById(archive.id)).toBeNull(); + }); + + it("rejects empty and overlong names before writing", async () => { + await expect(folders.create(" \t\n ")).rejects.toBeInstanceOf(EmDashValidationError); + await expect(folders.create("a".repeat(201))).rejects.toBeInstanceOf(EmDashValidationError); + expect(await folders.findMany()).toEqual({ items: [], nextCursor: undefined }); + }); + + it("composes folder, readiness, filename, and MIME filters in both list modes", async () => { + const photos = await folders.create("Photos"); + const archive = await folders.create("Archive"); + const rootCat = await media.create({ + filename: "root-cat.jpg", + mimeType: "image/jpeg", + storageKey: "root-cat.jpg", + }); + await media.create({ + filename: "root-guide.pdf", + mimeType: "application/pdf", + storageKey: "root-guide.pdf", + }); + const photoCat = await media.create({ + filename: "photo-cat.jpg", + mimeType: "image/jpeg", + storageKey: "photo-cat.jpg", + }); + const photoGuide = await media.create({ + filename: "photo-guide.pdf", + mimeType: "application/pdf", + storageKey: "photo-guide.pdf", + }); + const pendingCat = await media.create({ + filename: "pending-cat.jpg", + mimeType: "image/jpeg", + storageKey: "pending-cat.jpg", + status: "pending", + }); + const archivedCat = await media.create({ + filename: "archived-cat.jpg", + mimeType: "image/jpeg", + storageKey: "archived-cat.jpg", + }); + await media.update(photoCat.id, { folderId: photos.id }); + await media.update(photoGuide.id, { folderId: photos.id }); + await media.update(pendingCat.id, { folderId: photos.id }); + await media.update(archivedCat.id, { folderId: archive.id }); + + const allCats = await media.findMany({ limit: 20, q: "cat", mimeType: "image/" }); + expect(allCats.items.map((item) => item.filename).toSorted()).toEqual([ + "archived-cat.jpg", + "photo-cat.jpg", + "root-cat.jpg", + ]); + const mainCats = await media.findMany({ + limit: 20, + folderId: null, + q: "cat", + mimeType: "image/", + }); + expect(mainCats.items).toEqual([expect.objectContaining({ id: rootCat.id, folderId: null })]); + + const cursorResult = await media.findMany({ + limit: 20, + folderId: photos.id, + q: ".jpg", + mimeType: "image/", + }); + const pageResult = await media.findPage({ + page: 1, + limit: 20, + folderId: photos.id, + q: ".jpg", + mimeType: "image/", + }); + expect(cursorResult.items).toEqual([ + expect.objectContaining({ id: photoCat.id, folderId: photos.id }), + ]); + expect(pageResult).toEqual({ items: cursorResult.items, totalCount: 1 }); + }); + + it("updates assignments atomically and returns deleted-folder media to the Main library", async () => { + const folder = await folders.create("Working"); + const item = await media.create({ + filename: "source.jpg", + mimeType: "image/jpeg", + storageKey: "stable/source.jpg", + alt: "Original alt", + caption: "Original caption", + width: 1280, + height: 720, + }); + + const assigned = await media.update(item.id, { + folderId: folder.id, + alt: "Updated alt", + }); + expect(assigned).toEqual( + expect.objectContaining({ + id: item.id, + storageKey: "stable/source.jpg", + folderId: folder.id, + alt: "Updated alt", + caption: "Original caption", + }), + ); + expect(await media.update(item.id, { folderId: null })).toEqual({ + ...assigned, + folderId: null, + }); + expect(await media.update(item.id, { folderId: folder.id })).toEqual(assigned); + + await expect( + media.update(item.id, { folderId: "missing-folder", alt: "Must not persist" }), + ).rejects.toThrow(); + expect(await media.findById(item.id)).toEqual(assigned); + + expect(await folders.delete(folder.id)).toBe(true); + const afterDelete = await media.findById(item.id); + expect(afterDelete).toEqual({ ...assigned, folderId: null }); + expect(afterDelete).toEqual( + expect.objectContaining({ + id: item.id, + storageKey: "stable/source.jpg", + alt: "Updated alt", + caption: "Original caption", + width: 1280, + height: 720, + }), + ); + expect((await media.findMany({ folderId: null })).items).toEqual([afterDelete]); + }); +}); diff --git a/packages/core/tests/integration/database/media-page-pagination.test.ts b/packages/core/tests/integration/database/media-page-pagination.test.ts index c598eb0aac..73792eff3a 100644 --- a/packages/core/tests/integration/database/media-page-pagination.test.ts +++ b/packages/core/tests/integration/database/media-page-pagination.test.ts @@ -77,4 +77,16 @@ describeEachDialect("MediaRepository numbered pages", (dialect) => { expect(typeof result.totalCount).toBe("number"); expect(result.items.map((item) => item.filename)).toEqual(["oldest.jpg"]); }); + + it("applies the same folder filter to cursor rows, page rows, and totals", async () => { + await seedMedia(); + const cursorOptions = { limit: 2, folderId: "missing-folder" }; + const pageOptions = { page: 1, limit: 2, folderId: "missing-folder" }; + + const cursorResult = await repo.findMany(cursorOptions); + const pageResult = await repo.findPage(pageOptions); + + expect(cursorResult).toEqual({ items: [], nextCursor: undefined }); + expect(pageResult).toEqual({ items: [], totalCount: 0 }); + }); }); diff --git a/packages/core/tests/integration/database/migrations.test.ts b/packages/core/tests/integration/database/migrations.test.ts index 88987f0e0d..50ee1159fd 100644 --- a/packages/core/tests/integration/database/migrations.test.ts +++ b/packages/core/tests/integration/database/migrations.test.ts @@ -184,6 +184,7 @@ describe("Database Migrations (Integration)", () => { "068_content_taxonomy_entry_groups", "069_collection_title_date_fields", "070_collection_routable", + "071_media_folders", ]; await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute(); From 7da71d23a580edde01ed783bc77bbd5579eaef5c Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:45:48 +0100 Subject: [PATCH 2/3] feat(core): expose media folder API --- packages/core/src/api/errors.ts | 4 + packages/core/src/api/handlers/index.ts | 6 + .../core/src/api/handlers/media-folders.ts | 112 ++++++++++ packages/core/src/api/handlers/media.ts | 25 ++- packages/core/src/api/openapi/document.ts | 79 +++++++ packages/core/src/api/schemas/media.ts | 39 ++++ packages/core/src/astro/integration/routes.ts | 10 + packages/core/src/astro/routes/api/media.ts | 1 + .../core/src/astro/routes/api/media/[id].ts | 1 + .../astro/routes/api/media/folders/[id].ts | 40 ++++ .../astro/routes/api/media/folders/index.ts | 33 +++ packages/core/src/astro/types.ts | 9 +- .../database/repositories/media-folders.ts | 2 +- packages/core/src/emdash-runtime.ts | 9 +- .../unit/api/media-folders-handlers.test.ts | 113 ++++++++++ .../unit/api/media-folders-routes.test.ts | 209 ++++++++++++++++++ .../tests/unit/api/media-list-route.test.ts | 25 +++ .../unit/api/media-usage-summary.test.ts | 1 + packages/core/tests/unit/api/openapi.test.ts | 50 +++++ packages/core/tests/unit/astro/routes.test.ts | 12 + 20 files changed, 776 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/api/handlers/media-folders.ts create mode 100644 packages/core/src/astro/routes/api/media/folders/[id].ts create mode 100644 packages/core/src/astro/routes/api/media/folders/index.ts create mode 100644 packages/core/tests/unit/api/media-folders-handlers.test.ts create mode 100644 packages/core/tests/unit/api/media-folders-routes.test.ts diff --git a/packages/core/src/api/errors.ts b/packages/core/src/api/errors.ts index f211b7d4ba..660521dc26 100644 --- a/packages/core/src/api/errors.ts +++ b/packages/core/src/api/errors.ts @@ -86,6 +86,10 @@ export const ErrorCode = { MEDIA_CREATE_ERROR: "MEDIA_CREATE_ERROR", MEDIA_UPDATE_ERROR: "MEDIA_UPDATE_ERROR", MEDIA_DELETE_ERROR: "MEDIA_DELETE_ERROR", + MEDIA_FOLDER_LIST_ERROR: "MEDIA_FOLDER_LIST_ERROR", + MEDIA_FOLDER_CREATE_ERROR: "MEDIA_FOLDER_CREATE_ERROR", + MEDIA_FOLDER_UPDATE_ERROR: "MEDIA_FOLDER_UPDATE_ERROR", + MEDIA_FOLDER_DELETE_ERROR: "MEDIA_FOLDER_DELETE_ERROR", MEDIA_USAGE_READ_ERROR: "MEDIA_USAGE_READ_ERROR", MEDIA_USAGE_REPAIR_ERROR: "MEDIA_USAGE_REPAIR_ERROR", MEDIA_USAGE_WORK_LIST_ERROR: "MEDIA_USAGE_WORK_LIST_ERROR", diff --git a/packages/core/src/api/handlers/index.ts b/packages/core/src/api/handlers/index.ts index a18426d839..15e9833ba8 100644 --- a/packages/core/src/api/handlers/index.ts +++ b/packages/core/src/api/handlers/index.ts @@ -60,6 +60,12 @@ export { type MediaListResponse, type MediaResponse, } from "./media.js"; +export { + handleMediaFolderList, + handleMediaFolderCreate, + handleMediaFolderUpdate, + handleMediaFolderDelete, +} from "./media-folders.js"; export { aggregateMediaUsageCoverageStatus, diff --git a/packages/core/src/api/handlers/media-folders.ts b/packages/core/src/api/handlers/media-folders.ts new file mode 100644 index 0000000000..0d34167ff1 --- /dev/null +++ b/packages/core/src/api/handlers/media-folders.ts @@ -0,0 +1,112 @@ +import type { Kysely } from "kysely"; + +import { + MediaFolderRepository, + type MediaFolder, +} from "../../database/repositories/media-folders.js"; +import { EmDashValidationError, InvalidCursorError } from "../../database/repositories/types.js"; +import type { Database } from "../../database/types.js"; +import type { ApiResult } from "../types.js"; + +const UNIQUE_VIOLATION_RE = + /unique constraint failed|duplicate key value violates unique constraint/i; + +export async function handleMediaFolderList( + db: Kysely, + options: { limit?: number; cursor?: string } = {}, +): Promise> { + try { + const result = await new MediaFolderRepository(db).findMany(options); + return { success: true, data: result }; + } catch (error) { + if (error instanceof InvalidCursorError) { + return { success: false, error: { code: "INVALID_CURSOR", message: error.message } }; + } + return { + success: false, + error: { code: "MEDIA_FOLDER_LIST_ERROR", message: "Failed to list media folders" }, + }; + } +} + +export async function handleMediaFolderCreate( + db: Kysely, + input: { name: string }, +): Promise> { + try { + const item = await new MediaFolderRepository(db).create(input.name); + return { success: true, data: { item } }; + } catch (error) { + return mediaFolderWriteError( + error, + "MEDIA_FOLDER_CREATE_ERROR", + "Failed to create media folder", + ); + } +} + +export async function handleMediaFolderUpdate( + db: Kysely, + id: string, + input: { name: string }, +): Promise> { + try { + const item = await new MediaFolderRepository(db).update(id, input.name); + if (!item) { + return { success: false, error: { code: "NOT_FOUND", message: "Media folder not found" } }; + } + return { success: true, data: { item } }; + } catch (error) { + return mediaFolderWriteError( + error, + "MEDIA_FOLDER_UPDATE_ERROR", + "Failed to update media folder", + ); + } +} + +export async function handleMediaFolderDelete( + db: Kysely, + id: string, +): Promise> { + try { + const deleted = await new MediaFolderRepository(db).delete(id); + if (!deleted) { + return { success: false, error: { code: "NOT_FOUND", message: "Media folder not found" } }; + } + return { success: true, data: { deleted: true } }; + } catch { + return { + success: false, + error: { code: "MEDIA_FOLDER_DELETE_ERROR", message: "Failed to delete media folder" }, + }; + } +} + +function mediaFolderWriteError( + error: unknown, + code: "MEDIA_FOLDER_CREATE_ERROR" | "MEDIA_FOLDER_UPDATE_ERROR", + message: string, +): ApiResult { + if (error instanceof EmDashValidationError) { + return { success: false, error: { code: "VALIDATION_ERROR", message: error.message } }; + } + if (isUniqueViolation(error)) { + return { + success: false, + error: { code: "CONFLICT", message: "A media folder with this name already exists" }, + }; + } + return { success: false, error: { code, message } }; +} + +function isUniqueViolation(error: unknown): boolean { + if (error && typeof error === "object") { + if ("code" in error && error.code === "23505") return true; + } + const message = error instanceof Error ? error.message : ""; + if (UNIQUE_VIOLATION_RE.test(message)) return true; + return Boolean( + error && typeof error === "object" && "cause" in error && isUniqueViolation(error.cause), + ); +} diff --git a/packages/core/src/api/handlers/media.ts b/packages/core/src/api/handlers/media.ts index 39cb809988..2711556f1e 100644 --- a/packages/core/src/api/handlers/media.ts +++ b/packages/core/src/api/handlers/media.ts @@ -9,6 +9,8 @@ import { InvalidCursorError } from "../../database/repositories/types.js"; import type { Database } from "../../database/types.js"; import type { ApiResult } from "../types.js"; +const FOREIGN_KEY_VIOLATION_RE = /foreign key constraint failed/i; + export interface MediaListResponse { items: MediaItem[]; nextCursor?: string; @@ -30,6 +32,7 @@ export async function handleMediaList( limit?: number; mimeType?: string | readonly string[]; q?: string; + folderId?: string | null; }, ): Promise> { try { @@ -54,6 +57,7 @@ export async function handleMediaList( limit, mimeType: params.mimeType, q: params.q, + folderId: params.folderId, }); return { success: true, data: result }; } @@ -64,6 +68,7 @@ export async function handleMediaList( limit: Math.min(params.limit || 50, 100), mimeType: params.mimeType, q: params.q, + folderId: params.folderId, }); return { @@ -175,6 +180,7 @@ export async function handleMediaUpdate( caption?: string; width?: number; height?: number; + folderId?: string | null; }, ): Promise> { try { @@ -195,7 +201,13 @@ export async function handleMediaUpdate( success: true, data: { item }, }; - } catch { + } catch (error) { + if (isForeignKeyViolation(error)) { + return { + success: false, + error: { code: "NOT_FOUND", message: "Media folder not found" }, + }; + } return { success: false, error: { @@ -206,6 +218,17 @@ export async function handleMediaUpdate( } } +function isForeignKeyViolation(error: unknown): boolean { + if (error && typeof error === "object") { + if ("code" in error && error.code === "23503") return true; + } + const message = error instanceof Error ? error.message : ""; + if (FOREIGN_KEY_VIOLATION_RE.test(message)) return true; + return Boolean( + error && typeof error === "object" && "cause" in error && isForeignKeyViolation(error.cause), + ); +} + /** * Delete media item */ diff --git a/packages/core/src/api/openapi/document.ts b/packages/core/src/api/openapi/document.ts index c7cae3bf61..e76a23ce47 100644 --- a/packages/core/src/api/openapi/document.ts +++ b/packages/core/src/api/openapi/document.ts @@ -57,6 +57,11 @@ import { mediaConfirmBody, mediaConfirmResponseSchema, mediaExistingResponseSchema, + mediaFolderBody, + mediaFolderIdSchema, + mediaFolderListQuery, + mediaFolderListResponseSchema, + mediaFolderResponseSchema, mediaGetQuery, mediaListQuery, mediaListReadResponseSchema, @@ -702,6 +707,80 @@ function buildMediaPaths(maxUploadSize: number) { }, }, }, + "/_emdash/api/media/folders": { + get: { + operationId: "listMediaFolders", + summary: "List media folders", + tags: ["Media"], + requestParams: { query: mediaFolderListQuery }, + responses: { + "200": { + description: "Media folder list", + content: { + [JSON_CONTENT]: { schema: successEnvelope(mediaFolderListResponseSchema) }, + }, + }, + ...authErrors, + ...standardErrors(400, 500), + }, + }, + post: { + operationId: "createMediaFolder", + summary: "Create a media folder", + tags: ["Media"], + requestBody: { content: { [JSON_CONTENT]: { schema: mediaFolderBody } } }, + responses: { + "201": { + description: "Created media folder", + content: { + [JSON_CONTENT]: { schema: successEnvelope(mediaFolderResponseSchema) }, + }, + }, + ...authErrors, + ...standardErrors(400, 409, 500), + }, + }, + }, + "/_emdash/api/media/folders/{id}": { + put: { + operationId: "updateMediaFolder", + summary: "Update a media folder", + tags: ["Media"], + requestParams: { + path: z.object({ id: mediaFolderIdSchema.meta({ description: "Media folder ID" }) }), + }, + requestBody: { content: { [JSON_CONTENT]: { schema: mediaFolderBody } } }, + responses: { + "200": { + description: "Updated media folder", + content: { + [JSON_CONTENT]: { schema: successEnvelope(mediaFolderResponseSchema) }, + }, + }, + ...authErrors, + ...standardErrors(400, 404, 409, 500), + }, + }, + delete: { + operationId: "deleteMediaFolder", + summary: "Delete a media folder", + description: "Deletes the folder and returns its media to the Main library.", + tags: ["Media"], + requestParams: { + path: z.object({ id: mediaFolderIdSchema.meta({ description: "Media folder ID" }) }), + }, + responses: { + "200": { + description: "Deleted media folder", + content: { + [JSON_CONTENT]: { schema: successEnvelope(deleteResponseSchema) }, + }, + }, + ...authErrors, + ...standardErrors(400, 404, 500), + }, + }, + }, "/_emdash/api/media/{id}": { get: { operationId: "getMedia", diff --git a/packages/core/src/api/schemas/media.ts b/packages/core/src/api/schemas/media.ts index 749bb1c2a0..4d3ecdc399 100644 --- a/packages/core/src/api/schemas/media.ts +++ b/packages/core/src/api/schemas/media.ts @@ -23,6 +23,13 @@ export const mediaListQuery = cursorPaginationQuery .extend({ page: z.coerce.number().int().min(1).max(Number.MAX_SAFE_INTEGER).optional(), mimeType: mimeTypeFilter, + folderId: z + .union([z.literal("unfiled"), z.string().min(1).max(64)]) + .optional() + .meta({ + description: + "Filter by a media folder ID. Use `unfiled` for the Main library; omit for all media.", + }), /** Case-insensitive filename substring search (also matches extensions). */ q: z.string().trim().min(1).max(200).optional(), includeUsage: z.literal("1").optional().meta({ @@ -49,9 +56,28 @@ export const mediaUpdateBody = z caption: z.string().optional(), width: z.number().int().positive().optional(), height: z.number().int().positive().optional(), + folderId: z.string().min(1).max(64).nullable().optional().meta({ + description: "Assign a media folder ID, or use null to return the item to the Main library.", + }), }) .meta({ id: "MediaUpdateBody" }); +export const mediaFolderIdSchema = z.string().min(1).max(64); + +export const mediaFolderListQuery = cursorPaginationQuery.meta({ id: "MediaFolderListQuery" }); + +const mediaFolderNameSchema = z.string().refine( + (value) => { + const length = value.trim().length; + return length >= 1 && length <= 200; + }, + { message: "Folder name must be between 1 and 200 characters" }, +); + +export const mediaFolderBody = z + .object({ name: mediaFolderNameSchema }) + .meta({ id: "MediaFolderBody" }); + /** Default maximum allowed file upload size (50 MB). */ export const DEFAULT_MAX_UPLOAD_SIZE = 50 * 1024 * 1024; @@ -125,9 +151,22 @@ export const mediaItemSchema = z dominantColor: z.string().nullable(), createdAt: z.string(), authorId: z.string().nullable(), + folderId: z.string().nullable(), }) .meta({ id: "MediaItem" }); +export const mediaFolderSchema = z + .object({ id: z.string(), name: z.string() }) + .meta({ id: "MediaFolder" }); + +export const mediaFolderResponseSchema = z + .object({ item: mediaFolderSchema }) + .meta({ id: "MediaFolderResponse" }); + +export const mediaFolderListResponseSchema = z + .object({ items: z.array(mediaFolderSchema), nextCursor: z.string().optional() }) + .meta({ id: "MediaFolderListResponse" }); + export const mediaResponseSchema = z .object({ item: mediaItemSchema }) .meta({ id: "MediaResponse" }); diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index be522628cd..db45d4ca21 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -208,6 +208,16 @@ export function injectCoreRoutes( entrypoint: resolveRoute("api/media/file/[...key].ts"), }); + injectRoute({ + pattern: "/_emdash/api/media/folders", + entrypoint: resolveRoute("api/media/folders/index.ts"), + }); + + injectRoute({ + pattern: "/_emdash/api/media/folders/[id]", + entrypoint: resolveRoute("api/media/folders/[id].ts"), + }); + injectRoute({ pattern: "/_emdash/api/media/[id]", entrypoint: resolveRoute("api/media/[id].ts"), diff --git a/packages/core/src/astro/routes/api/media.ts b/packages/core/src/astro/routes/api/media.ts index 205f08644c..4671f371e0 100644 --- a/packages/core/src/astro/routes/api/media.ts +++ b/packages/core/src/astro/routes/api/media.ts @@ -59,6 +59,7 @@ export const GET: APIRoute = async ({ request, locals }) => { limit: query.limit, mimeType: query.mimeType, q: query.q, + folderId: query.folderId === "unfiled" ? null : query.folderId, }); if (!result.success) { diff --git a/packages/core/src/astro/routes/api/media/[id].ts b/packages/core/src/astro/routes/api/media/[id].ts index acda88d3db..c3cbf30e0c 100644 --- a/packages/core/src/astro/routes/api/media/[id].ts +++ b/packages/core/src/astro/routes/api/media/[id].ts @@ -92,6 +92,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => { caption: body.caption, width: body.width, height: body.height, + folderId: body.folderId, }); return unwrapResult(result); diff --git a/packages/core/src/astro/routes/api/media/folders/[id].ts b/packages/core/src/astro/routes/api/media/folders/[id].ts new file mode 100644 index 0000000000..7fa355135d --- /dev/null +++ b/packages/core/src/astro/routes/api/media/folders/[id].ts @@ -0,0 +1,40 @@ +import type { APIRoute } from "astro"; + +import { requirePerm } from "#api/authorize.js"; +import { apiError, unwrapResult } from "#api/error.js"; +import { handleMediaFolderDelete, handleMediaFolderUpdate } from "#api/handlers/media-folders.js"; +import { isParseError, parseBody } from "#api/parse.js"; +import { mediaFolderBody, mediaFolderIdSchema } from "#api/schemas.js"; + +export const prerender = false; + +function parseFolderId(id: string | undefined): string | Response { + const result = mediaFolderIdSchema.safeParse(id); + return result.success + ? result.data + : apiError("VALIDATION_ERROR", "Invalid media folder ID", 400); +} + +export const PUT: APIRoute = async ({ params, request, locals }) => { + const { emdash, user } = locals; + const denied = requirePerm(user, "media:edit_any"); + if (denied) return denied; + if (!emdash) return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + + const id = parseFolderId(params.id); + if (id instanceof Response) return id; + const body = await parseBody(request, mediaFolderBody); + if (isParseError(body)) return body; + return unwrapResult(await handleMediaFolderUpdate(emdash.db, id, body)); +}; + +export const DELETE: APIRoute = async ({ params, locals }) => { + const { emdash, user } = locals; + const denied = requirePerm(user, "media:edit_any"); + if (denied) return denied; + if (!emdash) return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + + const id = parseFolderId(params.id); + if (id instanceof Response) return id; + return unwrapResult(await handleMediaFolderDelete(emdash.db, id)); +}; diff --git a/packages/core/src/astro/routes/api/media/folders/index.ts b/packages/core/src/astro/routes/api/media/folders/index.ts new file mode 100644 index 0000000000..c464d34c96 --- /dev/null +++ b/packages/core/src/astro/routes/api/media/folders/index.ts @@ -0,0 +1,33 @@ +import type { APIRoute } from "astro"; + +import { requirePerm } from "#api/authorize.js"; +import { apiError, unwrapResult } from "#api/error.js"; +import { handleMediaFolderCreate, handleMediaFolderList } from "#api/handlers/media-folders.js"; +import { isParseError, parseBody, parseQuery } from "#api/parse.js"; +import { mediaFolderBody, mediaFolderListQuery } from "#api/schemas.js"; + +export const prerender = false; + +export const GET: APIRoute = async ({ request, locals }) => { + const { emdash, user } = locals; + const denied = requirePerm(user, "media:read"); + if (denied) return denied; + if (!emdash) return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + + const query = parseQuery(new URL(request.url), mediaFolderListQuery); + if (isParseError(query)) return query; + return unwrapResult( + await handleMediaFolderList(emdash.db, { limit: query.limit, cursor: query.cursor }), + ); +}; + +export const POST: APIRoute = async ({ request, locals }) => { + const { emdash, user } = locals; + const denied = requirePerm(user, "media:edit_any"); + if (denied) return denied; + if (!emdash) return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + + const body = await parseBody(request, mediaFolderBody); + if (isParseError(body)) return body; + return unwrapResult(await handleMediaFolderCreate(emdash.db, body), 201); +}; diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index 3d7e136879..d3ecef28f0 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -377,6 +377,7 @@ export interface EmDashHandlers { page?: number; limit?: number; mimeType?: string | readonly string[]; + folderId?: string | null; }) => Promise; handleMediaGet: (id: string) => Promise; @@ -396,7 +397,13 @@ export interface EmDashHandlers { handleMediaUpdate: ( id: string, - input: { alt?: string; caption?: string; width?: number; height?: number }, + input: { + alt?: string; + caption?: string; + width?: number; + height?: number; + folderId?: string | null; + }, ) => Promise; handleMediaDelete: (id: string) => Promise; diff --git a/packages/core/src/database/repositories/media-folders.ts b/packages/core/src/database/repositories/media-folders.ts index 47861fd9a6..6b927269b5 100644 --- a/packages/core/src/database/repositories/media-folders.ts +++ b/packages/core/src/database/repositories/media-folders.ts @@ -37,7 +37,7 @@ export class MediaFolderRepository { .orderBy("id", "asc") .limit(limit + 1); - if (options.cursor) { + if (options.cursor !== undefined) { const { orderValue: nameKey, id } = decodeCursor(options.cursor); query = query.where((eb) => eb.or([ diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index d2697a437e..ef72f6b0f7 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -3460,6 +3460,7 @@ export class EmDashRuntime { limit?: number; mimeType?: string | readonly string[]; q?: string; + folderId?: string | null; }) { return handleMediaList(this.db, params); } @@ -3520,7 +3521,13 @@ export class EmDashRuntime { async handleMediaUpdate( id: string, - input: { alt?: string; caption?: string; width?: number; height?: number }, + input: { + alt?: string; + caption?: string; + width?: number; + height?: number; + folderId?: string | null; + }, ) { const result = await handleMediaUpdate(this.db, id, input); // Resolved media references in site settings (`logo`, `favicon`, diff --git a/packages/core/tests/unit/api/media-folders-handlers.test.ts b/packages/core/tests/unit/api/media-folders-handlers.test.ts new file mode 100644 index 0000000000..1c59911728 --- /dev/null +++ b/packages/core/tests/unit/api/media-folders-handlers.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { + handleMediaFolderCreate, + handleMediaFolderDelete, + handleMediaFolderList, + handleMediaFolderUpdate, +} from "../../../src/api/handlers/media-folders.js"; +import { handleMediaUpdate } from "../../../src/api/handlers/media.js"; +import { MediaFolderRepository } from "../../../src/database/repositories/media-folders.js"; +import { MediaRepository } from "../../../src/database/repositories/media.js"; +import { + describeEachDialect, + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +describeEachDialect("media folder handlers", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialect(dialect); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + it("lists folders with bounded cursors and rejects malformed cursors", async () => { + await handleMediaFolderCreate(ctx.db, { name: "Beta" }); + await handleMediaFolderCreate(ctx.db, { name: "Alpha" }); + + const first = await handleMediaFolderList(ctx.db, { limit: 1 }); + expect(first).toMatchObject({ + success: true, + data: { items: [{ name: "Alpha" }], nextCursor: expect.any(String) }, + }); + if (!first.success) throw new Error("expected folder list success"); + const second = await handleMediaFolderList(ctx.db, { + limit: 1, + cursor: first.data.nextCursor, + }); + expect(second).toMatchObject({ success: true, data: { items: [{ name: "Beta" }] } }); + expect(await handleMediaFolderList(ctx.db, { cursor: "not-a-cursor" })).toMatchObject({ + success: false, + error: { code: "INVALID_CURSOR" }, + }); + }); + + it("normalizes names and maps create or rename collisions to conflicts", async () => { + const created = await handleMediaFolderCreate(ctx.db, { name: " Photos " }); + expect(created).toMatchObject({ success: true, data: { item: { name: "Photos" } } }); + expect(await handleMediaFolderCreate(ctx.db, { name: "PHOTOS" })).toMatchObject({ + success: false, + error: { code: "CONFLICT" }, + }); + expect(await handleMediaFolderCreate(ctx.db, { name: " " })).toMatchObject({ + success: false, + error: { code: "VALIDATION_ERROR" }, + }); + + const other = await handleMediaFolderCreate(ctx.db, { name: "Other" }); + if (!other.success) throw new Error("expected folder create success"); + expect( + await handleMediaFolderUpdate(ctx.db, other.data.item.id, { name: "photos" }), + ).toMatchObject({ success: false, error: { code: "CONFLICT" } }); + }); + + it("returns not found for missing updates and deletes", async () => { + expect(await handleMediaFolderUpdate(ctx.db, "missing", { name: "Missing" })).toMatchObject({ + success: false, + error: { code: "NOT_FOUND" }, + }); + expect(await handleMediaFolderDelete(ctx.db, "missing")).toMatchObject({ + success: false, + error: { code: "NOT_FOUND" }, + }); + + const created = await handleMediaFolderCreate(ctx.db, { name: "Temporary" }); + if (!created.success) throw new Error("expected folder create success"); + expect(await handleMediaFolderDelete(ctx.db, created.data.item.id)).toEqual({ + success: true, + data: { deleted: true }, + }); + }); + + it("maps missing assignment targets to not found without partial metadata writes", async () => { + const folders = new MediaFolderRepository(ctx.db); + const media = new MediaRepository(ctx.db); + const folder = await folders.create("Working"); + const item = await media.create({ + filename: "photo.jpg", + mimeType: "image/jpeg", + storageKey: "photo.jpg", + alt: "Original", + }); + + expect(await handleMediaUpdate(ctx.db, item.id, { folderId: folder.id })).toMatchObject({ + success: true, + data: { item: { folderId: folder.id } }, + }); + expect( + await handleMediaUpdate(ctx.db, item.id, { + folderId: "missing-folder", + alt: "Must not persist", + }), + ).toMatchObject({ success: false, error: { code: "NOT_FOUND" } }); + expect(await media.findById(item.id)).toEqual( + expect.objectContaining({ folderId: folder.id, alt: "Original" }), + ); + }); +}); diff --git a/packages/core/tests/unit/api/media-folders-routes.test.ts b/packages/core/tests/unit/api/media-folders-routes.test.ts new file mode 100644 index 0000000000..fcac50f692 --- /dev/null +++ b/packages/core/tests/unit/api/media-folders-routes.test.ts @@ -0,0 +1,209 @@ +import { Role } from "@emdash-cms/auth"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { GET as listMedia } from "../../../src/astro/routes/api/media.js"; +import { PUT as updateMedia } from "../../../src/astro/routes/api/media/[id].js"; +import { + DELETE as deleteFolder, + PUT as updateFolder, +} from "../../../src/astro/routes/api/media/folders/[id].js"; +import { + GET as listFolders, + POST as createFolder, +} from "../../../src/astro/routes/api/media/folders/index.js"; +import { MediaFolderRepository } from "../../../src/database/repositories/media-folders.js"; +import { + setupForDialect, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +describe("media folder routes", () => { + let ctx: DialectTestContext; + const user = (role: (typeof Role)[keyof typeof Role], id = "user-1") => ({ id, role }); + + beforeEach(async () => { + ctx = await setupForDialect("sqlite"); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + function routeContext( + request: Request, + role: (typeof Role)[keyof typeof Role], + params: Record = {}, + ) { + return { + params, + request, + locals: { emdash: { db: ctx.db }, user: user(role) }, + } as Parameters[0]; + } + + it("allows media readers to list folders but requires edit-any to create them", async () => { + await new MediaFolderRepository(ctx.db).create("Existing"); + const listRequest = new Request("http://localhost/_emdash/api/media/folders"); + const listResponse = await listFolders(routeContext(listRequest, Role.SUBSCRIBER)); + expect(listResponse.status).toBe(200); + await expect(listResponse.json()).resolves.toMatchObject({ + data: { items: [{ name: "Existing" }] }, + }); + const invalidList = await listFolders( + routeContext( + new Request("http://localhost/_emdash/api/media/folders?limit=101"), + Role.SUBSCRIBER, + ), + ); + expect(invalidList.status).toBe(400); + const emptyCursor = await listFolders( + routeContext( + new Request("http://localhost/_emdash/api/media/folders?cursor="), + Role.SUBSCRIBER, + ), + ); + expect(emptyCursor.status).toBe(400); + + const authorRequest = new Request("http://localhost/_emdash/api/media/folders", { + method: "POST", + headers: { "Content-Type": "application/json", "X-EmDash-Request": "1" }, + body: JSON.stringify({ name: "Author folder" }), + }); + expect(await createFolder(routeContext(authorRequest, Role.AUTHOR))).toMatchObject({ + status: 403, + }); + + const editorRequest = new Request("http://localhost/_emdash/api/media/folders", { + method: "POST", + headers: { "Content-Type": "application/json", "X-EmDash-Request": "1" }, + body: JSON.stringify({ name: " Editor folder " }), + }); + const editorResponse = await createFolder(routeContext(editorRequest, Role.EDITOR)); + expect(editorResponse.status).toBe(201); + await expect(editorResponse.json()).resolves.toMatchObject({ + data: { item: { name: "Editor folder" } }, + }); + }); + + it("requires edit-any for rename and delete and validates folder IDs", async () => { + const folder = await new MediaFolderRepository(ctx.db).create("Drafts"); + const authorRequest = new Request(`http://localhost/_emdash/api/media/folders/${folder.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json", "X-EmDash-Request": "1" }, + body: JSON.stringify({ name: "Published" }), + }); + expect( + await updateFolder( + routeContext(authorRequest, Role.AUTHOR, { id: folder.id }) as Parameters< + typeof updateFolder + >[0], + ), + ).toMatchObject({ status: 403 }); + + const editorRequest = new Request(`http://localhost/_emdash/api/media/folders/${folder.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json", "X-EmDash-Request": "1" }, + body: JSON.stringify({ name: "Published" }), + }); + const updated = await updateFolder( + routeContext(editorRequest, Role.EDITOR, { id: folder.id }) as Parameters< + typeof updateFolder + >[0], + ); + expect(updated.status).toBe(200); + + const invalidId = "x".repeat(65); + const invalidRequest = new Request(`http://localhost/_emdash/api/media/folders/${invalidId}`, { + method: "DELETE", + headers: { "X-EmDash-Request": "1" }, + }); + expect( + await deleteFolder( + routeContext(invalidRequest, Role.EDITOR, { id: invalidId }) as Parameters< + typeof deleteFolder + >[0], + ), + ).toMatchObject({ status: 400 }); + + const deleteRequest = new Request(`http://localhost/_emdash/api/media/folders/${folder.id}`, { + method: "DELETE", + headers: { "X-EmDash-Request": "1" }, + }); + expect( + await deleteFolder( + routeContext(deleteRequest, Role.AUTHOR, { id: folder.id }) as Parameters< + typeof deleteFolder + >[0], + ), + ).toMatchObject({ status: 403 }); + + const editorDeleteRequest = new Request( + `http://localhost/_emdash/api/media/folders/${folder.id}`, + { method: "DELETE", headers: { "X-EmDash-Request": "1" } }, + ); + expect( + await deleteFolder( + routeContext(editorDeleteRequest, Role.EDITOR, { id: folder.id }) as Parameters< + typeof deleteFolder + >[0], + ), + ).toMatchObject({ status: 200 }); + }); + + it("maps unfiled list requests to Main library", async () => { + const handleMediaList = vi.fn().mockResolvedValue({ success: true, data: { items: [] } }); + const request = new Request("http://localhost/_emdash/api/media?folderId=unfiled"); + + const response = await listMedia({ + request, + locals: { emdash: { handleMediaList }, user: user(Role.SUBSCRIBER) }, + } as Parameters[0]); + + expect(response.status).toBe(200); + expect(handleMediaList).toHaveBeenCalledWith(expect.objectContaining({ folderId: null })); + }); + + it("preserves ownership checks when assigning a folder to media", async () => { + const handleMediaGet = vi.fn().mockResolvedValue({ + success: true, + data: { item: { id: "media-1", authorId: "author-1" } }, + }); + const handleMediaUpdate = vi.fn().mockResolvedValue({ + success: true, + data: { item: { id: "media-1", folderId: "folder-1" } }, + }); + const makeRequest = () => + new Request("http://localhost/_emdash/api/media/media-1", { + method: "PUT", + headers: { "Content-Type": "application/json", "X-EmDash-Request": "1" }, + body: JSON.stringify({ folderId: "folder-1" }), + }); + + const ownResponse = await updateMedia({ + params: { id: "media-1" }, + request: makeRequest(), + locals: { + emdash: { handleMediaGet, handleMediaUpdate }, + user: user(Role.AUTHOR, "author-1"), + }, + } as Parameters[0]); + expect(ownResponse.status).toBe(200); + expect(handleMediaUpdate).toHaveBeenCalledWith( + "media-1", + expect.objectContaining({ folderId: "folder-1" }), + ); + + handleMediaUpdate.mockClear(); + const otherResponse = await updateMedia({ + params: { id: "media-1" }, + request: makeRequest(), + locals: { + emdash: { handleMediaGet, handleMediaUpdate }, + user: user(Role.AUTHOR, "author-2"), + }, + } as Parameters[0]); + expect(otherResponse.status).toBe(403); + expect(handleMediaUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/tests/unit/api/media-list-route.test.ts b/packages/core/tests/unit/api/media-list-route.test.ts index b27e0268dc..e160f0061c 100644 --- a/packages/core/tests/unit/api/media-list-route.test.ts +++ b/packages/core/tests/unit/api/media-list-route.test.ts @@ -2,6 +2,7 @@ import { it, expect, describe, beforeEach, afterEach } from "vitest"; import { handleMediaList } from "../../../src/api/handlers/media.js"; import { mediaListQuery } from "../../../src/api/schemas/media.js"; +import { MediaFolderRepository } from "../../../src/database/repositories/media-folders.js"; import { MediaRepository } from "../../../src/database/repositories/media.js"; import { setupForDialect, @@ -49,6 +50,29 @@ describe("handleMediaList multi-MIME", () => { ); }); + it("forwards folder filters to cursor and numbered repository queries", async () => { + const media = new MediaRepository(ctx.db); + const folders = new MediaFolderRepository(ctx.db); + const folder = await folders.create("Documents"); + const pdf = await media.findByFilename("b.pdf"); + if (!pdf) throw new Error("expected seeded PDF"); + await media.update(pdf.id, { folderId: folder.id }); + const folderParams = { folderId: folder.id }; + const mainPageParams = { page: 1, limit: 10, folderId: null }; + + const folderResult = await handleMediaList(ctx.db, folderParams); + const mainPageResult = await handleMediaList(ctx.db, mainPageParams); + + expect(folderResult).toMatchObject({ + success: true, + data: { items: [expect.objectContaining({ id: pdf.id, folderId: folder.id })] }, + }); + expect(mainPageResult).toMatchObject({ + success: true, + data: { totalCount: 2 }, + }); + }); + it("rejects invalid or ambiguous page requests before querying", async () => { await expect(handleMediaList(ctx.db, { page: 0 })).resolves.toMatchObject({ success: false, @@ -72,5 +96,6 @@ describe("handleMediaList multi-MIME", () => { expect(mediaListQuery.safeParse({ page: "1.5" }).success).toBe(false); expect(mediaListQuery.safeParse({ page: "not-a-page" }).success).toBe(false); expect(mediaListQuery.safeParse({ page: "1", cursor: "cursor" }).success).toBe(false); + expect(mediaListQuery.parse({ folderId: "unfiled" }).folderId).toBe("unfiled"); }); }); diff --git a/packages/core/tests/unit/api/media-usage-summary.test.ts b/packages/core/tests/unit/api/media-usage-summary.test.ts index de6f3f2b2e..302a72ea06 100644 --- a/packages/core/tests/unit/api/media-usage-summary.test.ts +++ b/packages/core/tests/unit/api/media-usage-summary.test.ts @@ -576,5 +576,6 @@ function mediaItemFixture(): MediaItem { dominantColor: null, createdAt: "2026-07-13T00:00:00.000Z", authorId: null, + folderId: null, }; } diff --git a/packages/core/tests/unit/api/openapi.test.ts b/packages/core/tests/unit/api/openapi.test.ts index 934af02595..c9d565aa5a 100644 --- a/packages/core/tests/unit/api/openapi.test.ts +++ b/packages/core/tests/unit/api/openapi.test.ts @@ -31,6 +31,8 @@ describe("OpenAPI document generation", () => { const paths = Object.keys(doc.paths ?? {}); expect(paths).toContain("/_emdash/api/media"); + expect(paths).toContain("/_emdash/api/media/folders"); + expect(paths).toContain("/_emdash/api/media/folders/{id}"); expect(paths).toContain("/_emdash/api/media/{id}"); expect(paths).toContain("/_emdash/api/media/{id}/usage"); expect(paths).toContain("/_emdash/api/media/upload-url"); @@ -40,6 +42,54 @@ describe("OpenAPI document generation", () => { expect(doc.paths?.["/_emdash/api/media/{id}/confirm"]?.post?.responses).toHaveProperty("409"); }); + it("documents media folder filters, CRUD operations, and errors", () => { + const doc = generateOpenApiDocument(); + const mediaList = doc.paths?.["/_emdash/api/media"]?.get as { + parameters?: Array<{ name?: string; in?: string }>; + }; + const mediaUpdate = doc.paths?.["/_emdash/api/media/{id}"]?.put; + const folders = doc.paths?.["/_emdash/api/media/folders"]; + const folder = doc.paths?.["/_emdash/api/media/folders/{id}"]; + + expect(mediaList.parameters).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "folderId", in: "query" })]), + ); + expect(mediaUpdate?.requestBody).toBeDefined(); + expect(JSON.stringify(doc.components?.schemas?.MediaUpdateBody)).toContain("folderId"); + expect(folders?.get?.responses).toEqual( + expect.objectContaining({ + "200": expect.any(Object), + "400": expect.any(Object), + "401": expect.any(Object), + "403": expect.any(Object), + "500": expect.any(Object), + }), + ); + expect(folders?.post?.responses).toEqual( + expect.objectContaining({ + "201": expect.any(Object), + "400": expect.any(Object), + "409": expect.any(Object), + }), + ); + expect(folder?.put?.responses).toEqual( + expect.objectContaining({ + "200": expect.any(Object), + "400": expect.any(Object), + "404": expect.any(Object), + "409": expect.any(Object), + }), + ); + expect(folder?.delete?.responses).toEqual( + expect.objectContaining({ + "200": expect.any(Object), + "400": expect.any(Object), + "404": expect.any(Object), + }), + ); + expect(JSON.stringify(folders?.get?.responses?.["200"])).toContain("MediaFolderListResponse"); + }); + it("documents media usage summary opt-in parameters and read responses", () => { const doc = generateOpenApiDocument(); const list = doc.paths?.["/_emdash/api/media"]?.get as { diff --git a/packages/core/tests/unit/astro/routes.test.ts b/packages/core/tests/unit/astro/routes.test.ts index 52fbef6c8b..628315135a 100644 --- a/packages/core/tests/unit/astro/routes.test.ts +++ b/packages/core/tests/unit/astro/routes.test.ts @@ -87,6 +87,18 @@ describe("core media route injection", () => { } }); + it("registers static media folder routes before the dynamic media item route", () => { + const patterns = collectRoutePatterns(); + const folders = patterns.indexOf("/_emdash/api/media/folders"); + const folder = patterns.indexOf("/_emdash/api/media/folders/[id]"); + const mediaItem = patterns.indexOf("/_emdash/api/media/[id]"); + + expect(folders).toBeGreaterThan(-1); + expect(folder).toBeGreaterThan(-1); + expect(folders).toBeLessThan(mediaItem); + expect(folder).toBeLessThan(mediaItem); + }); + it("injects default root SEO routes when the site does not define them", () => { const routes = collectRoutePatterns(); From b5b2821052266a1d00c555a4b154fd24f68d9853 Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:03:43 +0100 Subject: [PATCH 3/3] feat(core): add media folder client support --- .changeset/flat-media-folders.md | 5 ++ .../src/content/docs/guides/media-library.mdx | 78 ++--------------- docs/src/content/docs/reference/rest-api.mdx | 84 +++++++++++++++---- packages/core/src/client/index.ts | 75 +++++++++++++++++ .../tests/integration/client/client.test.ts | 14 ++++ .../core/tests/unit/client/client.test.ts | 78 +++++++++++++++++ 6 files changed, 244 insertions(+), 90 deletions(-) create mode 100644 .changeset/flat-media-folders.md diff --git a/.changeset/flat-media-folders.md b/.changeset/flat-media-folders.md new file mode 100644 index 0000000000..0d169f42ab --- /dev/null +++ b/.changeset/flat-media-folders.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Adds flat media folders to the media API. Media can be filtered or assigned to one folder, and deleting a folder returns its media to the Main library. diff --git a/docs/src/content/docs/guides/media-library.mdx b/docs/src/content/docs/guides/media-library.mdx index dafefed4da..80d5a01440 100644 --- a/docs/src/content/docs/guides/media-library.mdx +++ b/docs/src/content/docs/guides/media-library.mdx @@ -6,7 +6,7 @@ description: Upload and manage images and files in EmDash. import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components"; import mediaLibraryImg from "../../../assets/screenshots/admin-media-library.png"; -EmDash includes a media library for managing images, documents, and other files. This guide covers uploading, organizing, and using media in your content. +EmDash includes a media library for managing images, documents, and other files. This guide covers uploading, finding, and using media in your content. ## Accessing the Media Library @@ -151,31 +151,11 @@ This approach keeps large files off your application server and enables direct u your Worker. -## Organizing Media - -### Folders - -Create folders to organize your media: - -1. Click **New Folder** in the media library - -2. Enter a folder name - -3. Click **Create** - -4. Drag files into folders to organize them - -### Search +## Finding Media Use the search box to find files by name. Search matches partial filenames. -### Filters - -Filter media by: - -- **Type** - Images, Documents, Video, Audio -- **Date** - Upload date range -- **Folder** - Specific folder +Use the type menu to show all files, images, video, audio, or documents. ## Using Media in Content @@ -267,55 +247,9 @@ EmDash installs an image endpoint that produces the resized variants on request. ## Media API -Access media programmatically using the admin API. - -### Upload a File - -Upload media as multipart form data: - -```bash -POST /_emdash/api/media -Content-Type: multipart/form-data -Authorization: Bearer YOUR_API_TOKEN - -file= -``` - -A successful upload returns the stored media item: - -```json -{ - "success": true, - "data": { - "item": { - "id": "01ABC123", - "filename": "hero-image.jpg", - "mime_type": "image/jpeg", - "storage_key": "media/abc123/hero-image.jpg", - "width": 1200, - "height": 800 - } - } -} -``` - -### List Media - -The following request lists media under a prefix: - -```bash -GET /_emdash/api/media?prefix=images/&limit=20 -Authorization: Bearer YOUR_API_TOKEN -``` - -### Delete Media - -The following request deletes a stored file: - -```bash -DELETE /_emdash/api/media/images/hero.jpg -Authorization: Bearer YOUR_API_TOKEN -``` +Use the authenticated media endpoints to list, upload, update, and delete local media. The +[REST API reference](/reference/rest-api/#media-endpoints) documents request parameters, response +shapes, permissions, and folder operations. ## Media Providers diff --git a/docs/src/content/docs/reference/rest-api.mdx b/docs/src/content/docs/reference/rest-api.mdx index 593d6da617..26de3f93aa 100644 --- a/docs/src/content/docs/reference/rest-api.mdx +++ b/docs/src/content/docs/reference/rest-api.mdx @@ -186,11 +186,17 @@ GET /_emdash/api/media?includeUsage=1 | Parameter | Type | Description | | -------------- | -------- | -------------------------------------------------------------- | | `cursor` | `string` | Opaque pagination cursor | +| `page` | `number` | Numbered page, starting at 1; cannot be combined with `cursor` | | `limit` | `number` | Items per page, from 1 to 100 (default: 50) | | `mimeType` | `string` | Filter by one or more comma-separated MIME types | | `q` | `string` | Case-insensitive filename search | +| `folderId` | `string` | Folder ID, or `unfiled` for the Main library | | `includeUsage` | `1` | Include a coverage-aware `usage` summary on every returned item | +Omit `folderId` to list media from the Main library and every folder. Use `folderId=unfiled` to +list only media that is not assigned to a folder. A numbered request returns `totalCount`; cursor +mode returns `nextCursor` when another page is available. + #### Response ```json @@ -205,6 +211,7 @@ GET /_emdash/api/media?includeUsage=1 "size": 102400, "width": 1920, "height": 1080, + "folderId": null, "url": "/_emdash/api/media/file/uploads/photo.jpg", "createdAt": "2025-01-24T12:00:00Z", "usage": { @@ -317,10 +324,28 @@ fields, and Portable Text image blocks managed by EmDash content collections. It custom code, rendered HTML, settings, menus, widgets, plugin-private data, external sites, or provider-only assets. -### Create Media +### Upload Media + +Upload one file as multipart form data. The endpoint requires `media:upload`; bearer tokens also +require `media:write`. + +The following request uploads `photo.jpg`: + +```sh +curl -X POST "https://example.com/_emdash/api/media" \ + -H "Authorization: Bearer YOUR_API_TOKEN" \ + -H "X-EmDash-Request: 1" \ + -F "file=@photo.jpg" +``` + +A new upload returns 201 with the stored media item and `folderId: null`. If the file content +already exists, the endpoint returns 200 with `deduplicated: true`, the existing media item, and +its current folder assignment. + +### Update Media ```http -POST /_emdash/api/media +PUT /_emdash/api/media/:id Content-Type: application/json ``` @@ -328,36 +353,59 @@ Content-Type: application/json ```json { - "filename": "photo.jpg", - "mimeType": "image/jpeg", - "size": 102400, - "width": 1920, - "height": 1080, - "storageKey": "uploads/photo.jpg" + "alt": "Photo description", + "caption": "Photo caption", + "folderId": "01FOLDER..." } ``` - +Omit `folderId` to leave the current assignment unchanged. Set it to `null` to return the media +item to the Main library. Authors can update their own media; editors can update any media. -### Update Media +### List Media Folders ```http -PUT /_emdash/api/media/:id +GET /_emdash/api/media/folders?limit=50&cursor=... +``` + +Returns folders in name order with an optional `nextCursor`. `limit` accepts 1 to 100 and defaults +to 50. The endpoint requires `media:read`. + +### Create Media Folder + +```http +POST /_emdash/api/media/folders Content-Type: application/json + +{ + "name": "Product photos" +} ``` -#### Request Body +Folder names contain 1 to 200 UTF-16 code units after trimming. Names conflict when their +NFKC-normalized lowercase forms match. Creating, renaming, and deleting folders requires +`media:edit_any`. + +### Rename Media Folder + +```http +PUT /_emdash/api/media/folders/:id +Content-Type: application/json -```json { - "alt": "Photo description", - "caption": "Photo caption" + "name": "Published product photos" } ``` +### Delete Media Folder + +```http +DELETE /_emdash/api/media/folders/:id +``` + +Deleting a folder returns its media to the Main library. It does not delete media, change media +IDs or URLs, or change media usage records. + ### Delete Media ```http diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index dbcc918f01..c2efa423e0 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -197,9 +197,36 @@ export interface MediaItem { caption?: string; createdAt: string; updatedAt: string; + folderId?: string | null; usage?: MediaUsageSummary; } +/** Local media item returned by metadata update endpoints */ +export interface LocalMediaItem { + id: string; + filename: string; + mimeType: string; + size: number | null; + width: number | null; + height: number | null; + alt: string | null; + caption: string | null; + storageKey: string; + status: "pending" | "ready" | "failed"; + contentHash: string | null; + blurhash: string | null; + dominantColor: string | null; + createdAt: string; + authorId: string | null; + folderId: string | null; +} + +/** Flat media-library folder */ +export interface MediaFolder { + id: string; + name: string; +} + /** Media usage repair request */ export type MediaUsageRepairInput = { scope: "collection"; collection: string } | { scope: "all" }; @@ -813,6 +840,7 @@ export class EmDashClient { cursor?: string; page?: number; includeUsage?: boolean; + folderId?: string | null; }): Promise & { totalCount?: number }> { const params = new URLSearchParams(); if (options?.mimeType) params.set("mimeType", options.mimeType); @@ -820,6 +848,11 @@ export class EmDashClient { if (options?.cursor) params.set("cursor", options.cursor); if (options?.page !== undefined) params.set("page", String(options.page)); if (options?.includeUsage === true) params.set("includeUsage", "1"); + if (options?.folderId === null) { + params.set("folderId", "unfiled"); + } else if (options?.folderId !== undefined) { + params.set("folderId", options.folderId); + } const qs = params.toString(); return this.request & { totalCount?: number }>( @@ -841,6 +874,48 @@ export class EmDashClient { return data.item; } + /** List media folders */ + async mediaFolderList( + options: { limit?: number; cursor?: string } = {}, + ): Promise> { + const params = new URLSearchParams(); + if (options.limit !== undefined) params.set("limit", String(options.limit)); + if (options.cursor !== undefined) params.set("cursor", options.cursor); + const qs = params.toString(); + return this.request>("GET", `/media/folders${qs ? `?${qs}` : ""}`); + } + + /** Create a media folder */ + async mediaFolderCreate(name: string): Promise { + const data = await this.request<{ item: MediaFolder }>("POST", "/media/folders", { name }); + return data.item; + } + + /** Rename a media folder */ + async mediaFolderUpdate(id: string, name: string): Promise { + const data = await this.request<{ item: MediaFolder }>( + "PUT", + `/media/folders/${encodeURIComponent(id)}`, + { name }, + ); + return data.item; + } + + /** Delete a media folder */ + async mediaFolderDelete(id: string): Promise { + await this.request("DELETE", `/media/folders/${encodeURIComponent(id)}`); + } + + /** Assign media to a folder, or return it to the Main library */ + async mediaSetFolder(id: string, folderId: string | null): Promise { + const data = await this.request<{ item: LocalMediaItem }>( + "PUT", + `/media/${encodeURIComponent(id)}`, + { folderId }, + ); + return data.item; + } + /** Get entry-grouped usage details for a media item */ async mediaGetUsage( id: string, diff --git a/packages/core/tests/integration/client/client.test.ts b/packages/core/tests/integration/client/client.test.ts index 3868a9e862..da4446f1b7 100644 --- a/packages/core/tests/integration/client/client.test.ts +++ b/packages/core/tests/integration/client/client.test.ts @@ -251,6 +251,20 @@ describe("EmDashClient Integration", () => { const ids = list.items.map((m: { id: string }) => m.id); expect(ids).toContain(uploaded.id); + const folder = await ctx.client.mediaFolderCreate("Client photos"); + const assigned = await ctx.client.mediaSetFolder(uploaded.id, folder.id); + expect(assigned.folderId).toBe(folder.id); + const folderList = await ctx.client.mediaFolderList(); + expect(folderList.items).toContainEqual(folder); + const folderMedia = await ctx.client.mediaList({ folderId: folder.id }); + expect(folderMedia.items.map((item) => item.id)).toContain(uploaded.id); + expect(await ctx.client.mediaFolderUpdate(folder.id, "Renamed client photos")).toMatchObject({ + id: folder.id, + name: "Renamed client photos", + }); + await ctx.client.mediaFolderDelete(folder.id); + expect((await ctx.client.mediaGet(uploaded.id)).folderId).toBeNull(); + // Delete await ctx.client.mediaDelete(uploaded.id); diff --git a/packages/core/tests/unit/client/client.test.ts b/packages/core/tests/unit/client/client.test.ts index 31f605e658..cbe189754b 100644 --- a/packages/core/tests/unit/client/client.test.ts +++ b/packages/core/tests/unit/client/client.test.ts @@ -724,6 +724,84 @@ describe("EmDashClient", () => { }); describe("media usage reads", () => { + it("serializes Main library and folder media filters", async () => { + const capturedUrls: URL[] = []; + const backend: Interceptor = async (req) => { + capturedUrls.push(new URL(req.url)); + return jsonResponse({ items: [] }); + }; + const client = new EmDashClient({ + baseUrl: "http://localhost:4321", + token: "test", + interceptors: [backend], + }); + + await client.mediaList({ folderId: null }); + await client.mediaList({ folderId: "folder/one" }); + + expect(capturedUrls.map((url) => url.searchParams.get("folderId"))).toEqual([ + "unfiled", + "folder/one", + ]); + }); + + it("lists and mutates media folders with encoded IDs", async () => { + const requests: Array<{ method: string; url: URL; body: unknown }> = []; + const backend: Interceptor = async (req) => { + const url = new URL(req.url); + const text = await req.text(); + requests.push({ method: req.method, url, body: text ? JSON.parse(text) : undefined }); + if (req.method === "GET") { + return jsonResponse({ items: [{ id: "folder/one", name: "One" }], nextCursor: "next" }); + } + if (req.method === "DELETE") return jsonResponse({ deleted: true }); + if (url.pathname.endsWith("/media/media%2Fone")) { + return jsonResponse({ item: { id: "media/one", folderId: null } }); + } + return jsonResponse({ item: { id: "folder/one", name: "Updated" } }); + }; + const client = new EmDashClient({ + baseUrl: "http://localhost:4321", + token: "test", + interceptors: [backend], + }); + + const list = await client.mediaFolderList({ limit: 25, cursor: "after / folder" }); + const created = await client.mediaFolderCreate("Created"); + const updated = await client.mediaFolderUpdate("folder/one", "Updated"); + await client.mediaFolderDelete("folder/one"); + const media = await client.mediaSetFolder("media/one", null); + + expect(list).toEqual({ items: [{ id: "folder/one", name: "One" }], nextCursor: "next" }); + expect(created).toEqual({ id: "folder/one", name: "Updated" }); + expect(updated).toEqual({ id: "folder/one", name: "Updated" }); + expect(media).toEqual({ id: "media/one", folderId: null }); + expect(Object.fromEntries(requests[0]?.url.searchParams ?? [])).toEqual({ + limit: "25", + cursor: "after / folder", + }); + expect( + requests.slice(1).map(({ method, url, body }) => ({ method, path: url.pathname, body })), + ).toEqual([ + { method: "POST", path: "/_emdash/api/media/folders", body: { name: "Created" } }, + { + method: "PUT", + path: "/_emdash/api/media/folders/folder%2Fone", + body: { name: "Updated" }, + }, + { + method: "DELETE", + path: "/_emdash/api/media/folders/folder%2Fone", + body: undefined, + }, + { + method: "PUT", + path: "/_emdash/api/media/media%2Fone", + body: { folderId: null }, + }, + ]); + }); + it("requests a numbered media page and returns its total", async () => { let capturedUrl: URL | undefined; const backend: Interceptor = async (req) => {