fix(books): include subtitles in search

Match subtitle metadata in book searches and index it for large libraries. Add regression coverage for accent matching and library visibility.

+ add open collective

Closes #1552
This commit is contained in:
neon
2026-09-26 04:54:54 +00:00
parent 5a108bbd5e
commit 0b2df6e3d3
11 changed files with 22589 additions and 12 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username open_collective: bookorbit
ko_fi: neonbookorbit ko_fi: neonbookorbit
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
+1
View File
@@ -176,6 +176,7 @@ When adding user-facing text in code, add the Vue I18n key only to `client/src/l
## Support ## Support
- **Support development:** [Open Collective](https://opencollective.com/bookorbit)
- **Questions and discussion:** [GitHub Discussions](https://github.com/bookorbit/bookorbit/discussions) - **Questions and discussion:** [GitHub Discussions](https://github.com/bookorbit/bookorbit/discussions)
- **Bug reports:** [GitHub Issues](https://github.com/bookorbit/bookorbit/issues/new?template=bug_report.yml) - **Bug reports:** [GitHub Issues](https://github.com/bookorbit/bookorbit/issues/new?template=bug_report.yml)
- **Feature requests:** [GitHub Issues](https://github.com/bookorbit/bookorbit/issues/new?template=feature_request.yml) - **Feature requests:** [GitHub Issues](https://github.com/bookorbit/bookorbit/issues/new?template=feature_request.yml)
@@ -0,0 +1 @@
CREATE INDEX "bm_subtitle_unaccent_trgm_idx" ON "book_metadata" USING gin (public.bookorbit_unaccent("subtitle") gin_trgm_ops);
File diff suppressed because it is too large Load Diff
@@ -687,6 +687,13 @@
"when": 1790137565077, "when": 1790137565077,
"tag": "0097_add_book_covers", "tag": "0097_add_book_covers",
"breakpoints": true "breakpoints": true
},
{
"idx": 98,
"version": "7",
"when": 1790398181669,
"tag": "0098_add_subtitle_search_index",
"breakpoints": true
} }
] ]
} }
+1
View File
@@ -92,6 +92,7 @@ export const bookMetadata = pgTable(
index('bm_title_unaccent_trgm_idx').using('gin', sql`public.bookorbit_unaccent(${t.title}) gin_trgm_ops`), index('bm_title_unaccent_trgm_idx').using('gin', sql`public.bookorbit_unaccent(${t.title}) gin_trgm_ops`),
index('bm_title_lower_idx').on(sql`lower(${t.title})`), index('bm_title_lower_idx').on(sql`lower(${t.title})`),
index('bm_title_book_id_idx').on(t.title, t.bookId), index('bm_title_book_id_idx').on(t.title, t.bookId),
index('bm_subtitle_unaccent_trgm_idx').using('gin', sql`public.bookorbit_unaccent(${t.subtitle}) gin_trgm_ops`),
index('bm_series_trgm_idx').using('gin', t.seriesName.op('gin_trgm_ops')), index('bm_series_trgm_idx').using('gin', t.seriesName.op('gin_trgm_ops')),
index('bm_series_unaccent_trgm_idx').using('gin', sql`public.bookorbit_unaccent(${t.seriesName}) gin_trgm_ops`), index('bm_series_unaccent_trgm_idx').using('gin', sql`public.bookorbit_unaccent(${t.seriesName}) gin_trgm_ops`),
index('bm_series_id_idx').on(t.seriesId), index('bm_series_id_idx').on(t.seriesId),
@@ -48,7 +48,7 @@ import {
} from '@bookorbit/types'; } from '@bookorbit/types';
import { accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils'; import { accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils';
import { bookCustomMetadataValues, books } from '../../db/schema'; import { bookCustomMetadataValues, bookMetadata, books } from '../../db/schema';
import { BookQueryBuilder } from './book-query-builder.service'; import { BookQueryBuilder } from './book-query-builder.service';
import { BookSortBuilder } from './book-sort-builder.service'; import { BookSortBuilder } from './book-sort-builder.service';
@@ -778,22 +778,23 @@ describe('buildQuickSearch', () => {
builder.buildQuickSearch('gracian'); builder.buildQuickSearch('gracian');
expect(accentInsensitiveIlike).toHaveBeenCalledTimes(5); expect(accentInsensitiveIlike).toHaveBeenCalledTimes(6);
expect(accentInsensitiveIlike).toHaveBeenCalledWith(expect.anything(), '%gracian%'); expect(accentInsensitiveIlike).toHaveBeenCalledWith(expect.anything(), '%gracian%');
}); });
it('produces an OR of accent-insensitive title/series matches and author/series/narrator exists subqueries', () => { it('matches subtitle alongside title, series, author, and narrator', () => {
const { builder } = makeBuilder(); const { builder } = makeBuilder();
const result = builder.buildQuickSearch('tolkien') as any; const result = builder.buildQuickSearch('tolkien') as any;
expect(result).toMatchObject({ type: 'or' }); expect(result).toMatchObject({ type: 'or' });
expect(result.clauses).toHaveLength(5); expect(result.clauses).toHaveLength(6);
expect(result.clauses[0]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%tolkien%' }); expect(result.clauses[0]).toMatchObject({ type: 'accentInsensitiveIlike', left: bookMetadata.title, pattern: '%tolkien%' });
expect(result.clauses[1]).toMatchObject({ type: 'sql' }); expect(result.clauses[1]).toMatchObject({ type: 'accentInsensitiveIlike', left: bookMetadata.subtitle, pattern: '%tolkien%' });
expect(result.clauses[2]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%tolkien%' }); expect(result.clauses[2]).toMatchObject({ type: 'sql' });
expect(result.clauses[3]).toMatchObject({ type: 'sql' }); expect(result.clauses[3]).toMatchObject({ type: 'accentInsensitiveIlike', left: bookMetadata.seriesName, pattern: '%tolkien%' });
expect(result.clauses[4]).toMatchObject({ type: 'sql' }); expect(result.clauses[4]).toMatchObject({ type: 'sql' });
expect(result.clauses[5]).toMatchObject({ type: 'sql' });
}); });
it('uses phrase containment without trigram expansion for multi-word searches', () => { it('uses phrase containment without trigram expansion for multi-word searches', () => {
@@ -802,8 +803,9 @@ describe('buildQuickSearch', () => {
const result = builder.buildQuickSearch('The Wax Child'); const result = builder.buildQuickSearch('The Wax Child');
expect(accentInsensitiveIlike).toHaveBeenCalledTimes(5); expect(accentInsensitiveIlike).toHaveBeenCalledTimes(6);
expect(accentInsensitiveIlike).toHaveBeenCalledWith(expect.anything(), '%The Wax Child%'); expect(accentInsensitiveIlike).toHaveBeenCalledWith(expect.anything(), '%The Wax Child%');
expect(accentInsensitiveIlike).toHaveBeenCalledWith(bookMetadata.subtitle, '%The Wax Child%');
expect(collectSqlText(result).join(' ')).not.toContain(' % '); expect(collectSqlText(result).join(' ')).not.toContain(' % ');
}); });
@@ -813,7 +815,8 @@ describe('buildQuickSearch', () => {
const result = builder.buildQuickSearch('50% off') as any; const result = builder.buildQuickSearch('50% off') as any;
expect(result.clauses[0]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%50\\% off%' }); expect(result.clauses[0]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%50\\% off%' });
expect(result.clauses[2]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%50\\% off%' }); expect(result.clauses[1]).toMatchObject({ type: 'accentInsensitiveIlike', left: bookMetadata.subtitle, pattern: '%50\\% off%' });
expect(result.clauses[3]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%50\\% off%' });
}); });
it('escapes underscore in q', () => { it('escapes underscore in q', () => {
@@ -822,6 +825,7 @@ describe('buildQuickSearch', () => {
const result = builder.buildQuickSearch('book_one') as any; const result = builder.buildQuickSearch('book_one') as any;
expect(result.clauses[0]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%book\\_one%' }); expect(result.clauses[0]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%book\\_one%' });
expect(result.clauses[1]).toMatchObject({ type: 'accentInsensitiveIlike', left: bookMetadata.subtitle, pattern: '%book\\_one%' });
}); });
it('uses the same containment predicate for two-character queries', () => { it('uses the same containment predicate for two-character queries', () => {
@@ -830,7 +834,8 @@ describe('buildQuickSearch', () => {
const result = builder.buildQuickSearch('du') as any; const result = builder.buildQuickSearch('du') as any;
expect(result.clauses[0]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%du%' }); expect(result.clauses[0]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%du%' });
expect(result.clauses[2]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%du%' }); expect(result.clauses[1]).toMatchObject({ type: 'accentInsensitiveIlike', left: bookMetadata.subtitle, pattern: '%du%' });
expect(result.clauses[3]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%du%' });
}); });
it('calls db.select three times for author, series membership, and narrator exists subqueries', () => { it('calls db.select three times for author, series membership, and narrator exists subqueries', () => {
@@ -124,6 +124,7 @@ export class BookQueryBuilder {
return or( return or(
accentInsensitiveIlike(bookMetadata.title, pattern), accentInsensitiveIlike(bookMetadata.title, pattern),
accentInsensitiveIlike(bookMetadata.subtitle, pattern),
existsAuthor, existsAuthor,
accentInsensitiveIlike(bookMetadata.seriesName, pattern), accentInsensitiveIlike(bookMetadata.seriesName, pattern),
existsSeries, existsSeries,
@@ -1088,6 +1088,34 @@ describe('BookRepository', () => {
expect(db.select).not.toHaveBeenCalled(); expect(db.select).not.toHaveBeenCalled();
}); });
it('includes accent-insensitive subtitle matches in library-scoped global search', async () => {
const distinctChain = {
from: vi.fn(),
innerJoin: vi.fn(),
where: vi.fn(),
as: vi.fn().mockReturnValue({ bookId: sql`1` }),
};
distinctChain.from.mockReturnValue(distinctChain);
distinctChain.innerJoin.mockReturnValue(distinctChain);
distinctChain.where.mockReturnValue(distinctChain);
const mainChain = makeSelectChain('limit', []);
const db = {
selectDistinct: vi.fn().mockReturnValue(distinctChain),
select: vi.fn().mockReturnValue(mainChain),
};
const repo = new BookRepository(db as never);
await expect(repo.searchAcrossLibraries([7], 'Singapore', 10)).resolves.toEqual([]);
const query = new PgDialect().sqlToQuery(mainChain.where.mock.calls[0]![0]);
expect(query.sql).toContain('public.bookorbit_unaccent("book_metadata"."subtitle") ILIKE');
expect(query.sql).toContain('"books"."library_id" in');
expect(query.params).toContain('%Singapore%');
expect(query.params).toContain(7);
expect(mainChain.limit).toHaveBeenCalledWith(10);
});
it('combines title results with author names and unique formats', async () => { it('combines title results with author names and unique formats', async () => {
const rows = [ const rows = [
{ id: 10, title: 'Dune', seriesName: 'Dune', libraryId: 7, libraryName: 'Main' }, { id: 10, title: 'Dune', seriesName: 'Dune', libraryId: 7, libraryName: 'Main' },
@@ -1838,6 +1838,7 @@ export class BookRepository {
ne(books.status, 'processing'), ne(books.status, 'processing'),
or( or(
accentInsensitiveIlike(bookMetadata.title, pattern), accentInsensitiveIlike(bookMetadata.title, pattern),
accentInsensitiveIlike(bookMetadata.subtitle, pattern),
accentInsensitiveIlike(bookMetadata.seriesName, pattern), accentInsensitiveIlike(bookMetadata.seriesName, pattern),
isNotNull(matchedAuthors.bookId), isNotNull(matchedAuthors.bookId),
isNotNull(matchedSeries.bookId), isNotNull(matchedSeries.bookId),
+44
View File
@@ -301,6 +301,50 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
expectError(invalidLimit, 400, 'limit must not be greater than 20'); expectError(invalidLimit, 400, 'limit must not be greater than 20');
}); });
it('finds subtitle-only matches without exposing books in inaccessible libraries', async () => {
const [visibleMetadata] = await ctx.db
.select({ subtitle: bookMetadata.subtitle })
.from(bookMetadata)
.where(eq(bookMetadata.bookId, visibleEpub.bookId));
const [hiddenMetadata] = await ctx.db
.select({ subtitle: bookMetadata.subtitle })
.from(bookMetadata)
.where(eq(bookMetadata.bookId, hiddenEpub.bookId));
await ctx.db.update(bookMetadata).set({ subtitle: 'The Singapôre Story' }).where(eq(bookMetadata.bookId, visibleEpub.bookId));
await ctx.db.update(bookMetadata).set({ subtitle: 'A Singapôre Chronicle' }).where(eq(bookMetadata.bookId, hiddenEpub.bookId));
try {
const query = await ctx.app.inject({
method: 'POST',
url: '/api/v1/books/query',
headers: authHeader(limitedUser.accessToken),
payload: { q: 'Singapore', pagination: { page: 0, size: 10 } },
});
expect(query.statusCode).toBe(201);
expect(query.json()).toMatchObject({ total: 1, items: [{ id: visibleEpub.bookId }] });
const libraryQuery = await ctx.app.inject({
method: 'POST',
url: `/api/v1/libraries/${visibleLibrary.libraryId}/books`,
headers: authHeader(crossLibraryUser.accessToken),
payload: { q: 'Singapore', pagination: { page: 0, size: 10 } },
});
expect(libraryQuery.statusCode).toBe(201);
expect(libraryQuery.json()).toMatchObject({ total: 1, items: [{ id: visibleEpub.bookId }] });
const search = await ctx.app.inject({
method: 'GET',
url: '/api/v1/books/search?q=Singapore&limit=10',
headers: authHeader(limitedUser.accessToken),
});
expect(search.statusCode).toBe(200);
expect(search.json()).toEqual([expect.objectContaining({ id: visibleEpub.bookId })]);
} finally {
await ctx.db.update(bookMetadata).set({ subtitle: visibleMetadata!.subtitle }).where(eq(bookMetadata.bookId, visibleEpub.bookId));
await ctx.db.update(bookMetadata).set({ subtitle: hiddenMetadata!.subtitle }).where(eq(bookMetadata.bookId, hiddenEpub.bookId));
}
});
it('filters books by the selected primary file size', async () => { it('filters books by the selected primary file size', async () => {
const primaryFileSize = (await stat(visibleEpub.absolutePath)).size; const primaryFileSize = (await stat(visibleEpub.absolutePath)).size;
const response = await ctx.app.inject({ const response = await ctx.app.inject({