@@ -149,6 +191,46 @@ vi.mock("../src/components/MediaLibrary", () => ({
{items?.length ?? 0}
{items?.[0]?.id ?? ""}
{isLoading ? "loading" : "ready"}
+
{folders?.length ?? 0}
+
+
+
+ {canManageFolders && (
+ <>
+
+
+
+ >
+ )}
+
{folderId ?? "main"}
+
+ {canMoveMedia?.({ authorId: "user_01" }) ? "yes" : "no"}
+
+
+ {canMoveMedia?.({ authorId: "other-user" }) ? "yes" : "no"}
+
+
+
{moveStatus}
{pagination && (
<>
{pagination.page}
@@ -247,6 +329,36 @@ describe("MediaPage – upload completion", () => {
.on("GET", "/_emdash/api/auth/me", {
data: { id: "user_01", role: 60 },
})
+ .on("GET", "/_emdash/api/media/folders/folder-one", {
+ data: { item: { id: "folder-one", name: "Folder One" } },
+ })
+ .on("GET", "/_emdash/api/media/folders", {
+ data: { items: [{ id: "folder-one", name: "Folder One" }] },
+ })
+ .on("POST", "/_emdash/api/media/folders", {
+ data: { item: { id: "folder-created", name: "Created" } },
+ })
+ .on("PUT", "/_emdash/api/media/folders/folder-one", {
+ data: { item: { id: "folder-one", name: "Renamed" } },
+ })
+ .on("DELETE", "/_emdash/api/media/folders/folder-one", {
+ data: { deleted: true },
+ })
+ .on("PUT", "/_emdash/api/media/media_01", {
+ data: {
+ item: {
+ id: "media_01",
+ filename: "photo.jpg",
+ mimeType: "image/jpeg",
+ url: "/media/photo.jpg",
+ storageKey: "photo.jpg",
+ size: 1,
+ createdAt: "2025-01-01T00:00:00Z",
+ authorId: "user_01",
+ folderId: "folder-one",
+ },
+ },
+ })
.on("GET", "/_emdash/api/media", {
data: { items: [], totalCount: 60 },
});
@@ -294,6 +406,176 @@ describe("MediaPage – upload completion", () => {
}
});
+ it("persists a media folder move and refreshes media before resolving", async () => {
+ const requests: Array<{ method: string; path: string }> = [];
+ const mockedFetch = globalThis.fetch;
+ let movePersisted = false;
+ let mediaRefreshStarted = false;
+ let releaseMediaRefresh: () => void = () => undefined;
+ const mediaRefreshGate = new Promise
((resolve) => {
+ releaseMediaRefresh = resolve;
+ });
+ globalThis.fetch = async (input, init) => {
+ const rawUrl =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const path = new URL(rawUrl, "http://localhost").pathname;
+ const method = init?.method ?? "GET";
+ requests.push({
+ method,
+ path,
+ });
+ if (movePersisted && method === "GET" && path === "/_emdash/api/media") {
+ mediaRefreshStarted = true;
+ await mediaRefreshGate;
+ }
+ const response = await mockedFetch(input, init);
+ if (method === "PUT" && path === "/_emdash/api/media/media_01") movePersisted = true;
+ return response;
+ };
+
+ try {
+ const { router, TestApp } = buildRouter();
+ await router.navigate({ to: "/media" });
+ const screen = await render();
+ await expect.element(screen.getByTestId("move-status")).toHaveTextContent("move-ready");
+ const mediaReadsBefore = requests.filter(
+ (request) => request.method === "GET" && request.path === "/_emdash/api/media",
+ ).length;
+
+ await screen.getByRole("button", { name: "Move mock media" }).click();
+ await vi.waitFor(() => expect(mediaRefreshStarted).toBe(true));
+ expect(screen.getByTestId("move-status").element()).toHaveTextContent("moving");
+ releaseMediaRefresh();
+ await expect.element(screen.getByTestId("move-status")).toHaveTextContent("moved");
+ expect(
+ requests.some(
+ (request) => request.method === "PUT" && request.path === "/_emdash/api/media/media_01",
+ ),
+ ).toBe(true);
+ expect(
+ requests.filter(
+ (request) => request.method === "GET" && request.path === "/_emdash/api/media",
+ ).length,
+ ).toBeGreaterThan(mediaReadsBefore);
+ } finally {
+ releaseMediaRefresh();
+ globalThis.fetch = mockedFetch;
+ }
+ });
+
+ it("waits for stale media and folder recovery after a missing move target", async () => {
+ const mockedFetch = globalThis.fetch;
+ let recovering = false;
+ let releaseRecovery: () => void = () => undefined;
+ const recoveryGate = new Promise((resolve) => {
+ releaseRecovery = resolve;
+ });
+ const recoveryReads = new Set();
+ globalThis.fetch = async (input, init) => {
+ const rawUrl =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const url = new URL(rawUrl, "http://localhost");
+ if (url.pathname === "/_emdash/api/media/media_01" && init?.method === "PUT") {
+ recovering = true;
+ return new Response(
+ JSON.stringify({
+ success: false,
+ error: { code: "NOT_FOUND", message: "Media folder not found" },
+ }),
+ { status: 404, headers: { "Content-Type": "application/json" } },
+ );
+ }
+ if (
+ recovering &&
+ init?.method !== "PUT" &&
+ (url.pathname === "/_emdash/api/media" ||
+ url.pathname === "/_emdash/api/media/folders" ||
+ url.pathname === "/_emdash/api/media/folders/folder-one")
+ ) {
+ recoveryReads.add(url.pathname);
+ await recoveryGate;
+ }
+ return mockedFetch(input, init);
+ };
+
+ try {
+ const { router, TestApp } = buildRouter();
+ await router.navigate({ to: "/media", search: { folder: "folder-one" } });
+ const screen = await render();
+ await screen.getByRole("button", { name: "Search media" }).click();
+ await screen.getByRole("button", { name: "Move mock media" }).click();
+ await expect.element(screen.getByTestId("move-status")).toHaveTextContent("moving");
+ await vi.waitFor(() => {
+ expect(recoveryReads).toEqual(
+ new Set([
+ "/_emdash/api/media",
+ "/_emdash/api/media/folders",
+ "/_emdash/api/media/folders/folder-one",
+ ]),
+ );
+ });
+ expect(screen.getByTestId("move-status").element()).toHaveTextContent("moving");
+ releaseRecovery();
+ await expect.element(screen.getByTestId("move-status")).toHaveTextContent("move-error");
+ } finally {
+ releaseRecovery();
+ globalThis.fetch = mockedFetch;
+ }
+ });
+
+ it("clears cached move eligibility while recovering from authorization failure", async () => {
+ const mockedFetch = globalThis.fetch;
+ let recovering = false;
+ let releaseCurrentUser: () => void = () => undefined;
+ const currentUserGate = new Promise((resolve) => {
+ releaseCurrentUser = resolve;
+ });
+ let currentUserRecoveryStarted = false;
+ globalThis.fetch = async (input, init) => {
+ const rawUrl =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const url = new URL(rawUrl, "http://localhost");
+ if (url.pathname === "/_emdash/api/media/media_01" && init?.method === "PUT") {
+ recovering = true;
+ return new Response(
+ JSON.stringify({
+ success: false,
+ error: { code: "FORBIDDEN", message: "Permission denied" },
+ }),
+ { status: 403, headers: { "Content-Type": "application/json" } },
+ );
+ }
+ if (recovering && url.pathname === "/_emdash/api/auth/me") {
+ currentUserRecoveryStarted = true;
+ await currentUserGate;
+ return new Response(
+ JSON.stringify({
+ success: false,
+ error: { code: "INVALID_TOKEN", message: "Invalid token" },
+ }),
+ { status: 401, headers: { "Content-Type": "application/json" } },
+ );
+ }
+ return mockedFetch(input, init);
+ };
+
+ try {
+ const { router, TestApp } = buildRouter();
+ await router.navigate({ to: "/media" });
+ const screen = await render();
+ await expect.element(screen.getByTestId("can-move-own-media")).toHaveTextContent("yes");
+ await screen.getByRole("button", { name: "Move mock media" }).click();
+ await vi.waitFor(() => expect(currentUserRecoveryStarted).toBe(true));
+ expect(screen.getByTestId("move-status").element()).toHaveTextContent("moving");
+ releaseCurrentUser();
+ await expect.element(screen.getByTestId("move-status")).toHaveTextContent("move-error");
+ await expect.element(screen.getByTestId("can-move-own-media")).toHaveTextContent("no");
+ } finally {
+ releaseCurrentUser();
+ globalThis.fetch = mockedFetch;
+ }
+ });
+
it("requests numbered pages and resets page state for page size and search", async () => {
const requests: string[] = [];
const mockedFetch = globalThis.fetch;
@@ -333,6 +615,231 @@ describe("MediaPage – upload completion", () => {
});
});
+ it("maps root and direct folder URL state to media filters with global search precedence", async () => {
+ const requests: string[] = [];
+ const mockedFetch = globalThis.fetch;
+ globalThis.fetch = (input, init) => {
+ requests.push(
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url,
+ );
+ return mockedFetch(input, init);
+ };
+
+ const { router, TestApp } = buildRouter();
+ await router.navigate({ to: "/media" });
+ const screen = await render();
+
+ await vi.waitFor(() => {
+ expect(
+ requests.some(
+ (url) =>
+ url.includes("/_emdash/api/media?") &&
+ new URL(url, "http://localhost").searchParams.get("folderId") === "unfiled",
+ ),
+ ).toBe(true);
+ });
+
+ await router.navigate({ to: "/media", search: { folder: "folder-one" } });
+ await vi.waitFor(() => {
+ expect(requests.some((url) => url.includes("/media/folders/folder-one"))).toBe(true);
+ expect(
+ requests.some(
+ (url) => new URL(url, "http://localhost").searchParams.get("folderId") === "folder-one",
+ ),
+ ).toBe(true);
+ });
+
+ await screen.getByRole("button", { name: "Search media" }).click();
+ await vi.waitFor(() => {
+ expect(
+ requests.some((rawUrl) => {
+ const url = new URL(rawUrl, "http://localhost");
+ return url.searchParams.get("q") === "photo" && !url.searchParams.has("folderId");
+ }),
+ ).toBe(true);
+ });
+ });
+
+ it("does not request the previous page when direct folder state changes", async () => {
+ const requests: string[] = [];
+ const mockedFetch = globalThis.fetch;
+ globalThis.fetch = (input, init) => {
+ requests.push(
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url,
+ );
+ return mockedFetch(input, init);
+ };
+
+ const { router, TestApp } = buildRouter();
+ await router.navigate({ to: "/media" });
+ const screen = await render();
+ await screen.getByRole("button", { name: "Open page 2" }).click();
+ await expect.element(screen.getByTestId("media-page")).toHaveTextContent("2");
+
+ requests.length = 0;
+ await router.navigate({ to: "/media", search: { folder: "folder-one" } });
+ await vi.waitFor(() => {
+ expect(
+ requests.some((rawUrl) => {
+ const url = new URL(rawUrl, "http://localhost");
+ return (
+ url.searchParams.get("folderId") === "folder-one" &&
+ url.searchParams.get("page") === "1"
+ );
+ }),
+ ).toBe(true);
+ });
+ expect(
+ requests.some((rawUrl) => {
+ const url = new URL(rawUrl, "http://localhost");
+ return (
+ url.searchParams.get("folderId") === "folder-one" && url.searchParams.get("page") === "2"
+ );
+ }),
+ ).toBe(false);
+ });
+
+ it("loads bounded folder pages and exposes explicit load-more state", async () => {
+ const mockedFetch = globalThis.fetch;
+ const folderRequests: URL[] = [];
+ globalThis.fetch = (input, init) => {
+ const rawUrl =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const url = new URL(rawUrl, "http://localhost");
+ if (url.pathname === "/_emdash/api/media/folders") {
+ folderRequests.push(url);
+ const cursor = url.searchParams.get("cursor");
+ return Promise.resolve(
+ Response.json({
+ data:
+ cursor === "next-folder"
+ ? { items: [{ id: "folder-two", name: "Folder Two" }] }
+ : {
+ items: [{ id: "folder-one", name: "Folder One" }],
+ nextCursor: "next-folder",
+ },
+ }),
+ );
+ }
+ return mockedFetch(input, init);
+ };
+
+ const { router, TestApp } = buildRouter();
+ await router.navigate({ to: "/media" });
+ const screen = await render();
+
+ await expect.element(screen.getByTestId("folder-count")).toHaveTextContent("1");
+ await screen.getByRole("button", { name: "Load more folders" }).click();
+ await expect.element(screen.getByTestId("folder-count")).toHaveTextContent("2");
+ expect(folderRequests).toHaveLength(2);
+ expect(folderRequests[0]?.searchParams.get("limit")).toBe("100");
+ expect(folderRequests[1]?.searchParams.get("cursor")).toBe("next-folder");
+ });
+
+ it("orchestrates create, open, rename, and current-folder delete", async () => {
+ const calls: Array<{ url: string; method: string; body?: string }> = [];
+ const mockedFetch = globalThis.fetch;
+ globalThis.fetch = (input, init) => {
+ calls.push({
+ url: typeof input === "string" ? input : input instanceof URL ? input.href : input.url,
+ method: init?.method ?? "GET",
+ body: typeof init?.body === "string" ? init.body : undefined,
+ });
+ return mockedFetch(input, init);
+ };
+
+ const { router, TestApp } = buildRouter();
+ await router.navigate({ to: "/media" });
+ const navigateSpy = vi.spyOn(router, "navigate");
+ const screen = await render();
+ await expect.element(screen.getByTestId("folder-count")).toHaveTextContent("1");
+
+ await screen.getByRole("button", { name: "Create mock folder" }).click();
+ await vi.waitFor(() => {
+ const request = calls.find((call) => call.method === "POST" && call.url.endsWith("/folders"));
+ expect(request?.body && JSON.parse(request.body)).toEqual({ name: "Created" });
+ });
+
+ await screen.getByRole("button", { name: "Open mock folder" }).click();
+ await vi.waitFor(() => {
+ expect(router.state.location.search).toEqual({ folder: "folder-one" });
+ expect(screen.getByTestId("current-folder-id").element()).toHaveTextContent("folder-one");
+ expect(navigateSpy).toHaveBeenCalledWith(expect.objectContaining({ resetScroll: false }));
+ });
+
+ await screen.getByRole("button", { name: "Back to Main" }).click();
+ await vi.waitFor(() => {
+ expect(router.state.location.search).toEqual({});
+ expect(navigateSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ search: { folder: undefined }, resetScroll: false }),
+ );
+ });
+ await screen.getByRole("button", { name: "Open mock folder" }).click();
+
+ await screen.getByRole("button", { name: "Rename current folder" }).click();
+ await vi.waitFor(() => {
+ const request = calls.find(
+ (call) => call.method === "PUT" && call.url.endsWith("/folders/folder-one"),
+ );
+ expect(request?.body && JSON.parse(request.body)).toEqual({ name: "Renamed" });
+ });
+
+ await screen.getByRole("button", { name: "Delete current folder" }).click();
+ await vi.waitFor(() => {
+ expect(
+ calls.some((call) => call.method === "DELETE" && call.url.endsWith("/folders/folder-one")),
+ ).toBe(true);
+ expect(router.state.location.search).toEqual({});
+ expect(navigateSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ replace: true, resetScroll: false }),
+ );
+ });
+ });
+
+ it("allows authors to move their own media but not another user's media", async () => {
+ mockFetch.on("GET", "/_emdash/api/auth/me", {
+ data: { id: "user_01", role: 30 },
+ });
+ const { router, TestApp } = buildRouter();
+ await router.navigate({ to: "/media" });
+ const screen = await render();
+
+ await expect.element(screen.getByTestId("can-move-own-media")).toHaveTextContent("yes");
+ await expect.element(screen.getByTestId("can-move-other-media")).toHaveTextContent("no");
+ });
+
+ it("replaces a missing direct folder URL with Main library once", async () => {
+ mockFetch.on(
+ "GET",
+ "/_emdash/api/media/folders/missing-folder",
+ { error: { code: "NOT_FOUND", message: "Media folder not found" } },
+ 404,
+ );
+ const requests: string[] = [];
+ const mockedFetch = globalThis.fetch;
+ globalThis.fetch = (input, init) => {
+ requests.push(
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url,
+ );
+ return mockedFetch(input, init);
+ };
+
+ const { router, TestApp } = buildRouter();
+ await router.navigate({ to: "/media", search: { folder: "missing-folder" } });
+ const screen = await render();
+
+ await vi.waitFor(() => {
+ expect(router.state.location.search).toEqual({});
+ expect(
+ requests.some(
+ (rawUrl) =>
+ new URL(rawUrl, "http://localhost").searchParams.get("folderId") === "unfiled",
+ ),
+ ).toBe(true);
+ });
+ await expect.element(screen.getByText("Folder no longer exists")).toBeInTheDocument();
+ });
+
it("recovers an emptied later page without exposing an invalid page number", async () => {
const mockedFetch = globalThis.fetch;
let requestedSecondPage = false;
diff --git a/packages/core/src/api/errors.ts b/packages/core/src/api/errors.ts
index 660521dc26..70e61e6dbe 100644
--- a/packages/core/src/api/errors.ts
+++ b/packages/core/src/api/errors.ts
@@ -87,6 +87,7 @@ export const ErrorCode = {
MEDIA_UPDATE_ERROR: "MEDIA_UPDATE_ERROR",
MEDIA_DELETE_ERROR: "MEDIA_DELETE_ERROR",
MEDIA_FOLDER_LIST_ERROR: "MEDIA_FOLDER_LIST_ERROR",
+ MEDIA_FOLDER_GET_ERROR: "MEDIA_FOLDER_GET_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",
diff --git a/packages/core/src/api/handlers/index.ts b/packages/core/src/api/handlers/index.ts
index 15e9833ba8..be8049b287 100644
--- a/packages/core/src/api/handlers/index.ts
+++ b/packages/core/src/api/handlers/index.ts
@@ -62,6 +62,7 @@ export {
} from "./media.js";
export {
handleMediaFolderList,
+ handleMediaFolderGet,
handleMediaFolderCreate,
handleMediaFolderUpdate,
handleMediaFolderDelete,
diff --git a/packages/core/src/api/handlers/media-folders.ts b/packages/core/src/api/handlers/media-folders.ts
index 0d34167ff1..7374479590 100644
--- a/packages/core/src/api/handlers/media-folders.ts
+++ b/packages/core/src/api/handlers/media-folders.ts
@@ -13,7 +13,7 @@ const UNIQUE_VIOLATION_RE =
export async function handleMediaFolderList(
db: Kysely,
- options: { limit?: number; cursor?: string } = {},
+ options: { limit?: number; cursor?: string; q?: string } = {},
): Promise> {
try {
const result = await new MediaFolderRepository(db).findMany(options);
@@ -29,6 +29,24 @@ export async function handleMediaFolderList(
}
}
+export async function handleMediaFolderGet(
+ db: Kysely,
+ id: string,
+): Promise> {
+ try {
+ const item = await new MediaFolderRepository(db).findById(id);
+ if (!item) {
+ return { success: false, error: { code: "NOT_FOUND", message: "Media folder not found" } };
+ }
+ return { success: true, data: { item } };
+ } catch {
+ return {
+ success: false,
+ error: { code: "MEDIA_FOLDER_GET_ERROR", message: "Failed to get media folder" },
+ };
+ }
+}
+
export async function handleMediaFolderCreate(
db: Kysely,
input: { name: string },
diff --git a/packages/core/src/api/openapi/document.ts b/packages/core/src/api/openapi/document.ts
index e76a23ce47..992d877e91 100644
--- a/packages/core/src/api/openapi/document.ts
+++ b/packages/core/src/api/openapi/document.ts
@@ -742,6 +742,24 @@ function buildMediaPaths(maxUploadSize: number) {
},
},
"/_emdash/api/media/folders/{id}": {
+ get: {
+ operationId: "getMediaFolder",
+ summary: "Get a media folder",
+ tags: ["Media"],
+ requestParams: {
+ path: z.object({ id: mediaFolderIdSchema.meta({ description: "Media folder ID" }) }),
+ },
+ responses: {
+ "200": {
+ description: "Media folder",
+ content: {
+ [JSON_CONTENT]: { schema: successEnvelope(mediaFolderResponseSchema) },
+ },
+ },
+ ...authErrors,
+ ...standardErrors(400, 404, 500),
+ },
+ },
put: {
operationId: "updateMediaFolder",
summary: "Update a media folder",
diff --git a/packages/core/src/api/schemas/media.ts b/packages/core/src/api/schemas/media.ts
index 9c36d6aaa5..75859fbfd3 100644
--- a/packages/core/src/api/schemas/media.ts
+++ b/packages/core/src/api/schemas/media.ts
@@ -70,7 +70,9 @@ export const mediaUpdateBody = z
export const mediaFolderIdSchema = z.string().min(1).max(64);
-export const mediaFolderListQuery = cursorPaginationQuery.meta({ id: "MediaFolderListQuery" });
+export const mediaFolderListQuery = cursorPaginationQuery
+ .extend({ q: z.string().trim().min(1).max(200).optional() })
+ .meta({ id: "MediaFolderListQuery" });
const mediaFolderNameSchema = z.string().refine(
(value) => {
diff --git a/packages/core/src/astro/routes/api/media/folders/[id].ts b/packages/core/src/astro/routes/api/media/folders/[id].ts
index 7fa355135d..a0e230ec5d 100644
--- a/packages/core/src/astro/routes/api/media/folders/[id].ts
+++ b/packages/core/src/astro/routes/api/media/folders/[id].ts
@@ -2,7 +2,11 @@ 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 {
+ handleMediaFolderDelete,
+ handleMediaFolderGet,
+ handleMediaFolderUpdate,
+} from "#api/handlers/media-folders.js";
import { isParseError, parseBody } from "#api/parse.js";
import { mediaFolderBody, mediaFolderIdSchema } from "#api/schemas.js";
@@ -15,6 +19,17 @@ function parseFolderId(id: string | undefined): string | Response {
: apiError("VALIDATION_ERROR", "Invalid media folder ID", 400);
}
+export const GET: APIRoute = async ({ params, 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 id = parseFolderId(params.id);
+ if (id instanceof Response) return id;
+ return unwrapResult(await handleMediaFolderGet(emdash.db, id));
+};
+
export const PUT: APIRoute = async ({ params, request, locals }) => {
const { emdash, user } = locals;
const denied = requirePerm(user, "media:edit_any");
diff --git a/packages/core/src/astro/routes/api/media/folders/index.ts b/packages/core/src/astro/routes/api/media/folders/index.ts
index c464d34c96..ef1e496910 100644
--- a/packages/core/src/astro/routes/api/media/folders/index.ts
+++ b/packages/core/src/astro/routes/api/media/folders/index.ts
@@ -17,7 +17,11 @@ export const GET: APIRoute = async ({ request, locals }) => {
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 }),
+ await handleMediaFolderList(emdash.db, {
+ limit: query.limit,
+ cursor: query.cursor,
+ q: query.q,
+ }),
);
};
diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts
index 8a23ffc749..cf0d84a5ae 100644
--- a/packages/core/src/client/index.ts
+++ b/packages/core/src/client/index.ts
@@ -862,15 +862,25 @@ export class EmDashClient {
/** List media folders */
async mediaFolderList(
- options: { limit?: number; cursor?: string } = {},
+ options: { limit?: number; cursor?: string; q?: 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);
+ if (options.q !== undefined) params.set("q", options.q);
const qs = params.toString();
return this.request>("GET", `/media/folders${qs ? `?${qs}` : ""}`);
}
+ /** Get one media folder */
+ async mediaFolderGet(id: string): Promise {
+ const data = await this.request<{ item: MediaFolder }>(
+ "GET",
+ `/media/folders/${encodeURIComponent(id)}`,
+ );
+ return data.item;
+ }
+
/** Create a media folder */
async mediaFolderCreate(name: string): Promise {
const data = await this.request<{ item: MediaFolder }>("POST", "/media/folders", { name });
diff --git a/packages/core/src/database/repositories/media-folders.ts b/packages/core/src/database/repositories/media-folders.ts
index 6b927269b5..3b72439c05 100644
--- a/packages/core/src/database/repositories/media-folders.ts
+++ b/packages/core/src/database/repositories/media-folders.ts
@@ -1,4 +1,4 @@
-import type { Kysely } from "kysely";
+import { sql, type Kysely } from "kysely";
import { ulid } from "ulidx";
import type { Database } from "../types.js";
@@ -12,6 +12,15 @@ export interface MediaFolder {
export interface FindManyMediaFoldersOptions {
limit?: number;
cursor?: string;
+ q?: string;
+}
+
+function escapeLike(value: string): string {
+ return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
+}
+
+function normalizeFolderSearch(value: string): string {
+ return value.trim().normalize("NFKC").toLowerCase();
}
function normalizeFolderName(name: string): { name: string; nameKey: string } {
@@ -37,6 +46,12 @@ export class MediaFolderRepository {
.orderBy("id", "asc")
.limit(limit + 1);
+ const term = normalizeFolderSearch(options.q ?? "");
+ if (term) {
+ const pattern = `%${escapeLike(term)}%`;
+ query = query.where("name_key", "like", sql`${pattern} escape '\\'`);
+ }
+
if (options.cursor !== undefined) {
const { orderValue: nameKey, id } = decodeCursor(options.cursor);
query = query.where((eb) =>
diff --git a/packages/core/tests/unit/api/media-folders-handlers.test.ts b/packages/core/tests/unit/api/media-folders-handlers.test.ts
index 1c59911728..090e6f8f59 100644
--- a/packages/core/tests/unit/api/media-folders-handlers.test.ts
+++ b/packages/core/tests/unit/api/media-folders-handlers.test.ts
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, expect, it } from "vitest";
import {
handleMediaFolderCreate,
handleMediaFolderDelete,
+ handleMediaFolderGet,
handleMediaFolderList,
handleMediaFolderUpdate,
} from "../../../src/api/handlers/media-folders.js";
@@ -48,6 +49,42 @@ describeEachDialect("media folder handlers", (dialect) => {
});
});
+ it("normalizes and applies folder-name search before pagination", async () => {
+ await handleMediaFolderCreate(ctx.db, { name: "Archive" });
+ await handleMediaFolderCreate(ctx.db, { name: "Résumé" });
+ const options = { q: " re\u0301su " };
+
+ const result = await handleMediaFolderList(ctx.db, options);
+
+ expect(result).toMatchObject({
+ success: true,
+ data: { items: [{ name: "Résumé" }] },
+ });
+ });
+
+ it("treats folder-search wildcards literally", async () => {
+ await handleMediaFolderCreate(ctx.db, { name: "100% Real" });
+ await handleMediaFolderCreate(ctx.db, { name: "100 Percent" });
+
+ const result = await handleMediaFolderList(ctx.db, { q: "%" });
+
+ expect(result).toMatchObject({
+ success: true,
+ data: { items: [{ name: "100% Real" }] },
+ });
+ });
+
+ it("gets one folder and returns not found for an unknown ID", async () => {
+ const created = await handleMediaFolderCreate(ctx.db, { name: "Current" });
+ if (!created.success) throw new Error("expected folder create success");
+
+ expect(await handleMediaFolderGet(ctx.db, created.data.item.id)).toEqual(created);
+ expect(await handleMediaFolderGet(ctx.db, "missing-folder")).toMatchObject({
+ success: false,
+ error: { code: "NOT_FOUND" },
+ });
+ });
+
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" } } });
diff --git a/packages/core/tests/unit/api/media-folders-routes.test.ts b/packages/core/tests/unit/api/media-folders-routes.test.ts
index a25e06ff62..55e2d93a5a 100644
--- a/packages/core/tests/unit/api/media-folders-routes.test.ts
+++ b/packages/core/tests/unit/api/media-folders-routes.test.ts
@@ -5,6 +5,7 @@ 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,
+ GET as getFolder,
PUT as updateFolder,
} from "../../../src/astro/routes/api/media/folders/[id].js";
import {
@@ -151,6 +152,30 @@ describe("media folder routes", () => {
).toMatchObject({ status: 200 });
});
+ it("allows readers to get one folder and validates direct folder IDs", async () => {
+ const folder = await new MediaFolderRepository(ctx.db).create("Direct");
+ const request = new Request(`http://localhost/_emdash/api/media/folders/${folder.id}`);
+
+ const response = await getFolder(
+ routeContext(request, Role.SUBSCRIBER, { id: folder.id }) as Parameters[0],
+ );
+ expect(response.status).toBe(200);
+ await expect(response.json()).resolves.toMatchObject({ data: { item: folder } });
+
+ const missing = await getFolder(
+ routeContext(request, Role.SUBSCRIBER, { id: "missing-folder" }) as Parameters<
+ typeof getFolder
+ >[0],
+ );
+ expect(missing.status).toBe(404);
+
+ const invalidId = "x".repeat(65);
+ const invalid = await getFolder(
+ routeContext(request, Role.SUBSCRIBER, { id: invalidId }) as Parameters[0],
+ );
+ expect(invalid.status).toBe(400);
+ });
+
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");
diff --git a/packages/core/tests/unit/api/openapi.test.ts b/packages/core/tests/unit/api/openapi.test.ts
index c9d565aa5a..232729adf2 100644
--- a/packages/core/tests/unit/api/openapi.test.ts
+++ b/packages/core/tests/unit/api/openapi.test.ts
@@ -65,6 +65,16 @@ describe("OpenAPI document generation", () => {
"500": expect.any(Object),
}),
);
+ expect(folders?.get?.parameters).toEqual(
+ expect.arrayContaining([expect.objectContaining({ name: "q", in: "query" })]),
+ );
+ expect(folder?.get?.responses).toEqual(
+ expect.objectContaining({
+ "200": expect.any(Object),
+ "400": expect.any(Object),
+ "404": expect.any(Object),
+ }),
+ );
expect(folders?.post?.responses).toEqual(
expect.objectContaining({
"201": expect.any(Object),
diff --git a/packages/core/tests/unit/client/client.test.ts b/packages/core/tests/unit/client/client.test.ts
index cbe189754b..fb666f25c2 100644
--- a/packages/core/tests/unit/client/client.test.ts
+++ b/packages/core/tests/unit/client/client.test.ts
@@ -751,6 +751,9 @@ describe("EmDashClient", () => {
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" && url.pathname.endsWith("/media/folders/folder%2Fone")) {
+ return jsonResponse({ item: { id: "folder/one", name: "One" } });
+ }
if (req.method === "GET") {
return jsonResponse({ items: [{ id: "folder/one", name: "One" }], nextCursor: "next" });
}
@@ -766,7 +769,12 @@ describe("EmDashClient", () => {
interceptors: [backend],
});
- const list = await client.mediaFolderList({ limit: 25, cursor: "after / folder" });
+ const list = await client.mediaFolderList({
+ limit: 25,
+ cursor: "after / folder",
+ q: "photo set",
+ });
+ const fetched = await client.mediaFolderGet("folder/one");
const created = await client.mediaFolderCreate("Created");
const updated = await client.mediaFolderUpdate("folder/one", "Updated");
await client.mediaFolderDelete("folder/one");
@@ -775,14 +783,21 @@ describe("EmDashClient", () => {
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(fetched).toEqual({ id: "folder/one", name: "One" });
expect(media).toEqual({ id: "media/one", folderId: null });
expect(Object.fromEntries(requests[0]?.url.searchParams ?? [])).toEqual({
limit: "25",
cursor: "after / folder",
+ q: "photo set",
});
expect(
- requests.slice(1).map(({ method, url, body }) => ({ method, path: url.pathname, body })),
+ requests.map(({ method, url, body }) => ({ method, path: url.pathname, body })).slice(1),
).toEqual([
+ {
+ method: "GET",
+ path: "/_emdash/api/media/folders/folder%2Fone",
+ body: undefined,
+ },
{ method: "POST", path: "/_emdash/api/media/folders", body: { name: "Created" } },
{
method: "PUT",