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:
MechaCat02
2026-06-13 15:51:38 +02:00
parent 9910a0a995
commit 6c901e64c9
50 changed files with 6971 additions and 132 deletions

View 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$/);
});
});

View 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;
}

View 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$/);
});
});

View 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)}`
);
}