mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(naming): add a narrators token to file naming patterns
Multiple audiobook editions of one title resolved to the same target path, so
enabling file rename merged or skipped every narrator but the first. Add
{narrators}, plus a :max3 modifier that yields nothing above three values, and
judge an optional group by what its placeholders render so the fallback literal
stays in the user's own pattern. The five duplicate token builders collapse into
one shared helper, which also gives audio uploads the metadata tokens they never
had. Build the shared types package before server lint, since type-aware rules
degrade to unknown on a clean checkout without it.
Refs #1108
This commit is contained in:
@@ -14,6 +14,7 @@ const TOKEN_DESCRIPTION_KEYS: Record<PatternToken, string> = {
|
||||
title: 'settings.reader.fileNaming.tokenTitle',
|
||||
subtitle: 'settings.reader.fileNaming.tokenSubtitle',
|
||||
authors: 'settings.reader.fileNaming.tokenAuthors',
|
||||
narrators: 'settings.reader.fileNaming.tokenNarrators',
|
||||
year: 'settings.reader.fileNaming.tokenYear',
|
||||
series: 'settings.reader.fileNaming.tokenSeries',
|
||||
seriesIndex: 'settings.reader.fileNaming.tokenSeriesIndex',
|
||||
@@ -30,6 +31,7 @@ const MODIFIER_DESCRIPTION_KEYS: Record<PatternModifier, string> = {
|
||||
sort: 'settings.reader.fileNaming.modSort',
|
||||
initial: 'settings.reader.fileNaming.modInitial',
|
||||
fixed2: 'settings.reader.fileNaming.modFixed2',
|
||||
max3: 'settings.reader.fileNaming.modMax3',
|
||||
upper: 'settings.reader.fileNaming.modUpper',
|
||||
lower: 'settings.reader.fileNaming.modLower',
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface PatternPiece {
|
||||
* its switch and returns the value unchanged, so a typo silently produces a wrong path
|
||||
* rather than an error. Listing them here lets both the palette and the validator agree.
|
||||
*/
|
||||
export const PATTERN_MODIFIERS = ['first', 'sort', 'initial', 'fixed2', 'upper', 'lower'] as const
|
||||
export const PATTERN_MODIFIERS = ['first', 'sort', 'initial', 'fixed2', 'max3', 'upper', 'lower'] as const
|
||||
export type PatternModifier = (typeof PATTERN_MODIFIERS)[number]
|
||||
|
||||
const KNOWN_MODIFIERS = new Set<string>(PATTERN_MODIFIERS)
|
||||
|
||||
@@ -2126,6 +2126,7 @@
|
||||
"modSort": "Last, First format",
|
||||
"modInitial": "First letter only",
|
||||
"modFixed2": "Two decimal places",
|
||||
"modMax3": "Up to three values, otherwise nothing",
|
||||
"folderOnlyPrefix": "End a pattern with",
|
||||
"folderOnlySuffix": "to specify a folder only. The original filename will be preserved inside it.",
|
||||
"conditionalLogic": "Conditional Logic",
|
||||
@@ -2181,6 +2182,7 @@
|
||||
"tokenTitle": "Book title",
|
||||
"tokenSubtitle": "Book subtitle",
|
||||
"tokenAuthors": "Author(s), comma-separated",
|
||||
"tokenNarrators": "Narrator(s), comma-separated",
|
||||
"tokenYear": "Publication year",
|
||||
"tokenSeries": "Series name",
|
||||
"tokenSeriesIndex": "Series index (zero-padded)",
|
||||
|
||||
@@ -9,6 +9,7 @@ export const EXAMPLE_PATTERN_METADATA: Record<string, string> = {
|
||||
title: "Neuromancer",
|
||||
subtitle: "20th Anniversary Edition",
|
||||
authors: "William Gibson",
|
||||
narrators: "Robertson Dean",
|
||||
year: "1984",
|
||||
series: "Sprawl",
|
||||
seriesIndex: "01",
|
||||
@@ -24,6 +25,7 @@ export const PATTERN_TOKENS = [
|
||||
{ token: "title", description: "Book title" },
|
||||
{ token: "subtitle", description: "Book subtitle" },
|
||||
{ token: "authors", description: "Author(s), comma-separated" },
|
||||
{ token: "narrators", description: "Narrator(s), comma-separated" },
|
||||
{ token: "year", description: "Publication year" },
|
||||
{ token: "series", description: "Series name" },
|
||||
{ token: "seriesIndex", description: "Series index (zero-padded)" },
|
||||
@@ -90,6 +92,13 @@ export function applyModifier(value: string, modifier: string, fieldName: string
|
||||
}
|
||||
return target.charAt(0).toUpperCase();
|
||||
}
|
||||
case "max3": {
|
||||
const parts = value
|
||||
.split(", ")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
return parts.length > 3 ? "" : parts.join(", ");
|
||||
}
|
||||
case "upper":
|
||||
return value.toUpperCase();
|
||||
case "lower":
|
||||
@@ -113,7 +122,11 @@ function resolveModifierPlaceholders(block: string, values: Record<string, strin
|
||||
|
||||
function checkAllPlaceholdersPresent(block: string, values: Record<string, string>): boolean {
|
||||
const matches = [...block.matchAll(MODIFIER_PLACEHOLDER_REGEX)];
|
||||
return matches.every((m) => values[m[1]]?.trim());
|
||||
return matches.every(([, fieldName, modifier]) => {
|
||||
const raw = values[fieldName] ?? "";
|
||||
if (!raw.trim()) return false;
|
||||
return (modifier ? applyModifier(raw, modifier, fieldName) : raw).trim().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function replacePlaceholders(pattern: string, values: Record<string, string>): string {
|
||||
|
||||
@@ -42,7 +42,9 @@
|
||||
"pretype-check": "pnpm --filter @bookorbit/types build",
|
||||
"type-check": "tsc --noEmit -p tsconfig.build.json",
|
||||
"lint": "pnpm run lint:check",
|
||||
"prelint:check": "pnpm --filter @bookorbit/types build",
|
||||
"lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
||||
"prelint:fix": "pnpm --filter @bookorbit/types build",
|
||||
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "node ../scripts/dev/with-test-lock.mjs -- vitest run --exclude test/scanner-scenarios.e2e-spec.ts --exclude test/scanner-file-operations.e2e-spec.ts",
|
||||
"test:watch": "vitest",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildPatternTokens } from './pattern-tokens.utils';
|
||||
|
||||
describe('pattern-tokens.utils', () => {
|
||||
describe('buildPatternTokens', () => {
|
||||
const metadata = {
|
||||
title: 'Dune',
|
||||
subtitle: 'A Novel',
|
||||
publisher: 'Ace',
|
||||
language: 'en',
|
||||
isbn13: '9780441172719',
|
||||
publishedYear: 1965,
|
||||
seriesName: 'Dune Chronicles',
|
||||
seriesIndex: '1',
|
||||
};
|
||||
|
||||
it('builds all tokens from full metadata', () => {
|
||||
const tokens = buildPatternTokens({ metadata, authors: ['Frank Herbert'], originalStem: 'dune', format: 'epub' });
|
||||
|
||||
expect(tokens).toEqual({
|
||||
originalFilename: 'dune',
|
||||
extension: 'epub',
|
||||
title: 'Dune',
|
||||
subtitle: 'A Novel',
|
||||
publisher: 'Ace',
|
||||
language: 'en',
|
||||
isbn: '9780441172719',
|
||||
year: '1965',
|
||||
series: 'Dune Chronicles',
|
||||
seriesIndex: '01',
|
||||
authors: 'Frank Herbert',
|
||||
});
|
||||
});
|
||||
|
||||
it('joins multiple authors with comma', () => {
|
||||
const tokens = buildPatternTokens({ metadata, authors: ['Author A', 'Author B'], originalStem: 'book', format: 'pdf' });
|
||||
expect(tokens['authors']).toBe('Author A, Author B');
|
||||
});
|
||||
|
||||
it('joins multiple narrators with comma', () => {
|
||||
const tokens = buildPatternTokens({ metadata, narrators: ['Simon Vance', 'Scott Brick'], originalStem: 'dune', format: 'm4b' });
|
||||
expect(tokens['narrators']).toBe('Simon Vance, Scott Brick');
|
||||
});
|
||||
|
||||
it('omits narrators when none are given', () => {
|
||||
expect(buildPatternTokens({ metadata, originalStem: 'dune', format: 'epub' })['narrators']).toBeUndefined();
|
||||
expect(buildPatternTokens({ metadata, narrators: [], originalStem: 'dune', format: 'epub' })['narrators']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes the library token when a library name is given', () => {
|
||||
const tokens = buildPatternTokens({ metadata, originalStem: 'dune', format: 'epub', libraryName: 'Science Fiction' });
|
||||
expect(tokens['library']).toBe('Science Fiction');
|
||||
});
|
||||
|
||||
it('omits the library token when no library name is given', () => {
|
||||
expect(buildPatternTokens({ metadata, originalStem: 'dune', format: 'epub' })['library']).toBeUndefined();
|
||||
expect(buildPatternTokens({ metadata, originalStem: 'dune', format: 'epub', libraryName: '' })['library']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits tokens for null metadata fields', () => {
|
||||
const tokens = buildPatternTokens({
|
||||
metadata: {
|
||||
title: null,
|
||||
subtitle: null,
|
||||
publisher: null,
|
||||
language: null,
|
||||
isbn13: null,
|
||||
publishedYear: null,
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
},
|
||||
originalStem: 'file',
|
||||
format: 'epub',
|
||||
});
|
||||
|
||||
expect(tokens).toEqual({ originalFilename: 'file', extension: 'epub' });
|
||||
});
|
||||
|
||||
it('includes only non-null fields', () => {
|
||||
const tokens = buildPatternTokens({
|
||||
metadata: { title: 'Test', publishedYear: 2023 },
|
||||
originalStem: 'test',
|
||||
format: 'pdf',
|
||||
});
|
||||
|
||||
expect(Object.keys(tokens).sort()).toEqual(['extension', 'originalFilename', 'title', 'year'].sort());
|
||||
});
|
||||
|
||||
it('formats decimal series index tokens without precision artifacts', () => {
|
||||
const tokens = buildPatternTokens({ metadata: { ...metadata, seriesIndex: '5.02' }, originalStem: 'book', format: 'epub' });
|
||||
|
||||
expect(tokens['seriesIndex']).toBe('05.02');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { formatSeriesIndex } from './series-index-format.utils';
|
||||
|
||||
/**
|
||||
* The metadata a naming pattern can draw on. Every consumer reads these same field names
|
||||
* off its own row shape, so callers pass the row through rather than mapping it field by
|
||||
* field. `isbn13` is the only token whose source differs between callers: the KOReader
|
||||
* catalogue falls back to ISBN-10, and resolves that before calling.
|
||||
*/
|
||||
export interface PatternTokenMetadata {
|
||||
title?: string | null;
|
||||
subtitle?: string | null;
|
||||
publisher?: string | null;
|
||||
language?: string | null;
|
||||
isbn13?: string | null;
|
||||
publishedYear?: number | null;
|
||||
seriesName?: string | null;
|
||||
seriesIndex?: string | null;
|
||||
}
|
||||
|
||||
export interface PatternTokenInput {
|
||||
metadata: PatternTokenMetadata;
|
||||
authors?: string[];
|
||||
narrators?: string[];
|
||||
originalStem: string;
|
||||
format: string;
|
||||
libraryName?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the token map every naming pattern resolves against. This is the single place a
|
||||
* new token is added: upload, rename, bulk rename, move, Book Dock, download and the
|
||||
* KOReader catalogue all resolve patterns through it, and a token wired into only some of
|
||||
* them fails silently, producing a wrong path rather than an error.
|
||||
*
|
||||
* Absent values are left out rather than set to an empty string. `replacePlaceholders`
|
||||
* treats the two identically, and omitting them keeps the map readable in logs.
|
||||
*/
|
||||
export function buildPatternTokens(input: PatternTokenInput): Record<string, string> {
|
||||
const { metadata, authors = [], narrators = [], originalStem, format, libraryName } = input;
|
||||
const tokens: Record<string, string> = { originalFilename: originalStem, extension: format };
|
||||
|
||||
if (libraryName) tokens['library'] = libraryName;
|
||||
if (metadata.title) tokens['title'] = metadata.title;
|
||||
if (metadata.subtitle) tokens['subtitle'] = metadata.subtitle;
|
||||
if (metadata.publisher) tokens['publisher'] = metadata.publisher;
|
||||
if (metadata.language) tokens['language'] = metadata.language;
|
||||
if (metadata.isbn13) tokens['isbn'] = metadata.isbn13;
|
||||
if (metadata.publishedYear) tokens['year'] = String(metadata.publishedYear);
|
||||
if (metadata.seriesName) tokens['series'] = metadata.seriesName;
|
||||
|
||||
const seriesIndex = formatSeriesIndex(metadata.seriesIndex ?? null);
|
||||
if (seriesIndex) tokens['seriesIndex'] = seriesIndex;
|
||||
if (authors.length > 0) tokens['authors'] = authors.join(', ');
|
||||
if (narrators.length > 0) tokens['narrators'] = narrators.join(', ');
|
||||
|
||||
return tokens;
|
||||
}
|
||||
@@ -36,7 +36,7 @@ import { SeriesMembershipService } from '../../common/services/series-membership
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { resolveExistingPathSpelling } from '../../common/utils/path-identity.utils';
|
||||
import { normalizePublishedDate, publishedYearFromDateKey } from '../../common/utils/published-date.utils';
|
||||
import { formatSeriesIndex } from '../../common/utils/series-index-format.utils';
|
||||
import { buildPatternTokens } from '../../common/utils/pattern-tokens.utils';
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { bookMetadata, libraries, libraryFolders } from '../../db/schema';
|
||||
@@ -744,7 +744,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
|
||||
const pattern = libraryPattern ?? appPattern;
|
||||
|
||||
if (pattern) {
|
||||
const tokens = this.buildPatternTokens(meta, row.fileName, format, lib?.name);
|
||||
const tokens = this.buildFilePatternTokens(meta, row.fileName, format, lib?.name);
|
||||
const resolved =
|
||||
lib?.organizationMode === 'book_per_file'
|
||||
? resolveDownloadFilename(pattern, tokens, format, { sanitizeForCrossPlatform })
|
||||
@@ -771,7 +771,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
|
||||
const meta = row.selectedMetadata ?? row.embeddedMetadata ?? {};
|
||||
|
||||
if (pattern) {
|
||||
const tokens = this.buildPatternTokens(meta, row.fileName, format, library.name);
|
||||
const tokens = this.buildFilePatternTokens(meta, row.fileName, format, library.name);
|
||||
const resolved =
|
||||
library.organizationMode === 'book_per_file'
|
||||
? resolveDownloadFilename(pattern, tokens, format, { sanitizeForCrossPlatform })
|
||||
@@ -785,25 +785,15 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
|
||||
return join(folderPath, stem, row.fileName);
|
||||
}
|
||||
|
||||
private buildPatternTokens(meta: BookDockMetadata, fileName: string, format: string, libraryName?: string | null): Record<string, string> {
|
||||
const stem = basename(fileName, extname(fileName));
|
||||
const tokens: Record<string, string> = { originalFilename: stem, extension: format };
|
||||
|
||||
if (libraryName) tokens['library'] = libraryName;
|
||||
if (meta.title) tokens['title'] = meta.title;
|
||||
if (meta.subtitle) tokens['subtitle'] = meta.subtitle;
|
||||
if (meta.publisher) tokens['publisher'] = meta.publisher;
|
||||
if (meta.language) tokens['language'] = meta.language;
|
||||
if (meta.isbn13) tokens['isbn'] = meta.isbn13;
|
||||
if (meta.publishedYear) tokens['year'] = String(meta.publishedYear);
|
||||
if (meta.seriesName) tokens['series'] = meta.seriesName;
|
||||
const seriesIndex = formatSeriesIndex(meta.seriesIndex ?? null);
|
||||
if (seriesIndex) tokens['seriesIndex'] = seriesIndex;
|
||||
if (meta.authors && meta.authors.length > 0) {
|
||||
tokens['authors'] = meta.authors.join(', ');
|
||||
}
|
||||
|
||||
return tokens;
|
||||
private buildFilePatternTokens(meta: BookDockMetadata, fileName: string, format: string, libraryName?: string | null): Record<string, string> {
|
||||
return buildPatternTokens({
|
||||
metadata: meta,
|
||||
authors: meta.authors,
|
||||
narrators: meta.narrators,
|
||||
originalStem: basename(fileName, extname(fileName)),
|
||||
format,
|
||||
libraryName,
|
||||
});
|
||||
}
|
||||
|
||||
private async applyMetadata(bookId: number, row: BookDockFileRow): Promise<void> {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { basename, dirname, extname, join, relative } from 'path';
|
||||
import type { BookMoveCollisionKind, BookMoveCollisionPolicy, BookMoveIneligibleReason, BookMoveLayoutChange } from '@bookorbit/types';
|
||||
import { resolveUploadPath } from '@bookorbit/types';
|
||||
|
||||
import { buildTokens } from '../file-write/file-rename.utils';
|
||||
import { buildPatternTokens } from '../../common/utils/pattern-tokens.utils';
|
||||
import type { MoveBookData, MoveBookFile, MoveTargetLibrary } from './book-move.repository';
|
||||
import { MAX_PATH_LENGTH, isInsideRoot, withCollisionSuffix } from './book-move.utils';
|
||||
|
||||
@@ -213,7 +213,14 @@ export class BookMovePlannerService {
|
||||
|
||||
private resolvePathForFile(file: MoveBookFile, book: MoveBookData, input: PlanInput, format: string): string | null {
|
||||
const originalStem = basename(file.absolutePath, extname(file.absolutePath));
|
||||
const tokens = buildTokens(book.metadata, book.authors, originalStem, format, input.target.libraryName);
|
||||
const tokens = buildPatternTokens({
|
||||
metadata: book.metadata,
|
||||
authors: book.authors,
|
||||
narrators: book.narrators,
|
||||
originalStem,
|
||||
format,
|
||||
libraryName: input.target.libraryName,
|
||||
});
|
||||
return resolveUploadPath(input.pattern!, tokens, format, { sanitizeForCrossPlatform: input.sanitizeForCrossPlatform });
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
bookFiles,
|
||||
bookMetadata,
|
||||
bookMoveJobs,
|
||||
bookNarrators,
|
||||
books,
|
||||
koboDevices,
|
||||
libraries,
|
||||
libraryFolders,
|
||||
narrators,
|
||||
userLibraryAccess,
|
||||
users,
|
||||
} from '../../db/schema';
|
||||
@@ -53,6 +55,7 @@ export interface MoveBookData {
|
||||
seriesIndex: string | null;
|
||||
};
|
||||
authors: string[];
|
||||
narrators: string[];
|
||||
files: MoveBookFile[];
|
||||
}
|
||||
|
||||
@@ -163,12 +166,20 @@ export class BookMoveRepository {
|
||||
.where(inArray(bookFiles.bookId, batchIds))
|
||||
.orderBy(asc(bookFiles.id));
|
||||
|
||||
const authorRows = await this.db
|
||||
.select({ bookId: bookAuthors.bookId, name: authors.name })
|
||||
.from(bookAuthors)
|
||||
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
|
||||
.where(inArray(bookAuthors.bookId, batchIds))
|
||||
.orderBy(asc(bookAuthors.displayOrder));
|
||||
const [authorRows, narratorRows] = await Promise.all([
|
||||
this.db
|
||||
.select({ bookId: bookAuthors.bookId, name: authors.name })
|
||||
.from(bookAuthors)
|
||||
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
|
||||
.where(inArray(bookAuthors.bookId, batchIds))
|
||||
.orderBy(asc(bookAuthors.displayOrder)),
|
||||
this.db
|
||||
.select({ bookId: bookNarrators.bookId, name: narrators.name })
|
||||
.from(bookNarrators)
|
||||
.innerJoin(narrators, eq(narrators.id, bookNarrators.narratorId))
|
||||
.where(inArray(bookNarrators.bookId, batchIds))
|
||||
.orderBy(asc(bookNarrators.displayOrder)),
|
||||
]);
|
||||
|
||||
const filesByBook = new Map<number, MoveBookFile[]>();
|
||||
for (const file of fileRows) {
|
||||
@@ -193,6 +204,13 @@ export class BookMoveRepository {
|
||||
else authorsByBook.set(author.bookId, [author.name]);
|
||||
}
|
||||
|
||||
const narratorsByBook = new Map<number, string[]>();
|
||||
for (const narrator of narratorRows) {
|
||||
const list = narratorsByBook.get(narrator.bookId);
|
||||
if (list) list.push(narrator.name);
|
||||
else narratorsByBook.set(narrator.bookId, [narrator.name]);
|
||||
}
|
||||
|
||||
for (const row of bookRows) {
|
||||
rows.push({
|
||||
bookId: row.bookId,
|
||||
@@ -215,6 +233,7 @@ export class BookMoveRepository {
|
||||
seriesIndex: row.seriesIndex,
|
||||
},
|
||||
authors: authorsByBook.get(row.bookId) ?? [],
|
||||
narrators: narratorsByBook.get(row.bookId) ?? [],
|
||||
files: filesByBook.get(row.bookId) ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -666,7 +666,7 @@ describe('BookRepository', () => {
|
||||
expect(db.select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('merges metadata rows with ordered author names per book', async () => {
|
||||
it('merges metadata rows with ordered author and narrator names per book', async () => {
|
||||
const metaRows = [
|
||||
{
|
||||
bookId: 10,
|
||||
@@ -696,11 +696,16 @@ describe('BookRepository', () => {
|
||||
{ bookId: 10, name: 'Coauthor' },
|
||||
{ bookId: 11, name: 'Dan Simmons' },
|
||||
];
|
||||
const narratorRows = [
|
||||
{ bookId: 10, name: 'Simon Vance' },
|
||||
{ bookId: 10, name: 'Scott Brick' },
|
||||
];
|
||||
|
||||
const metaChain = makeSelectChain('where', metaRows);
|
||||
const authorChain = makeSelectChain('orderBy', authorRows);
|
||||
const narratorChain = makeSelectChain('orderBy', narratorRows);
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValueOnce(metaChain).mockReturnValueOnce(authorChain),
|
||||
select: vi.fn().mockReturnValueOnce(metaChain).mockReturnValueOnce(authorChain).mockReturnValueOnce(narratorChain),
|
||||
};
|
||||
|
||||
const repo = new BookRepository(db as never);
|
||||
@@ -711,10 +716,12 @@ describe('BookRepository', () => {
|
||||
{
|
||||
...metaRows[0],
|
||||
authors: ['Frank Herbert', 'Coauthor'],
|
||||
narrators: ['Simon Vance', 'Scott Brick'],
|
||||
},
|
||||
{
|
||||
...metaRows[1],
|
||||
authors: ['Dan Simmons'],
|
||||
narrators: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -113,6 +113,7 @@ type PatternMetadataRow = {
|
||||
seriesIndex: string | null;
|
||||
isbn13: string | null;
|
||||
authors: string[];
|
||||
narrators: string[];
|
||||
};
|
||||
|
||||
function parseDateByBookId(value: JsonObj | null | undefined): Record<number, Date | null> {
|
||||
@@ -1759,7 +1760,7 @@ export class BookRepository {
|
||||
async findPatternMetadataByBookIds(bookIds: number[]): Promise<PatternMetadataRow[]> {
|
||||
if (bookIds.length === 0) return [];
|
||||
|
||||
const [metaRows, authorRows] = await Promise.all([
|
||||
const [metaRows, authorRows, narratorRows] = await Promise.all([
|
||||
this.db
|
||||
.select({
|
||||
bookId: books.id,
|
||||
@@ -1785,16 +1786,32 @@ export class BookRepository {
|
||||
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
|
||||
.where(inArray(bookAuthors.bookId, bookIds))
|
||||
.orderBy(bookAuthors.displayOrder),
|
||||
this.db
|
||||
.select({ bookId: bookNarrators.bookId, name: narrators.name })
|
||||
.from(bookNarrators)
|
||||
.innerJoin(narrators, eq(narrators.id, bookNarrators.narratorId))
|
||||
.where(inArray(bookNarrators.bookId, bookIds))
|
||||
.orderBy(bookNarrators.displayOrder),
|
||||
]);
|
||||
|
||||
const authorsByBookId = new Map<number, string[]>();
|
||||
for (const row of authorRows) {
|
||||
const list = authorsByBookId.get(row.bookId) ?? [];
|
||||
list.push(row.name);
|
||||
authorsByBookId.set(row.bookId, list);
|
||||
}
|
||||
const groupByBookId = (rows: { bookId: number; name: string }[]): Map<number, string[]> => {
|
||||
const byBookId = new Map<number, string[]>();
|
||||
for (const row of rows) {
|
||||
const list = byBookId.get(row.bookId) ?? [];
|
||||
list.push(row.name);
|
||||
byBookId.set(row.bookId, list);
|
||||
}
|
||||
return byBookId;
|
||||
};
|
||||
|
||||
return metaRows.map((row) => ({ ...row, authors: authorsByBookId.get(row.bookId) ?? [] }));
|
||||
const authorsByBookId = groupByBookId(authorRows);
|
||||
const narratorsByBookId = groupByBookId(narratorRows);
|
||||
|
||||
return metaRows.map((row) => ({
|
||||
...row,
|
||||
authors: authorsByBookId.get(row.bookId) ?? [],
|
||||
narrators: narratorsByBookId.get(row.bookId) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
async findAllIds(): Promise<number[]> {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { normalizeMetadataText, normalizeMetadataTextKey } from '../../common/utils/metadata-text-normalize.utils';
|
||||
import { naturalCompare } from '../../common/utils/natural-sort.utils';
|
||||
import { normalizePublishedDate, publishedYearFromDateKey } from '../../common/utils/published-date.utils';
|
||||
import { formatSeriesIndex } from '../../common/utils/series-index-format.utils';
|
||||
import { buildPatternTokens } from '../../common/utils/pattern-tokens.utils';
|
||||
import { SeriesExpectedCountService } from '../../common/services/series-expected-count.service';
|
||||
import { SeriesMembershipService } from '../../common/services/series-membership.service';
|
||||
import { isDateKey, resolveTimeZone, toDateKeyInTimeZone, toTimeZoneStartOfDay } from '../../common/utils/timezone.utils';
|
||||
@@ -1371,23 +1371,15 @@ export class BookService {
|
||||
const pathExtension = extname(absolutePath).toLowerCase().slice(1);
|
||||
const extension = pathExtension || (format && format !== 'unknown' ? format : 'bin');
|
||||
const stem = basename(absolutePath, extname(absolutePath));
|
||||
const tokens: Record<string, string> = { originalFilename: stem, extension };
|
||||
|
||||
if (!meta) return tokens;
|
||||
if (meta.libraryName) tokens['library'] = meta.libraryName;
|
||||
if (meta.title) tokens['title'] = meta.title;
|
||||
if (meta.subtitle) tokens['subtitle'] = meta.subtitle;
|
||||
if (meta.publisher) tokens['publisher'] = meta.publisher;
|
||||
if (meta.language) tokens['language'] = meta.language;
|
||||
if (meta.isbn13) tokens['isbn'] = meta.isbn13;
|
||||
if (meta.publishedYear) tokens['year'] = String(meta.publishedYear);
|
||||
if (meta.seriesName) tokens['series'] = meta.seriesName;
|
||||
|
||||
const seriesIndex = formatSeriesIndex(meta.seriesIndex);
|
||||
if (seriesIndex) tokens['seriesIndex'] = seriesIndex;
|
||||
if (meta.authors.length > 0) tokens['authors'] = meta.authors.join(', ');
|
||||
|
||||
return tokens;
|
||||
return buildPatternTokens({
|
||||
metadata: meta ?? {},
|
||||
authors: meta?.authors,
|
||||
narrators: meta?.narrators,
|
||||
originalStem: stem,
|
||||
format: extension,
|
||||
libraryName: meta?.libraryName,
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveDownloadFilenameForFile(file: { bookId: number; absolutePath: string; format: string | null }): Promise<string> {
|
||||
|
||||
@@ -65,9 +65,20 @@ describe('BulkRenameRepository', () => {
|
||||
{ bookId: 1, name: 'Author Two' },
|
||||
{ bookId: 2, name: 'Author Three' },
|
||||
];
|
||||
const narratorRows = [
|
||||
{ bookId: 1, name: 'Narrator One' },
|
||||
{ bookId: 2, name: 'Narrator Two' },
|
||||
{ bookId: 2, name: 'Narrator Three' },
|
||||
];
|
||||
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValueOnce(queryChain(bookRows)).mockReturnValueOnce(queryChain(authorRows)).mockReturnValue(subqueryChain()),
|
||||
select: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(queryChain(bookRows))
|
||||
.mockReturnValueOnce(subqueryChain())
|
||||
.mockReturnValueOnce(queryChain(authorRows))
|
||||
.mockReturnValueOnce(queryChain(narratorRows))
|
||||
.mockReturnValue(subqueryChain()),
|
||||
};
|
||||
|
||||
const repo = new BulkRenameRepository(db as never);
|
||||
@@ -79,12 +90,13 @@ describe('BulkRenameRepository', () => {
|
||||
title: 'A',
|
||||
absolutePath: '/lib/old/a.epub',
|
||||
authors: ['Author One', 'Author Two'],
|
||||
narrators: ['Narrator One'],
|
||||
metadata: { language: 'en', publishedYear: 2001 },
|
||||
});
|
||||
expect(result[1]).toMatchObject({ bookId: 2, authors: ['Author Three'] });
|
||||
expect(result[1]).toMatchObject({ bookId: 2, authors: ['Author Three'], narrators: ['Narrator Two', 'Narrator Three'] });
|
||||
});
|
||||
|
||||
it('returns books with empty author lists when none are linked', async () => {
|
||||
it('returns books with empty author and narrator lists when none are linked', async () => {
|
||||
const bookRows = [
|
||||
{
|
||||
bookId: 1,
|
||||
@@ -107,7 +119,13 @@ describe('BulkRenameRepository', () => {
|
||||
];
|
||||
|
||||
const db = {
|
||||
select: vi.fn().mockReturnValueOnce(queryChain(bookRows)).mockReturnValueOnce(queryChain([])).mockReturnValue(subqueryChain()),
|
||||
select: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(queryChain(bookRows))
|
||||
.mockReturnValueOnce(subqueryChain())
|
||||
.mockReturnValueOnce(queryChain([]))
|
||||
.mockReturnValueOnce(queryChain([]))
|
||||
.mockReturnValue(subqueryChain()),
|
||||
};
|
||||
|
||||
const repo = new BulkRenameRepository(db as never);
|
||||
@@ -115,9 +133,10 @@ describe('BulkRenameRepository', () => {
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].authors).toEqual([]);
|
||||
expect(result[0].narrators).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array and skips the author query when the library has no books', async () => {
|
||||
it('returns an empty array and skips the contributor queries when the library has no books', async () => {
|
||||
const db = { select: vi.fn().mockReturnValueOnce(queryChain([])) };
|
||||
|
||||
const repo = new BulkRenameRepository(db as never);
|
||||
@@ -127,7 +146,7 @@ describe('BulkRenameRepository', () => {
|
||||
expect(db.select).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fetches authors via a library subquery, never a per-book parameter list (issue #361)', async () => {
|
||||
it('fetches contributors via a library subquery, never a per-book parameter list (issue #361)', async () => {
|
||||
const calls: Array<{ text: string; params: unknown[] }> = [];
|
||||
const fakeClient = {
|
||||
query: vi.fn().mockImplementation((cfg: { text: string }, params: unknown[]) => {
|
||||
@@ -142,16 +161,17 @@ describe('BulkRenameRepository', () => {
|
||||
|
||||
await repo.findAllBooksForLibrary(42);
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls).toHaveLength(3);
|
||||
|
||||
const authorQuery = calls[1];
|
||||
// The ONLY bind parameter is the libraryId - not one entry per book - so the statement can
|
||||
// never exceed PostgreSQL's 65535-parameter wire-protocol limit, regardless of library size.
|
||||
expect(authorQuery.params).toEqual([42]);
|
||||
// The ONLY bind parameter is the libraryId - not one entry per book - so neither statement
|
||||
// can exceed PostgreSQL's 65535-parameter wire-protocol limit, regardless of library size.
|
||||
for (const contributorQuery of calls.slice(1)) {
|
||||
expect(contributorQuery.params).toEqual([42]);
|
||||
|
||||
const sqlText = authorQuery.text.toLowerCase();
|
||||
expect(sqlText).toContain('in (select');
|
||||
expect(sqlText).toContain('"library_id"');
|
||||
const sqlText = contributorQuery.text.toLowerCase();
|
||||
expect(sqlText).toContain('in (select');
|
||||
expect(sqlText).toContain('"library_id"');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { authors, bookAuthors, bookFiles, bookMetadata, books, libraries, libraryFolders } from '../../db/schema';
|
||||
import { authors, bookAuthors, bookFiles, bookMetadata, bookNarrators, books, libraries, libraryFolders, narrators } from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface BulkRenameBookData {
|
||||
seriesIndex: string | null;
|
||||
};
|
||||
authors: string[];
|
||||
narrators: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -67,25 +68,44 @@ export class BulkRenameRepository {
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const authorRows = await this.db
|
||||
.select({
|
||||
bookId: bookAuthors.bookId,
|
||||
name: authors.name,
|
||||
})
|
||||
.from(bookAuthors)
|
||||
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
|
||||
.where(inArray(bookAuthors.bookId, this.db.select({ id: books.id }).from(books).where(eq(books.libraryId, libraryId))))
|
||||
.orderBy(asc(bookAuthors.bookId), asc(bookAuthors.displayOrder));
|
||||
const libraryBookIds = this.db.select({ id: books.id }).from(books).where(eq(books.libraryId, libraryId));
|
||||
|
||||
const authorsByBook = new Map<number, string[]>();
|
||||
for (const row of authorRows) {
|
||||
const existing = authorsByBook.get(row.bookId);
|
||||
if (existing) {
|
||||
existing.push(row.name);
|
||||
} else {
|
||||
authorsByBook.set(row.bookId, [row.name]);
|
||||
const [authorRows, narratorRows] = await Promise.all([
|
||||
this.db
|
||||
.select({
|
||||
bookId: bookAuthors.bookId,
|
||||
name: authors.name,
|
||||
})
|
||||
.from(bookAuthors)
|
||||
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
|
||||
.where(inArray(bookAuthors.bookId, libraryBookIds))
|
||||
.orderBy(asc(bookAuthors.bookId), asc(bookAuthors.displayOrder)),
|
||||
this.db
|
||||
.select({
|
||||
bookId: bookNarrators.bookId,
|
||||
name: narrators.name,
|
||||
})
|
||||
.from(bookNarrators)
|
||||
.innerJoin(narrators, eq(narrators.id, bookNarrators.narratorId))
|
||||
.where(inArray(bookNarrators.bookId, libraryBookIds))
|
||||
.orderBy(asc(bookNarrators.bookId), asc(bookNarrators.displayOrder)),
|
||||
]);
|
||||
|
||||
const groupByBook = (rows: { bookId: number; name: string }[]): Map<number, string[]> => {
|
||||
const byBook = new Map<number, string[]>();
|
||||
for (const row of rows) {
|
||||
const existing = byBook.get(row.bookId);
|
||||
if (existing) {
|
||||
existing.push(row.name);
|
||||
} else {
|
||||
byBook.set(row.bookId, [row.name]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return byBook;
|
||||
};
|
||||
|
||||
const authorsByBook = groupByBook(authorRows);
|
||||
const narratorsByBook = groupByBook(narratorRows);
|
||||
|
||||
return rows.map((row) => ({
|
||||
bookId: row.bookId,
|
||||
@@ -109,6 +129,7 @@ export class BulkRenameRepository {
|
||||
seriesIndex: row.seriesIndex,
|
||||
},
|
||||
authors: authorsByBook.get(row.bookId) ?? [],
|
||||
narrators: narratorsByBook.get(row.bookId) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,12 @@ describe('FileRenameRepository', () => {
|
||||
innerJoin: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockResolvedValue([{ name: 'Frank Herbert' }, { name: 'Brian Herbert' }]),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
innerJoin: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockResolvedValue([{ name: 'Simon Vance' }]),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -79,6 +85,7 @@ describe('FileRenameRepository', () => {
|
||||
seriesIndex: '1',
|
||||
},
|
||||
authors: ['Frank Herbert', 'Brian Herbert'],
|
||||
narrators: ['Simon Vance'],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { authors, bookAuthors, bookFiles, bookMetadata, books, libraries, libraryFolders } from '../../db/schema';
|
||||
import { authors, bookAuthors, bookFiles, bookMetadata, bookNarrators, books, libraries, libraryFolders, narrators } from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface BookRenameData {
|
||||
seriesIndex: string | null;
|
||||
};
|
||||
authors: string[];
|
||||
narrators: string[];
|
||||
}
|
||||
|
||||
export interface BookFilePathUpdate {
|
||||
@@ -81,12 +82,20 @@ export class FileRenameRepository {
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const authorRows = await this.db
|
||||
.select({ name: authors.name })
|
||||
.from(bookAuthors)
|
||||
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
|
||||
.where(eq(bookAuthors.bookId, bookId))
|
||||
.orderBy(asc(bookAuthors.displayOrder));
|
||||
const [authorRows, narratorRows] = await Promise.all([
|
||||
this.db
|
||||
.select({ name: authors.name })
|
||||
.from(bookAuthors)
|
||||
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
|
||||
.where(eq(bookAuthors.bookId, bookId))
|
||||
.orderBy(asc(bookAuthors.displayOrder)),
|
||||
this.db
|
||||
.select({ name: narrators.name })
|
||||
.from(bookNarrators)
|
||||
.innerJoin(narrators, eq(narrators.id, bookNarrators.narratorId))
|
||||
.where(eq(bookNarrators.bookId, bookId))
|
||||
.orderBy(asc(bookNarrators.displayOrder)),
|
||||
]);
|
||||
|
||||
return {
|
||||
file: {
|
||||
@@ -115,6 +124,7 @@ export class FileRenameRepository {
|
||||
seriesIndex: row.seriesIndex,
|
||||
},
|
||||
authors: authorRows.map((author) => author.name),
|
||||
narrators: narratorRows.map((narrator) => narrator.name),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { NotificationService } from '../notification/notification.service';
|
||||
import type { BookFilePathUpdate, BookRenameData } from './file-rename.repository';
|
||||
import { FileRenameRepository } from './file-rename.repository';
|
||||
import { FileLockService, bookOperationLockKey } from './file-lock.service';
|
||||
import { buildTokens } from './file-rename.utils';
|
||||
import { buildPatternTokens } from '../../common/utils/pattern-tokens.utils';
|
||||
|
||||
const FILE_RENAME_EVENT = 'file.rename';
|
||||
const FILE_RENAME_ROLLBACK_EVENT = 'file.rename_rollback';
|
||||
@@ -110,7 +110,14 @@ export class FileRenameService implements OnModuleDestroy {
|
||||
|
||||
const format = (data.file.format ?? extname(data.file.absolutePath).slice(1)).toLowerCase();
|
||||
const originalStem = basename(data.file.absolutePath, extname(data.file.absolutePath));
|
||||
const tokens = buildTokens(data.metadata, data.authors, originalStem, format, data.libraryName);
|
||||
const tokens = buildPatternTokens({
|
||||
metadata: data.metadata,
|
||||
authors: data.authors,
|
||||
narrators: data.narrators,
|
||||
originalStem,
|
||||
format,
|
||||
libraryName: data.libraryName,
|
||||
});
|
||||
const sanitizeForCrossPlatform = await this.appSettings.isCrossPlatformPathSanitizationEnabled();
|
||||
const resolvedRelPath = resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform });
|
||||
|
||||
@@ -135,7 +142,14 @@ export class FileRenameService implements OnModuleDestroy {
|
||||
const fileExt = extname(file.absolutePath);
|
||||
const fileFormat = (file.format ?? fileExt.slice(1)).toLowerCase();
|
||||
const fileOriginalStem = basename(file.absolutePath, fileExt);
|
||||
const fileTokens = buildTokens(data.metadata, data.authors, fileOriginalStem, fileFormat, data.libraryName);
|
||||
const fileTokens = buildPatternTokens({
|
||||
metadata: data.metadata,
|
||||
authors: data.authors,
|
||||
narrators: data.narrators,
|
||||
originalStem: fileOriginalStem,
|
||||
format: fileFormat,
|
||||
libraryName: data.libraryName,
|
||||
});
|
||||
const fileResolvedRelPath = resolveUploadPath(pattern, fileTokens, fileFormat, { sanitizeForCrossPlatform });
|
||||
|
||||
let targetAbs: string;
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { buildTokens, formatSeriesIndex } from './file-rename.utils';
|
||||
|
||||
describe('file-rename.utils', () => {
|
||||
describe('formatSeriesIndex', () => {
|
||||
it('returns null for null input', () => {
|
||||
expect(formatSeriesIndex(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('formats whole number with zero-padding', () => {
|
||||
expect(formatSeriesIndex('1')).toBe('01');
|
||||
expect(formatSeriesIndex('5')).toBe('05');
|
||||
expect(formatSeriesIndex('12')).toBe('12');
|
||||
});
|
||||
|
||||
it('formats fractional index correctly', () => {
|
||||
expect(formatSeriesIndex('1.5')).toBe('01.5');
|
||||
expect(formatSeriesIndex('3.25')).toBe('03.25');
|
||||
expect(formatSeriesIndex('5.02')).toBe('05.02');
|
||||
expect(formatSeriesIndex('5.08')).toBe('05.08');
|
||||
expect(formatSeriesIndex('5.09')).toBe('05.09');
|
||||
expect(formatSeriesIndex('5.10')).toBe('05.10');
|
||||
expect(formatSeriesIndex('5.11')).toBe('05.11');
|
||||
});
|
||||
|
||||
it('formats zero correctly', () => {
|
||||
expect(formatSeriesIndex('0')).toBe('00');
|
||||
});
|
||||
|
||||
it('formats large numbers', () => {
|
||||
expect(formatSeriesIndex('100')).toBe('100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTokens', () => {
|
||||
const fullMetadata = {
|
||||
title: 'Dune',
|
||||
subtitle: 'A Novel',
|
||||
publisher: 'Ace',
|
||||
language: 'en',
|
||||
isbn13: '9780441172719',
|
||||
publishedYear: 1965,
|
||||
seriesName: 'Dune Chronicles',
|
||||
seriesIndex: '1',
|
||||
};
|
||||
|
||||
it('builds all tokens from full metadata', () => {
|
||||
const tokens = buildTokens(fullMetadata, ['Frank Herbert'], 'dune', 'epub');
|
||||
|
||||
expect(tokens).toEqual({
|
||||
originalFilename: 'dune',
|
||||
extension: 'epub',
|
||||
title: 'Dune',
|
||||
subtitle: 'A Novel',
|
||||
publisher: 'Ace',
|
||||
language: 'en',
|
||||
isbn: '9780441172719',
|
||||
year: '1965',
|
||||
series: 'Dune Chronicles',
|
||||
seriesIndex: '01',
|
||||
authors: 'Frank Herbert',
|
||||
});
|
||||
});
|
||||
|
||||
it('joins multiple authors with comma', () => {
|
||||
const tokens = buildTokens(fullMetadata, ['Author A', 'Author B'], 'book', 'pdf');
|
||||
expect(tokens['authors']).toBe('Author A, Author B');
|
||||
});
|
||||
|
||||
it('includes the library token when a library name is given', () => {
|
||||
const tokens = buildTokens(fullMetadata, ['Frank Herbert'], 'dune', 'epub', 'Science Fiction');
|
||||
expect(tokens['library']).toBe('Science Fiction');
|
||||
});
|
||||
|
||||
it('omits the library token when no library name is given', () => {
|
||||
expect(buildTokens(fullMetadata, ['Frank Herbert'], 'dune', 'epub')['library']).toBeUndefined();
|
||||
expect(buildTokens(fullMetadata, ['Frank Herbert'], 'dune', 'epub', '')['library']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits tokens for null metadata fields', () => {
|
||||
const emptyMetadata = {
|
||||
title: null,
|
||||
subtitle: null,
|
||||
publisher: null,
|
||||
language: null,
|
||||
isbn13: null,
|
||||
publishedYear: null,
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
};
|
||||
|
||||
const tokens = buildTokens(emptyMetadata, [], 'file', 'epub');
|
||||
expect(tokens).toEqual({ originalFilename: 'file', extension: 'epub' });
|
||||
});
|
||||
|
||||
it('omits authors when array is empty', () => {
|
||||
const tokens = buildTokens(fullMetadata, [], 'book', 'epub');
|
||||
expect(tokens['authors']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes only non-null fields', () => {
|
||||
const partialMetadata = {
|
||||
title: 'Test',
|
||||
subtitle: null,
|
||||
publisher: null,
|
||||
language: null,
|
||||
isbn13: null,
|
||||
publishedYear: 2023,
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
};
|
||||
|
||||
const tokens = buildTokens(partialMetadata, [], 'test', 'pdf');
|
||||
expect(Object.keys(tokens).sort()).toEqual(['extension', 'originalFilename', 'title', 'year'].sort());
|
||||
});
|
||||
|
||||
it('formats decimal series index tokens without precision artifacts', () => {
|
||||
const tokens = buildTokens({ ...fullMetadata, seriesIndex: '5.02' }, [], 'book', 'epub');
|
||||
|
||||
expect(tokens['seriesIndex']).toBe('05.02');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { formatSeriesIndex } from '../../common/utils/series-index-format.utils';
|
||||
|
||||
export { formatSeriesIndex };
|
||||
|
||||
export interface RenameMetadata {
|
||||
title: string | null;
|
||||
subtitle: string | null;
|
||||
publisher: string | null;
|
||||
language: string | null;
|
||||
isbn13: string | null;
|
||||
publishedYear: number | null;
|
||||
seriesName: string | null;
|
||||
seriesIndex: string | null;
|
||||
}
|
||||
|
||||
export function buildTokens(
|
||||
metadata: RenameMetadata,
|
||||
authors: string[],
|
||||
originalStem: string,
|
||||
format: string,
|
||||
libraryName?: string | null,
|
||||
): Record<string, string> {
|
||||
const tokens: Record<string, string> = { originalFilename: originalStem, extension: format };
|
||||
|
||||
if (libraryName) tokens['library'] = libraryName;
|
||||
if (metadata.title) tokens['title'] = metadata.title;
|
||||
if (metadata.subtitle) tokens['subtitle'] = metadata.subtitle;
|
||||
if (metadata.publisher) tokens['publisher'] = metadata.publisher;
|
||||
if (metadata.language) tokens['language'] = metadata.language;
|
||||
if (metadata.isbn13) tokens['isbn'] = metadata.isbn13;
|
||||
if (metadata.publishedYear) tokens['year'] = String(metadata.publishedYear);
|
||||
if (metadata.seriesName) tokens['series'] = metadata.seriesName;
|
||||
|
||||
const seriesIndex = formatSeriesIndex(metadata.seriesIndex);
|
||||
if (seriesIndex) tokens['seriesIndex'] = seriesIndex;
|
||||
if (authors.length > 0) tokens['authors'] = authors.join(', ');
|
||||
|
||||
return tokens;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type { ConfigType } from '@nestjs/config';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
|
||||
import { DEFAULT_KOREADER_DEVICE_PATTERN, resolveUploadPath } from '@bookorbit/types';
|
||||
import { buildPatternTokens } from '../../common/utils/pattern-tokens.utils';
|
||||
import type {
|
||||
KoreaderCatalogBookDetail,
|
||||
KoreaderCatalogBookListItem,
|
||||
@@ -40,7 +41,6 @@ import { MAX_OFFSET_ROWS, isOffsetWithinLimit } from '../../common/constants/pag
|
||||
import { imageContentTypeFromPath } from '../../common/image-content-type';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { contentDispositionHeader } from '../../common/utils/content-disposition.utils';
|
||||
import { formatSeriesIndex } from '../../common/utils/series-index-format.utils';
|
||||
import { storageConfig } from '../../config/config';
|
||||
import { BookReadService } from '../book/book-read.service';
|
||||
import { BookService } from '../book/book.service';
|
||||
@@ -352,7 +352,6 @@ export class KoreaderCatalogService {
|
||||
}
|
||||
|
||||
private mapManifestBook(row: OpdsManifestBookRow, pattern: string, sanitizeForCrossPlatform: boolean): KoreaderCatalogManifestBook {
|
||||
const authorNames = row.authors.join(', ');
|
||||
const files = row.files.map<KoreaderCatalogManifestFile>((file) => {
|
||||
const extension = this.normalizeFormat(file.format);
|
||||
return {
|
||||
@@ -365,20 +364,22 @@ export class KoreaderCatalogService {
|
||||
devicePath:
|
||||
resolveUploadPath(
|
||||
pattern,
|
||||
{
|
||||
title: row.title,
|
||||
subtitle: row.subtitle ?? '',
|
||||
authors: authorNames,
|
||||
year: row.publishedYear ? String(row.publishedYear) : '',
|
||||
series: row.seriesName ?? '',
|
||||
seriesIndex: formatSeriesIndex(row.seriesIndex) ?? '',
|
||||
language: row.language ?? '',
|
||||
publisher: row.publisher ?? '',
|
||||
isbn: row.isbn13 ?? row.isbn10 ?? '',
|
||||
library: row.libraryName,
|
||||
originalFilename: basename(file.filename ?? row.title, `.${extension}`),
|
||||
extension,
|
||||
},
|
||||
buildPatternTokens({
|
||||
metadata: {
|
||||
title: row.title,
|
||||
subtitle: row.subtitle,
|
||||
publisher: row.publisher,
|
||||
language: row.language,
|
||||
isbn13: row.isbn13 ?? row.isbn10,
|
||||
publishedYear: row.publishedYear,
|
||||
seriesName: row.seriesName,
|
||||
seriesIndex: row.seriesIndex,
|
||||
},
|
||||
authors: row.authors,
|
||||
originalStem: basename(file.filename ?? row.title, `.${extension}`),
|
||||
format: extension,
|
||||
libraryName: row.libraryName,
|
||||
}),
|
||||
extension,
|
||||
{ sanitizeForCrossPlatform },
|
||||
) ??
|
||||
@@ -714,20 +715,22 @@ export class KoreaderCatalogService {
|
||||
devicePath:
|
||||
resolveUploadPath(
|
||||
filePattern,
|
||||
{
|
||||
title,
|
||||
subtitle: detail.subtitle ?? '',
|
||||
authors: detail.authors.map((author) => author.name).join(', '),
|
||||
year: detail.publishedYear ? String(detail.publishedYear) : '',
|
||||
series: detail.seriesName ?? '',
|
||||
seriesIndex: formatSeriesIndex(detail.seriesIndex) ?? '',
|
||||
language: detail.language ?? '',
|
||||
publisher: detail.publisher ?? '',
|
||||
isbn: detail.isbn13 ?? detail.isbn10 ?? '',
|
||||
library: detail.libraryName,
|
||||
originalFilename: basename(file.filename ?? title, `.${extension}`),
|
||||
extension,
|
||||
},
|
||||
buildPatternTokens({
|
||||
metadata: {
|
||||
title,
|
||||
subtitle: detail.subtitle,
|
||||
publisher: detail.publisher,
|
||||
language: detail.language,
|
||||
isbn13: detail.isbn13 ?? detail.isbn10,
|
||||
publishedYear: detail.publishedYear,
|
||||
seriesName: detail.seriesName,
|
||||
seriesIndex: detail.seriesIndex,
|
||||
},
|
||||
authors: detail.authors.map((author) => author.name),
|
||||
originalStem: basename(file.filename ?? title, `.${extension}`),
|
||||
format: extension,
|
||||
libraryName: detail.libraryName,
|
||||
}),
|
||||
extension,
|
||||
{ sanitizeForCrossPlatform },
|
||||
) ??
|
||||
|
||||
@@ -12,7 +12,7 @@ import { FileWatcherService } from '../scanner/file-watcher.service';
|
||||
import { LibraryRepository } from './library.repository';
|
||||
import type { BulkRenameBookData } from '../file-write/bulk-rename.repository';
|
||||
import { BulkRenameRepository } from '../file-write/bulk-rename.repository';
|
||||
import { buildTokens } from '../file-write/file-rename.utils';
|
||||
import { buildPatternTokens } from '../../common/utils/pattern-tokens.utils';
|
||||
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
|
||||
@@ -262,7 +262,14 @@ export class BulkRenameService {
|
||||
try {
|
||||
const format = (book.format ?? extname(book.absolutePath).slice(1)).toLowerCase();
|
||||
const originalStem = basename(book.absolutePath, extname(book.absolutePath));
|
||||
const tokens = buildTokens(book.metadata, book.authors, originalStem, format, book.libraryName);
|
||||
const tokens = buildPatternTokens({
|
||||
metadata: book.metadata,
|
||||
authors: book.authors,
|
||||
narrators: book.narrators,
|
||||
originalStem,
|
||||
format,
|
||||
libraryName: book.libraryName,
|
||||
});
|
||||
const resolvedRelPath = resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform });
|
||||
|
||||
if (!resolvedRelPath) {
|
||||
|
||||
@@ -34,6 +34,11 @@ const DECIMAL_INDEX: Record<string, string> = {
|
||||
seriesIndex: '01.5',
|
||||
};
|
||||
|
||||
const FULL_CAST: Record<string, string> = {
|
||||
...FULL,
|
||||
narrators: 'Simon Vance, Scott Brick, Ilyana Kadushin, Euan Morton',
|
||||
};
|
||||
|
||||
describe('sanitizePathSegment', () => {
|
||||
it('removes a large trailing run of dots and spaces in linear time', () => {
|
||||
expect(sanitizePathSegment(`book${'. '.repeat(100_000)}`)).toBe('book');
|
||||
@@ -105,6 +110,24 @@ describe('applyModifier', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('max3', () => {
|
||||
it('passes a single value through', () => {
|
||||
expect(applyModifier('Simon Vance', 'max3', 'narrators')).toBe('Simon Vance');
|
||||
});
|
||||
|
||||
it('passes three values through', () => {
|
||||
expect(applyModifier('A, B, C', 'max3', 'narrators')).toBe('A, B, C');
|
||||
});
|
||||
|
||||
it('collapses to nothing above three values', () => {
|
||||
expect(applyModifier('A, B, C, D', 'max3', 'narrators')).toBe('');
|
||||
});
|
||||
|
||||
it('ignores empty entries when counting', () => {
|
||||
expect(applyModifier('A, , B, , C', 'max3', 'narrators')).toBe('A, B, C');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown modifier', () => {
|
||||
it('returns the value unchanged', () => {
|
||||
expect(applyModifier('Patrick Rothfuss', 'nonexistent', 'authors')).toBe('Patrick Rothfuss');
|
||||
@@ -149,6 +172,24 @@ describe('replacePlaceholders', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('optional groups judge modifier output', () => {
|
||||
it('takes the fallback when a modifier collapses to nothing', () => {
|
||||
expect(replacePlaceholders('<{narrators:max3}|Full Cast>', FULL_CAST)).toBe('Full Cast');
|
||||
});
|
||||
|
||||
it('keeps the primary branch while the modifier still yields text', () => {
|
||||
expect(replacePlaceholders('<{narrators:max3}|Full Cast>', FULL)).toBe('Robertson Dean');
|
||||
});
|
||||
|
||||
it('drops an unguarded group whose modifier collapses to nothing', () => {
|
||||
expect(replacePlaceholders('{title}< - {narrators:max3}>', FULL_CAST)).toBe('Neuromancer');
|
||||
});
|
||||
|
||||
it('still takes the fallback when the underlying value is absent', () => {
|
||||
expect(replacePlaceholders('<{narrators:max3}|Full Cast>', PARTIAL)).toBe('Full Cast');
|
||||
});
|
||||
});
|
||||
|
||||
describe('modifiers', () => {
|
||||
it('{authors:first} picks the first author', () => {
|
||||
expect(replacePlaceholders('{authors:first}', MULTI_AUTHOR)).toBe('Bruce Sterling');
|
||||
@@ -174,6 +215,14 @@ describe('replacePlaceholders', () => {
|
||||
expect(replacePlaceholders('{title:initial}', FULL)).toBe('N');
|
||||
});
|
||||
|
||||
it('{narrators} joins every narrator', () => {
|
||||
expect(replacePlaceholders('{narrators}', FULL_CAST)).toBe('Simon Vance, Scott Brick, Ilyana Kadushin, Euan Morton');
|
||||
});
|
||||
|
||||
it('{narrators:first} picks the first narrator', () => {
|
||||
expect(replacePlaceholders('{narrators:first}', FULL_CAST)).toBe('Simon Vance');
|
||||
});
|
||||
|
||||
it('modifier on a missing token resolves to empty string', () => {
|
||||
expect(replacePlaceholders('{series:upper}', PARTIAL)).toBe('');
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Readable } from 'stream';
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { formatSeriesIndex } from '../../common/utils/series-index-format.utils';
|
||||
import { buildPatternTokens } from '../../common/utils/pattern-tokens.utils';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
@@ -18,12 +18,13 @@ import { UploadValidatorService } from './upload-validator.service';
|
||||
import { UploadStorageService } from './upload-storage.service';
|
||||
import { UploadProcessorService } from './upload-processor.service';
|
||||
import { FileRenameService } from '../file-write/file-rename.service';
|
||||
import { resolveDownloadFilename, resolveUploadPath } from '@bookorbit/types';
|
||||
import { isAudioFormat, resolveDownloadFilename, resolveUploadPath } from '@bookorbit/types';
|
||||
import type { AddBookFileResult, UploadResult } from '@bookorbit/types';
|
||||
import { extractEpubMetadata } from '../metadata/lib/epub';
|
||||
import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../metadata/lib/cbz-metadata';
|
||||
import { parseMobiFile } from '../metadata/lib/mobi-parser';
|
||||
import { parsePdfFile, type PdfParseWarning } from '../metadata/lib/pdf-parser';
|
||||
import { extractAudioMetadata } from '../metadata/extractors/audio.extractor';
|
||||
import { computeFileHash } from '../scanner/lib/hash';
|
||||
import { resolveExistingPathSpelling } from '../../common/utils/path-identity.utils';
|
||||
|
||||
@@ -346,7 +347,7 @@ export class UploadService {
|
||||
|
||||
if (pattern) {
|
||||
const stem = basename(filename, extname(filename));
|
||||
const tokens = await this.buildPatternTokens(tempPath, format, stem, library.name);
|
||||
const tokens = await this.buildUploadPatternTokens(tempPath, format, stem, library.name);
|
||||
if (library.organizationMode === 'book_per_file') {
|
||||
const resolvedFilename = resolveDownloadFilename(pattern, tokens, format, { sanitizeForCrossPlatform });
|
||||
if (resolvedFilename) {
|
||||
@@ -385,9 +386,13 @@ export class UploadService {
|
||||
return { absolutePath, bookFolderPath, relPath };
|
||||
}
|
||||
|
||||
private async buildPatternTokens(tempPath: string, format: string, stem: string, libraryName?: string | null): Promise<Record<string, string>> {
|
||||
const base: Record<string, string> = { originalFilename: stem, extension: format };
|
||||
if (libraryName) base['library'] = libraryName;
|
||||
private async buildUploadPatternTokens(
|
||||
tempPath: string,
|
||||
format: string,
|
||||
stem: string,
|
||||
libraryName?: string | null,
|
||||
): Promise<Record<string, string>> {
|
||||
const fallback = buildPatternTokens({ metadata: {}, originalStem: stem, format, libraryName });
|
||||
const event = 'upload.pattern_tokens';
|
||||
const startedAt = Date.now();
|
||||
|
||||
@@ -402,6 +407,7 @@ export class UploadService {
|
||||
seriesIndex?: string | null;
|
||||
isbn13?: string | null;
|
||||
authors: { name: string }[];
|
||||
narrators?: string[];
|
||||
} | null = null;
|
||||
|
||||
if (format === 'epub') {
|
||||
@@ -435,22 +441,20 @@ export class UploadService {
|
||||
if (pdf) {
|
||||
parsed = { title: pdf.title, publisher: pdf.publisher, authors: pdf.authors, seriesName: null, seriesIndex: null };
|
||||
}
|
||||
} else if (isAudioFormat(format)) {
|
||||
parsed = await extractAudioMetadata(tempPath);
|
||||
}
|
||||
|
||||
if (!parsed) return base;
|
||||
if (!parsed) return fallback;
|
||||
|
||||
if (parsed.title) base['title'] = parsed.title;
|
||||
if (parsed.subtitle) base['subtitle'] = parsed.subtitle;
|
||||
if (parsed.publisher) base['publisher'] = parsed.publisher;
|
||||
if (parsed.language) base['language'] = parsed.language;
|
||||
if (parsed.isbn13) base['isbn'] = parsed.isbn13;
|
||||
if (parsed.publishedYear) base['year'] = String(parsed.publishedYear);
|
||||
if (parsed.seriesName) base['series'] = parsed.seriesName;
|
||||
const seriesIndex = formatSeriesIndex(parsed.seriesIndex ?? null);
|
||||
if (seriesIndex) base['seriesIndex'] = seriesIndex;
|
||||
if (parsed.authors.length > 0) {
|
||||
base['authors'] = parsed.authors.map((a) => a.name).join(', ');
|
||||
}
|
||||
return buildPatternTokens({
|
||||
metadata: parsed,
|
||||
authors: parsed.authors.map((author) => author.name),
|
||||
narrators: parsed.narrators,
|
||||
originalStem: stem,
|
||||
format,
|
||||
libraryName,
|
||||
});
|
||||
} catch (err) {
|
||||
const { errorClass, errorMessage } = this.parseError(err);
|
||||
this.logger.warn(
|
||||
@@ -458,7 +462,7 @@ export class UploadService {
|
||||
);
|
||||
}
|
||||
|
||||
return base;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private parseError(err: unknown): { errorClass: string; errorMessage: string } {
|
||||
|
||||
Reference in New Issue
Block a user