feat(api, ui): add bookdrop bulk edit and metadata pattern extraction (#1846)

* feat: add bulk editors for bookdrop

* fix: update pattern behaviour and remove redundant frontend logic

* fix: clean up pattern extractor

* fix: create shared logic to align bulk edit and pattern extract and resolve some minor behaviour issues

* fix: date matching pattern and resolve issues with pattern matching to ignore extra trailing data

* chore: cleanup tests and code to be cleaner

* chore: cleanup autogenerated testing rules

* fix: update to use the new dialog launcher service

* fix: add boolean null check and data validation on pattern extract api

* feat: add bulk edit batching to avoid issues with extremely large import counts

* fix: adding timeout to avoid potential redos issue

* fix: add try blocks for issues with potential NumberFormatException

* fix: update isbn and asin regex to better match spec

* fix: improve error handling and logging

* fix: make component names consistent with the project

* fix: mising import for pattern syntax exception

* chore: add additional tests for the bulk edit service

* fix: improve accessibility to new ui elements

* fix: further improvements to the pattern extractor timeout

* fix: improve frontend placeholder validation

* fix: add back changes accidently removed by merge
This commit is contained in:
CounterClops
2025-12-17 14:27:17 +08:00
committed by GitHub
parent 0a5f12f38c
commit 6df338a0d7
24 changed files with 3139 additions and 10 deletions

View File

@@ -0,0 +1,118 @@
<div class="bulk-edit-container">
<div class="info-banner">
<i class="pi pi-info-circle"></i>
<span>Select which fields to apply to <strong>{{ fileCount }}</strong> selected file(s). Only checked fields will be updated.</span>
</div>
<p-divider></p-divider>
<div class="fields-section">
<h4>Text Fields</h4>
<div class="field-grid">
@for (field of textFields; track field.name) {
<div class="field-row">
<p-checkbox
[binary]="true"
[ngModel]="isFieldEnabled(field.name)"
(ngModelChange)="toggleField(field.name)"
[ariaLabel]="'Enable ' + field.label">
</p-checkbox>
<label class="field-label" [attr.for]="field.controlName">{{ field.label }}</label>
<input
pInputText
[formControl]="$any(bulkEditForm.get(field.controlName))"
[attr.id]="field.controlName"
[attr.aria-label]="field.label"
class="field-input"/>
</div>
}
</div>
</div>
<p-divider></p-divider>
<div class="fields-section">
<h4>Number Fields</h4>
<div class="field-grid">
@for (field of numberFields; track field.name) {
<div class="field-row">
<p-checkbox
[binary]="true"
[ngModel]="isFieldEnabled(field.name)"
(ngModelChange)="toggleField(field.name)"
[ariaLabel]="'Enable ' + field.label">
</p-checkbox>
<label class="field-label" [attr.for]="field.controlName">{{ field.label }}</label>
<input
pInputText
type="number"
[formControl]="$any(bulkEditForm.get(field.controlName))"
[attr.id]="field.controlName"
[attr.aria-label]="field.label"
class="field-input field-input-small"/>
</div>
}
</div>
</div>
<p-divider></p-divider>
<div class="fields-section">
<div class="section-header">
<h4>Array Fields</h4>
<div class="merge-toggle">
<span class="merge-label" id="merge-mode-label">Mode:</span>
<p-selectButton
[options]="mergeOptions"
[(ngModel)]="mergeArrays"
optionLabel="label"
optionValue="value"
ariaLabelledBy="merge-mode-label">
</p-selectButton>
</div>
</div>
<p class="helper-text"><i class="pi pi-info-circle"></i> Type and press Enter to add each item.</p>
<div class="field-grid">
@for (field of chipFields; track field.name) {
<div class="field-row">
<p-checkbox
[binary]="true"
[ngModel]="isFieldEnabled(field.name)"
(ngModelChange)="toggleField(field.name)"
[ariaLabel]="'Enable ' + field.label">
</p-checkbox>
<label class="field-label" [attr.for]="field.controlName">{{ field.label }}</label>
<p-autoComplete
[formControl]="$any(bulkEditForm.get(field.controlName))"
[multiple]="true"
[suggestions]="[]"
[forceSelection]="false"
[typeahead]="false"
[dropdown]="false"
[ariaLabel]="field.label"
(onBlur)="onAutoCompleteBlur(field.name, $event)"
class="field-input">
</p-autoComplete>
</div>
}
</div>
</div>
<p-divider></p-divider>
<div class="dialog-footer">
<p-button
label="Cancel"
icon="pi pi-times"
severity="secondary"
(click)="cancel()">
</p-button>
<p-button
label="Apply to Selected"
icon="pi pi-check"
severity="success"
[disabled]="!hasEnabledFields"
(click)="apply()">
</p-button>
</div>
</div>

View File

@@ -0,0 +1,100 @@
.bulk-edit-container {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
.helper-text {
font-size: 0.875rem;
color: var(--text-color-secondary);
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
padding: 0.5rem 0;
i {
font-size: 1rem;
}
}
}
.info-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(59, 130, 246, 0.08);
border: 1px solid var(--p-primary-color);
border-radius: 6px;
color: var(--p-text-color);
i {
font-size: 1.25rem;
color: var(--p-primary-color);
}
}
.fields-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
h4 {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--p-text-secondary-color);
}
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.merge-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
.merge-label {
font-size: 0.85rem;
color: var(--p-text-secondary-color);
}
}
.field-grid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-row {
display: grid;
grid-template-columns: auto 120px 1fr;
gap: 0.75rem;
align-items: center;
}
.field-label {
font-size: 0.9rem;
font-weight: 500;
color: var(--p-text-color);
}
.field-input {
width: 100%;
}
.field-input-small {
max-width: 120px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 0.5rem;
}

