mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
open_collective: bookorbit
|
||||
ko_fi: neonbookorbit
|
||||
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
|
||||
|
||||
@@ -176,6 +176,7 @@ When adding user-facing text in code, add the Vue I18n key only to `client/src/l
|
||||
|
||||
## Support
|
||||
|
||||
- **Support development:** [Open Collective](https://opencollective.com/bookorbit)
|
||||
- **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)
|
||||
- **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,
|
||||
"tag": "0097_add_book_covers",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 98,
|
||||
"version": "7",
|
||||
"when": 1790398181669,
|
||||
"tag": "0098_add_subtitle_search_index",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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_lower_idx').on(sql`lower(${t.title})`),
|
||||
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_unaccent_trgm_idx').using('gin', sql`public.bookorbit_unaccent(${t.seriesName}) gin_trgm_ops`),
|
||||
index('bm_series_id_idx').on(t.seriesId),
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
} from '@bookorbit/types';
|
||||
|
||||
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 { BookSortBuilder } from './book-sort-builder.service';
|
||||
|
||||
@@ -778,22 +778,23 @@ describe('buildQuickSearch', () => {
|
||||
|
||||
builder.buildQuickSearch('gracian');
|
||||
|
||||
expect(accentInsensitiveIlike).toHaveBeenCalledTimes(5);
|
||||
expect(accentInsensitiveIlike).toHaveBeenCalledTimes(6);
|
||||
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 result = builder.buildQuickSearch('tolkien') as any;
|
||||
|
||||
expect(result).toMatchObject({ type: 'or' });
|
||||
expect(result.clauses).toHaveLength(5);
|
||||
expect(result.clauses[0]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%tolkien%' });
|
||||
expect(result.clauses[1]).toMatchObject({ type: 'sql' });
|
||||
expect(result.clauses[2]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%tolkien%' });
|
||||
expect(result.clauses[3]).toMatchObject({ type: 'sql' });
|
||||
expect(result.clauses).toHaveLength(6);
|
||||
expect(result.clauses[0]).toMatchObject({ type: 'accentInsensitiveIlike', left: bookMetadata.title, pattern: '%tolkien%' });
|
||||
expect(result.clauses[1]).toMatchObject({ type: 'accentInsensitiveIlike', left: bookMetadata.subtitle, pattern: '%tolkien%' });
|
||||
expect(result.clauses[2]).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[5]).toMatchObject({ type: 'sql' });
|
||||
});
|
||||
|
||||
it('uses phrase containment without trigram expansion for multi-word searches', () => {
|
||||
@@ -802,8 +803,9 @@ describe('buildQuickSearch', () => {
|
||||
|
||||
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(bookMetadata.subtitle, '%The Wax Child%');
|
||||
expect(collectSqlText(result).join(' ')).not.toContain(' % ');
|
||||
});
|
||||
|
||||
@@ -813,7 +815,8 @@ describe('buildQuickSearch', () => {
|
||||
const result = builder.buildQuickSearch('50% off') as any;
|
||||
|
||||
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', () => {
|
||||
@@ -822,6 +825,7 @@ describe('buildQuickSearch', () => {
|
||||
const result = builder.buildQuickSearch('book_one') as any;
|
||||
|
||||
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', () => {
|
||||
@@ -830,7 +834,8 @@ describe('buildQuickSearch', () => {
|
||||
const result = builder.buildQuickSearch('du') as any;
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -124,6 +124,7 @@ export class BookQueryBuilder {
|
||||
|
||||
return or(
|
||||
accentInsensitiveIlike(bookMetadata.title, pattern),
|
||||
accentInsensitiveIlike(bookMetadata.subtitle, pattern),
|
||||
existsAuthor,
|
||||
accentInsensitiveIlike(bookMetadata.seriesName, pattern),
|
||||
existsSeries,
|
||||
|
||||
@@ -1088,6 +1088,34 @@ describe('BookRepository', () => {
|
||||
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 () => {
|
||||
const rows = [
|
||||
{ id: 10, title: 'Dune', seriesName: 'Dune', libraryId: 7, libraryName: 'Main' },
|
||||
|
||||
@@ -1838,6 +1838,7 @@ export class BookRepository {
|
||||
ne(books.status, 'processing'),
|
||||
or(
|
||||
accentInsensitiveIlike(bookMetadata.title, pattern),
|
||||
accentInsensitiveIlike(bookMetadata.subtitle, pattern),
|
||||
accentInsensitiveIlike(bookMetadata.seriesName, pattern),
|
||||
isNotNull(matchedAuthors.bookId),
|
||||
isNotNull(matchedSeries.bookId),
|
||||
|
||||
@@ -301,6 +301,50 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
|
||||
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 () => {
|
||||
const primaryFileSize = (await stat(visibleEpub.absolutePath)).size;
|
||||
const response = await ctx.app.inject({
|
||||
|
||||
Reference in New Issue
Block a user