diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityUtil.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityUtil.java index 79b2829dc..9aa0046cd 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityUtil.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityUtil.java @@ -54,6 +54,11 @@ public class SecurityUtil { var user = getCurrentUser(); return user != null && user.getPermissions().isCanEmailBook(); } + public boolean canDeleteBook() { + var user = getCurrentUser(); + return user != null && user.getPermissions().isCanDeleteBook(); + } + public boolean canViewUserProfile(Long userId) { var user = getCurrentUser(); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookController.java index bc4b02f6c..0b23c61dc 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookController.java @@ -6,6 +6,7 @@ import com.adityachandel.booklore.model.dto.BookRecommendation; import com.adityachandel.booklore.model.dto.BookViewerSettings; import com.adityachandel.booklore.model.dto.request.ReadProgressRequest; import com.adityachandel.booklore.model.dto.request.ShelvesAssignmentRequest; +import com.adityachandel.booklore.model.dto.response.BookDeletionResponse; import com.adityachandel.booklore.service.metadata.MetadataBackupRestoreService; import com.adityachandel.booklore.service.recommender.BookRecommendationService; import com.adityachandel.booklore.service.BookService; @@ -21,6 +22,7 @@ import org.springframework.web.bind.annotation.*; import java.io.IOException; import java.util.List; +import java.util.Map; import java.util.Set; @RequestMapping("/api/v1/books") @@ -43,6 +45,12 @@ public class BookController { return ResponseEntity.ok(bookService.getBook(bookId, withDescription)); } + @PreAuthorize("@securityUtil.canDeleteBook() or @securityUtil.isAdmin()") + @DeleteMapping + public ResponseEntity deleteBooks(@RequestParam Set ids) { + return bookService.deleteBooks(ids); + } + @GetMapping("/batch") public ResponseEntity> getBooksByIds(@RequestParam Set ids, @RequestParam(required = false, defaultValue = "false") boolean withDescription) { return ResponseEntity.ok(bookService.getBooksByIds(ids, withDescription)); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java b/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java index 396c540c3..9335a1eca 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java @@ -44,7 +44,8 @@ public enum ApiError { INVALID_CREDENTIALS(HttpStatus.BAD_REQUEST, "Invalid credentials"), REMOTE_AUTH_DISABLED(HttpStatus.NON_AUTHORITATIVE_INFORMATION, "Remote login is disabled"), SELF_DELETION_NOT_ALLOWED(HttpStatus.FORBIDDEN, "You cannot delete your own account"), - INVALID_INPUT(HttpStatus.BAD_REQUEST, "%s"); + INVALID_INPUT(HttpStatus.BAD_REQUEST, "%s"), + FILE_DELETION_DISABLED(HttpStatus.BAD_REQUEST, "File deletion is disabled"),; private final HttpStatus status; private final String message; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/exception/GlobalExceptionHandler.java b/booklore-api/src/main/java/com/adityachandel/booklore/exception/GlobalExceptionHandler.java index 973f5f8ef..850211ca2 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/exception/GlobalExceptionHandler.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/exception/GlobalExceptionHandler.java @@ -64,7 +64,7 @@ public class GlobalExceptionHandler { @ExceptionHandler(AccessDeniedException.class) public ResponseEntity handleAccessDeniedException(AccessDeniedException ex) { - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.FORBIDDEN.value(), "Access Denied: " + ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.FORBIDDEN.value(), ex.getMessage()); return new ResponseEntity<>(errorResponse, HttpStatus.FORBIDDEN); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java index a00ef5f88..370f350cf 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java @@ -28,6 +28,7 @@ public class BookLoreUserTransformer { permissions.setCanDownload(userEntity.getPermissions().isPermissionDownload()); permissions.setCanEditMetadata(userEntity.getPermissions().isPermissionEditMetadata()); permissions.setCanEmailBook(userEntity.getPermissions().isPermissionEmailBook()); + permissions.setCanDeleteBook(userEntity.getPermissions().isPermissionDeleteBook()); permissions.setCanManipulateLibrary(userEntity.getPermissions().isPermissionManipulateLibrary()); BookLoreUser bookLoreUser = new BookLoreUser(); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java index 62e04b911..c827fde4d 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java @@ -29,6 +29,7 @@ public class BookLoreUser { private boolean canEditMetadata; private boolean canManipulateLibrary; private boolean canEmailBook; + private boolean canDeleteBook; } @Data diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UserCreateRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UserCreateRequest.java index 2ee5f9afc..d77d10355 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UserCreateRequest.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UserCreateRequest.java @@ -15,6 +15,7 @@ public class UserCreateRequest { private boolean permissionDownload; private boolean permissionEditMetadata; private boolean permissionEmailBook; + private boolean permissionDeleteBook; private boolean permissionAdmin; private Set selectedLibraries; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/UserUpdateRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/UserUpdateRequest.java index 791a3fe37..cf4bf1045 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/UserUpdateRequest.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/UserUpdateRequest.java @@ -19,5 +19,6 @@ public class UserUpdateRequest { private boolean canEditMetadata; private boolean canManipulateLibrary; private boolean canEmailBook; + private boolean canDeleteBook; } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/BookDeletionResponse.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/BookDeletionResponse.java new file mode 100644 index 000000000..f59e96bd6 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/BookDeletionResponse.java @@ -0,0 +1,16 @@ +package com.adityachandel.booklore.model.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Set; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class BookDeletionResponse { + private Set deleted; + private List failedFileDeletions; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettingKey.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettingKey.java index 2eb95050d..6cee1a929 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettingKey.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettingKey.java @@ -21,6 +21,7 @@ public enum AppSettingKey { OIDC_ENABLED("oidc_enabled", false), CBX_CACHE_SIZE_IN_MB("cbx_cache_size_in_mb", false), PDF_CACHE_SIZE_IN_MB("pdf_cache_size_in_mb", false), + BOOK_DELETION_ENABLED("book_deletion_enabled", false), MAX_FILE_UPLOAD_SIZE_IN_MB("max_file_upload_size_in_mb", false); private final String dbKey; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java index 7997afaed..1c2e029ea 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java @@ -22,6 +22,7 @@ public class AppSettings { private Integer maxFileUploadSizeInMb; private boolean remoteAuthEnabled; private boolean oidcEnabled; + private boolean bookDeletionEnabled; private OidcProviderDetails oidcProviderDetails; private OidcAutoProvisionDetails oidcAutoProvisionDetails; private MetadataProviderSettings metadataProviderSettings; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserPermissionsEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserPermissionsEntity.java index 712fe8d90..e0f55e2b2 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserPermissionsEntity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserPermissionsEntity.java @@ -35,6 +35,9 @@ public class UserPermissionsEntity { @Column(name = "permission_email_book", nullable = false) private boolean permissionEmailBook = false; + @Column(name = "permission_delete_book", nullable = false) + private boolean permissionDeleteBook = false; + @Column(name = "permission_admin", nullable = false) private boolean permissionAdmin; } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java index d0092ae4d..142e7b606 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java @@ -5,9 +5,11 @@ import com.adityachandel.booklore.exception.ApiError; import com.adityachandel.booklore.mapper.BookMapper; import com.adityachandel.booklore.model.dto.*; import com.adityachandel.booklore.model.dto.request.ReadProgressRequest; +import com.adityachandel.booklore.model.dto.response.BookDeletionResponse; import com.adityachandel.booklore.model.entity.*; import com.adityachandel.booklore.model.enums.BookFileType; import com.adityachandel.booklore.repository.*; +import com.adityachandel.booklore.service.appsettings.AppSettingService; import com.adityachandel.booklore.util.FileService; import com.adityachandel.booklore.util.FileUtils; import lombok.AllArgsConstructor; @@ -16,6 +18,7 @@ import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -24,12 +27,11 @@ import org.springframework.transaction.annotation.Transactional; import java.io.FileInputStream; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Instant; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; import java.util.stream.Collectors; @Slf4j @@ -50,6 +52,7 @@ public class BookService { private final AuthenticationService authenticationService; private final BookQueryService bookQueryService; private final UserProgressService userProgressService; + private final AppSettingService appSettingService; public List getBookDTOs(boolean includeDescription) { BookLoreUser user = authenticationService.getAuthenticatedUser(); @@ -349,4 +352,26 @@ public class BookService { .body(new ByteArrayResource(inputStream.readAllBytes())); } } + + + @Transactional + public ResponseEntity deleteBooks(Set ids) { + List books = bookQueryService.findAllWithMetadataByIds(ids); + List failedFileDeletions = new ArrayList<>(); + for (BookEntity book : books) { + Path fullFilePath = book.getFullFilePath(); + try { + if (Files.exists(fullFilePath)) { + Files.delete(fullFilePath); + } + } catch (IOException e) { + failedFileDeletions.add(book.getId()); + } + } + bookRepository.deleteAll(books); + BookDeletionResponse response = new BookDeletionResponse(ids, failedFileDeletions); + return failedFileDeletions.isEmpty() + ? ResponseEntity.ok(response) + : ResponseEntity.status(HttpStatus.MULTI_STATUS).body(response); + } } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/AppSettingService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/AppSettingService.java index 2093494ca..9cae8b746 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/AppSettingService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/AppSettingService.java @@ -80,6 +80,7 @@ public class AppSettingService { builder.cbxCacheSizeInMb(Integer.parseInt(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.CBX_CACHE_SIZE_IN_MB, "5120"))); builder.pdfCacheSizeInMb(Integer.parseInt(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.PDF_CACHE_SIZE_IN_MB, "5120"))); builder.maxFileUploadSizeInMb(Integer.parseInt(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.MAX_FILE_UPLOAD_SIZE_IN_MB, "100"))); + builder.bookDeletionEnabled(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.BOOK_DELETION_ENABLED, "false"))); return builder.build(); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserProvisioningService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserProvisioningService.java index 1a7b7e94c..a703f108a 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserProvisioningService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserProvisioningService.java @@ -50,6 +50,7 @@ public class UserProvisioningService { perms.setPermissionEditMetadata(true); perms.setPermissionManipulateLibrary(true); perms.setPermissionEmailBook(true); + perms.setPermissionDeleteBook(true); user.setPermissions(perms); createUser(user); @@ -76,6 +77,7 @@ public class UserProvisioningService { permissions.setPermissionDownload(request.isPermissionDownload()); permissions.setPermissionEditMetadata(request.isPermissionEditMetadata()); permissions.setPermissionEmailBook(request.isPermissionEmailBook()); + permissions.setPermissionDeleteBook(request.isPermissionDeleteBook()); permissions.setPermissionAdmin(request.isPermissionAdmin()); user.setPermissions(permissions); @@ -105,6 +107,7 @@ public class UserProvisioningService { perms.setPermissionEditMetadata(defaultPermissions.contains("permissionEditMetadata")); perms.setPermissionManipulateLibrary(defaultPermissions.contains("permissionManipulateLibrary")); perms.setPermissionEmailBook(defaultPermissions.contains("permissionEmailBook")); + perms.setPermissionDeleteBook(defaultPermissions.contains("permissionDeleteBooks")); } user.setPermissions(perms); @@ -145,6 +148,7 @@ public class UserProvisioningService { permissions.setPermissionDownload(true); permissions.setPermissionEditMetadata(true); permissions.setPermissionEmailBook(true); + permissions.setPermissionDeleteBook(true); permissions.setPermissionAdmin(isAdmin); user.setPermissions(permissions); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserService.java index d0b5eaafa..202c19aed 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserService.java @@ -14,12 +14,8 @@ import com.adityachandel.booklore.model.entity.LibraryEntity; import com.adityachandel.booklore.model.entity.UserSettingEntity; import com.adityachandel.booklore.repository.LibraryRepository; import com.adityachandel.booklore.repository.UserRepository; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -56,6 +52,7 @@ public class UserService { user.getPermissions().setPermissionEditMetadata(updateRequest.getPermissions().isCanEditMetadata()); user.getPermissions().setPermissionManipulateLibrary(updateRequest.getPermissions().isCanManipulateLibrary()); user.getPermissions().setPermissionEmailBook(updateRequest.getPermissions().isCanEmailBook()); + user.getPermissions().setPermissionDeleteBook(updateRequest.getPermissions().isCanDeleteBook()); } if (updateRequest.getAssignedLibraries() != null && getMyself().getPermissions().isAdmin()) { diff --git a/booklore-api/src/main/resources/db/migration/V31__Add_delete_books_permission.sql b/booklore-api/src/main/resources/db/migration/V31__Add_delete_books_permission.sql new file mode 100644 index 000000000..6b8dd72b0 --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V31__Add_delete_books_permission.sql @@ -0,0 +1,6 @@ +ALTER TABLE user_permissions + ADD COLUMN permission_delete_book BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE user_permissions up +SET up.permission_delete_book = TRUE +WHERE up.permission_admin = TRUE; \ No newline at end of file diff --git a/booklore-ui/src/app/book/components/book-browser/book-browser.component.html b/booklore-ui/src/app/book/components/book-browser/book-browser.component.html index e8a74efe6..f6ccbd4ff 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-browser.component.html +++ b/booklore-ui/src/app/book/components/book-browser/book-browser.component.html @@ -214,70 +214,91 @@ } @if (selectedBooks.size > 0) { - diff --git a/booklore-ui/src/app/book/components/book-browser/book-browser.component.scss b/booklore-ui/src/app/book/components/book-browser/book-browser.component.scss index 01d517bed..6bf37cd5d 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-browser.component.scss +++ b/booklore-ui/src/app/book/components/book-browser/book-browser.component.scss @@ -33,8 +33,8 @@ .book-browser-footer { position: absolute; bottom: 0; - left: 30%; - right: 30%; + left: 25%; + right: 25%; display: flex; justify-content: center; padding: 1rem 0.5rem 0.5rem; diff --git a/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts b/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts index 3be5305ff..514e6ea7f 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts +++ b/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts @@ -1,6 +1,6 @@ import {AfterViewInit, ChangeDetectorRef, Component, inject, OnInit, ViewChild} from '@angular/core'; import {ActivatedRoute, Router} from '@angular/router'; -import {MenuItem, MessageService, PrimeTemplate} from 'primeng/api'; +import {ConfirmationService, MenuItem, MessageService, PrimeTemplate} from 'primeng/api'; import {LibraryService} from '../../service/library.service'; import {BookService} from '../../service/book.service'; import {debounceTime, filter, map, switchMap, take} from 'rxjs/operators'; @@ -42,6 +42,7 @@ import {Slider} from 'primeng/slider'; import {Select} from 'primeng/select'; import {FilterSortPreferenceService} from './filters/filter-sorting-preferences.service'; import {TieredMenu} from 'primeng/tieredmenu'; +import {Divider} from 'primeng/divider'; export enum EntityType { LIBRARY = 'Library', @@ -74,7 +75,7 @@ const SORT_DIRECTION = { standalone: true, templateUrl: './book-browser.component.html', styleUrls: ['./book-browser.component.scss'], - imports: [Button, VirtualScrollerModule, BookCardComponent, AsyncPipe, ProgressSpinner, Menu, InputText, FormsModule, BookTableComponent, BookFilterComponent, Tooltip, NgClass, Fluid, PrimeTemplate, NgStyle, OverlayPanelModule, DropdownModule, Checkbox, Popover, Slider, Select, TieredMenu], + imports: [Button, VirtualScrollerModule, BookCardComponent, AsyncPipe, ProgressSpinner, Menu, InputText, FormsModule, BookTableComponent, BookFilterComponent, Tooltip, NgClass, Fluid, PrimeTemplate, NgStyle, OverlayPanelModule, DropdownModule, Checkbox, Popover, Slider, Select, TieredMenu, Divider], providers: [SeriesCollapseFilter], animations: [ trigger('slideInOut', [ @@ -135,6 +136,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit { private changeDetectorRef = inject(ChangeDetectorRef); private libraryShelfMenuService = inject(LibraryShelfMenuService); protected seriesCollapseFilter = inject(SeriesCollapseFilter); + protected confirmationService = inject(ConfirmationService); private sideBarFilter = new SideBarFilter(this.selectedFilter, this.selectedFilterMode); private headerFilter = new HeaderFilter(this.searchTerm$); @@ -538,6 +540,21 @@ export class BookBrowserComponent implements OnInit, AfterViewInit { } } + confirmDeleteBooks(): void { + this.confirmationService.confirm({ + message: `Are you sure you want to delete ${this.selectedBooks.size} book(s)?`, + header: 'Confirm Deletion', + icon: 'pi pi-exclamation-triangle', + accept: () => { + this.bookService.deleteBooks(this.selectedBooks).subscribe(() => { + this.selectedBooks.clear(); + }); + }, + reject: () => { + } + }); + } + onSeriesCollapseCheckboxChange(value: boolean): void { this.seriesCollapseFilter.setCollapsed(value); } diff --git a/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.ts b/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.ts index a0e0970e6..a81d5726c 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.ts +++ b/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.ts @@ -2,7 +2,7 @@ import {Component, ElementRef, EventEmitter, inject, Input, OnInit, Output, View import {Book, BookMetadata} from '../../../model/book.model'; import {Button} from 'primeng/button'; import {MenuModule} from 'primeng/menu'; -import {MenuItem, MessageService} from 'primeng/api'; +import {ConfirmationService, MenuItem, MessageService} from 'primeng/api'; import {DialogService} from 'primeng/dynamicdialog'; import {ShelfAssignerComponent} from '../../shelf-assigner/shelf-assigner.component'; import {BookService} from '../../../service/book.service'; @@ -53,6 +53,7 @@ export class BookCardComponent implements OnInit { private messageService = inject(MessageService); private router = inject(Router); protected urlHelper = inject(UrlHelperService); + private confirmationService = inject(ConfirmationService); private userPermissions: any; @@ -125,49 +126,6 @@ export class BookCardComponent implements OnInit { private getPermissionBasedMenuItems(): MenuItem[] { const items: MenuItem[] = []; - if (this.hasEditMetadataPermission()) { - items.push( - { - label: 'Match Book', - icon: 'pi pi-sparkles', - command: () => { - setTimeout(() => { - this.router.navigate(['/book', this.book.id], { - queryParams: {tab: 'match'} - }) - }, 150); - }, - }, - { - label: 'Quick Refresh', - icon: 'pi pi-bolt', - command: () => { - const metadataRefreshRequest: MetadataRefreshRequest = { - quick: true, - refreshType: MetadataRefreshType.BOOKS, - bookIds: [this.book.id], - }; - this.bookService.autoRefreshMetadata(metadataRefreshRequest).subscribe(); - }, - }, - { - label: 'Granular Refresh', - icon: 'pi pi-database', - command: () => { - this.dialogService.open(MetadataFetchOptionsComponent, { - header: 'Metadata Refresh Options', - modal: true, - closable: true, - data: { - bookIds: [this.book!.id], - metadataRefreshType: MetadataRefreshType.BOOKS, - }, - }); - }, - } - ); - } - if (this.hasDownloadPermission()) { items.push({ label: 'Download', @@ -178,6 +136,26 @@ export class BookCardComponent implements OnInit { }); } + 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(); + } + }); + }, + }); + } + if (this.hasEmailBookPermission()) { items.push( { @@ -228,6 +206,53 @@ export class BookCardComponent implements OnInit { }); } + if (this.hasEditMetadataPermission()) { + items.push({ + label: 'Metadata', + icon: 'pi pi-database', + items: [ + { + label: 'Search Metadata', + icon: 'pi pi-sparkles', + command: () => { + setTimeout(() => { + this.router.navigate(['/book', this.book.id], { + queryParams: {tab: 'match'} + }) + }, 150); + }, + }, + { + label: 'Auto Fetch', + icon: 'pi pi-bolt', + command: () => { + const metadataRefreshRequest: MetadataRefreshRequest = { + quick: true, + refreshType: MetadataRefreshType.BOOKS, + bookIds: [this.book.id], + }; + this.bookService.autoRefreshMetadata(metadataRefreshRequest).subscribe(); + }, + }, + { + label: 'Advanced Fetch', + icon: 'pi pi-database', + command: () => { + this.dialogService.open(MetadataFetchOptionsComponent, { + header: 'Metadata Refresh Options', + modal: true, + closable: true, + data: { + bookIds: [this.book!.id], + metadataRefreshType: MetadataRefreshType.BOOKS, + }, + }); + }, + } + ] + }); + } + return items; } @@ -254,16 +279,24 @@ export class BookCardComponent implements OnInit { }); } + private isAdmin(): boolean { + return this.userPermissions?.admin ?? false; + } + private hasEditMetadataPermission(): boolean { - return this.userPermissions?.canEditMetadata ?? false; + return this.isAdmin() || (this.userPermissions?.canEditMetadata ?? false); } private hasDownloadPermission(): boolean { - return this.userPermissions?.canDownload ?? false; + return this.isAdmin() || (this.userPermissions?.canDownload ?? false); } private hasEmailBookPermission(): boolean { - return this.userPermissions?.canEmailBook ?? false; + return this.isAdmin() || (this.userPermissions?.canEmailBook ?? false); + } + + private hasDeleteBookPermission(): boolean { + return this.isAdmin() || (this.userPermissions?.canDeleteBook ?? false); } isMetadataFullyLocked(metadata: BookMetadata): boolean { diff --git a/booklore-ui/src/app/book/metadata/book-metadata-center/metadata-viewer/metadata-viewer.component.html b/booklore-ui/src/app/book/metadata/book-metadata-center/metadata-viewer/metadata-viewer.component.html index bb3ace850..76f817842 100644 --- a/booklore-ui/src/app/book/metadata/book-metadata-center/metadata-viewer/metadata-viewer.component.html +++ b/booklore-ui/src/app/book/metadata/book-metadata-center/metadata-viewer/metadata-viewer.component.html @@ -286,6 +286,12 @@ } } + @if (userData.permissions.canDeleteBook || userData.permissions.admin) { + @if (otherItems$ | async; as otherItems) { + + + } + } } diff --git a/booklore-ui/src/app/book/metadata/book-metadata-center/metadata-viewer/metadata-viewer.component.ts b/booklore-ui/src/app/book/metadata/book-metadata-center/metadata-viewer/metadata-viewer.component.ts index 02432d505..3a44be940 100644 --- a/booklore-ui/src/app/book/metadata/book-metadata-center/metadata-viewer/metadata-viewer.component.ts +++ b/booklore-ui/src/app/book/metadata/book-metadata-center/metadata-viewer/metadata-viewer.component.ts @@ -11,7 +11,7 @@ import {Divider} from 'primeng/divider'; import {UrlHelperService} from '../../../../utilities/service/url-helper.service'; import {UserService} from '../../../../settings/user-management/user.service'; import {SplitButton} from 'primeng/splitbutton'; -import {MenuItem, MessageService} from 'primeng/api'; +import {ConfirmationService, MenuItem, MessageService} from 'primeng/api'; import {BookSenderComponent} from '../../../components/book-sender/book-sender.component'; import {DialogService} from 'primeng/dynamicdialog'; import {EmailService} from '../../../../settings/email/email.service'; @@ -26,15 +26,16 @@ import {ToggleButton} from 'primeng/togglebutton'; import {MetadataFetchOptionsComponent} from '../../metadata-options-dialog/metadata-fetch-options/metadata-fetch-options.component'; import {MetadataRefreshType} from '../../model/request/metadata-refresh-type.enum'; import {MetadataRefreshRequest} from '../../model/request/metadata-refresh-request.model'; -import {RouterLink} from '@angular/router'; +import {Router, RouterLink} from '@angular/router'; import {filter, map, take} from 'rxjs/operators'; +import {Menu} from 'primeng/menu'; @Component({ selector: 'app-metadata-viewer', standalone: true, templateUrl: './metadata-viewer.component.html', styleUrl: './metadata-viewer.component.scss', - imports: [Button, AsyncPipe, Rating, FormsModule, Tag, Divider, SplitButton, NgClass, Tooltip, DecimalPipe, InfiniteScrollDirective, BookCardComponent, ButtonDirective, Editor, ProgressBar, ToggleButton, RouterLink] + imports: [Button, AsyncPipe, Rating, FormsModule, Tag, Divider, SplitButton, NgClass, Tooltip, DecimalPipe, InfiniteScrollDirective, BookCardComponent, ButtonDirective, Editor, ProgressBar, ToggleButton, RouterLink, Menu] }) export class MetadataViewerComponent implements OnInit, OnChanges { @@ -50,16 +51,20 @@ export class MetadataViewerComponent implements OnInit, OnChanges { protected urlHelper = inject(UrlHelperService); protected userService = inject(UserService); private destroyRef = inject(DestroyRef); + private confirmationService = inject(ConfirmationService); + private router = inject(Router); emailMenuItems$!: Observable; readMenuItems$!: Observable; refreshMenuItems$!: Observable; + otherItems$!: Observable; bookInSeries: Book[] = []; isExpanded = false; showFilePath = false; isAutoFetching = false; + ngOnInit(): void { this.emailMenuItems$ = this.book$.pipe( map(book => book?.metadata ?? null), @@ -111,6 +116,35 @@ export class MetadataViewerComponent implements OnInit, OnChanges { ]) ); + 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: () => { + this.router.navigate(['/dashboard']); + }, + error: () => { + } + }); + } + }); + }, + } + ]) + ); + this.book$ .pipe( takeUntilDestroyed(this.destroyRef), diff --git a/booklore-ui/src/app/book/model/book.model.ts b/booklore-ui/src/app/book/model/book.model.ts index 2f3c18a9f..1f55b4e3b 100644 --- a/booklore-ui/src/app/book/model/book.model.ts +++ b/booklore-ui/src/app/book/model/book.model.ts @@ -159,3 +159,8 @@ export interface BulkMetadataUpdateRequest { publishedDate?: string; genres?: string[]; } + +export interface BookDeletionResponse { + deleted: number[]; + failedFileDeletions: number[]; +} diff --git a/booklore-ui/src/app/book/service/book.service.ts b/booklore-ui/src/app/book/service/book.service.ts index 3a4aaaf1e..bd97b149f 100644 --- a/booklore-ui/src/app/book/service/book.service.ts +++ b/booklore-ui/src/app/book/service/book.service.ts @@ -1,8 +1,8 @@ import {inject, Injectable} from '@angular/core'; -import {BehaviorSubject, first, Observable, of} from 'rxjs'; +import {BehaviorSubject, first, Observable, of, throwError} from 'rxjs'; import {HttpClient, HttpParams} from '@angular/common/http'; import {catchError, filter, map, tap} from 'rxjs/operators'; -import {Book, BookMetadata, BookRecommendation, BookSetting, BulkMetadataUpdateRequest} from '../model/book.model'; +import {Book, BookDeletionResponse, BookMetadata, BookRecommendation, BookSetting, BulkMetadataUpdateRequest} 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'; @@ -176,13 +176,46 @@ export class BookService { }); } - getBooksByIdsFromAPI(bookIds: number[], withDescription: boolean) { - return this.http.get(`${this.url}/batch`, { - params: { - ids: bookIds.map(id => id.toString()), - withDescription: withDescription.toString() - } - }); + deleteBooks(ids: Set): Observable { + const idList = Array.from(ids); + const params = new HttpParams().set('ids', idList.join(',')); + + return this.http.delete(this.url, { params }).pipe( + tap(response => { + const currentState = this.bookStateSubject.value; + const remainingBooks = (currentState.books || []).filter( + book => !ids.has(book.id) + ); + + this.bookStateSubject.next({ + books: remainingBooks, + loaded: true, + error: null, + }); + + if (response.failedFileDeletions?.length > 0) { + this.messageService.add({ + severity: 'warn', + summary: 'Some files could not be deleted', + detail: `Books: ${response.failedFileDeletions.join(', ')}`, + }); + } else { + this.messageService.add({ + severity: 'success', + summary: 'Books Deleted', + detail: `${idList.length} book(s) deleted successfully.`, + }); + } + }), + catchError(error => { + this.messageService.add({ + severity: 'error', + summary: 'Delete Failed', + detail: error?.error?.message || error?.message || 'An error occurred while deleting books.', + }); + return throwError(() => error); + }) + ); } downloadFile(bookId: number): void { diff --git a/booklore-ui/src/app/core/model/app-settings.model.ts b/booklore-ui/src/app/core/model/app-settings.model.ts index 7fcb5883a..be6832e36 100644 --- a/booklore-ui/src/app/core/model/app-settings.model.ts +++ b/booklore-ui/src/app/core/model/app-settings.model.ts @@ -106,5 +106,6 @@ export enum AppSettingKey { MAX_FILE_UPLOAD_SIZE_IN_MB = 'MAX_FILE_UPLOAD_SIZE_IN_MB', METADATA_PROVIDER_SETTINGS = 'METADATA_PROVIDER_SETTINGS', METADATA_MATCH_WEIGHTS = 'METADATA_MATCH_WEIGHTS', - METADATA_PERSISTENCE_SETTINGS = 'METADATA_PERSISTENCE_SETTINGS' + METADATA_PERSISTENCE_SETTINGS = 'METADATA_PERSISTENCE_SETTINGS', + BOOK_DELETION_ENABLED = 'BOOK_DELETION_ENABLED' } diff --git a/booklore-ui/src/app/settings/user-management/user-management.component.html b/booklore-ui/src/app/settings/user-management/user-management.component.html index 770f92ca3..006b66d92 100644 --- a/booklore-ui/src/app/settings/user-management/user-management.component.html +++ b/booklore-ui/src/app/settings/user-management/user-management.component.html @@ -21,6 +21,7 @@ Edit Metadata Manage Library Email Books + Delete Books Edit Change Password Delete @@ -114,6 +115,14 @@ } + + @if (user.isEditing) { + + } + @if (!user.isEditing) { + + } + @if (!user.isEditing) { diff --git a/booklore-ui/src/app/settings/user-management/user.service.ts b/booklore-ui/src/app/settings/user-management/user.service.ts index 8d98e1aa7..5e670ec14 100644 --- a/booklore-ui/src/app/settings/user-management/user.service.ts +++ b/booklore-ui/src/app/settings/user-management/user.service.ts @@ -90,6 +90,7 @@ export interface User { canUpload: boolean; canDownload: boolean; canEmailBook: boolean; + canDeleteBook: boolean; canEditMetadata: boolean; canManipulateLibrary: boolean; };