View File

@@ -0,0 +1,167 @@
import {Component, inject, OnInit, ChangeDetectorRef} from '@angular/core';
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms';
import {DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog';
import {Button} from 'primeng/button';
import {Checkbox} from 'primeng/checkbox';
import {InputText} from 'primeng/inputtext';
import {AutoComplete} from 'primeng/autocomplete';
import {Divider} from 'primeng/divider';
import {SelectButton} from 'primeng/selectbutton';
import {BookMetadata} from '../../../book/model/book.model';
export interface BulkEditResult {
fields: Partial<BookMetadata>;
enabledFields: Set<string>;
mergeArrays: boolean;
}
interface BulkEditField {
name: string;
label: string;
type: 'text' | 'chips' | 'number';
controlName: string;
}
@Component({
selector: 'app-bookdrop-bulk-edit-dialog',
standalone: true,
imports: [
ReactiveFormsModule,
FormsModule,
Button,
Checkbox,
InputText,
AutoComplete,
Divider,
SelectButton,
],
templateUrl: './bookdrop-bulk-edit-dialog.component.html',
styleUrl: './bookdrop-bulk-edit-dialog.component.scss'
})
export class BookdropBulkEditDialogComponent implements OnInit {
private readonly dialogRef = inject(DynamicDialogRef);
private readonly config = inject(DynamicDialogConfig);
private readonly cdr = inject(ChangeDetectorRef);
fileCount: number = 0;
mergeArrays = true;
enabledFields = new Set<string>();
bulkEditForm = new FormGroup({
seriesName: new FormControl(''),
seriesTotal: new FormControl<number | null>(null),
authors: new FormControl<string[]>([]),
publisher: new FormControl(''),
language: new FormControl(''),
categories: new FormControl<string[]>([]),
moods: new FormControl<string[]>([]),
tags: new FormControl<string[]>([]),
});
textFields: BulkEditField[] = [
{name: 'seriesName', label: 'Series Name', type: 'text', controlName: 'seriesName'},
{name: 'publisher', label: 'Publisher', type: 'text', controlName: 'publisher'},
{name: 'language', label: 'Language', type: 'text', controlName: 'language'},
];
numberFields: BulkEditField[] = [
{name: 'seriesTotal', label: 'Series Total', type: 'number', controlName: 'seriesTotal'},
];
chipFields: BulkEditField[] = [
{name: 'authors', label: 'Authors', type: 'chips', controlName: 'authors'},
{name: 'categories', label: 'Genres', type: 'chips', controlName: 'categories'},
{name: 'moods', label: 'Moods', type: 'chips', controlName: 'moods'},
{name: 'tags', label: 'Tags', type: 'chips', controlName: 'tags'},
];
mergeOptions = [
{label: 'Merge', value: true},
{label: 'Replace', value: false},
];
ngOnInit(): void {
this.fileCount = this.config.data?.fileCount ?? 0;
this.setupFormValueChangeListeners();
}
private setupFormValueChangeListeners(): void {
Object.keys(this.bulkEditForm.controls).forEach(fieldName => {
const control = this.bulkEditForm.get(fieldName);
control?.valueChanges.subscribe(value => {
const hasValue = Array.isArray(value) ? value.length > 0 : (value !== null && value !== '' && value !== undefined);
if (hasValue && !this.enabledFields.has(fieldName)) {
this.enabledFields.add(fieldName);
this.cdr.detectChanges();
}
});
});
}
onAutoCompleteBlur(fieldName: string, event: Event): void {
const target = event.target as HTMLInputElement;
const inputValue = target?.value?.trim();
if (inputValue) {
const control = this.bulkEditForm.get(fieldName);
const currentValue = (control?.value as string[]) || [];
if (!currentValue.includes(inputValue)) {
control?.setValue([...currentValue, inputValue]);
}
if (target) {
target.value = '';
}
}
if (!this.enabledFields.has(fieldName)) {
const control = this.bulkEditForm.get(fieldName);
const value = control?.value;
if (Array.isArray(value) && value.length > 0) {
this.enabledFields.add(fieldName);
this.cdr.detectChanges();
}
}
}
toggleField(fieldName: string): void {
if (this.enabledFields.has(fieldName)) {
this.enabledFields.delete(fieldName);
} else {
this.enabledFields.add(fieldName);
}
}
isFieldEnabled(fieldName: string): boolean {
return this.enabledFields.has(fieldName);
}
get hasEnabledFields(): boolean {
return this.enabledFields.size > 0;
}
cancel(): void {
this.dialogRef.close(null);
}
apply(): void {
const formValue = this.bulkEditForm.value;
const fields: Partial<BookMetadata> = {};
this.enabledFields.forEach(fieldName => {
const value = formValue[fieldName as keyof typeof formValue];
if (value !== undefined && value !== null) {
(fields as Record<string, unknown>)[fieldName] = value;
}
});
const result: BulkEditResult = {
fields,
enabledFields: new Set(this.enabledFields),
mergeArrays: this.mergeArrays,
};
this.dialogRef.close(result);
}
}

View File

@@ -153,8 +153,9 @@ export class BookdropFileMetadataPickerComponent {
}
}
onAutoCompleteBlur(fieldName: string, event: any) {
const inputValue = event.target.value?.trim();
onAutoCompleteBlur(fieldName: string, event: Event): void {
const target = event.target as HTMLInputElement;
const inputValue = target?.value?.trim();
if (inputValue) {
const currentValue = this.metadataForm.get(fieldName)?.value || [];
const values = Array.isArray(currentValue) ? currentValue :
@@ -163,7 +164,9 @@ export class BookdropFileMetadataPickerComponent {
values.push(inputValue);
this.metadataForm.get(fieldName)?.setValue(values);
}
event.target.value = '';
if (target) {
target.value = '';
}
}
}

View File

@@ -66,6 +66,28 @@
pTooltip="Replace current metadata with fetched metadata on all files"
tooltipPosition="top">
</p-button>
<p-button
size="small"
outlined
severity="help"
label="Bulk&nbsp;Edit"
icon="pi pi-pencil"
[disabled]="!hasSelectedFiles"
(click)="openBulkEditDialog()"
pTooltip="Edit metadata fields in bulk for selected files"
tooltipPosition="top">
</p-button>
<p-button
size="small"
outlined
severity="warn"
label="Extract&nbsp;Pattern"
icon="pi pi-sliders-h"
[disabled]="!hasSelectedFiles"
(click)="openPatternExtractDialog()"
pTooltip="Extract metadata from filenames using a pattern"
tooltipPosition="top">
</p-button>
<span pTooltip="Include book covers when importing fetched metadata"><p-checkbox
inputId="includecovers"
[binary]="true"
@@ -123,7 +145,6 @@
<div class="file-item">
<div class="file-row">
<p-checkbox
[binary]="true"
[(ngModel)]="file.selected"

View File

@@ -3,7 +3,7 @@ import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {filter, startWith, take, tap} from 'rxjs/operators';
import {PageTitleService} from "../../../../shared/service/page-title.service";
import {BookdropFile, BookdropFinalizePayload, BookdropFinalizeResult, BookdropService} from '../../service/bookdrop.service';
import {BookdropFile, BookdropFinalizePayload, BookdropFinalizeResult, BookdropService, FileExtractionResult, BulkEditRequest as BackendBulkEditRequest, BulkEditResult as BackendBulkEditResult} from '../../service/bookdrop.service';
import {LibraryService} from '../../../book/service/library.service';
import {Library} from '../../../book/model/library.model';
@@ -25,6 +25,9 @@ import {NgClass} from '@angular/common';
import {Paginator} from 'primeng/paginator';
import {ActivatedRoute} from '@angular/router';
import {BookdropFileMetadataPickerComponent} from '../bookdrop-file-metadata-picker/bookdrop-file-metadata-picker.component';
import {BookdropFinalizeResultDialogComponent} from '../bookdrop-finalize-result-dialog/bookdrop-finalize-result-dialog.component';
import {BookdropBulkEditDialogComponent, BulkEditResult} from '../bookdrop-bulk-edit-dialog/bookdrop-bulk-edit-dialog.component';
import {BookdropPatternExtractDialogComponent} from '../bookdrop-pattern-extract-dialog/bookdrop-pattern-extract-dialog.component';
import {DialogLauncherService} from '../../../../shared/services/dialog-launcher.service';
export interface BookdropFileUI {
@@ -381,7 +384,7 @@ export class BookdropFileReviewComponent implements OnInit {
icon: 'pi pi-exclamation-triangle',
acceptButtonStyleClass: 'p-button-danger',
accept: () => {
const payload: any = {
const payload: { selectAll: boolean; excludedIds?: number[]; selectedIds?: number[] } = {
selectAll: this.selectAllAcrossPages,
};
@@ -583,4 +586,180 @@ export class BookdropFileReviewComponent implements OnInit {
}
});
}
openBulkEditDialog(): void {
const selectedFiles = this.getSelectedFiles();
const totalCount = this.selectAllAcrossPages
? this.totalRecords - this.excludedFiles.size
: selectedFiles.length;
if (totalCount === 0) {
this.messageService.add({
severity: 'warn',
summary: 'No files selected',
detail: 'Please select files to bulk edit.',
});
return;
}
const dialogRef = this.dialogLauncherService.openDialog(BookdropBulkEditDialogComponent, {
header: `Bulk Edit ${totalCount} Files`,
width: '600px',
modal: true,
closable: true,
data: {fileCount: totalCount},
});
dialogRef?.onClose.subscribe((result: BulkEditResult | null) => {
if (result) {
this.applyBulkMetadataViaBackend(result);
}
});
}
openPatternExtractDialog(): void {
const selectedFiles = this.getSelectedFiles();
const totalCount = this.selectAllAcrossPages
? this.totalRecords - this.excludedFiles.size
: selectedFiles.length;
if (totalCount === 0) {
this.messageService.add({
severity: 'warn',
summary: 'No files selected',
detail: 'Please select files to extract metadata from.',
});
return;
}
const sampleFiles = selectedFiles.slice(0, 5).map(f => f.file.fileName);
const selectedIds = selectedFiles.map(f => f.file.id);
const dialogRef = this.dialogLauncherService.openDialog(BookdropPatternExtractDialogComponent, {
header: 'Extract Metadata from Filenames',
width: '700px',
modal: true,
closable: true,
data: {
sampleFiles,
fileCount: totalCount,
selectAll: this.selectAllAcrossPages,
excludedIds: Array.from(this.excludedFiles),
selectedIds,
},
});
dialogRef?.onClose.subscribe((result: { results: FileExtractionResult[] } | null) => {
if (result?.results) {
this.applyExtractedMetadata(result.results);
}
});
}
private getSelectedFiles(): BookdropFileUI[] {
return Object.values(this.fileUiCache).filter(file => {
if (this.selectAllAcrossPages) {
return !this.excludedFiles.has(file.file.id);
}
return file.selected;
});
}
private applyBulkMetadataViaBackend(result: BulkEditResult): void {
const selectedFiles = this.getSelectedFiles();
const selectedIds = selectedFiles.map(f => f.file.id);
this.applyBulkMetadataToUI(result, selectedFiles);
const enabledFieldsArray = Array.from(result.enabledFields);
const payload: BackendBulkEditRequest = {
fields: result.fields,
enabledFields: enabledFieldsArray,
mergeArrays: result.mergeArrays,
selectAll: this.selectAllAcrossPages,
excludedIds: this.selectAllAcrossPages ? Array.from(this.excludedFiles) : undefined,
selectedIds: !this.selectAllAcrossPages ? selectedIds : undefined,
};
this.bookdropService.bulkEditMetadata(payload).subscribe({
next: (backendResult: BackendBulkEditResult) => {
this.messageService.add({
severity: 'success',
summary: 'Bulk Edit Applied',
detail: `Updated metadata for ${backendResult.successfullyUpdated} of ${backendResult.totalFiles} file(s).`,
});
},
error: (err) => {
console.error('Error applying bulk edit:', err);
this.messageService.add({
severity: 'error',
summary: 'Bulk Edit Failed',
detail: 'An error occurred while applying bulk edits.',
});
},
});
}
private applyBulkMetadataToUI(result: BulkEditResult, selectedFiles: BookdropFileUI[]): void {
selectedFiles.forEach(fileUi => {
result.enabledFields.forEach(fieldName => {
const value = result.fields[fieldName as keyof BookMetadata];
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value) && value.length === 0) {
return;
}
const control = fileUi.metadataForm.get(fieldName);
if (!control) {
return;
}
if (result.mergeArrays && Array.isArray(value)) {
const currentValue = control.value || [];
const merged = [...new Set([...currentValue, ...value])];
control.setValue(merged);
} else {
control.setValue(value);
}
});
});
}
private applyExtractedMetadata(results: FileExtractionResult[]): void {
let appliedCount = 0;
results.forEach(result => {
if (!result.success || !result.extractedMetadata) {
return;
}
const fileUi = this.fileUiCache[result.fileId];
if (!fileUi) {
return;
}
Object.entries(result.extractedMetadata).forEach(([key, value]) => {
if (value === null || value === undefined) {
return;
}
const control = fileUi.metadataForm.get(key);
if (control) {
control.setValue(value);
}
});
appliedCount++;
});
this.messageService.add({
severity: 'success',
summary: 'Pattern Extraction Applied',
detail: `Applied extracted metadata to ${appliedCount} file(s).`,
});
}
}

