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$/);
|
||||
});
|
||||
});
|
||||
68
frontend/src/lib/api/page_collections.ts
Normal file
68
frontend/src/lib/api/page_collections.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { request, type Page } from './client';
|
||||
|
||||
/** Row returned by `GET /v1/collections/:id/pages`. */
|
||||
export type CollectionPageItem = {
|
||||
page_id: string;
|
||||
chapter_id: string;
|
||||
manga_id: string;
|
||||
page_number: number;
|
||||
chapter_number: number;
|
||||
chapter_title: string | null;
|
||||
manga_title: string;
|
||||
storage_key: string;
|
||||
added_at: string;
|
||||
};
|
||||
|
||||
export type CollectionPagesPage = {
|
||||
items: CollectionPageItem[];
|
||||
page: Page;
|
||||
};
|
||||
|
||||
export type ListOptions = { limit?: number; offset?: number };
|
||||
|
||||
export async function listCollectionPages(
|
||||
collectionId: string,
|
||||
opts: ListOptions = {}
|
||||
): Promise<CollectionPagesPage> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.limit != null) params.set('limit', String(opts.limit));
|
||||
if (opts.offset != null) params.set('offset', String(opts.offset));
|
||||
const qs = params.toString();
|
||||
return request<CollectionPagesPage>(
|
||||
`/v1/collections/${encodeURIComponent(collectionId)}/pages${qs ? `?${qs}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function addPageToCollection(
|
||||
collectionId: string,
|
||||
pageId: string
|
||||
): Promise<void> {
|
||||
await request<void>(
|
||||
`/v1/collections/${encodeURIComponent(collectionId)}/pages`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ page_id: pageId })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function removePageFromCollection(
|
||||
collectionId: string,
|
||||
pageId: string
|
||||
): Promise<void> {
|
||||
await request<void>(
|
||||
`/v1/collections/${encodeURIComponent(collectionId)}/pages/${encodeURIComponent(pageId)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
}
|
||||
|
||||
/** Which of the user's collections currently contain this page. */
|
||||
export async function getMyCollectionsContainingPage(
|
||||
pageId: string
|
||||
): Promise<string[]> {
|
||||
const r = await request<{ collection_ids: string[] }>(
|
||||
`/v1/pages/${encodeURIComponent(pageId)}/my-collections`
|
||||
);
|
||||
return r.collection_ids;
|
||||
}
|
||||
166
frontend/src/lib/api/page_tags.test.ts
Normal file
166
frontend/src/lib/api/page_tags.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type MockInstance
|
||||
} from 'vitest';
|
||||
import {
|
||||
getMyTagsForPage,
|
||||
addTagToPage,
|
||||
removeTagFromPage,
|
||||
listMyPageTags,
|
||||
listMyDistinctPageTags,
|
||||
listTaggedChapters,
|
||||
listTaggedMangas
|
||||
} from './page_tags';
|
||||
|
||||
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 taggedItemFixture(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
tag: 'funny',
|
||||
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',
|
||||
tagged_at: '2026-01-01T00:00:00Z',
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
describe('page_tags api client', () => {
|
||||
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('getMyTagsForPage unwraps `tags`', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok({ tags: ['funny', 'fight'] }));
|
||||
const tags = await getMyTagsForPage('p1');
|
||||
expect(tags).toEqual(['funny', 'fight']);
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/pages\/p1\/my-tags$/);
|
||||
});
|
||||
|
||||
it('addTagToPage POSTs { tag }', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok({}, 201));
|
||||
await addTagToPage('p1', 'funny');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/pages\/p1\/tags$/);
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(init.body as string)).toEqual({ tag: 'funny' });
|
||||
});
|
||||
|
||||
it('removeTagFromPage URL-encodes the tag', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(noContent());
|
||||
await removeTagFromPage('p1', 'character:askeladd');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
// ':' becomes %3A under encodeURIComponent.
|
||||
expect(url).toContain('/v1/pages/p1/tags/character%3Aaskeladd');
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('listMyPageTags forwards tag / q / pagination params', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({
|
||||
items: [taggedItemFixture()],
|
||||
page: { limit: 50, offset: 0, total: 1 }
|
||||
})
|
||||
);
|
||||
await listMyPageTags({ tag: 'funny', q: 'fu', limit: 10, offset: 20 });
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('tag=funny');
|
||||
expect(url).toContain('q=fu');
|
||||
expect(url).toContain('limit=10');
|
||||
expect(url).toContain('offset=20');
|
||||
});
|
||||
|
||||
it('listMyPageTags omits query string when no params', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||
);
|
||||
await listMyPageTags();
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/me\/page-tags$/);
|
||||
});
|
||||
|
||||
it('listMyDistinctPageTags unwraps `items`', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({
|
||||
items: [
|
||||
{ tag: 'funny', count: 5 },
|
||||
{ tag: 'fight', count: 2 }
|
||||
]
|
||||
})
|
||||
);
|
||||
const summaries = await listMyDistinctPageTags();
|
||||
expect(summaries.map((s) => s.tag)).toEqual(['funny', 'fight']);
|
||||
});
|
||||
|
||||
it('listMyDistinctPageTags sends only `q` when prefix supplied', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok({ items: [] }));
|
||||
await listMyDistinctPageTags('fu');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('q=fu');
|
||||
});
|
||||
|
||||
it('listTaggedChapters always sends tag, order/limit/offset are optional', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({
|
||||
items: [],
|
||||
page: { limit: 50, offset: 0, total: 0 }
|
||||
})
|
||||
);
|
||||
await listTaggedChapters({ tag: 'funny' });
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/me\/page-tags\/chapters\?tag=funny$/);
|
||||
});
|
||||
|
||||
it('listTaggedChapters forwards order + pagination', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [], page: { limit: 25, offset: 10, total: 0 } })
|
||||
);
|
||||
await listTaggedChapters({
|
||||
tag: 'funny',
|
||||
order: 'asc',
|
||||
limit: 25,
|
||||
offset: 10
|
||||
});
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toContain('tag=funny');
|
||||
expect(url).toContain('order=asc');
|
||||
expect(url).toContain('limit=25');
|
||||
expect(url).toContain('offset=10');
|
||||
});
|
||||
|
||||
it('listTaggedMangas hits the mangas endpoint', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||
);
|
||||
await listTaggedMangas({ tag: 'funny' });
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/me\/page-tags\/mangas\?tag=funny$/);
|
||||
});
|
||||
});
|
||||
145
frontend/src/lib/api/page_tags.ts
Normal file
145
frontend/src/lib/api/page_tags.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { request, type Page } from './client';
|
||||
|
||||
/** Row returned by `GET /v1/me/page-tags`. */
|
||||
export type TaggedPageItem = {
|
||||
tag: string;
|
||||
page_id: string;
|
||||
chapter_id: string;
|
||||
manga_id: string;
|
||||
page_number: number;
|
||||
chapter_number: number;
|
||||
chapter_title: string | null;
|
||||
manga_title: string;
|
||||
storage_key: string;
|
||||
tagged_at: string;
|
||||
};
|
||||
|
||||
export type MyPageTagsPage = {
|
||||
items: TaggedPageItem[];
|
||||
page: Page;
|
||||
};
|
||||
|
||||
export type PageTagSummary = {
|
||||
tag: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type ListMineOptions = {
|
||||
/** Exact-match filter (chip filter in the library tab). */
|
||||
tag?: string;
|
||||
/** Prefix filter (autocomplete-style). */
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export async function getMyTagsForPage(pageId: string): Promise<string[]> {
|
||||
const r = await request<{ tags: string[] }>(
|
||||
`/v1/pages/${encodeURIComponent(pageId)}/my-tags`
|
||||
);
|
||||
return r.tags;
|
||||
}
|
||||
|
||||
export async function addTagToPage(pageId: string, tag: string): Promise<void> {
|
||||
await request<void>(`/v1/pages/${encodeURIComponent(pageId)}/tags`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ tag })
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeTagFromPage(pageId: string, tag: string): Promise<void> {
|
||||
await request<void>(
|
||||
`/v1/pages/${encodeURIComponent(pageId)}/tags/${encodeURIComponent(tag)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function listMyPageTags(
|
||||
opts: ListMineOptions = {}
|
||||
): Promise<MyPageTagsPage> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.tag != null) params.set('tag', opts.tag);
|
||||
if (opts.q != null) params.set('q', opts.q);
|
||||
if (opts.limit != null) params.set('limit', String(opts.limit));
|
||||
if (opts.offset != null) params.set('offset', String(opts.offset));
|
||||
const qs = params.toString();
|
||||
return request<MyPageTagsPage>(`/v1/me/page-tags${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function listMyDistinctPageTags(
|
||||
prefix?: string,
|
||||
limit?: number
|
||||
): Promise<PageTagSummary[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (prefix != null && prefix !== '') params.set('q', prefix);
|
||||
if (limit != null) params.set('limit', String(limit));
|
||||
const qs = params.toString();
|
||||
const r = await request<{ items: PageTagSummary[] }>(
|
||||
`/v1/me/page-tags/distinct${qs ? `?${qs}` : ''}`
|
||||
);
|
||||
return r.items;
|
||||
}
|
||||
|
||||
/** Row returned by `GET /v1/me/page-tags/chapters`. */
|
||||
export type TaggedChapterAggregate = {
|
||||
chapter_id: string;
|
||||
manga_id: string;
|
||||
manga_title: string;
|
||||
chapter_number: number;
|
||||
chapter_title: string | null;
|
||||
match_count: number;
|
||||
/** Up to 3 storage keys of matching pages, page-number ascending. */
|
||||
sample_storage_keys: string[];
|
||||
};
|
||||
|
||||
/** Row returned by `GET /v1/me/page-tags/mangas`. */
|
||||
export type TaggedMangaAggregate = {
|
||||
manga_id: string;
|
||||
manga_title: string;
|
||||
manga_cover_image_path: string | null;
|
||||
match_count: number;
|
||||
sample_storage_keys: string[];
|
||||
};
|
||||
|
||||
export type TaggedChaptersPage = {
|
||||
items: TaggedChapterAggregate[];
|
||||
page: Page;
|
||||
};
|
||||
|
||||
export type TaggedMangasPage = {
|
||||
items: TaggedMangaAggregate[];
|
||||
page: Page;
|
||||
};
|
||||
|
||||
export type AggregateOptions = {
|
||||
tag: string;
|
||||
order?: 'desc' | 'asc';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
function aggregateQs(opts: AggregateOptions): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set('tag', opts.tag);
|
||||
if (opts.order != null) params.set('order', opts.order);
|
||||
if (opts.limit != null) params.set('limit', String(opts.limit));
|
||||
if (opts.offset != null) params.set('offset', String(opts.offset));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export async function listTaggedChapters(
|
||||
opts: AggregateOptions
|
||||
): Promise<TaggedChaptersPage> {
|
||||
return request<TaggedChaptersPage>(
|
||||
`/v1/me/page-tags/chapters?${aggregateQs(opts)}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function listTaggedMangas(
|
||||
opts: AggregateOptions
|
||||
): Promise<TaggedMangasPage> {
|
||||
return request<TaggedMangasPage>(
|
||||
`/v1/me/page-tags/mangas?${aggregateQs(opts)}`
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user