Book additional files support UI (2/3) (#870)

* feat(api/model): add new entity BookAdditionalFileEntity

* feat(api/db): add book additional file repository

* feat(api/db): add migration

* test(api/db): add book additional file repository test

* test(api/db): add book additional file repository tests for hash uniqueness

* feat(api/domain): add support to additional file model

* feat(api): add additional files controller

* refactor(api): move addAdditionalFile to FileUploadService as uploadAdditionalFile method

* feat(service): search book by additional file

* feat(services): process deleted additional files with ability to promote alternative formats to book instead of deleting them

* refactor(util): use common code to resolve patter for entity and domain object

* feat(service): move additional files

* test(service): test move additional files along with book itself

* feat(ui/domain): add alternativeFormats and supplementaryFiles to book model

* feat(ui/domain): add download additional file method to book service

* refactor(api/domain): extract FileInfo interface with common fields

Allow to share the same interface thet is implemented by AdditionFile and Book

* feat(ui): show multiple download options

* feat(ui/domain): add delete additional file method to book service

* feat(ui): add delete additional file ui

* feat(ui): add additional-file-uploader.component

* feat(ui/domain): add uploadAdditionalFile to the service

* feat(ui): add Upload File menu item

* feat(ui): show supplementary files in download menu item

* feat(ui): show supplementary files in delete file menu item

* feat(ui): book card allow to select single file to download or delete
This commit is contained in:
Alexander Puzynia
2025-08-27 07:11:43 -07:00
committed by GitHub
parent b81a8d1422
commit f725ececf5
9 changed files with 960 additions and 74 deletions

View File

@@ -0,0 +1,129 @@
<div class="flex flex-col gap-6 p-4 items-center justify-center w-full max-w-[700px]">
<!-- Book Information -->
<div class="w-full text-center">
<h3 class="text-lg font-semibold mb-2">{{ book.metadata?.title || 'Unknown Title' }}</h3>
<p class="text-sm text-gray-600">{{ book.filePath }}</p>
</div>
<!-- File Type Selection -->
<p-select
[options]="fileTypeOptions"
[(ngModel)]="fileType"
optionLabel="label"
optionValue="value"
placeholder="Select file type"
class="w-full"
[disabled]="isUploading"
></p-select>
<!-- Description Field -->
<div class="w-full">
<label for="description" class="block text-sm font-medium mb-2">Description (Optional)</label>
<p-textarea
[(ngModel)]="description"
rows="3"
placeholder="Add a description for this file..."
class="w-full"
[disabled]="isUploading"
></p-textarea>
</div>
<!-- File Upload -->
<p-fileupload class="w-full" name="file"
[maxFileSize]="maxFileSizeBytes"
[customUpload]="true"
[multiple]="false"
(onSelect)="onFilesSelect($event)"
(uploadHandler)="uploadFiles($event)"
[disabled]="isUploading">
<ng-template #header let-files let-chooseCallback="chooseCallback" let-clearCallback="clearCallback" let-uploadCallback="uploadCallback">
<div class="flex flex-wrap items-center justify-center flex-1 gap-4">
<div class="flex gap-4">
<p-button (onClick)="choose($event, chooseCallback)"
icon="pi pi-images"
[rounded]="true"
[outlined]="true"
[disabled]="isChooseDisabled()"/>
<p-button (onClick)="uploadEvent(uploadCallback)"
icon="pi pi-cloud-upload"
[rounded]="true"
[outlined]="true"
severity="success"
[disabled]="isUploadDisabled()"/>
<p-button (onClick)="onClear(clearCallback)"
icon="pi pi-times"
[rounded]="true"
[outlined]="true"
severity="danger"
[disabled]="!filesPresent() || isUploading"/>
</div>
</div>
</ng-template>
<ng-template #content let-files let-removeFileCallback="removeFileCallback">
<div class="flex flex-col gap-8 px-4">
@if (files?.length > 0) {
<div>
<div class="max-h-96 max-w-[22rem] md:max-w-none overflow-y-auto pr-2">
<div class="flex flex-wrap">
@for (uploadFile of this.files; track uploadFile; let i = $index) {
<div class="flex justify-between items-center w-full gap-4">
<div class="flex items-center gap-4 overflow-hidden flex-1">
<p-badge [value]="uploadFile.status" [severity]="getBadgeSeverity(uploadFile.status)" class="shrink-0"/>
<span class="font-semibold text-ellipsis whitespace-nowrap overflow-hidden flex-1">
{{ uploadFile.file.name }}
</span>
<div class="shrink-0">{{ formatSize(uploadFile.file.size) }}</div>
</div>
@switch (uploadFile.status) {
@case ('Pending') {
<p-button
icon="pi pi-times"
(click)="onRemoveTemplatingFile($event, uploadFile.file, removeFileCallback, i)"
severity="danger"
[text]="true"/>
}
@case ('Uploading') {
<i
class="pi pi-spin pi-spinner p-3"
style="color: slateblue"
pTooltip="Uploading"
tooltipPosition="top">
</i>
}
@case ('Uploaded') {
<i
class="pi pi-check p-3"
style="color: green"
pTooltip="Uploaded"
tooltipPosition="top">
</i>
}
@case ('Failed') {
<i
class="pi pi-exclamation-triangle p-3"
style="color: darkred"
pTooltip="{{ uploadFile.errorMessage || 'Upload failed' }}"
tooltipPosition="top">
</i>
}
}
</div>
}
</div>
</div>
</div>
}
</div>
</ng-template>
<ng-template #file></ng-template>
<ng-template #empty>
<div class="flex items-center justify-center flex-col text-center">
<i class="pi pi-cloud-upload !border-2 !rounded-full !p-8 !text-4xl !text-muted-color"></i>
<p class="mt-6 mb-2">Drag and drop a file here to upload.</p>
<p class="mt-2 mb-2">
Upload an additional file for <strong>{{ book.metadata?.title || 'this book' }}</strong>.
</p>
</div>
</ng-template>
</p-fileupload>
</div>

View File

@@ -0,0 +1,3 @@
:host ::ng-deep .p-fileupload-content p-progressbar {
display: none !important;
}

View File

@@ -0,0 +1,189 @@
import { Component, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DynamicDialogRef, DynamicDialogConfig } from 'primeng/dynamicdialog';
import { Select } from 'primeng/select';
import { Button } from 'primeng/button';
import { FileSelectEvent, FileUpload, FileUploadHandlerEvent } from 'primeng/fileupload';
import { Badge } from 'primeng/badge';
import { Tooltip } from 'primeng/tooltip';
import { Subject, takeUntil } from 'rxjs';
import { BookService } from '../../service/book.service';
import { AppSettingsService } from '../../../core/service/app-settings.service';
import { Book, AdditionalFileType } from '../../model/book.model';
import { MessageService } from 'primeng/api';
import { filter, take } from 'rxjs/operators';
interface FileTypeOption {
label: string;
value: AdditionalFileType;
}
interface UploadingFile {
file: File;
status: 'Pending' | 'Uploading' | 'Uploaded' | 'Failed';
errorMessage?: string;
}
@Component({
selector: 'app-additional-file-uploader',
standalone: true,
imports: [
CommonModule,
FormsModule,
Select,
Button,
FileUpload,
Badge,
Tooltip
],
templateUrl: './additional-file-uploader.component.html',
styleUrls: ['./additional-file-uploader.component.scss']
})
export class AdditionalFileUploaderComponent implements OnInit, OnDestroy {
book!: Book;
files: UploadingFile[] = [];
fileType: AdditionalFileType = AdditionalFileType.ALTERNATIVE_FORMAT;
description: string = '';
isUploading = false;
maxFileSizeBytes?: number;
fileTypeOptions: FileTypeOption[] = [
{ label: 'Alternative Format', value: AdditionalFileType.ALTERNATIVE_FORMAT },
{ label: 'Supplementary File', value: AdditionalFileType.SUPPLEMENTARY }
];
private destroy$ = new Subject<void>();
constructor(
private dialogRef: DynamicDialogRef,
private config: DynamicDialogConfig,
private bookService: BookService,
private appSettingsService: AppSettingsService,
private messageService: MessageService,
private cdr: ChangeDetectorRef
) {}
ngOnInit(): void {
this.book = this.config.data.book;
this.appSettingsService.appSettings$
.pipe(
filter(settings => settings != null),
take(1)
)
.subscribe(settings => {
if (settings) {
this.maxFileSizeBytes = (settings.maxFileUploadSizeInMb || 100) * 1024 * 1024;
}
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
hasPendingFiles(): boolean {
return this.files.some(f => f.status === 'Pending');
}
filesPresent(): boolean {
return this.files.length > 0;
}
choose(_event: any, chooseCallback: () => void): void {
chooseCallback();
}
onClear(clearCallback: () => void): void {
clearCallback();
this.files = [];
}
onFilesSelect(event: FileSelectEvent): void {
const newFiles = event.currentFiles;
// Only take the first file for single file upload
if (newFiles.length > 0) {
const file = newFiles[0];
this.files = [{
file,
status: 'Pending'
}];
}
}
onRemoveTemplatingFile(_event: any, _file: File, removeFileCallback: (event: any, index: number) => void, index: number): void {
removeFileCallback(_event, index);
}
uploadEvent(uploadCallback: () => void): void {
uploadCallback();
}
uploadFiles(event: FileUploadHandlerEvent): void {
const filesToUpload = this.files.filter(f => f.status === 'Pending');
if (filesToUpload.length === 0) return;
this.isUploading = true;
let pending = filesToUpload.length;
for (const uploadFile of filesToUpload) {
uploadFile.status = 'Uploading';
this.bookService.uploadAdditionalFile(
this.book.id,
uploadFile.file,
this.fileType,
this.description || undefined
).subscribe({
next: () => {
uploadFile.status = 'Uploaded';
if (--pending === 0) {
this.isUploading = false;
this.dialogRef.close({ success: true });
}
},
error: (err) => {
uploadFile.status = 'Failed';
uploadFile.errorMessage = err?.error?.message || 'Upload failed due to unknown error.';
console.error('Upload failed for', uploadFile.file.name, err);
if (--pending === 0) {
this.isUploading = false;
}
}
});
}
}
isChooseDisabled(): boolean {
return this.isUploading;
}
isUploadDisabled(): boolean {
return this.isChooseDisabled() || !this.filesPresent() || !this.hasPendingFiles();
}
formatSize(bytes: number): string {
const k = 1024;
const dm = 2;
if (bytes < k) return `${bytes} B`;
if (bytes < k * k) return `${(bytes / k).toFixed(dm)} KB`;
return `${(bytes / (k * k)).toFixed(dm)} MB`;
}
getBadgeSeverity(status: UploadingFile['status']): 'info' | 'warn' | 'success' | 'danger' {
switch (status) {
case 'Pending':
return 'warn';
case 'Uploading':
return 'info';
case 'Uploaded':
return 'success';
case 'Failed':
return 'danger';
default:
return 'info';
}
}
}

View File

@@ -78,7 +78,7 @@
class="custom-button-padding"
size="small"
[text]="true"
(click)="menu.toggle($event)"
(click)="onMenuToggle($event, menu)"
icon="pi pi-ellipsis-v">
</p-button>
</div>

View File

@@ -1,6 +1,6 @@
import {Component, ElementRef, EventEmitter, inject, Input, OnDestroy, OnInit, Output, ViewChild} from '@angular/core';
import {Component, ElementRef, EventEmitter, inject, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild} from '@angular/core';
import {TooltipModule} from "primeng/tooltip";
import {Book, ReadStatus} from '../../../model/book.model';
import {Book, ReadStatus, AdditionalFile} from '../../../model/book.model';
import {Button} from 'primeng/button';
import {MenuModule} from 'primeng/menu';
import {ConfirmationService, MenuItem, MessageService} from 'primeng/api';
@@ -33,7 +33,7 @@ import {ResetProgressTypes} from '../../../../shared/constants/reset-progress-ty
imports: [Button, MenuModule, CheckboxModule, FormsModule, NgClass, TieredMenu, ProgressBar, TooltipModule],
standalone: true
})
export class BookCardComponent implements OnInit, OnDestroy {
export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
@Output() checkboxClick = new EventEmitter<{ index: number; bookId: number; selected: boolean; shiftKey: boolean }>();
@@ -51,6 +51,8 @@ export class BookCardComponent implements OnInit, OnDestroy {
items: MenuItem[] | undefined;
isHovered: boolean = false;
isImageLoaded: boolean = false;
isSubMenuLoading = false;
private additionalFilesLoaded = false;
private bookService = inject(BookService);
private dialogService = inject(DialogService);
@@ -79,6 +81,14 @@ export class BookCardComponent implements OnInit, OnDestroy {
});
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['book'] && !changes['book'].firstChange) {
// Reset the flag when book changes
this.additionalFilesLoaded = false;
this.initMenu();
}
}
get progressPercentage(): number | null {
if (this.book.epubProgress?.percentage != null) {
return this.book.epubProgress.percentage;
@@ -107,6 +117,38 @@ export class BookCardComponent implements OnInit, OnDestroy {
this.bookService.readBook(book.id);
}
onMenuToggle(event: Event, menu: TieredMenu): void {
menu.toggle(event);
// Load additional files if not already loaded and needed
if (!this.additionalFilesLoaded && !this.isSubMenuLoading && this.needsAdditionalFilesData()) {
this.isSubMenuLoading = true;
this.bookService.getBookByIdFromAPI(this.book.id, true).subscribe({
next: (book) => {
this.book = book;
this.additionalFilesLoaded = true;
this.isSubMenuLoading = false;
this.initMenu();
},
error: () => {
this.isSubMenuLoading = false;
}
});
}
}
private needsAdditionalFilesData(): boolean {
// Don't need to load if already loaded
if (this.additionalFilesLoaded) {
return false;
}
const hasNoAlternativeFormats = !this.book.alternativeFormats || this.book.alternativeFormats.length === 0;
const hasNoSupplementaryFiles = !this.book.supplementaryFiles || this.book.supplementaryFiles.length === 0;
return (this.hasDownloadPermission() || this.hasDeleteBookPermission()) &&
hasNoAlternativeFormats && hasNoSupplementaryFiles;
}
private initMenu() {
this.items = [
{
@@ -144,33 +186,73 @@ export class BookCardComponent implements OnInit, OnDestroy {
const items: MenuItem[] = [];
if (this.hasDownloadPermission()) {
items.push({
label: 'Download',
icon: 'pi pi-download',
command: () => {
this.bookService.downloadFile(this.book.id);
},
});
const hasAdditionalFiles = (this.book.alternativeFormats && this.book.alternativeFormats.length > 0) ||
(this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0);
if (hasAdditionalFiles) {
const downloadItems = this.getDownloadMenuItems();
items.push({
label: 'Download',
icon: 'pi pi-download',
items: downloadItems
});
} else if (this.additionalFilesLoaded) {
// Data has been loaded but no additional files exist
items.push({
label: 'Download',
icon: 'pi pi-download',
command: () => {
this.bookService.downloadFile(this.book.id);
}
});
} else {
// Data not loaded yet
items.push({
label: 'Download',
icon: this.isSubMenuLoading ? 'pi pi-spin pi-spinner' : 'pi pi-download',
items: [{label: 'Loading...', disabled: true}]
});
}
}
if (this.hasDeleteBookPermission()) {
items.push({
label: 'Delete Book',
icon: 'pi pi-trash',
command: () => {
this.confirmationService.confirm({
message: `Are you sure you want to delete "${this.book.metadata?.title}"?`,
header: 'Confirm Deletion',
icon: 'pi pi-exclamation-triangle',
acceptIcon: 'pi pi-trash',
rejectIcon: 'pi pi-times',
acceptButtonStyleClass: 'p-button-danger',
accept: () => {
this.bookService.deleteBooks(new Set([this.book.id])).subscribe();
}
});
},
});
const hasAdditionalFiles = (this.book.alternativeFormats && this.book.alternativeFormats.length > 0) ||
(this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0);
if (hasAdditionalFiles) {
const deleteItems = this.getDeleteMenuItems();
items.push({
label: 'Delete',
icon: 'pi pi-trash',
items: deleteItems
});
} else if (this.additionalFilesLoaded) {
// Data has been loaded but no additional files exist - show delete book option
items.push({
label: 'Delete',
icon: 'pi pi-trash',
command: () => {
this.confirmationService.confirm({
message: `Are you sure you want to delete "${this.book.metadata?.title}"?`,
header: 'Confirm Deletion',
icon: 'pi pi-exclamation-triangle',
acceptIcon: 'pi pi-trash',
rejectIcon: 'pi pi-times',
acceptButtonStyleClass: 'p-button-danger',
accept: () => {
this.bookService.deleteBooks(new Set([this.book.id])).subscribe();
}
});
}
});
} else {
// Data not loaded yet
items.push({
label: 'Delete',
icon: this.isSubMenuLoading ? 'pi pi-spin pi-spinner' : 'pi pi-trash',
items: [{label: 'Loading...', disabled: true}]
});
}
}
if (this.hasEmailBookPermission()) {
@@ -396,6 +478,184 @@ export class BookCardComponent implements OnInit, OnDestroy {
}
}
private getDownloadMenuItems(): MenuItem[] {
const items: MenuItem[] = [];
// Add main book file first
items.push({
label: `${this.book.fileName || 'Book File'}`,
icon: 'pi pi-file',
command: () => {
this.bookService.downloadFile(this.book.id);
}
});
// Add separator if there are additional files
if (this.hasAdditionalFiles()) {
items.push({ separator: true });
}
// Add alternative formats
if (this.book.alternativeFormats && this.book.alternativeFormats.length > 0) {
this.book.alternativeFormats.forEach(format => {
const extension = this.getFileExtension(format.filePath);
items.push({
label: `${format.fileName} (${this.getFileSizeInMB(format)})`,
icon: this.getFileIcon(extension),
command: () => this.downloadAdditionalFile(this.book.id, format.id)
});
});
}
// Add separator if both alternative formats and supplementary files exist
if (this.book.alternativeFormats && this.book.alternativeFormats.length > 0 &&
this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0) {
items.push({ separator: true });
}
// Add supplementary files
if (this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0) {
this.book.supplementaryFiles.forEach(file => {
const extension = this.getFileExtension(file.filePath);
items.push({
label: `${file.fileName} (${this.getFileSizeInMB(file)})`,
icon: this.getFileIcon(extension),
command: () => this.downloadAdditionalFile(this.book.id, file.id)
});
});
}
return items;
}
private getDeleteMenuItems(): MenuItem[] {
const items: MenuItem[] = [];
// Add main book deletion
items.push({
label: 'Book',
icon: 'pi pi-book',
command: () => {
this.confirmationService.confirm({
message: `Are you sure you want to delete "${this.book.metadata?.title}"?`,
header: 'Confirm Deletion',
icon: 'pi pi-exclamation-triangle',
acceptIcon: 'pi pi-trash',
rejectIcon: 'pi pi-times',
acceptButtonStyleClass: 'p-button-danger',
accept: () => {
this.bookService.deleteBooks(new Set([this.book.id])).subscribe();
}
});
}
});
// Add separator if there are additional files
if (this.hasAdditionalFiles()) {
items.push({ separator: true });
}
// Add alternative formats
if (this.book.alternativeFormats && this.book.alternativeFormats.length > 0) {
this.book.alternativeFormats.forEach(format => {
const extension = this.getFileExtension(format.filePath);
items.push({
label: `${format.fileName} (${this.getFileSizeInMB(format)})`,
icon: this.getFileIcon(extension),
command: () => this.deleteAdditionalFile(this.book.id, format.id, format.fileName || 'file')
});
});
}
// Add separator if both alternative formats and supplementary files exist
if (this.book.alternativeFormats && this.book.alternativeFormats.length > 0 &&
this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0) {
items.push({ separator: true });
}
// Add supplementary files
if (this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0) {
this.book.supplementaryFiles.forEach(file => {
const extension = this.getFileExtension(file.filePath);
items.push({
label: `${file.fileName} (${this.getFileSizeInMB(file)})`,
icon: this.getFileIcon(extension),
command: () => this.deleteAdditionalFile(this.book.id, file.id, file.fileName || 'file')
});
});
}
return items;
}
private hasAdditionalFiles(): boolean {
return !!(this.book.alternativeFormats && this.book.alternativeFormats.length > 0) ||
!!(this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0);
}
private downloadAdditionalFile(bookId: number, fileId: number): void {
this.bookService.downloadAdditionalFile(bookId, fileId);
}
private deleteAdditionalFile(bookId: number, fileId: number, fileName: string): void {
this.confirmationService.confirm({
message: `Are you sure you want to delete the additional file "${fileName}"?`,
header: 'Confirm File Deletion',
icon: 'pi pi-exclamation-triangle',
acceptIcon: 'pi pi-trash',
rejectIcon: 'pi pi-times',
acceptButtonStyleClass: 'p-button-danger',
accept: () => {
this.bookService.deleteAdditionalFile(bookId, fileId).subscribe({
next: () => {
this.messageService.add({
severity: 'success',
summary: 'Success',
detail: `Additional file "${fileName}" deleted successfully`
});
},
error: (error) => {
this.messageService.add({
severity: 'error',
summary: 'Error',
detail: `Failed to delete additional file: ${error.message || 'Unknown error'}`
});
}
});
}
});
}
private getFileExtension(filePath?: string): string | null {
if (!filePath) return null;
const parts = filePath.split('.');
if (parts.length < 2) return null;
return parts.pop()?.toUpperCase() || null;
}
private getFileIcon(fileType: string | null): string {
if (!fileType) return 'pi pi-file';
switch (fileType.toLowerCase()) {
case 'pdf':
return 'pi pi-file-pdf';
case 'epub':
case 'mobi':
case 'azw3':
return 'pi pi-book';
case 'cbz':
case 'cbr':
case 'cbx':
return 'pi pi-image';
default:
return 'pi pi-file';
}
}
private getFileSizeInMB(fileInfo: AdditionalFile): string {
const sizeKb = fileInfo?.fileSizeKb;
return sizeKb != null ? `${(sizeKb / 1024).toFixed(2)} MB` : '-';
}
private isAdmin(): boolean {
return this.userPermissions?.admin ?? false;
}

View File

@@ -4,7 +4,27 @@ import {BookReview} from '../../book-review-service';
export type BookType = "PDF" | "EPUB" | "CBX";
export interface Book {
export enum AdditionalFileType {
ALTERNATIVE_FORMAT = 'ALTERNATIVE_FORMAT',
SUPPLEMENTARY = 'SUPPLEMENTARY'
}
export interface FileInfo {
fileName?: string;
filePath?: string;
fileSubPath?: string;
fileSizeKb?: number;
}
export interface AdditionalFile extends FileInfo {
id: number;
bookId: number;
additionalFileType: AdditionalFileType;
description?: string;
addedOn?: string;
}
export interface Book extends FileInfo {
id: number;
bookType: BookType;
libraryId: number;
@@ -16,15 +36,13 @@ export interface Book {
pdfProgress?: PdfProgress;
cbxProgress?: CbxProgress;
koreaderProgress?: KoReaderProgress;
filePath?: string;
fileSubPath?: string;
fileName?: string;
fileSizeKb?: number;
seriesCount?: number | null;
metadataMatchScore?: number | null;
readStatus?: ReadStatus;
dateFinished?: string;
libraryPath?: { id: number };
alternativeFormats?: AdditionalFile[];
supplementaryFiles?: AdditionalFile[];
}
export interface EpubProgress {

View File

@@ -2,7 +2,7 @@ import {inject, Injectable} from '@angular/core';
import {BehaviorSubject, first, Observable, of, throwError} from 'rxjs';
import {HttpClient, HttpParams} from '@angular/common/http';
import {catchError, filter, map, tap, shareReplay, finalize, distinctUntilChanged} from 'rxjs/operators';
import {Book, BookDeletionResponse, BookMetadata, BookRecommendation, BookSetting, BulkMetadataUpdateRequest, MetadataUpdateWrapper, ReadStatus} from '../model/book.model';
import {Book, BookDeletionResponse, BookMetadata, BookRecommendation, BookSetting, BulkMetadataUpdateRequest, MetadataUpdateWrapper, ReadStatus, AdditionalFileType, AdditionalFile} from '../model/book.model';
import {BookState} from '../model/state/book-state.model';
import {API_CONFIG} from '../../config/api-config';
import {FetchMetadataRequest} from '../../metadata/model/request/fetch-metadata-request.model';
@@ -88,6 +88,26 @@ export class BookService {
);
}
refreshBooks(): void {
this.http.get<Book[]>(this.url).pipe(
tap(books => {
this.bookStateSubject.next({
books: books || [],
loaded: true,
error: null,
});
}),
catchError(error => {
this.bookStateSubject.next({
books: null,
loaded: true,
error: error.message,
});
return of(null);
})
).subscribe();
}
getBookByIdFromState(bookId: number): Book | undefined {
const currentState = this.bookStateSubject.value;
return currentState.books?.find(book => +book.id === +bookId);
@@ -245,6 +265,90 @@ export class BookService {
);
}
deleteAdditionalFile(bookId: number, fileId: number): Observable<void> {
const deleteUrl = `${this.url}/${bookId}/files/${fileId}`;
return this.http.delete<void>(deleteUrl).pipe(
tap(() => {
const currentState = this.bookStateSubject.value;
const updatedBooks = (currentState.books || []).map(book => {
if (book.id === bookId) {
return {
...book,
alternativeFormats: book.alternativeFormats?.filter(file => file.id !== fileId),
supplementaryFiles: book.supplementaryFiles?.filter(file => file.id !== fileId)
};
}
return book;
});
this.bookStateSubject.next({
...currentState,
books: updatedBooks
});
this.messageService.add({
severity: 'success',
summary: 'File Deleted',
detail: 'Additional file deleted successfully.'
});
}),
catchError(error => {
this.messageService.add({
severity: 'error',
summary: 'Delete Failed',
detail: error?.error?.message || error?.message || 'An error occurred while deleting the file.'
});
return throwError(() => error);
})
);
}
uploadAdditionalFile(bookId: number, file: File, fileType: AdditionalFileType, description?: string): Observable<AdditionalFile> {
const formData = new FormData();
formData.append('file', file);
formData.append('additionalFileType', fileType);
if (description) {
formData.append('description', description);
}
return this.http.post<AdditionalFile>(`${this.url}/${bookId}/files`, formData).pipe(
tap((newFile) => {
const currentState = this.bookStateSubject.value;
const updatedBooks = (currentState.books || []).map(book => {
if (book.id === bookId) {
const updatedBook = { ...book };
if (fileType === AdditionalFileType.ALTERNATIVE_FORMAT) {
updatedBook.alternativeFormats = [...(book.alternativeFormats || []), newFile];
} else {
updatedBook.supplementaryFiles = [...(book.supplementaryFiles || []), newFile];
}
return updatedBook;
}
return book;
});
this.bookStateSubject.next({
...currentState,
books: updatedBooks
});
this.messageService.add({
severity: 'success',
summary: 'File Uploaded',
detail: 'Additional file uploaded successfully.'
});
}),
catchError(error => {
this.messageService.add({
severity: 'error',
summary: 'Upload Failed',
detail: error?.error?.message || error?.message || 'An error occurred while uploading the file.'
});
return throwError(() => error);
})
);
}
downloadFile(bookId: number): void {
const downloadUrl = `${this.url}/${bookId}/download`;
this.http.get(downloadUrl, {responseType: 'blob', observe: 'response'})
@@ -260,6 +364,21 @@ export class BookService {
});
}
downloadAdditionalFile(bookId: number, fileId: number): void {
const downloadUrl = `${this.url}/${bookId}/files/${fileId}/download`;
this.http.get(downloadUrl, {responseType: 'blob', observe: 'response'})
.subscribe({
next: (response) => {
const contentDisposition = response.headers.get('Content-Disposition');
const filename = contentDisposition
? contentDisposition.match(/filename="(.+?)"/)?.[1] || `additional_file_${fileId}`
: `additional_file_${fileId}`;
this.saveFile(response.body as Blob, filename);
},
error: (err) => console.error('Error downloading additional file:', err),
});
}
private saveFile(blob: Blob, filename: string): void {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');

View File

@@ -419,13 +419,25 @@
(onClick)="assignShelf(book.id)">
</p-button>
@if (userState.user!.permissions.canDownload || userState.user!.permissions.admin) {
<p-button
label="Download"
icon="pi pi-download"
severity="success"
outlined
(onClick)="download(book!.id)">
</p-button>
@if ((book!.alternativeFormats && book!.alternativeFormats.length > 0) || (book!.supplementaryFiles && book!.supplementaryFiles.length > 0)) {
@if (downloadMenuItems$ | async; as downloadItems) {
<p-splitbutton
label="Download"
icon="pi pi-download"
[model]="downloadItems"
(onClick)="download(book!.id)"
severity="success"
outlined/>
}
} @else {
<p-button
label="Download"
icon="pi pi-download"
severity="success"
outlined
(onClick)="download(book!.id)">
</p-button>
}
}
@if (userState.user!.permissions.canEmailBook || userState.user!.permissions.admin) {
@if (emailMenuItems$ | async; as emailItems) {
@@ -457,7 +469,7 @@
@if (userState.user!.permissions.canDeleteBook || userState.user!.permissions.admin) {
@if (otherItems$ | async; as otherItems) {
<p-button icon="pi pi-ellipsis-v" outlined severity="danger" (click)="entitymenu.toggle($event)"></p-button>
<p-menu #entitymenu [model]="otherItems" [popup]="true" appendTo="body"></p-menu>
<p-tieredMenu #entitymenu [model]="otherItems" [popup]="true" appendTo="body"></p-tieredMenu>
}
}
</div>

View File

@@ -6,7 +6,7 @@ import {BookService} from '../../../book/service/book.service';
import {Rating, RatingRateEvent} from 'primeng/rating';
import {FormsModule} from '@angular/forms';
import {Tag} from 'primeng/tag';
import {Book, BookMetadata, BookRecommendation, ReadStatus} from '../../../book/model/book.model';
import {Book, BookMetadata, BookRecommendation, ReadStatus, FileInfo} from '../../../book/model/book.model';
import {UrlHelperService} from '../../../utilities/service/url-helper.service';
import {UserService} from '../../../settings/user-management/user.service';
import {SplitButton} from 'primeng/splitbutton';
@@ -33,13 +33,15 @@ import {Tab, TabList, TabPanel, TabPanels, Tabs} from 'primeng/tabs';
import {BookReviewsComponent} from '../../../book/components/book-reviews/book-reviews.component';
import {BookNotesComponent} from '../../../book/components/book-notes-component/book-notes-component';
import {ProgressSpinner} from 'primeng/progressspinner';
import {TieredMenu} from 'primeng/tieredmenu';
import {AdditionalFileUploaderComponent} from '../../../book/components/additional-file-uploader/additional-file-uploader.component';
@Component({
selector: 'app-metadata-viewer',
standalone: true,
templateUrl: './metadata-viewer.component.html',
styleUrl: './metadata-viewer.component.scss',
imports: [Button, AsyncPipe, Rating, FormsModule, Tag, SplitButton, NgClass, Tooltip, DecimalPipe, Editor, ProgressBar, Menu, InfiniteScrollDirective, BookCardLiteComponent, DatePicker, Tab, TabList, TabPanel, TabPanels, Tabs, BookReviewsComponent, BookNotesComponent, ProgressSpinner]
imports: [Button, AsyncPipe, Rating, FormsModule, Tag, SplitButton, NgClass, Tooltip, DecimalPipe, Editor, ProgressBar, Menu, InfiniteScrollDirective, BookCardLiteComponent, DatePicker, Tab, TabList, TabPanel, TabPanels, Tabs, BookReviewsComponent, BookNotesComponent, ProgressSpinner, TieredMenu]
})
export class MetadataViewerComponent implements OnInit, OnChanges {
@Input() book$!: Observable<Book | null>;
@@ -62,7 +64,7 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
readMenuItems$!: Observable<MenuItem[]>;
refreshMenuItems$!: Observable<MenuItem[]>;
otherItems$!: Observable<MenuItem[]>;
downloadMenuItems$!: Observable<MenuItem[]>;
bookInSeries: Book[] = [];
isExpanded = false;
showFilePath = false;
@@ -135,37 +137,140 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
])
);
this.downloadMenuItems$ = this.book$.pipe(
filter((book): book is Book => book !== null &&
((book.alternativeFormats !== undefined && book.alternativeFormats.length > 0) ||
(book.supplementaryFiles !== undefined && book.supplementaryFiles.length > 0))),
map((book): MenuItem[] => {
const items: MenuItem[] = [];
// Add alternative formats
if (book.alternativeFormats && book.alternativeFormats.length > 0) {
book.alternativeFormats.forEach(format => {
const extension = this.getFileExtension(format.filePath);
items.push({
label: `${format.fileName} (${this.getFileSizeInMB(format)})`,
icon: this.getFileIcon(extension),
command: () => this.downloadAdditionalFile(book.id, format.id)
});
});
}
// Add separator if both types exist
if (book.alternativeFormats && book.alternativeFormats.length > 0 &&
book.supplementaryFiles && book.supplementaryFiles.length > 0) {
items.push({ separator: true });
}
// Add supplementary files
if (book.supplementaryFiles && book.supplementaryFiles.length > 0) {
book.supplementaryFiles.forEach(file => {
const extension = this.getFileExtension(file.filePath);
items.push({
label: `${file.fileName} (${this.getFileSizeInMB(file)})`,
icon: this.getFileIcon(extension),
command: () => this.downloadAdditionalFile(book.id, file.id)
});
});
}
return items;
})
);
this.otherItems$ = this.book$.pipe(
filter((book): book is Book => book !== null),
map((book): MenuItem[] => [
{
label: 'Delete Book',
icon: 'pi pi-trash',
command: () => {
this.confirmationService.confirm({
message: `Are you sure you want to delete "${book.metadata?.title}"?`,
header: 'Confirm Deletion',
icon: 'pi pi-exclamation-triangle',
acceptIcon: 'pi pi-trash',
rejectIcon: 'pi pi-times',
acceptButtonStyleClass: 'p-button-danger',
accept: () => {
this.bookService.deleteBooks(new Set([book.id])).subscribe({
next: () => {
if (this.metadataCenterViewMode === 'route') {
this.router.navigate(['/dashboard']);
} else {
this.dialogRef?.close();
}
},
error: () => {
}
});
}
});
map((book): MenuItem[] => {
const items: MenuItem[] = [
{
label: 'Upload File',
icon: 'pi pi-upload',
command: () => {
this.dialogService.open(AdditionalFileUploaderComponent, {
header: 'Upload Additional File',
modal: true,
closable: true,
style: {
position: 'absolute',
top: '10%',
},
data: {book}
});
},
},
{
label: 'Delete Book',
icon: 'pi pi-trash',
command: () => {
this.confirmationService.confirm({
message: `Are you sure you want to delete "${book.metadata?.title}"?`,
header: 'Confirm Deletion',
icon: 'pi pi-exclamation-triangle',
acceptIcon: 'pi pi-trash',
rejectIcon: 'pi pi-times',
acceptButtonStyleClass: 'p-button-danger',
accept: () => {
this.bookService.deleteBooks(new Set([book.id])).subscribe({
next: () => {
if (this.metadataCenterViewMode === 'route') {
this.router.navigate(['/dashboard']);
} else {
this.dialogRef?.close();
}
},
error: () => {
}
});
}
});
},
},
];
// Add delete additional files menu if there are any additional files
if ((book.alternativeFormats && book.alternativeFormats.length > 0) ||
(book.supplementaryFiles && book.supplementaryFiles.length > 0)) {
const deleteFileItems: MenuItem[] = [];
// Add alternative formats
if (book.alternativeFormats && book.alternativeFormats.length > 0) {
book.alternativeFormats.forEach(format => {
const extension = this.getFileExtension(format.filePath);
deleteFileItems.push({
label: `${format.fileName} (${this.getFileSizeInMB(format)})`,
icon: this.getFileIcon(extension),
command: () => this.deleteAdditionalFile(book.id, format.id, format.fileName || 'file')
});
});
}
// Add separator if both types exist
if (book.alternativeFormats && book.alternativeFormats.length > 0 &&
book.supplementaryFiles && book.supplementaryFiles.length > 0) {
deleteFileItems.push({ separator: true });
}
// Add supplementary files
if (book.supplementaryFiles && book.supplementaryFiles.length > 0) {
book.supplementaryFiles.forEach(file => {
const extension = this.getFileExtension(file.filePath);
deleteFileItems.push({
label: `${file.fileName} (${this.getFileSizeInMB(file)})`,
icon: this.getFileIcon(extension),
command: () => this.deleteAdditionalFile(book.id, file.id, file.fileName || 'file')
});
});
}
items.push({
label: 'Delete Additional Files',
icon: 'pi pi-trash',
items: deleteFileItems
});
}
])
return items;
})
);
this.userService.userState$
@@ -240,6 +345,39 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
this.bookService.downloadFile(bookId);
}
downloadAdditionalFile(bookId: number, fileId: number) {
this.bookService.downloadAdditionalFile(bookId, fileId);
}
deleteAdditionalFile(bookId: number, fileId: number, fileName: string) {
this.confirmationService.confirm({
message: `Are you sure you want to delete the additional file "${fileName}"?`,
header: 'Confirm File Deletion',
icon: 'pi pi-exclamation-triangle',
acceptIcon: 'pi pi-trash',
rejectIcon: 'pi pi-times',
acceptButtonStyleClass: 'p-button-danger',
accept: () => {
this.bookService.deleteAdditionalFile(bookId, fileId).subscribe({
next: () => {
this.messageService.add({
severity: 'success',
summary: 'Success',
detail: `Additional file "${fileName}" deleted successfully`
});
},
error: (error) => {
this.messageService.add({
severity: 'error',
summary: 'Error',
detail: `Failed to delete additional file: ${error.message || 'Unknown error'}`
});
}
});
}
});
}
quickRefresh(bookId: number) {
this.isAutoFetching = true;
const request: MetadataRefreshRequest = {
@@ -436,8 +574,8 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
return lockedKeys.length > 0 && lockedKeys.every(k => metadata[k] === true);
}
getFileSizeInMB(book: Book | null): string {
const sizeKb = book?.fileSizeKb;
getFileSizeInMB(fileInfo: FileInfo | null | undefined): string {
const sizeKb = fileInfo?.fileSizeKb;
return sizeKb != null ? `${(sizeKb / 1024).toFixed(2)} MB` : '-';
}
@@ -468,6 +606,24 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
return parts.pop()?.toUpperCase() || null;
}
getFileIcon(fileType: string | null): string {
if (!fileType) return 'pi pi-file';
switch (fileType.toLowerCase()) {
case 'pdf':
return 'pi pi-file-pdf';
case 'epub':
case 'mobi':
case 'azw3':
return 'pi pi-book';
case 'cbz':
case 'cbr':
case 'cbx':
return 'pi pi-image';
default:
return 'pi pi-file';
}
}
getFileTypeColorClass(fileType: string | null | undefined): string {
if (!fileType) return 'bg-gray-600 text-white';
switch (fileType.toLowerCase()) {