View File

@@ -4,13 +4,13 @@ import {BookdropFinalizeResult} from '../../service/bookdrop.service';
import {DynamicDialogConfig, DynamicDialogRef} from "primeng/dynamicdialog";
@Component({
selector: 'app-bookdrop-finalize-result-dialog-component',
selector: 'app-bookdrop-finalize-result-dialog',
imports: [
NgClass,
DatePipe
],
templateUrl: './bookdrop-finalize-result-dialog-component.html',
styleUrl: './bookdrop-finalize-result-dialog-component.scss'
templateUrl: './bookdrop-finalize-result-dialog.component.html',
styleUrl: './bookdrop-finalize-result-dialog.component.scss'
})
export class BookdropFinalizeResultDialogComponent implements OnDestroy {

View File

@@ -0,0 +1,123 @@
<div class="pattern-extract-container">
<div class="info-banner">
<i class="pi pi-info-circle"></i>
<span>
Enter a pattern to extract metadata from filenames of <strong>{{ fileCount }}</strong> selected file(s).
Use placeholders like <code>{{ '{' }}SeriesName{{ '}' }}</code> to capture values.
</span>
</div>
<p-divider></p-divider>
<div class="pattern-section">
<h4>Pattern</h4>
<div class="pattern-input-row">
<input
#patternInput
pInputText
[formControl]="$any(patternForm.get('pattern'))"
[placeholder]="patternPlaceholderText"
id="pattern-input"
aria-label="Filename pattern for metadata extraction"
class="pattern-input"
(input)="previewPattern()"/>
<p-button
icon="pi pi-eye"
label="Preview"
severity="secondary"
[disabled]="!hasValidPattern"
[ariaLabel]="'Preview pattern extraction on sample files'"
(click)="previewPattern()">
</p-button>
</div>
</div>
<div class="placeholders-section">
<h4>Available Placeholders</h4>
<div class="placeholder-chips">
@for (placeholder of availablePlaceholders; track placeholder.name) {
<p-chip
[label]="getPlaceholderLabel(placeholder.name)"
[pTooltip]="getPlaceholderTooltip(placeholder)"
tooltipPosition="top"
styleClass="placeholder-chip"
(click)="insertPlaceholder(placeholder.name)">
</p-chip>
}
</div>
</div>
<div class="common-patterns-section">
<h4>Common Patterns</h4>
<div class="common-pattern-buttons">
@for (commonPattern of commonPatterns; track commonPattern.pattern) {
<p-button
[label]="commonPattern.label"
severity="secondary"
size="small"
outlined
(click)="applyCommonPattern(commonPattern.pattern)">
</p-button>
}
</div>
</div>
<p-divider></p-divider>
@if (previewResults.length > 0) {
<div class="preview-section">
<h4>Preview (Sample Files)</h4>
<div class="preview-list">
@for (preview of previewResults; track preview.fileName) {
<div class="preview-item" [ngClass]="getPreviewClass(preview)">
<div class="preview-filename">
<i class="pi"
[ngClass]="getPreviewIconClass(preview)"
[pTooltip]="getErrorTooltip(preview)"
tooltipPosition="top"
[tooltipOptions]="{showDelay: 300}"></i>
<span>{{ preview.fileName }}</span>
</div>
@if (preview.success) {
<div class="preview-extracted">
@for (entry of getPreviewEntries(preview); track entry.key) {
<div class="extracted-field">
<span class="field-name">{{ entry.key }}:</span>
<span class="field-value">{{ entry.value }}</span>
</div>
}
</div>
} @else {
<div class="preview-error">{{ getErrorMessage(preview) }}</div>
}
</div>
}
</div>
</div>
<p-divider></p-divider>
}
<div class="dialog-footer">
@if (isExtracting) {
<div class="extracting-indicator">
<p-progressSpinner strokeWidth="4" [style]="spinnerStyle"></p-progressSpinner>
<span>Extracting metadata...</span>
</div>
}
<p-button
label="Cancel"
icon="pi pi-times"
severity="secondary"
[disabled]="isExtracting"
(click)="cancel()">
</p-button>
<p-button
label="Extract and Apply"
icon="pi pi-check"
severity="success"
[disabled]="!hasValidPattern || isExtracting"
(click)="extract()">
</p-button>
</div>
</div>

View File

@@ -0,0 +1,171 @@
.pattern-extract-container {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem;
max-height: 70vh;
overflow-y: auto;
}
.info-banner {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(59, 130, 246, 0.08);
border: 1px solid var(--p-primary-color);
border-radius: 6px;
color: var(--p-text-color);
i {
font-size: 1.25rem;
color: var(--p-primary-color);
margin-top: 2px;
}
code {
background: rgba(255, 255, 255, 0.1);
padding: 0.1rem 0.3rem;
border-radius: 4px;
font-family: monospace;
}
}
.pattern-section,
.placeholders-section,
.common-patterns-section,
.preview-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
h4 {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--p-text-secondary-color);
}
}
.pattern-input-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.pattern-input {
flex: 1;
font-family: monospace;
}
.placeholder-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
:host ::ng-deep .placeholder-chip {
cursor: pointer;
font-family: monospace;
font-size: 0.85rem;
&:hover {
background-color: var(--p-primary-color);
color: var(--p-primary-contrast-color);
}
}
.common-pattern-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.preview-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 200px;
overflow-y: auto;
}
.preview-item {
padding: 0.75rem;
border-radius: 6px;
border: 1px solid var(--p-surface-300);
&.preview-success {
background-color: rgba(76, 175, 80, 0.1);
border-color: rgba(76, 175, 80, 0.3);
}
&.preview-failure {
background-color: rgba(244, 67, 54, 0.1);
border-color: rgba(244, 67, 54, 0.3);
}
}
.preview-filename {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
margin-bottom: 0.5rem;
i {
font-size: 1rem;
}
.pi-check-circle {
color: #4caf50;
}
.pi-times-circle {
color: #f44336;
}
}
.preview-extracted {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
padding-left: 1.5rem;
}
.extracted-field {
display: flex;
gap: 0.25rem;
font-size: 0.85rem;
.field-name {
color: var(--p-text-secondary-color);
}
.field-value {
font-weight: 500;
color: var(--p-primary-color);
}
}
.preview-error {
padding-left: 1.5rem;
font-size: 0.85rem;
color: var(--p-text-secondary-color);
font-style: italic;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.75rem;
padding-top: 0.5rem;
}
.extracting-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
margin-right: auto;
color: var(--p-text-secondary-color);
}

