feat(search): tag-based page search surface + per-page tags & collections
Add the /search surface (Pages / Chapters / Mangas tabs) backed by per-user page tags and per-page collections: schema (migration 0023), backend endpoints for page tags/collections and tagged-page aggregations (with the OCR text-search param reserved at 501), plus the frontend API clients, library Page-tags tab, collection page sections, page context menu / AddTagsSheet, and reader long-press wiring. Includes the continuous-reader navigation fixes (?page=N handling, chapter-reset timing, back-button pops history) and tag-normalization hardening accumulated on the branch. Bump version 0.60.2 -> 0.62.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
101
frontend/src/lib/api/page_collections.test.ts
Normal file
101
frontend/src/lib/api/page_collections.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type MockInstance
|
||||
} from 'vitest';
|
||||
import {
|
||||
addPageToCollection,
|
||||
removePageFromCollection,
|
||||
listCollectionPages,
|
||||
getMyCollectionsContainingPage
|
||||
} from './page_collections';
|
||||
|
||||
function ok(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
function noContent(): Response {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
function pageItemFixture(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
page_id: 'p1',
|
||||
chapter_id: 'ch1',
|
||||
manga_id: 'm1',
|
||||
page_number: 1,
|
||||
chapter_number: 1,
|
||||
chapter_title: null,
|
||||
manga_title: 'Berserk',
|
||||
storage_key: 'mangas/m1/chapters/ch1/pages/0001.png',
|
||||
added_at: '2026-01-01T00:00:00Z',
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
describe('page_collections api client', () => {
|
||||
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('listCollectionPages hits the breadcrumb endpoint', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({
|
||||
items: [pageItemFixture()],
|
||||
page: { limit: 50, offset: 0, total: 1 }
|
||||
})
|
||||
);
|
||||
const r = await listCollectionPages('c1');
|
||||
expect(r.items[0].manga_title).toBe('Berserk');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/collections\/c1\/pages$/);
|
||||
});
|
||||
|
||||
it('listCollectionPages forwards pagination params', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [], page: { limit: 10, offset: 20, total: 0 } })
|
||||
);
|
||||
await listCollectionPages('c1', { limit: 10, offset: 20 });
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('limit=10');
|
||||
expect(url).toContain('offset=20');
|
||||
});
|
||||
|
||||
it('addPageToCollection POSTs { page_id }', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok({}, 201));
|
||||
await addPageToCollection('c1', 'p1');
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(init.body as string)).toEqual({ page_id: 'p1' });
|
||||
});
|
||||
|
||||
it('removePageFromCollection DELETEs with both ids encoded', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(noContent());
|
||||
await removePageFromCollection('c with space', 'p1');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
// Path includes encoded "c with space" + page id.
|
||||
expect(url).toContain('/v1/collections/c%20with%20space/pages/p1');
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('getMyCollectionsContainingPage unwraps `collection_ids`', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok({ collection_ids: ['c1', 'c2'] }));
|
||||
const ids = await getMyCollectionsContainingPage('p1');
|
||||
expect(ids).toEqual(['c1', 'c2']);
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/pages\/p1\/my-collections$/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user