mirror of
https://github.com/booklore-app/booklore.git
synced 2025-12-23 22:28:11 -05:00
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:
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 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 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"
|
||||
|
||||
@@ -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).`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user