View File

@@ -0,0 +1,277 @@
import {Component, ElementRef, inject, OnInit, ViewChild} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog';
import {Button} from 'primeng/button';
import {InputText} from 'primeng/inputtext';
import {Divider} from 'primeng/divider';
import {Chip} from 'primeng/chip';
import {ProgressSpinner} from 'primeng/progressspinner';
import {BookdropService, PatternExtractResult} from '../../service/bookdrop.service';
import {MessageService} from 'primeng/api';
import {NgClass} from '@angular/common';
import {Tooltip} from 'primeng/tooltip';
interface PatternPlaceholder {
name: string;
description: string;
example: string;
}
interface PreviewResult {
fileName: string;
success: boolean;
preview: Record<string, string>;
errorMessage?: string;
}
@Component({
selector: 'app-bookdrop-pattern-extract-dialog',
standalone: true,
imports: [
ReactiveFormsModule,
Button,
InputText,
Divider,
Chip,
ProgressSpinner,
NgClass,
Tooltip,
],
templateUrl: './bookdrop-pattern-extract-dialog.component.html',
styleUrl: './bookdrop-pattern-extract-dialog.component.scss'
})
export class BookdropPatternExtractDialogComponent implements OnInit {
private readonly dialogRef = inject(DynamicDialogRef);
private readonly config = inject(DynamicDialogConfig);
private readonly bookdropService = inject(BookdropService);
private readonly messageService = inject(MessageService);
@ViewChild('patternInput', {static: false}) patternInput?: ElementRef<HTMLInputElement>;
fileCount = 0;
selectAll = false;
excludedIds: number[] = [];
selectedIds: number[] = [];
isExtracting = false;
previewResults: PreviewResult[] = [];
patternPlaceholderText = 'e.g., {SeriesName} - Ch {SeriesNumber}';
spinnerStyle = {width: '24px', height: '24px'};
patternForm = new FormGroup({
pattern: new FormControl('', Validators.required),
});
availablePlaceholders: PatternPlaceholder[] = [
{name: '*', description: 'Wildcard - skips any text (not a metadata field)', example: 'anything'},
{name: 'SeriesName', description: 'Series or comic name', example: 'Chronicles of Earth'},
{name: 'Title', description: 'Book title', example: 'The Lost City'},
{name: 'Subtitle', description: 'Book subtitle', example: 'A Tale of Adventure'},
{name: 'Authors', description: 'Author name(s)', example: 'John Smith'},
{name: 'SeriesNumber', description: 'Book number in series', example: '25'},
{name: 'Published', description: 'Full date with format', example: '{Published:yyyy-MM-dd}'},
{name: 'Publisher', description: 'Publisher name', example: 'Epic Press'},
{name: 'Language', description: 'Language code', example: 'en'},
{name: 'SeriesTotal', description: 'Total books in series', example: '50'},
{name: 'ISBN10', description: 'ISBN-10 identifier', example: '1234567890'},
{name: 'ISBN13', description: 'ISBN-13 identifier', example: '1234567890123'},
{name: 'ASIN', description: 'Amazon ASIN', example: 'B012345678'},
];
commonPatterns = [
{label: 'Author - Title', pattern: '{Authors} - {Title}'},
{label: 'Title - Author', pattern: '{Title} - {Authors}'},
{label: 'Title (Year)', pattern: '{Title} ({Published:yyyy})'},
{label: 'Author - Title (Year)', pattern: '{Authors} - {Title} ({Published:yyyy})'},
{label: 'Series #Number', pattern: '{SeriesName} #{SeriesNumber}'},
{label: 'Series - Chapter Number', pattern: '{SeriesName} - Chapter {SeriesNumber}'},
{label: 'Series - Vol Number', pattern: '{SeriesName} - Vol {SeriesNumber}'},
{label: '[Tag] Series - Chapter Number', pattern: '[*] {SeriesName} - Chapter {SeriesNumber}'},
{label: 'Title by Author', pattern: '{Title} by {Authors}'},
{label: 'Series vX (of Total)', pattern: '{SeriesName} v{SeriesNumber} (of {SeriesTotal})'},
];
ngOnInit(): void {
this.fileCount = this.config.data?.fileCount ?? 0;
this.selectAll = this.config.data?.selectAll ?? false;
this.excludedIds = this.config.data?.excludedIds ?? [];
this.selectedIds = this.config.data?.selectedIds ?? [];
}
insertPlaceholder(placeholderName: string): void {
const patternControl = this.patternForm.get('pattern');
const currentPattern = patternControl?.value ?? '';
const inputElement = this.patternInput?.nativeElement;
const textToInsert = placeholderName === '*' ? '*' : `{${placeholderName}}`;
const patternToModify = placeholderName === '*'
? currentPattern
: this.removeExistingPlaceholder(currentPattern, placeholderName);
if (inputElement) {
const cursorPosition = this.calculateCursorPosition(inputElement, currentPattern, patternToModify);
const newPattern = this.insertTextAtCursor(patternToModify, textToInsert, cursorPosition);
patternControl?.setValue(newPattern);
this.focusInputAfterInsertion(inputElement, cursorPosition, textToInsert.length);
} else {
patternControl?.setValue(patternToModify + textToInsert);
}
this.previewPattern();
}
private removeExistingPlaceholder(pattern: string, placeholderName: string): string {
const existingPlaceholderRegex = new RegExp(`\\{${placeholderName}(?::[^}]*)?\\}`, 'g');
return pattern.replace(existingPlaceholderRegex, '');
}
private calculateCursorPosition(inputElement: HTMLInputElement, originalPattern: string, modifiedPattern: string): number {
let cursorPosition = inputElement.selectionStart ?? modifiedPattern.length;
if (originalPattern !== modifiedPattern) {
const existingPlaceholderRegex = new RegExp(`\\{\\w+(?::[^}]*)?\\}`, 'g');
const matchBefore = originalPattern.substring(0, cursorPosition).match(existingPlaceholderRegex);
if (matchBefore) {
cursorPosition -= matchBefore.reduce((sum, match) => sum + match.length, 0);
}
cursorPosition = Math.max(0, cursorPosition);
}
return cursorPosition;
}
private insertTextAtCursor(pattern: string, text: string, cursorPosition: number): string {
const textBefore = pattern.substring(0, cursorPosition);
const textAfter = pattern.substring(cursorPosition);
return textBefore + text + textAfter;
}
private focusInputAfterInsertion(inputElement: HTMLInputElement, cursorPosition: number, insertedTextLength: number): void {
setTimeout(() => {
const newCursorPosition = cursorPosition + insertedTextLength;
inputElement.setSelectionRange(newCursorPosition, newCursorPosition);
inputElement.focus();
}, 0);
}
applyCommonPattern(pattern: string): void {
this.patternForm.get('pattern')?.setValue(pattern);
this.previewPattern();
}
previewPattern(): void {
const pattern = this.patternForm.get('pattern')?.value;
if (!pattern) {
this.previewResults = [];
return;
}
const request = {
pattern,
selectAll: this.selectAll,
excludedIds: this.excludedIds,
selectedIds: this.selectedIds,
preview: true
};
this.bookdropService.extractFromPattern(request).subscribe({
next: (result) => {
this.previewResults = result.results.map(r => ({
fileName: r.fileName,
success: r.success,
preview: r.extractedMetadata || {},
errorMessage: r.errorMessage
}));
},
error: () => {
this.previewResults = [];
}
});
}
cancel(): void {
this.dialogRef.close(null);
}
extract(): void {
const pattern = this.patternForm.get('pattern')?.value;
if (!pattern) {
return;
}
this.isExtracting = true;
const payload = {
pattern,
selectAll: this.selectAll,
excludedIds: this.excludedIds,
selectedIds: this.selectedIds,
preview: false,
};
this.bookdropService.extractFromPattern(payload).subscribe({
next: (result: PatternExtractResult) => {
this.isExtracting = false;
this.messageService.add({
severity: 'success',
summary: 'Extraction Complete',
detail: `Successfully extracted metadata from ${result.successfullyExtracted} of ${result.totalFiles} files.`,
});
this.dialogRef.close(result);
},
error: (err) => {
this.isExtracting = false;
console.error('Pattern extraction failed:', err);
this.messageService.add({
severity: 'error',
summary: 'Extraction Failed',
detail: 'An error occurred during pattern extraction.',
});
},
});
}
get hasValidPattern(): boolean {
const pattern: string = this.patternForm.get('pattern')?.value ?? '';
if (!this.patternForm.valid || !pattern) {
return false;
}
const placeholderRegex = /\{[a-zA-Z0-9_]+(?::[^{}]+)?\}|\*/;
return placeholderRegex.test(pattern);
}
getPlaceholderLabel(name: string): string {
return name === '*' ? '*' : `{${name}}`;
}
getPlaceholderTooltip(placeholder: PatternPlaceholder): string {
return `${placeholder.description} (e.g., ${placeholder.example})`;
}
getPreviewClass(preview: PreviewResult): Record<string, boolean> {
return {
'preview-success': preview.success,
'preview-failure': !preview.success
};
}
getPreviewIconClass(preview: PreviewResult): string {
return preview.success ? 'pi-check-circle' : 'pi-times-circle';
}
getPreviewEntries(preview: PreviewResult): Array<{key: string; value: string}> {
return Object.entries(preview.preview).map(([key, value]) => ({key, value}));
}
getErrorMessage(preview: PreviewResult): string {
return preview.errorMessage || 'Pattern did not match';
}
getErrorTooltip(preview: PreviewResult): string {
return preview.success ? '' : (preview.errorMessage || 'Pattern did not match filename structure');
}
}

