feat(metadata): list every visible language for a blank language query

The iOS library filter offers languages as chips and needs the whole list, not a typeahead. A blank
term on /metadata/languages now returns every language value on a visible book, most common first
and capped at 200, under the same library and content-filter scope as the search. Every other
suggestion endpoint still answers a blank term with nothing.

The web client never sends a blank term. Released iOS builds do, when the Smart Shelf language
picker opens, and now see the list there instead of a search prompt. The iOS filter calls this
route too, though nothing in the web client shows it.

Ref bookorbit/bookorbit-ios-support#54
This commit is contained in:
neon
2026-10-12 05:30:58 +00:00
parent 30229c7451
commit 9a7970f0b3
4 changed files with 70 additions and 2 deletions
@@ -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();
@@ -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<SearchResult[]> {
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<SearchResult[]> {
const pattern = this.toContainsPattern(q);
if (!pattern) return Promise.resolve([]);
@@ -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<CatalogRepository>;
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' }]);
@@ -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<T>(user: RequestUser, q: string, search: (scope: CatalogSearchScope) => Promise<T[]>): Promise<T[]> {
if (!q.trim()) return [];
return this.inVisibleScope(user, search);
}
private async inVisibleScope<T>(user: RequestUser, search: (scope: CatalogSearchScope) => Promise<T[]>): Promise<T[]> {
const libraryIds = await this.libraryService.findAccessibleLibraryIds(user);
if (libraryIds.length === 0) return [];