mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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 [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user