View File

@@ -56,6 +56,44 @@ export interface Page<T> {
number: number;
}
export interface PatternExtractRequest {
pattern: string;
selectAll?: boolean;
excludedIds?: number[];
selectedIds?: number[];
preview?: boolean;
}
export interface FileExtractionResult {
fileId: number;
fileName: string;
success: boolean;
extractedMetadata?: BookMetadata;
errorMessage?: string;
}
export interface PatternExtractResult {
totalFiles: number;
successfullyExtracted: number;
failed: number;
results: FileExtractionResult[];
}
export interface BulkEditRequest {
fields: Partial<BookMetadata>;
enabledFields: string[];
mergeArrays: boolean;
selectAll?: boolean;
excludedIds?: number[];
selectedIds?: number[];
}
export interface BulkEditResult {
totalFiles: number;
successfullyUpdated: number;
failed: number;
}
@Injectable({providedIn: 'root'})
export class BookdropService {
private readonly url = `${API_CONFIG.BASE_URL}/api/v1/bookdrop`;
@@ -76,4 +114,12 @@ export class BookdropService {
rescan(): Observable<void> {
return this.http.post<void>(`${this.url}/rescan`, {});
}
extractFromPattern(payload: PatternExtractRequest): Observable<PatternExtractResult> {
return this.http.post<PatternExtractResult>(`${this.url}/files/extract-pattern`, payload);
}
bulkEditMetadata(payload: BulkEditRequest): Observable<BulkEditResult> {
return this.http.post<BulkEditResult>(`${this.url}/files/bulk-edit`, payload);
}
}

View File

@@ -11,7 +11,7 @@ import {CreateUserDialogComponent} from '../../features/settings/user-management
import {CreateEmailRecipientDialogComponent} from '../../features/settings/email-v2/create-email-recipient-dialog/create-email-recipient-dialog.component';
import {CreateEmailProviderDialogComponent} from '../../features/settings/email-v2/create-email-provider-dialog/create-email-provider-dialog.component';
import {DirectoryPickerComponent} from '../components/directory-picker/directory-picker.component';
import {BookdropFinalizeResultDialogComponent} from '../../features/bookdrop/component/bookdrop-finalize-result-dialog/bookdrop-finalize-result-dialog-component';
import {BookdropFinalizeResultDialogComponent} from '../../features/bookdrop/component/bookdrop-finalize-result-dialog/bookdrop-finalize-result-dialog.component';
import {BookdropFinalizeResult} from '../../features/bookdrop/service/bookdrop.service';
import {MetadataReviewDialogComponent} from '../../features/metadata/component/metadata-review-dialog/metadata-review-dialog-component';
import {MetadataRefreshType} from '../../features/metadata/model/request/metadata-refresh-type.enum';