mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
Add book deletion feature with admin permission control
This commit is contained in:
committed by
Aditya Chandel
parent
6f032153b8
commit
ce96fcff6d
@@ -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();
|
||||
|
||||
@@ -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<BookDeletionResponse> deleteBooks(@RequestParam Set<Long> ids) {
|
||||
return bookService.deleteBooks(ids);
|
||||
}
|
||||
|
||||
@GetMapping("/batch")
|
||||
public ResponseEntity<List<Book>> getBooksByIds(@RequestParam Set<Long> ids, @RequestParam(required = false, defaultValue = "false") boolean withDescription) {
|
||||
return ResponseEntity.ok(bookService.getBooksByIds(ids, withDescription));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -64,7 +64,7 @@ public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ResponseEntity<ErrorResponse> 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -29,6 +29,7 @@ public class BookLoreUser {
|
||||
private boolean canEditMetadata;
|
||||
private boolean canManipulateLibrary;
|
||||
private boolean canEmailBook;
|
||||
private boolean canDeleteBook;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -15,6 +15,7 @@ public class UserCreateRequest {
|
||||
private boolean permissionDownload;
|
||||
private boolean permissionEditMetadata;
|
||||
private boolean permissionEmailBook;
|
||||
private boolean permissionDeleteBook;
|
||||
private boolean permissionAdmin;
|
||||
|
||||
private Set<Long> selectedLibraries;
|
||||
|
||||
@@ -19,5 +19,6 @@ public class UserUpdateRequest {
|
||||
private boolean canEditMetadata;
|
||||
private boolean canManipulateLibrary;
|
||||
private boolean canEmailBook;
|
||||
private boolean canDeleteBook;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Long> deleted;
|
||||
private List<Long> failedFileDeletions;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<Book> getBookDTOs(boolean includeDescription) {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
@@ -349,4 +352,26 @@ public class BookService {
|
||||
.body(new ByteArrayResource(inputStream.readAllBytes()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Transactional
|
||||
public ResponseEntity<BookDeletionResponse> deleteBooks(Set<Long> ids) {
|
||||
List<BookEntity> books = bookQueryService.findAllWithMetadataByIds(ids);
|
||||
List<Long> 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
@@ -214,70 +214,91 @@
|
||||
</div>
|
||||
}
|
||||
@if (selectedBooks.size > 0) {
|
||||
<div class="book-browser-footer bg-[var(--card-background)] bg-opacity-10" [@slideInOut]>
|
||||
<div class="flex justify-between gap-8">
|
||||
@if (entityType$ | async; as entityType) {
|
||||
@if (userService.userState$ | async; as userData) {
|
||||
@if (userData.permissions.canEditMetadata) {
|
||||
<p-tieredmenu #menu [model]="metadataMenuItems" [popup]="true" appendTo="body"/>
|
||||
<p-button
|
||||
(click)="menu.toggle($event)"
|
||||
pTooltip="Metadata actions"
|
||||
tooltipPosition="top"
|
||||
outlined="true"
|
||||
severity="info"
|
||||
icon="pi pi-database">
|
||||
</p-button>
|
||||
@if (userService.userState$ | async; as userData) {
|
||||
<div class="book-browser-footer bg-[var(--card-background)] bg-opacity-10" [@slideInOut]>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex">
|
||||
@if (entityType$ | async; as entityType) {
|
||||
<div class="flex gap-6 pr-2">
|
||||
@if (userData.permissions.canEditMetadata) {
|
||||
<p-menu #menu [model]="metadataMenuItems" [popup]="true" appendTo="body"/>
|
||||
<p-button
|
||||
(click)="menu.toggle($event)"
|
||||
pTooltip="Metadata actions"
|
||||
tooltipPosition="top"
|
||||
outlined="true"
|
||||
severity="info"
|
||||
icon="pi pi-database">
|
||||
</p-button>
|
||||
}
|
||||
@if (entityType === EntityType.LIBRARY || entityType === EntityType.ALL_BOOKS || entityType === EntityType.UNSHELVED) {
|
||||
<p-button
|
||||
icon="pi pi-bookmark-fill"
|
||||
outlined="true"
|
||||
severity="info"
|
||||
(onClick)="openShelfAssigner()"
|
||||
pTooltip="Assign to shelf"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
}
|
||||
@if (entityType === EntityType.SHELF) {
|
||||
<p-button
|
||||
icon="pi pi-bookmark"
|
||||
outlined="true"
|
||||
severity="info"
|
||||
(click)="unshelfBooks()"
|
||||
pTooltip="Remove from shelf"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
}
|
||||
@if (userData.permissions.canEditMetadata) {
|
||||
<p-button
|
||||
outlined="true"
|
||||
icon="pi pi-lock"
|
||||
severity="info"
|
||||
(click)="lockUnlockMetadata()"
|
||||
pTooltip="Lock/Unlock metadata"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (entityType === EntityType.LIBRARY || entityType === EntityType.ALL_BOOKS || entityType === EntityType.UNSHELVED) {
|
||||
<p-divider layout="vertical"></p-divider>
|
||||
<div class="flex gap-6 px-2">
|
||||
<p-button
|
||||
icon="pi pi-bookmark-fill"
|
||||
outlined="true"
|
||||
severity="info"
|
||||
(onClick)="openShelfAssigner()"
|
||||
pTooltip="Assign to shelf"
|
||||
icon="pi pi-check-square"
|
||||
severity="success"
|
||||
(click)="selectAllBooks()"
|
||||
pTooltip="Select all books"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
}
|
||||
@if (entityType === EntityType.SHELF) {
|
||||
<p-button
|
||||
icon="pi pi-bookmark"
|
||||
outlined="true"
|
||||
severity="info"
|
||||
(click)="unshelfBooks()"
|
||||
pTooltip="Remove from shelf"
|
||||
icon="pi pi-times"
|
||||
severity="warn"
|
||||
(click)="deselectAllBooks()"
|
||||
pTooltip="Deselect all books"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p-button
|
||||
outlined="true"
|
||||
icon="pi pi-lock"
|
||||
severity="info"
|
||||
(click)="lockUnlockMetadata()"
|
||||
pTooltip="Lock/Unlock metadata"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
<p-button
|
||||
outlined="true"
|
||||
icon="pi pi-check-square"
|
||||
severity="success"
|
||||
(click)="selectAllBooks()"
|
||||
pTooltip="Select all books"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
<p-button
|
||||
outlined="true"
|
||||
icon="pi pi-times"
|
||||
severity="warn"
|
||||
(click)="deselectAllBooks()"
|
||||
pTooltip="Deselect all books"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
}
|
||||
@if (userData?.permissions?.admin || userData?.permissions?.canDeleteBook) {
|
||||
<p-divider layout="vertical"></p-divider>
|
||||
<p-button
|
||||
outlined="true"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
(click)="confirmDeleteBooks()"
|
||||
pTooltip="Delete selected books"
|
||||
class="pl-2"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -286,6 +286,12 @@
|
||||
</p-splitbutton>
|
||||
}
|
||||
}
|
||||
@if (userData.permissions.canDeleteBook || userData.permissions.admin) {
|
||||
@if (otherItems$ | async; as otherItems) {
|
||||
<p-button icon="pi pi-ellipsis-v" outlined severity="info" (click)="entitymenu.toggle($event)"></p-button>
|
||||
<p-menu #entitymenu [model]="otherItems" [popup]="true" appendTo="body"></p-menu>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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<MenuItem[]>;
|
||||
readMenuItems$!: Observable<MenuItem[]>;
|
||||
refreshMenuItems$!: Observable<MenuItem[]>;
|
||||
otherItems$!: Observable<MenuItem[]>;
|
||||
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),
|
||||
|
||||
@@ -159,3 +159,8 @@ export interface BulkMetadataUpdateRequest {
|
||||
publishedDate?: string;
|
||||
genres?: string[];
|
||||
}
|
||||
|
||||
export interface BookDeletionResponse {
|
||||
deleted: number[];
|
||||
failedFileDeletions: number[];
|
||||
}
|
||||
|
||||
@@ -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<Book[]>(`${this.url}/batch`, {
|
||||
params: {
|
||||
ids: bookIds.map(id => id.toString()),
|
||||
withDescription: withDescription.toString()
|
||||
}
|
||||
});
|
||||
deleteBooks(ids: Set<number>): Observable<BookDeletionResponse> {
|
||||
const idList = Array.from(ids);
|
||||
const params = new HttpParams().set('ids', idList.join(','));
|
||||
|
||||
return this.http.delete<BookDeletionResponse>(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 {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<th style="width: 80px;">Edit Metadata</th>
|
||||
<th style="width: 80px;">Manage Library</th>
|
||||
<th style="width: 80px;">Email Books</th>
|
||||
<th style="width: 80px;">Delete Books</th>
|
||||
<th style="width: 120px;">Edit</th>
|
||||
<th style="width: 80px;">Change Password</th>
|
||||
<th style="width: 80px;">Delete</th>
|
||||
@@ -114,6 +115,14 @@
|
||||
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canEmailBook" disabled></p-checkbox>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@if (user.isEditing) {
|
||||
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canDeleteBook"></p-checkbox>
|
||||
}
|
||||
@if (!user.isEditing) {
|
||||
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canDeleteBook" disabled></p-checkbox>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<ng-container>
|
||||
@if (!user.isEditing) {
|
||||
|
||||
@@ -90,6 +90,7 @@ export interface User {
|
||||
canUpload: boolean;
|
||||
canDownload: boolean;
|
||||
canEmailBook: boolean;
|
||||
canDeleteBook: boolean;
|
||||
canEditMetadata: boolean;
|
||||
canManipulateLibrary: boolean;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user