feat: honor OCR text and tag scope in search Chapters/Mangas views

The Chapters/Mangas aggregation views accept an OCR ?text= filter and the
page search intersects tags, but the client dropped both: any text query
collapsed every view into a flat page search that discarded the selected tag
and grouping. Add text to AggregateOptions, thread it into
listTaggedChapters/listTaggedMangas, and rework the search loader so a
tag-scoped view runs the aggregation (optionally text-filtered) and the page
search passes the selected tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-11 14:58:48 +02:00
parent 1ed1a134ea
commit 69a9309c54
4 changed files with 43 additions and 11 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "mangalord-frontend", "name": "mangalord-frontend",
"version": "0.126.0", "version": "0.127.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -165,6 +165,16 @@ describe('page_tags api client', () => {
expect(url).toMatch(/\/v1\/me\/page-tags\/mangas\?tag=funny$/); expect(url).toMatch(/\/v1\/me\/page-tags\/mangas\?tag=funny$/);
}); });
it('aggregation calls forward the OCR text filter when provided', async () => {
fetchSpy.mockResolvedValueOnce(
ok({ items: [], page: { limit: 50, offset: 0, total: 0 } })
);
await listTaggedChapters({ tag: 'funny', text: 'hello world' });
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toContain('tag=funny');
expect(url).toContain('text=hello+world');
});
it('searchPages serializes tags (CSV), text and content-warning filters', async () => { it('searchPages serializes tags (CSV), text and content-warning filters', async () => {
fetchSpy.mockResolvedValueOnce( fetchSpy.mockResolvedValueOnce(
ok({ items: [], page: { limit: 50, offset: 0, total: 0 } }) ok({ items: [], page: { limit: 50, offset: 0, total: 0 } })

View File

@@ -179,6 +179,9 @@ export type AggregateOptions = {
order?: 'desc' | 'asc'; order?: 'desc' | 'asc';
limit?: number; limit?: number;
offset?: number; offset?: number;
/** OCR full-text filter: when set, only pages whose analysis search_doc
* matches are aggregated (and `match_count` reflects that). */
text?: string;
}; };
function aggregateQs(opts: AggregateOptions): string { function aggregateQs(opts: AggregateOptions): string {
@@ -187,6 +190,7 @@ function aggregateQs(opts: AggregateOptions): string {
if (opts.order != null) params.set('order', opts.order); if (opts.order != null) params.set('order', opts.order);
if (opts.limit != null) params.set('limit', String(opts.limit)); if (opts.limit != null) params.set('limit', String(opts.limit));
if (opts.offset != null) params.set('offset', String(opts.offset)); if (opts.offset != null) params.set('offset', String(opts.offset));
if (opts.text != null && opts.text !== '') params.set('text', opts.text);
return params.toString(); return params.toString();
} }

View File

@@ -103,26 +103,44 @@ export const load: PageLoad = async ({ url }) => {
// every tag / view / sort / text change instead of freezing the old list. // every tag / view / sort / text change instead of freezing the old list.
const results = (async () => { const results = (async () => {
try { try {
// Content search takes precedence over tag browsing. // Chapters / Mangas are tag-scoped aggregation views. When a tag is
// selected, run the aggregation — optionally filtered by the OCR
// `text` — so the selected view AND tag are honoured. Previously any
// `text` collapsed every view into a flat page search, discarding
// both the tag and the chapters/mangas grouping.
if (tag && view === 'chapters') {
const r = await listTaggedChapters({
tag,
order,
limit: 100,
text: text || undefined
});
return { ...emptyResults, chapters: r.items, total: r.page.total ?? 0 };
}
if (tag && view === 'mangas') {
const r = await listTaggedMangas({
tag,
order,
limit: 100,
text: text || undefined
});
return { ...emptyResults, mangas: r.items, total: r.page.total ?? 0 };
}
// Pages view (or a content search with no tag): a positive filter
// runs the page search — intersecting the selected tag with the OCR
// text / content-warning filters instead of ignoring the tag.
if (contentSearch) { if (contentSearch) {
const r = await searchPages({ const r = await searchPages({
text: text || undefined, text: text || undefined,
tags: tag ? [tag] : undefined,
cwInclude, cwInclude,
cwExclude, cwExclude,
limit: 100 limit: 100
}); });
return { ...emptyResults, results: r.items, total: r.page.total ?? 0 }; return { ...emptyResults, results: r.items, total: r.page.total ?? 0 };
} }
// No tag selected → just the chip cloud, no results. // No tag selected and no content filter → just the chip cloud.
if (!tag) return emptyResults; if (!tag) return emptyResults;
if (view === 'chapters') {
const r = await listTaggedChapters({ tag, order, limit: 100 });
return { ...emptyResults, chapters: r.items, total: r.page.total ?? 0 };
}
if (view === 'mangas') {
const r = await listTaggedMangas({ tag, order, limit: 100 });
return { ...emptyResults, mangas: r.items, total: r.page.total ?? 0 };
}
const r = await listMyPageTags({ tag, limit: 100 }); const r = await listMyPageTags({ tag, limit: 100 });
return { ...emptyResults, pages: r.items, total: r.page.total ?? 0 }; return { ...emptyResults, pages: r.items, total: r.page.total ?? 0 };
} catch (e) { } catch (e) {