feat(library): add {library} placeholder to file naming patterns (#976)

Resolve {library} to the name of the library a book belongs to, so a
single KOReader device pattern can keep each library in its own subtree
on the device instead of merging every library into one tree.

The token is populated in every server-side pattern surface: KOReader
manifest and book detail device paths, upload, automatic and bulk
rename, book move (target library), download filenames, and BookDock
finalize.
This commit is contained in:
Moritz
2026-08-11 17:57:58 +02:00
committed by GitHub
parent 7dcc509a54
commit ed0964a50c
19 changed files with 113 additions and 12 deletions
@@ -714,7 +714,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
const pattern = libraryPattern ?? appPattern;
if (pattern) {
const tokens = this.buildPatternTokens(meta, row.fileName, format);
const tokens = this.buildPatternTokens(meta, row.fileName, format, lib?.name);
const resolved =
lib?.organizationMode === 'book_per_file'
? resolveDownloadFilename(pattern, tokens, format, { sanitizeForCrossPlatform })
@@ -727,7 +727,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
}
private async resolveDestination(
library: { fileNamingPattern?: string | null; organizationMode?: string | null },
library: { name?: string | null; fileNamingPattern?: string | null; organizationMode?: string | null },
folderPath: string,
row: BookDockFileRow,
format: string,
@@ -741,7 +741,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
const meta = row.selectedMetadata ?? row.embeddedMetadata ?? {};
if (pattern) {
const tokens = this.buildPatternTokens(meta, row.fileName, format);
const tokens = this.buildPatternTokens(meta, row.fileName, format, library.name);
const resolved =
library.organizationMode === 'book_per_file'
? resolveDownloadFilename(pattern, tokens, format, { sanitizeForCrossPlatform })
@@ -755,10 +755,11 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
return join(folderPath, stem, row.fileName);
}
private buildPatternTokens(meta: BookDockMetadata, fileName: string, format: string): Record<string, string> {
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;
@@ -213,7 +213,7 @@ 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);
const tokens = buildTokens(book.metadata, book.authors, originalStem, format, input.target.libraryName);
return resolveUploadPath(input.pattern!, tokens, format, { sanitizeForCrossPlatform: input.sanitizeForCrossPlatform });
}
@@ -96,6 +96,7 @@ type CollapsedRawRow = {
};
type PatternMetadataRow = {
bookId: number;
libraryName: string;
title: string | null;
subtitle: string | null;
publisher: string | null;
@@ -1734,6 +1735,7 @@ export class BookRepository {
this.db
.select({
bookId: books.id,
libraryName: libraries.name,
title: bookMetadata.title,
subtitle: bookMetadata.subtitle,
publisher: bookMetadata.publisher,
@@ -1746,6 +1748,7 @@ export class BookRepository {
isbn13: bookMetadata.isbn13,
})
.from(books)
.innerJoin(libraries, eq(libraries.id, books.libraryId))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(inArray(books.id, bookIds)),
this.db
+1
View File
@@ -1362,6 +1362,7 @@ export class BookService {
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;
@@ -15,6 +15,7 @@ export interface BulkRenameBookData {
relPath: string | null;
format: string | null;
libraryFolderPath: string;
libraryName: string;
organizationMode: string;
fileNamingPattern: string | null;
bookFolderPath: string;
@@ -43,6 +44,7 @@ export class BulkRenameRepository {
relPath: bookFiles.relPath,
format: bookFiles.format,
libraryFolderPath: libraryFolders.path,
libraryName: libraries.name,
organizationMode: libraries.organizationMode,
fileNamingPattern: libraries.fileNamingPattern,
bookFolderPath: books.folderPath,
@@ -92,6 +94,7 @@ export class BulkRenameRepository {
relPath: row.relPath,
format: row.format,
libraryFolderPath: row.libraryFolderPath,
libraryName: row.libraryName,
organizationMode: row.organizationMode,
fileNamingPattern: row.fileNamingPattern,
bookFolderPath: row.bookFolderPath,
@@ -17,6 +17,7 @@ export interface BookRenameData {
role: string;
};
libraryId: number;
libraryName: string;
libraryFolderId: number;
libraryFolderPath: string;
organizationMode: string;
@@ -57,6 +58,7 @@ export class FileRenameRepository {
libraryFolderId: bookFiles.libraryFolderId,
libraryFolderPath: libraryFolders.path,
libraryId: books.libraryId,
libraryName: libraries.name,
organizationMode: libraries.organizationMode,
fileRenameEnabled: libraries.fileRenameEnabled,
fileNamingPattern: libraries.fileNamingPattern,
@@ -95,6 +97,7 @@ export class FileRenameRepository {
role: row.role,
},
libraryId: row.libraryId,
libraryName: row.libraryName,
libraryFolderId: row.libraryFolderId,
libraryFolderPath: row.libraryFolderPath,
organizationMode: row.organizationMode,
@@ -109,7 +109,7 @@ 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);
const tokens = buildTokens(data.metadata, data.authors, originalStem, format, data.libraryName);
const sanitizeForCrossPlatform = await this.appSettings.isCrossPlatformPathSanitizationEnabled();
const resolvedRelPath = resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform });
@@ -134,7 +134,7 @@ 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);
const fileTokens = buildTokens(data.metadata, data.authors, fileOriginalStem, fileFormat, data.libraryName);
const fileResolvedRelPath = resolveUploadPath(pattern, fileTokens, fileFormat, { sanitizeForCrossPlatform });
let targetAbs: string;
@@ -66,6 +66,16 @@ describe('file-rename.utils', () => {
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,
@@ -13,9 +13,16 @@ export interface RenameMetadata {
seriesIndex: number | null;
}
export function buildTokens(metadata: RenameMetadata, authors: string[], originalStem: string, format: string): Record<string, string> {
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;
@@ -950,6 +950,7 @@ describe('KoreaderCatalogService', () => {
function makeManifestRow(overrides: Record<string, unknown> = {}) {
return {
id: 10,
libraryName: 'Main',
title: 'Dune',
subtitle: null,
authors: ['Frank Herbert'],
@@ -1107,5 +1108,35 @@ describe('KoreaderCatalogService', () => {
expect(result.items[0]!.files[0]!.devicePath).toBe('Dune/1 - Dune.epub');
});
it('resolves the library token so each library keeps its own subtree on the device', async () => {
const { service, opdsBookService } = makeService({
fileNamingPattern: '{library:upper}/<{series}/>{title}',
seriesFileNamingPattern: '',
standaloneFileNamingPattern: '',
});
opdsBookService.getBookManifestPage.mockResolvedValueOnce({
rows: [makeManifestRow(), makeManifestRow({ id: 11, libraryName: 'Comics', title: 'Sandman', seriesName: 'Sandman' })],
hasNext: false,
});
const result = await service.getBulkManifest(makeUser({ id: 7 }), makeQuery({ deviceId: 'device-1' }));
expect(result.items[0]!.files[0]!.devicePath).toBe('MAIN/Dune/Dune.epub');
expect(result.items[1]!.files[0]!.devicePath).toBe('COMICS/Sandman/Sandman.epub');
});
it('drops the library segment when the pattern makes it optional and the name is missing', async () => {
const { service, opdsBookService } = makeService({
fileNamingPattern: '<{library}/>{title}',
seriesFileNamingPattern: '',
standaloneFileNamingPattern: '',
});
opdsBookService.getBookManifestPage.mockResolvedValueOnce({ rows: [makeManifestRow({ libraryName: '' })], hasNext: false });
const result = await service.getBulkManifest(makeUser({ id: 7 }), makeQuery({ deviceId: 'device-1' }));
expect(result.items[0]!.files[0]!.devicePath).toBe('Dune.epub');
});
});
});
@@ -374,6 +374,7 @@ export class KoreaderCatalogService {
language: row.language ?? '',
publisher: row.publisher ?? '',
isbn: row.isbn13 ?? row.isbn10 ?? '',
library: row.libraryName,
originalFilename: basename(file.filename ?? row.title, `.${extension}`),
extension,
},
@@ -722,6 +723,7 @@ export class KoreaderCatalogService {
language: detail.language ?? '',
publisher: detail.publisher ?? '',
isbn: detail.isbn13 ?? detail.isbn10 ?? '',
library: detail.libraryName,
originalFilename: basename(file.filename ?? title, `.${extension}`),
extension,
},
@@ -262,7 +262,7 @@ 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);
const tokens = buildTokens(book.metadata, book.authors, originalStem, format, book.libraryName);
const resolvedRelPath = resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform });
if (!resolvedRelPath) {
@@ -119,6 +119,7 @@ export interface OpdsManifestFileRow {
export interface OpdsManifestBookRow {
id: number;
libraryName: string;
title: string;
subtitle: string | null;
authors: string[];
@@ -305,6 +306,7 @@ export class OpdsBookService {
.select({
id: books.id,
folderPath: books.folderPath,
libraryName: libraries.name,
title: bookMetadata.title,
subtitle: bookMetadata.subtitle,
seriesName: bookMetadata.seriesName,
@@ -316,6 +318,7 @@ export class OpdsBookService {
isbn13: bookMetadata.isbn13,
})
.from(books)
.innerJoin(libraries, eq(libraries.id, books.libraryId))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(inArray(books.id, bookIds)),
this.db
@@ -366,6 +369,7 @@ export class OpdsBookService {
return metaRows
.map((row) => ({
id: row.id,
libraryName: row.libraryName,
title: row.title ?? row.folderPath.split('/').pop() ?? 'Untitled',
subtitle: row.subtitle,
authors: authorsByBook.get(row.id) ?? [],
@@ -302,6 +302,12 @@ describe('KOReader device pattern', () => {
expect(resolveUploadPath(pattern, FULL, 'epub')).toBe('Series/Sprawl/1.00 - Neuromancer.epub');
expect(resolveUploadPath(pattern, { ...FULL, series: '', seriesIndex: '' }, 'epub')).toBe('Standalone/William Gibson - Neuromancer.epub');
});
it('keeps each library in its own subtree when the pattern starts with the library token', () => {
const pattern = '{library:upper}/<Series/{series}/|{authors:first}/>{title}';
expect(resolveUploadPath(pattern, FULL, 'epub')).toBe('BOOKS/Series/Sprawl/Neuromancer.epub');
expect(resolveUploadPath(pattern, { ...FULL, library: 'Comics', series: '' }, 'cbz')).toBe('COMICS/William Gibson/Neuromancer.cbz');
});
});
describe('resolveUploadPath', () => {
@@ -350,6 +356,22 @@ describe('resolveUploadPath', () => {
});
});
describe('library token', () => {
it('uses the library token in path position', () => {
expect(resolveUploadPath('{library}/{title}', FULL, 'epub')).toBe('Books/Neuromancer.epub');
});
it('drops an optional library segment when the book has no library name', () => {
expect(resolveUploadPath('<{library}/>{title}', { ...FULL, library: '' }, 'epub')).toBe('Neuromancer.epub');
});
it('sanitizes a library name containing path separators', () => {
expect(resolveUploadPath('{library}/{title}', { ...FULL, library: 'Comics/Manga' }, 'epub', { sanitizeForCrossPlatform: true })).toBe(
'Comics_Manga/Neuromancer.epub',
);
});
});
describe('empty / null resolution', () => {
it('returns null when the pattern resolves to an empty string', () => {
expect(resolveUploadPath('{series}', PARTIAL, 'epub')).toBeNull();
+4 -3
View File
@@ -325,7 +325,7 @@ export class UploadService {
}
private async resolveDestination(
library: { fileNamingPattern?: string | null; organizationMode?: string | null },
library: { name?: string | null; fileNamingPattern?: string | null; organizationMode?: string | null },
libraryFolderPath: string,
tempPath: string,
filename: string,
@@ -340,7 +340,7 @@ export class UploadService {
if (pattern) {
const stem = basename(filename, extname(filename));
const tokens = await this.buildPatternTokens(tempPath, format, stem);
const tokens = await this.buildPatternTokens(tempPath, format, stem, library.name);
if (library.organizationMode === 'book_per_file') {
const resolvedFilename = resolveDownloadFilename(pattern, tokens, format, { sanitizeForCrossPlatform });
if (resolvedFilename) {
@@ -379,8 +379,9 @@ export class UploadService {
return { absolutePath, bookFolderPath, relPath };
}
private async buildPatternTokens(tempPath: string, format: string, stem: string): Promise<Record<string, string>> {
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;
const event = 'upload.pattern_tokens';
const startedAt = Date.now();