mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -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]
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user