diff --git a/server/src/modules/catalog/catalog.repository.test.ts b/server/src/modules/catalog/catalog.repository.test.ts index 02d151a8d..c482c9842 100644 --- a/server/src/modules/catalog/catalog.repository.test.ts +++ b/server/src/modules/catalog/catalog.repository.test.ts @@ -1,5 +1,6 @@ vi.mock('drizzle-orm', () => ({ and: vi.fn((...clauses: unknown[]) => ({ type: 'and', clauses })), + desc: vi.fn((value: unknown) => ({ type: 'desc', value })), eq: vi.fn((left: unknown, right: unknown) => ({ type: 'eq', left, right })), inArray: vi.fn((column: unknown, values: unknown[]) => ({ type: 'inArray', column, values })), isNotNull: vi.fn((value: unknown) => ({ type: 'isNotNull', value })), @@ -16,7 +17,7 @@ vi.mock('../../common/utils/content-filter-sql.utils', () => ({ buildContentFilterClauses: vi.fn(() => [{ type: 'contentFilter' }]), })); -import { and, eq, inArray, isNotNull } from 'drizzle-orm'; +import { and, desc, eq, inArray, isNotNull } from 'drizzle-orm'; import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types'; import { accentInsensitiveExactMatchRank, accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils'; @@ -146,6 +147,23 @@ describe('CatalogRepository', () => { expect(inArray).toHaveBeenCalledWith(books.libraryId, [4, 9]); }); + it('lists every visible language, most common first, without a search term', async () => { + const { repository, selectChain } = makeRepository([{ name: 'English' }, { name: 'nl' }, { name: null }, { name: ' ' }]); + + await expect(repository.listLanguages(filteredScope)).resolves.toEqual([{ name: 'English' }, { name: 'nl' }]); + + expect(selectChain.from).toHaveBeenCalledWith(bookMetadata); + expect(selectChain.innerJoin).toHaveBeenCalledWith(books, expect.anything()); + expect(isNotNull).toHaveBeenCalledWith(bookMetadata.language); + expect(inArray).toHaveBeenCalledWith(books.libraryId, [4, 9]); + expect(buildContentFilterClauses).toHaveBeenCalledWith(filteredScope.contentFilters, expect.anything()); + expect(accentInsensitiveIlike).not.toHaveBeenCalled(); + expect(selectChain.groupBy).toHaveBeenCalledWith(bookMetadata.language); + expect(desc).toHaveBeenCalledWith(expect.objectContaining({ type: 'sql' })); + expect(selectChain.orderBy).toHaveBeenCalledWith(expect.objectContaining({ type: 'desc' }), bookMetadata.language); + expect(selectChain.limit).toHaveBeenCalledWith(200); + }); + it('does not build content-filter SQL for a superuser scope', async () => { const { repository } = makeRepository(); diff --git a/server/src/modules/catalog/catalog.repository.ts b/server/src/modules/catalog/catalog.repository.ts index 10b07ff4a..a52c12c20 100644 --- a/server/src/modules/catalog/catalog.repository.ts +++ b/server/src/modules/catalog/catalog.repository.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { and, eq, inArray, isNotNull } from 'drizzle-orm'; +import { and, desc, eq, inArray, isNotNull, sql } from 'drizzle-orm'; import type { SQL } from 'drizzle-orm'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; import type { PgColumn, PgTable } from 'drizzle-orm/pg-core'; @@ -51,6 +51,8 @@ export interface CatalogSearchScope { const DEFAULT_SEARCH_LIMIT = 15; const COLLECTION_SEARCH_LIMIT = 20; +// Distinct stored spellings, not languages: "en", "eng" and "English" are three rows here. +const LANGUAGE_LIST_LIMIT = 200; @Injectable() export class CatalogRepository { @@ -84,6 +86,20 @@ export class CatalogRepository { return this.searchDistinctMetadataField(q, bookMetadata.language, scope); } + /** Every language value on a visible book, most common first so a capped list keeps the ones that matter. */ + async listLanguages(scope: CatalogSearchScope): Promise { + const rows = await this.db + .select({ name: bookMetadata.language }) + .from(bookMetadata) + .innerJoin(books, eq(books.id, bookMetadata.bookId)) + .where(and(isNotNull(bookMetadata.language), ...this.visibilityClauses(scope))) + .groupBy(bookMetadata.language) + .orderBy(desc(sql`count(*)`), bookMetadata.language) + .limit(LANGUAGE_LIST_LIMIT); + + return rows.filter((row): row is SearchResult => row.name !== null && row.name.trim() !== ''); + } + searchCollections(userId: number, q: string): Promise { const pattern = this.toContainsPattern(q); if (!pattern) return Promise.resolve([]); diff --git a/server/src/modules/catalog/catalog.service.test.ts b/server/src/modules/catalog/catalog.service.test.ts index 521cd1506..12746bc64 100644 --- a/server/src/modules/catalog/catalog.service.test.ts +++ b/server/src/modules/catalog/catalog.service.test.ts @@ -23,6 +23,7 @@ function makeService(libraryIds: number[] = [1, 2]) { searchPublishers: vi.fn(), searchSeries: vi.fn(), searchLanguages: vi.fn(), + listLanguages: vi.fn(), searchCollections: vi.fn(), } as unknown as Mocked; const libraryService = { @@ -108,6 +109,29 @@ describe('CatalogService', () => { }); }); + it('lists every visible language for a blank language term', async () => { + const { service, catalogRepository, libraryService } = makeService([4, 9]); + const user = requestUser(); + catalogRepository.listLanguages.mockResolvedValue([{ name: 'English' }, { name: 'nl' }]); + + await expect(service.searchLanguages(user, ' ')).resolves.toEqual([{ name: 'English' }, { name: 'nl' }]); + + expect(libraryService.findAccessibleLibraryIds).toHaveBeenCalledWith(user); + expect(catalogRepository.listLanguages).toHaveBeenCalledWith({ + libraryIds: [4, 9], + contentFilters: EMPTY_CONTENT_FILTER_RULES, + }); + expect(catalogRepository.searchLanguages).not.toHaveBeenCalled(); + }); + + it('lists no languages without an accessible library', async () => { + const { service, catalogRepository } = makeService([]); + + await expect(service.searchLanguages(requestUser(), '')).resolves.toEqual([]); + + expect(catalogRepository.listLanguages).not.toHaveBeenCalled(); + }); + it('keeps collection searches scoped directly by owner id', async () => { const { service, catalogRepository, libraryService } = makeService(); catalogRepository.searchCollections.mockResolvedValue([{ name: 'Favorites' }]); diff --git a/server/src/modules/catalog/catalog.service.ts b/server/src/modules/catalog/catalog.service.ts index 988faeee4..28a479bca 100644 --- a/server/src/modules/catalog/catalog.service.ts +++ b/server/src/modules/catalog/catalog.service.ts @@ -35,7 +35,13 @@ export class CatalogService { return this.searchVisible(user, q, (scope) => this.catalogRepository.searchSeries(q, scope)); } + /** + * A blank term lists every language the user can see rather than nothing: languages are a short + * list, and the iOS library filter offers all of them at once. Every other suggestion endpoint + * still needs a term. + */ searchLanguages(user: RequestUser, q: string) { + if (!q.trim()) return this.inVisibleScope(user, (scope) => this.catalogRepository.listLanguages(scope)); return this.searchVisible(user, q, (scope) => this.catalogRepository.searchLanguages(q, scope)); } @@ -46,6 +52,10 @@ export class CatalogService { private async searchVisible(user: RequestUser, q: string, search: (scope: CatalogSearchScope) => Promise): Promise { if (!q.trim()) return []; + return this.inVisibleScope(user, search); + } + + private async inVisibleScope(user: RequestUser, search: (scope: CatalogSearchScope) => Promise): Promise { const libraryIds = await this.libraryService.findAccessibleLibraryIds(user); if (libraryIds.length === 0) return [];