mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -82,4 +82,13 @@ describe('useKoreaderFileNamingDrafts', () => {
|
||||
})
|
||||
expect(state.normalizedDeviceDraft('device-1').pattern).toBe('')
|
||||
})
|
||||
|
||||
it('previews the library token so a device pattern can be split per library', () => {
|
||||
const devices = ref([device()])
|
||||
const accountPattern = ref('{library}/<{series}/>{title}')
|
||||
const state = useKoreaderFileNamingDrafts(devices, accountPattern)
|
||||
|
||||
expect(state.defaultPreview.value).toBe('Books/Sprawl/Neuromancer.epub')
|
||||
expect(state.deviceStandalonePreview('device-1')).toBe('Books/Neuromancer.epub')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,6 +33,7 @@ const TOKEN_DESCRIPTIONS = computed<Record<PatternToken, string>>(() => ({
|
||||
publisher: t('settings.reader.fileNaming.tokenPublisher'),
|
||||
isbn: t('settings.reader.fileNaming.tokenIsbn'),
|
||||
language: t('settings.reader.fileNaming.tokenLanguage'),
|
||||
library: t('settings.reader.fileNaming.tokenLibrary'),
|
||||
originalFilename: t('settings.reader.fileNaming.tokenOriginalFilename'),
|
||||
extension: t('settings.reader.fileNaming.tokenExtension'),
|
||||
}))
|
||||
|
||||
@@ -1849,6 +1849,7 @@
|
||||
"tokenPublisher": "Publisher",
|
||||
"tokenIsbn": "ISBN-13",
|
||||
"tokenLanguage": "Language",
|
||||
"tokenLibrary": "Library name",
|
||||
"tokenOriginalFilename": "Original filename (without extension)",
|
||||
"tokenExtension": "File extension (without dot)",
|
||||
"patternHelpIntro": "Patterns use tokens like {titleToken} and {authorsToken}, plus modifiers and optional segments.",
|
||||
|
||||
@@ -15,6 +15,7 @@ export const EXAMPLE_PATTERN_METADATA: Record<string, string> = {
|
||||
language: "English",
|
||||
publisher: "Ace Books",
|
||||
isbn: "9780441569595",
|
||||
library: "Books",
|
||||
originalFilename: "neuromancer",
|
||||
extension: "epub",
|
||||
};
|
||||
@@ -29,6 +30,7 @@ export const PATTERN_TOKENS = [
|
||||
{ token: "publisher", description: "Publisher" },
|
||||
{ token: "isbn", description: "ISBN-13" },
|
||||
{ token: "language", description: "Language" },
|
||||
{ token: "library", description: "Library name" },
|
||||
{ token: "originalFilename", description: "Original filename (without extension)" },
|
||||
{ token: "extension", description: "File extension (without dot)" },
|
||||
] as const;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user