Add per-library customizable file naming patterns

This commit is contained in:
aditya.chandel
2025-08-10 11:26:57 -06:00
committed by Aditya Chandel
parent 1f83dfa85d
commit 978bd05b1e
20 changed files with 394 additions and 342 deletions
@@ -11,6 +11,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/libraries")
@@ -72,4 +73,13 @@ public class LibraryController {
libraryService.rescanLibrary(libraryId);
return ResponseEntity.noContent().build();
}
@PatchMapping("/{libraryId}/file-naming-pattern")
@CheckLibraryAccess(libraryIdParam = "libraryId")
@PreAuthorize("@securityUtil.canManipulateLibrary() or @securityUtil.isAdmin()")
public ResponseEntity<Library> setFileNamingPattern(@PathVariable long libraryId, @RequestBody Map<String, String> body) {
String pattern = body.get("fileNamingPattern");
Library updated = libraryService.setFileNamingPattern(libraryId, pattern);
return ResponseEntity.ok(updated);
}
}
@@ -14,6 +14,7 @@ public class Library {
private String name;
private Sort sort;
private String icon;
private String fileNamingPattern;
private boolean watch;
private List<LibraryPath> paths;
}
@@ -7,7 +7,6 @@ import java.util.List;
@Data
public class BookdropFinalizeRequest {
private String uploadPattern;
private Boolean selectAll;
private List<Long> excludedIds;
private List<BookdropFinalizeFile> files;
@@ -37,4 +37,7 @@ public class LibraryEntity {
private boolean watch;
private String icon;
@Column(name = "file_naming_pattern")
private String fileNamingPattern;
}
@@ -22,6 +22,7 @@ import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.repository.BookdropFileRepository;
import com.adityachandel.booklore.repository.LibraryRepository;
import com.adityachandel.booklore.service.NotificationService;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry;
import com.adityachandel.booklore.service.metadata.MetadataRefreshService;
@@ -70,6 +71,7 @@ public class BookDropService {
private final AppProperties appProperties;
private final BookdropFileMapper mapper;
private final ObjectMapper objectMapper;
AppSettingService appSettingService;
public BookdropFileNotification getFileNotificationSummary() {
long pendingCount = bookdropFileRepository.countByStatus(BookdropFileEntity.Status.PENDING_REVIEW);
@@ -127,7 +129,7 @@ public class BookDropService {
failedCount.incrementAndGet();
continue;
}
processFile(file, metadataById.get(id), defaultLibraryId, defaultPathId, request.getUploadPattern(), results, failedCount);
processFile(file, metadataById.get(id), defaultLibraryId, defaultPathId, results, failedCount);
}
}
} else {
@@ -156,7 +158,7 @@ public class BookDropService {
failedCount.incrementAndGet();
continue;
}
processFile(file, metadataById.get(id), defaultLibraryId, defaultPathId, request.getUploadPattern(), results, failedCount);
processFile(file, metadataById.get(id), defaultLibraryId, defaultPathId, results, failedCount);
}
}
}
@@ -188,7 +190,6 @@ public class BookDropService {
BookdropFinalizeRequest.BookdropFinalizeFile fileReq,
Long defaultLibraryId,
Long defaultPathId,
String uploadPattern,
BookdropFinalizeResult results,
AtomicInteger failedCount
) {
@@ -217,13 +218,7 @@ public class BookDropService {
log.debug("Processing fileId={}, fileName={} with default metadata, libraryId={}, pathId={}", fileEntity.getId(), fileEntity.getFileName(), libraryId, pathId);
}
BookdropFileResult result = moveFile(
libraryId,
pathId,
uploadPattern,
metadata,
fileEntity
);
BookdropFileResult result = moveFile(libraryId, pathId, metadata, fileEntity);
results.getResults().add(result);
if (!result.isSuccess()) {
@@ -241,7 +236,7 @@ public class BookDropService {
}
}
private BookdropFileResult moveFile(long libraryId, long pathId, String filePattern, BookMetadata metadata, BookdropFileEntity bookdropFile) throws Exception {
private BookdropFileResult moveFile(long libraryId, long pathId, BookMetadata metadata, BookdropFileEntity bookdropFile) throws Exception {
LibraryEntity library = libraryRepository.findById(libraryId)
.orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId));
@@ -250,6 +245,11 @@ public class BookDropService {
.findFirst()
.orElseThrow(() -> ApiError.INVALID_LIBRARY_PATH.createException(libraryId));
String filePattern = library.getFileNamingPattern();
if (filePattern == null || filePattern.isBlank()) {
filePattern = appSettingService.getAppSettings().getUploadPattern();
}
if (filePattern.endsWith("/") || filePattern.endsWith("\\")) {
filePattern += "{currentFilename}";
}
@@ -231,4 +231,10 @@ public class LibraryService {
List<BookEntity> bookEntities = bookRepository.findAllWithMetadataByLibraryId(libraryId);
return bookEntities.stream().map(bookMapper::toBook).toList();
}
public Library setFileNamingPattern(long libraryId, String pattern) {
LibraryEntity library = libraryRepository.findById(libraryId).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId));
library.setFileNamingPattern(pattern);
return libraryMapper.toLibrary(libraryRepository.save(library));
}
}
@@ -0,0 +1,3 @@
ALTER TABLE library
ADD COLUMN IF NOT EXISTS file_naming_pattern VARCHAR(1000);
@@ -5,6 +5,7 @@ export interface Library {
name: string;
icon: string;
watch: boolean;
fileNamingPattern?: string;
sort?: SortOption;
paths: LibraryPath[];
}
@@ -122,14 +122,27 @@ export class LibraryService {
getLibraryPathById(pathId: number): string | undefined {
const libraries = this.libraryStateSubject.value.libraries || [];
for (const library of libraries) {
const match = library.paths.find(p => p.id === pathId);
if (match) {
return match.path;
}
}
return undefined;
}
updateLibraryFileNamingPattern(libraryId: number, fileNamingPattern: string): Observable<Library> {
return this.http.patch<Library>(`${this.url}/${libraryId}/file-naming-pattern`, { fileNamingPattern }).pipe(
map(updatedLibrary => {
const currentState = this.libraryStateSubject.value;
const updatedLibraries = currentState.libraries ? currentState.libraries.map(existingLibrary =>
existingLibrary.id === updatedLibrary.id ? updatedLibrary : existingLibrary) : [updatedLibrary];
this.libraryStateSubject.next({...currentState, libraries: updatedLibraries});
return updatedLibrary;
}),
catchError(error => {
throw error;
})
);
}
}
@@ -33,25 +33,6 @@
}
@if (bookdropFileUis.length !== 0) {
<div class="flex gap-2 items-center px-1 pb-4 w-full">
<label for="uploadPatternInput" class="text-sm text-gray-300 font-medium">File Naming Pattern:</label>
<input
id="uploadPatternInput"
fluid
class="min-w-[20rem] max-w-[45.5rem]"
pSize="small"
type="text"
pInputText
[(ngModel)]="uploadPattern"
placeholder="e.g., {title} - {authors}"/>
<i
class="pi pi-info-circle text-blue-500 cursor-help"
pTooltip="Sets the filename pattern used when moving files from bookdrop folder to the library, using metadata placeholders like {title}, {authors}, etc."
tooltipPosition="top"
style="font-size: 1rem; margin-left: 0.25rem;"></i>
</div>
<div class="flex justify-between items-center gap-4 px-1">
<div class="flex gap-4 items-center">
@@ -126,7 +107,7 @@
}
</div>
<div class="flex-1 overflow-y-auto px-6 space-y-2 pb-4">
<div class="flex-1 overflow-y-auto px-6 pt-4 space-y-2 pb-4">
@if (bookdropFileUis.length === 0) {
<div class="h-full w-full flex items-center justify-center text-gray-400 italic py-8">
No bookdrop files to review.
@@ -11,7 +11,6 @@ import {DropdownModule} from 'primeng/dropdown';
import {FormControl, FormGroup, FormsModule} from '@angular/forms';
import {Button} from 'primeng/button';
import {Select} from 'primeng/select';
import {InputText} from 'primeng/inputtext';
import {Tooltip} from 'primeng/tooltip';
import {Divider} from 'primeng/divider';
import {ConfirmationService, MessageService} from 'primeng/api';
@@ -55,7 +54,6 @@ export interface BookdropFileUI {
BookdropFileMetadataPickerComponent,
Tooltip,
Divider,
InputText,
Checkbox,
NgStyle,
NgClass,
@@ -425,7 +423,6 @@ export class BookdropFileReviewComponent implements OnInit {
});
const payload: BookdropFinalizePayload = {
uploadPattern: this.uploadPattern,
selectAll: this.selectAllAcrossPages,
excludedIds: this.selectAllAcrossPages ? Array.from(this.excludedFiles) : undefined,
defaultLibraryId: this.defaultLibraryId ? Number(this.defaultLibraryId) : undefined,
@@ -5,13 +5,11 @@ import {BookMetadata} from '../book/model/book.model';
import {API_CONFIG} from '../config/api-config';
export enum BookdropFileStatus {
PENDING_REVIEW = 'PENDING_REVIEW',
ACCEPTED = 'ACCEPTED',
REJECTED = 'REJECTED',
}
export interface BookdropFinalizePayload {
uploadPattern: string;
selectAll?: boolean;
excludedIds?: number[];
defaultLibraryId?: number;
@@ -0,0 +1,262 @@
<div class="w-full h-[calc(100dvh-10.5rem)] md:h-[calc(100dvh-11.65rem)] overflow-y-auto border rounded-lg enclosing-container">
<div class="pt-8 px-4">
<p class="text-lg flex items-center gap-2">
File Naming Patterns
<i
class="pi pi-info-circle text-sky-600"
pTooltip="Define custom naming patterns for uploaded files and for moving files within your library. Use metadata placeholders to automate organization."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</p>
<div class="flex flex-col space-y-6 p-4 m-4 custom-border">
<div>
<h2 class="mb-2 text-lg">Default File Naming Pattern:</h2>
<p class="mb-3 text-sm text-gray-400">
Define the default naming pattern for files. This pattern applies to all libraries unless overridden.
</p>
<div class="flex gap-4 items-center">
<input type="text" [(ngModel)]="defaultPattern" class="p-inputtext w-[700px]" placeholder="e.g., {title} - {authors}" (input)="onDefaultPatternChange(defaultPattern)"/>
<p-button label="Save" (onClick)="savePatterns()" severity="primary" outlined="true" [disabled]="!!defaultErrorMessage"></p-button>
</div>
@if (defaultErrorMessage) {
<div class="text-red-500 mt-1">{{ defaultErrorMessage }}</div>
}
<div class="flex gap-2 pt-2">
<p class="text-zinc-400">Preview:</p>
<p class="text-green-500">{{ generateDefaultPreview() }}</p>
</div>
</div>
<div>
<h2 class="py-2 text-lg">Overrides for Libraries:</h2>
<p class="mb-3 text-sm text-gray-400">
Define custom naming patterns for specific libraries. Leave empty to use the default pattern.
</p>
@for (library of libraries; track library.id) {
<div class="flex flex-col gap-2 p-3">
<div class="flex gap-3 items-center">
<i [class]="'pi pi-' + library.icon"></i>
<span class="min-w-[100px] font-medium">{{ library.name }}</span>
<input
type="text"
[(ngModel)]="library.fileNamingPattern"
class="p-inputtext flex-1"
placeholder="Leave empty to use default pattern"
(input)="onLibraryPatternChange(library)"/>
<p-button
label="Clear"
(onClick)="clearLibraryPattern(library)"
severity="secondary"
outlined="true"
size="small"
[disabled]="!library.fileNamingPattern">
</p-button>
</div>
<div class="ml-8 flex gap-2">
<p class="text-sm text-zinc-400">Preview:</p>
<p class="text-sm text-green-500">{{ generateLibraryPreview(library) }}</p>
</div>
</div>
}
<div class="flex justify-end">
<p-button label="Save All Library Patterns" (onClick)="saveLibraryPatterns()" severity="primary" outlined="true"></p-button>
</div>
</div>
<div>
<p-divider></p-divider>
<p class="pt-4">Available Placeholders:</p>
<p class="text-sm text-gray-400 mb-2 mt-2">
Use placeholders to dynamically insert book metadata into file names and folder paths. These will be replaced with actual values when uploading or moving files.
You can also wrap parts of the pattern in <code>{{ '{{<...>}' }}</code> to make them optional, if any placeholder inside is missing, that section will be omitted entirely.
</p>
<div class="mt-2">
<div class="mx-4 my-2 flex flex-wrap gap-4">
<ul class="list-disc pl-4 min-w-[250px] text-gray-300">
<li><code class="text-orange-400">{{ '{title}' }}</code> Book title</li>
<li><code class="text-orange-400">{{ '{authors}' }}</code> Author(s)</li>
<li><code class="text-orange-400">{{ '{year}' }}</code> Full year (e.g. 2025)</li>
<li><code class="text-orange-400">{{ '{series}' }}</code> Series name</li>
<li><code class="text-orange-400">{{ '{seriesIndex}' }}</code> Series index (e.g. 01)</li>
<li><code class="text-orange-400">{{ '{language}' }}</code> Language code (e.g. en)</li>
<li><code class="text-orange-400">{{ '{publisher}' }}</code> Publisher name</li>
<li><code class="text-orange-400">{{ '{isbn}' }}</code> ISBN number</li>
<li><code class="text-orange-400">{{ '{currentFilename}' }}</code> Original file name (with extension)</li>
</ul>
<p-divider layout="vertical"></p-divider>
<div class="text-sm p-3 min-w-[250px]">
<p class="mb-1 font-bold text-gray-200 text">Optional blocks</p>
<p class="text-gray-300">
Surround parts of your pattern with angle brackets
<code class="text-blue-400">{{ '{<...>}' }}</code>
to make them optional. <br> If any placeholder inside the block has no value, the whole block is excluded.
</p>
<p class="mt-2 font-semibold text-gray-300">Example:</p>
<p><code>{{ '{<{seriesIndex} - >{title}' }}</code></p>
<p class="pl-4 mt-1 text-gray-300"><code>01 - Dune</code> (if <code>{{ '{seriesIndex}' }}</code> exists)</p>
<p class="pl-4 text-gray-300"><code>Dune</code> (if <code>{{ '{seriesIndex}' }}</code> is missing)</p>
</div>
</div>
</div>
<p-divider></p-divider>
<div class="mt-6">
<p class="mb-4">Example Patterns & Output:</p>
<div class="px-4 text-sm space-y-4">
<p class="text-base font-semibold text-gray-200 mt-6 mb-2">Examples with Full Metadata</p>
<p class="text-gray-400 mb-4">
<span class="block">title: <code>Harry Potter and the Sorcerer's Stone</code></span>
<span class="block">authors: <code>J.K. Rowling</code></span>
<span class="block">series: <code>Harry Potter</code></span>
<span class="block">seriesIndex: <code>01</code></span>
<span class="block">year: <code>1997</code></span>
<span class="block">currentFilename: <code>harry1_original.epub</code></span>
</p>
<div>
<p class="mb-1"><strong class="text-gray-300">Basic pattern:</strong><code class="text-gray-400"> {{ '{authors} - {title}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> J.K. Rowling - Harry Potter and the Sorcerer's Stone.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Pattern with punctuation:</strong><code class="text-gray-400"> {{ '{title}: {series}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Harry Potter and the Sorcerer's Stone: Harry Potter.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Series in folder path:</strong><code class="text-gray-400"> {{ '{authors}/{series}/{seriesIndex} - {title}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> J.K. Rowling/Harry Potter/01 - Harry Potter and the Sorcerer's Stone.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Folder only:</strong><code class="text-gray-400"> {{ '{title}/' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> /Harry Potter and the Sorcerer's Stone/harry1_original.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Absolute path:</strong><code class="text-gray-400"> {{ '/{authors}/{title}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> /J.K. Rowling/Harry Potter and the Sorcerer's Stone.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Reuse original filename in path:</strong><code class="text-gray-400"> {{ '{authors}/{series}/{currentFilename}' }}</code>
</p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> J.K. Rowling/Harry Potter/harry1_original.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Preserve original filename only:</strong><code class="text-gray-400"> {{ '{currentFilename}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> harry1_original.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Empty pattern (defaults to current filename):</strong><code class="text-gray-400"> {{ '' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> harry1_original.epub</code></p>
</div>
</div>
<div class="px-4 text-sm space-y-4 mt-10">
<p class="text-base font-semibold text-gray-200 mb-2">Examples with Missing Optional Fields</p>
<p class="text-gray-400 mb-4">
<span class="block">title: <code>Project Hail Mary</code></span>
<span class="block">authors: <code>Andy Weir</code></span>
<span class="block">year: <code>2021</code></span>
<span class="block">series: <code>(not provided)</code></span>
<span class="block">seriesIndex: <code>(not provided)</code></span>
<span class="block">currentFilename: <code>project_hail_mary_final.epub</code></span>
</p>
<div>
<p class="mb-1"><strong class="text-gray-300">Pattern with optional blocks:</strong><code
class="text-gray-400"> {{ '{authors}/<{series}/><{seriesIndex}. >{title}< ({year})>' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/Project Hail Mary (2021).epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Fallback with seriesIndex and dash:</strong><code
class="text-gray-400"> {{ '<{seriesIndex}. >{title}< - {authors}>' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Project Hail Mary - Andy Weir.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Brackets & punctuation fallback:</strong><code class="text-gray-400"> {{ '<[{series}] >{title} - {authors}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Project Hail Mary - Andy Weir.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Series + punctuation fallback:</strong><code class="text-gray-400"> {{ '<{series}: >{title} by {authors}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Project Hail Mary by Andy Weir.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Only folders, no filename:</strong><code class="text-gray-400"> {{ '{authors}/' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Deep nested folders:</strong><code class="text-gray-400"> {{ '{authors}/books/<{series}/>{title}/' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/books/Project Hail Mary/project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">With static folder prefix:</strong><code class="text-gray-400"> {{ 'Books/<{series}/>{authors} - {title}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Books/Andy Weir - Project Hail Mary.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Use original filename in path:</strong><code
class="text-gray-400"> {{ '{authors}/books/<{series}/>{currentFilename}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/books/project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Prefix current filename manually:</strong><code class="text-gray-400"> {{ '{authors}/final__{currentFilename}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/final__project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Filename preserved, renamed folder:</strong><code class="text-gray-400"> {{ '{title}/source/{currentFilename}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Project Hail Mary/source/project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Use current filename with year suffix:</strong><code
class="text-gray-400"> {{ '{authors}/{year}__{currentFilename}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/2021__project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Fallback with optional + extras folder:</strong><code
class="text-gray-400"> {{ '<{series}/>{authors}/extras/{currentFilename}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/extras/project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Archive structure using original name:</strong><code
class="text-gray-400"> {{ 'archive/<{series}/>{year}/{currentFilename}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> archive/2021/project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Structured folder + renamed file:</strong><code
class="text-gray-400"> {{ '{authors}/<{series}/>{title} ({year}) by {authors}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/Project Hail Mary (2021) by Andy Weir.epub</code></p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -1,3 +1,7 @@
.enclosing-container {
border-color: var(--p-content-border-color);
}
.custom-border {
border: 1px solid var(--border-color);
border-radius: var(--card-border);
@@ -2,21 +2,23 @@ import {Component, inject, OnInit} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {Button} from 'primeng/button';
import {MessageService} from 'primeng/api';
import {AppSettingsService} from '../../../core/service/app-settings.service';
import {Observable} from 'rxjs';
import {AppSettingKey, AppSettings} from '../../../core/model/app-settings.model';
import {filter, take} from 'rxjs/operators';
import {AppSettingsService} from '../../core/service/app-settings.service';
import {forkJoin, Observable, of} from 'rxjs';
import {AppSettingKey, AppSettings} from '../../core/model/app-settings.model';
import {catchError, filter, take} from 'rxjs/operators';
import {Divider} from 'primeng/divider';
import {Tooltip} from 'primeng/tooltip';
import {Library} from '../../book/model/library.model';
import {LibraryService} from '../../book/service/library.service';
@Component({
selector: 'app-file-upload-pattern',
templateUrl: './file-upload-pattern.component.html',
selector: 'app-file-naming-pattern',
templateUrl: './file-naming-pattern.component.html',
standalone: true,
imports: [FormsModule, Button, Divider, Tooltip],
styleUrls: ['./file-upload-pattern.component.scss'],
styleUrls: ['./file-naming-pattern.component.scss'],
})
export class FileUploadPatternComponent implements OnInit {
export class FileNamingPatternComponent implements OnInit {
readonly exampleMetadata: Record<string, string> = {
title: 'The Fellowship of the Ring',
authors: 'J.R.R. Tolkien',
@@ -28,14 +30,13 @@ export class FileUploadPatternComponent implements OnInit {
isbn: '9780618574940',
};
uploadPattern = '';
movePattern = '';
uploadErrorMessage = '';
moveErrorMessage = '';
defaultPattern = '';
libraries: Library[] = [];
defaultErrorMessage = '';
private appSettingsService = inject(AppSettingsService);
private messageService = inject(MessageService);
private libraryService = inject(LibraryService);
appSettings$: Observable<AppSettings | null> = this.appSettingsService.appSettings$;
@@ -43,8 +44,13 @@ export class FileUploadPatternComponent implements OnInit {
this.appSettings$
.pipe(filter((settings) => settings != null), take(1))
.subscribe((settings) => {
this.uploadPattern = settings?.uploadPattern ?? '';
this.movePattern = settings?.movePattern ?? '';
this.defaultPattern = settings?.uploadPattern ?? '';
});
this.libraryService.libraryState$
.pipe(filter(state => state.loaded && !!state.libraries))
.subscribe(state => {
this.libraries = state.libraries ?? [];
});
}
@@ -65,77 +71,77 @@ export class FileUploadPatternComponent implements OnInit {
return hasExtension ? path : path + ext;
}
generateUploadPreview(): string {
let path = this.replacePlaceholders(this.uploadPattern || '', this.exampleMetadata);
private generatePreview(pattern: string): string {
let path = this.replacePlaceholders(pattern || '', this.exampleMetadata);
if (!path) return '/original_filename.pdf';
// If pattern ends with slash, append original filename
if (path.endsWith('/')) {
return path + 'original_filename.pdf';
}
// Replace {originalFilename} placeholder if present
if (path.endsWith('/')) return path + 'original_filename.pdf';
if (path.includes('{originalFilename}')) {
path = path.replace('{originalFilename}', 'original_filename.pdf');
return path.startsWith('/') ? path : `/${path}`;
}
path = this.appendExtensionIfMissing(path);
return path.startsWith('/') ? path : `/${path}`;
}
generateMovePreview(): string {
let path = this.replacePlaceholders(this.movePattern || '', this.exampleMetadata);
generateDefaultPreview(): string {
return this.generatePreview(this.defaultPattern);
}
if (!path) return '/original_filename.pdf';
if (path.endsWith('/')) {
return path + 'original_filename.pdf';
}
if (path.includes('{originalFilename}')) {
path = path.replace('{originalFilename}', 'original_filename.pdf');
return path.startsWith('/') ? path : `/${path}`;
}
path = this.appendExtensionIfMissing(path);
return path.startsWith('/') ? path : `/${path}`;
generateLibraryPreview(library: Library): string {
return this.generatePreview(library.fileNamingPattern || this.defaultPattern);
}
validatePattern(pattern: string): boolean {
// Allow letters, numbers, whitespace, common punctuation and placeholders syntax
const validPatternRegex = /^[\w\s\-{}\/().<>.,:'"]*$/;
return validPatternRegex.test(pattern);
}
onUploadPatternChange(pattern: string): void {
this.uploadPattern = pattern;
this.uploadErrorMessage = this.validatePattern(pattern) ? '' : 'Pattern contains invalid characters.';
onDefaultPatternChange(pattern: string): void {
this.defaultPattern = pattern;
this.defaultErrorMessage = this.validatePattern(pattern) ? '' : 'Pattern contains invalid characters.';
}
onMovePatternChange(pattern: string): void {
this.movePattern = pattern;
this.moveErrorMessage = this.validatePattern(pattern) ? '' : 'Pattern contains invalid characters.';
onLibraryPatternChange(library: Library): void {
// Optionally add per-library validation here
}
clearLibraryPattern(library: Library): void {
library.fileNamingPattern = '';
}
savePatterns(): void {
if (this.uploadErrorMessage || this.moveErrorMessage) {
if (this.defaultErrorMessage) {
this.showMessage('error', 'Invalid Pattern', 'Please fix errors before saving.');
return;
}
this.appSettingsService
.saveSettings([
{key: AppSettingKey.UPLOAD_FILE_PATTERN, newValue: this.uploadPattern},
{key: AppSettingKey.MOVE_FILE_PATTERN, newValue: this.movePattern},
{ key: AppSettingKey.UPLOAD_FILE_PATTERN, newValue: this.defaultPattern },
])
.subscribe({
next: () => this.showMessage('success', 'Settings Saved', 'The patterns were successfully saved!'),
next: () => this.showMessage('success', 'Settings Saved', 'The default pattern was successfully saved!'),
error: () => this.showMessage('error', 'Error', 'There was an error saving the settings.'),
});
}
saveLibraryPatterns(): void {
const patchRequests = this.libraries.map(library =>
this.libraryService.updateLibraryFileNamingPattern(library.id!, library.fileNamingPattern || '').pipe(
catchError(() => of(null))
)
);
forkJoin(patchRequests).subscribe(results => {
const failures = results.filter(result => result === null);
if (failures.length === 0) {
this.showMessage('success', 'Library Patterns Saved', 'Library-specific patterns were successfully saved!');
} else {
this.showMessage('error', 'Error', `Failed to save ${failures.length} library pattern(s).`);
}
});
}
private showMessage(severity: 'success' | 'error', summary: string, detail: string): void {
this.messageService.add({severity, summary, detail});
this.messageService.add({ severity, summary, detail });
}
}
@@ -1,235 +0,0 @@
<p class="text-lg flex items-center gap-2">
File Naming Patterns
<i
class="pi pi-info-circle text-sky-600"
pTooltip="Define custom naming patterns for uploaded files and for moving files within your library. Use metadata placeholders to automate organization."
tooltipPosition="right"
style="cursor: pointer;">
</i>
</p>
<div class="flex flex-col space-y-6 p-4 m-4 custom-border">
<div>
<h2 class="mb-3 text-lg">Enter Upload File Naming Pattern:</h2>
<p class="mb-3 text-sm text-gray-400">
Define how newly uploaded files are named and organized using book metadata.
</p>
<div class="flex gap-4 items-center">
<input type="text" [(ngModel)]="uploadPattern" class="p-inputtext w-[700px]" placeholder="e.g., {title} - {authors}" (input)="onUploadPatternChange(uploadPattern)"/>
<p-button label="Save" (onClick)="savePatterns()" severity="primary" outlined="true" [disabled]="!!uploadErrorMessage || !!moveErrorMessage"></p-button>
</div>
@if (uploadErrorMessage) {
<div class="text-red-500 mt-1">{{ uploadErrorMessage }}</div>
}
<h3 class="mt-4 mb-2">Upload Pattern Preview:</h3>
<p class="text-lg text-green-500">{{ generateUploadPreview() }}</p>
</div>
<div>
<h2 class="mb-3 text-lg">Enter File Move Naming Pattern:</h2>
<p class="mb-3 text-sm text-gray-400">
Specify how book files should be renamed and relocated using metadata such as title, authors, or series. This pattern is applied when using the Move Files feature.
</p>
<div class="flex gap-4 items-center">
<input type="text" [(ngModel)]="movePattern" class="p-inputtext w-[700px]" placeholder="e.g., {authors}/<{series}/><{seriesIndex}. >{title} - {authors}< ({year})>.pdf"
(input)="onMovePatternChange(movePattern)"/>
<p-button label="Save" (onClick)="savePatterns()" severity="primary" outlined="true" [disabled]="!!uploadErrorMessage || !!moveErrorMessage"></p-button>
</div>
@if (moveErrorMessage) {
<div class="text-red-500 mt-1">{{ moveErrorMessage }}</div>
}
<h3 class="mt-4 mb-2">Move Pattern Preview:</h3>
<p class="text-lg text-green-500">{{ generateMovePreview() }}</p>
<p-divider></p-divider>
<p class="pt-4">Available Placeholders:</p>
<p class="text-sm text-gray-400 mb-2 mt-2">
Use placeholders to dynamically insert book metadata into file names and folder paths. These will be replaced with actual values when uploading or moving files.
You can also wrap parts of the pattern in <code>{{ '{{<...>}' }}</code> to make them optional, if any placeholder inside is missing, that section will be omitted entirely.
</p>
<div class="mt-2">
<div class="mx-4 my-2 flex flex-wrap gap-4">
<ul class="list-disc pl-4 min-w-[250px] text-gray-300">
<li><code class="text-orange-400">{{ '{title}' }}</code> Book title</li>
<li><code class="text-orange-400">{{ '{authors}' }}</code> Author(s)</li>
<li><code class="text-orange-400">{{ '{year}' }}</code> Full year (e.g. 2025)</li>
<li><code class="text-orange-400">{{ '{series}' }}</code> Series name</li>
<li><code class="text-orange-400">{{ '{seriesIndex}' }}</code> Series index (e.g. 01)</li>
<li><code class="text-orange-400">{{ '{language}' }}</code> Language code (e.g. en)</li>
<li><code class="text-orange-400">{{ '{publisher}' }}</code> Publisher name</li>
<li><code class="text-orange-400">{{ '{isbn}' }}</code> ISBN number</li>
<li><code class="text-orange-400">{{ '{currentFilename}' }}</code> Original file name (with extension)</li>
</ul>
<p-divider layout="vertical"></p-divider>
<div class="text-sm p-3 min-w-[250px]">
<p class="mb-1 font-bold text-gray-200 text">Optional blocks</p>
<p class="text-gray-300">
Surround parts of your pattern with angle brackets
<code class="text-blue-400">{{ '{<...>}' }}</code>
to make them optional. <br> If any placeholder inside the block has no value, the whole block is excluded.
</p>
<p class="mt-2 font-semibold text-gray-300">Example:</p>
<p><code>{{ '{<{seriesIndex} - >{title}' }}</code></p>
<p class="pl-4 mt-1 text-gray-300"><code>01 - Dune</code> (if <code>{{ '{seriesIndex}' }}</code> exists)</p>
<p class="pl-4 text-gray-300"><code>Dune</code> (if <code>{{ '{seriesIndex}' }}</code> is missing)</p>
</div>
</div>
</div>
<p-divider></p-divider>
<div class="mt-6">
<p class="mb-4">Example Patterns & Output:</p>
<div class="px-4 text-sm space-y-4">
<p class="text-base font-semibold text-gray-200 mt-6 mb-2">Examples with Full Metadata</p>
<p class="text-gray-400 mb-4">
<span class="block">title: <code>Harry Potter and the Sorcerer's Stone</code></span>
<span class="block">authors: <code>J.K. Rowling</code></span>
<span class="block">series: <code>Harry Potter</code></span>
<span class="block">seriesIndex: <code>01</code></span>
<span class="block">year: <code>1997</code></span>
<span class="block">currentFilename: <code>harry1_original.epub</code></span>
</p>
<div>
<p class="mb-1"><strong class="text-gray-300">Basic pattern:</strong><code class="text-gray-400"> {{ '{authors} - {title}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> J.K. Rowling - Harry Potter and the Sorcerer's Stone.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Pattern with punctuation:</strong><code class="text-gray-400"> {{ '{title}: {series}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Harry Potter and the Sorcerer's Stone: Harry Potter.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Series in folder path:</strong><code class="text-gray-400"> {{ '{authors}/{series}/{seriesIndex} - {title}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> J.K. Rowling/Harry Potter/01 - Harry Potter and the Sorcerer's Stone.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Folder only:</strong><code class="text-gray-400"> {{ '{title}/' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> /Harry Potter and the Sorcerer's Stone/harry1_original.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Absolute path:</strong><code class="text-gray-400"> {{ '/{authors}/{title}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> /J.K. Rowling/Harry Potter and the Sorcerer's Stone.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Reuse original filename in path:</strong><code class="text-gray-400"> {{ '{authors}/{series}/{currentFilename}' }}</code>
</p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> J.K. Rowling/Harry Potter/harry1_original.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Preserve original filename only:</strong><code class="text-gray-400"> {{ '{currentFilename}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> harry1_original.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Empty pattern (defaults to current filename):</strong><code class="text-gray-400"> {{ '' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> harry1_original.epub</code></p>
</div>
</div>
<div class="px-4 text-sm space-y-4 mt-10">
<p class="text-base font-semibold text-gray-200 mb-2">Examples with Missing Optional Fields</p>
<p class="text-gray-400 mb-4">
<span class="block">title: <code>Project Hail Mary</code></span>
<span class="block">authors: <code>Andy Weir</code></span>
<span class="block">year: <code>2021</code></span>
<span class="block">series: <code>(not provided)</code></span>
<span class="block">seriesIndex: <code>(not provided)</code></span>
<span class="block">currentFilename: <code>project_hail_mary_final.epub</code></span>
</p>
<div>
<p class="mb-1"><strong class="text-gray-300">Pattern with optional blocks:</strong><code
class="text-gray-400"> {{ '{authors}/<{series}/><{seriesIndex}. >{title}< ({year})>' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/Project Hail Mary (2021).epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Fallback with seriesIndex and dash:</strong><code
class="text-gray-400"> {{ '<{seriesIndex}. >{title}< - {authors}>' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Project Hail Mary - Andy Weir.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Brackets & punctuation fallback:</strong><code class="text-gray-400"> {{ '<[{series}] >{title} - {authors}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Project Hail Mary - Andy Weir.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Series + punctuation fallback:</strong><code class="text-gray-400"> {{ '<{series}: >{title} by {authors}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Project Hail Mary by Andy Weir.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Only folders, no filename:</strong><code class="text-gray-400"> {{ '{authors}/' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Deep nested folders:</strong><code class="text-gray-400"> {{ '{authors}/books/<{series}/>{title}/' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/books/Project Hail Mary/project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">With static folder prefix:</strong><code class="text-gray-400"> {{ 'Books/<{series}/>{authors} - {title}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Books/Andy Weir - Project Hail Mary.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Use original filename in path:</strong><code
class="text-gray-400"> {{ '{authors}/books/<{series}/>{currentFilename}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/books/project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Prefix current filename manually:</strong><code class="text-gray-400"> {{ '{authors}/final__{currentFilename}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/final__project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Filename preserved, renamed folder:</strong><code class="text-gray-400"> {{ '{title}/source/{currentFilename}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Project Hail Mary/source/project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Use current filename with year suffix:</strong><code
class="text-gray-400"> {{ '{authors}/{year}__{currentFilename}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/2021__project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Fallback with optional + extras folder:</strong><code
class="text-gray-400"> {{ '<{series}/>{authors}/extras/{currentFilename}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/extras/project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Archive structure using original name:</strong><code
class="text-gray-400"> {{ 'archive/<{series}/>{year}/{currentFilename}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> archive/2021/project_hail_mary_final.epub</code></p>
</div>
<div>
<p class="mb-1"><strong class="text-gray-300">Structured folder + renamed file:</strong><code
class="text-gray-400"> {{ '{authors}/<{series}/>{title} ({year}) by {authors}' }}</code></p>
<p><strong class="text-gray-300">Output:</strong><code class="text-gray-400"> Andy Weir/Project Hail Mary (2021) by Andy Weir.epub</code></p>
</div>
</div>
</div>
</div>
</div>
@@ -130,8 +130,4 @@
<p-divider></p-divider>
</div>
<div class="px-4 py-2">
<app-file-upload-pattern></app-file-upload-pattern>
</div>
</div>
@@ -14,7 +14,6 @@ import {AppSettingsService} from '../../core/service/app-settings.service';
import {BookService} from '../../book/service/book.service';
import {AppSettingKey, AppSettings} from '../../core/model/app-settings.model';
import {filter, take} from 'rxjs/operators';
import {FileUploadPatternComponent} from './file-upload-pattern/file-upload-pattern.component';
import {InputText} from 'primeng/inputtext';
@Component({
@@ -28,7 +27,6 @@ import {InputText} from 'primeng/inputtext';
Tooltip,
ToggleSwitch,
FormsModule,
FileUploadPatternComponent,
InputText
],
templateUrl: './global-preferences.component.html',
@@ -22,6 +22,9 @@
<p-tab [value]="SettingsTab.EmailSettings">
<i class="pi pi-envelope"></i> Email
</p-tab>
<p-tab [value]="SettingsTab.NamingPattern">
<i class="pi pi-sitemap"></i> Patterns
</p-tab>
<p-tab [value]="SettingsTab.AuthenticationSettings">
<i class="pi pi-lock"></i> Authentication
</p-tab>
@@ -55,6 +58,9 @@
<p-tabpanel [value]="SettingsTab.EmailSettings">
<app-email></app-email>
</p-tabpanel>
<p-tabpanel [value]="SettingsTab.NamingPattern">
<app-file-naming-pattern></app-file-naming-pattern>
</p-tabpanel>
<p-tabpanel [value]="SettingsTab.AuthenticationSettings">
<app-authentication-settings></app-authentication-settings>
</p-tabpanel>
@@ -13,17 +13,19 @@ import {ReaderPreferences} from './reader-preferences/reader-preferences.compone
import {MetadataSettingsComponent} from './metadata-settings-component/metadata-settings-component';
import {OpdsSettingsComponent} from './global-preferences/opds-settings/opds-settings.component';
import {DeviceSettingsComponent} from './device-settings-component/device-settings-component';
import {FileNamingPatternComponent} from './file-naming-pattern/file-naming-pattern.component';
export enum SettingsTab {
ReaderSettings = 'reader-settings',
ViewPreferences = 'view-settings',
DeviceSettings = 'device-settings',
UserManagement = 'user-management',
EmailSettings = 'email-settings',
MetadataSettings = 'metadata-settings',
ApplicationSettings = 'app-settings',
AuthenticationSettings = 'auth-settings',
Opds = 'opds-settings'
ReaderSettings = 'reader',
ViewPreferences = 'view',
DeviceSettings = 'device',
UserManagement = 'user',
EmailSettings = 'email',
NamingPattern = 'naming-pattern',
MetadataSettings = 'metadata',
ApplicationSettings = 'application',
AuthenticationSettings = 'authentication',
Opds = 'opds'
}
@Component({
@@ -43,7 +45,8 @@ export enum SettingsTab {
ReaderPreferences,
MetadataSettingsComponent,
OpdsSettingsComponent,
DeviceSettingsComponent
DeviceSettingsComponent,
FileNamingPatternComponent
],
templateUrl: './settings.component.html',
styleUrl: './settings.component.scss'