Add book deletion feature with admin permission control

This commit is contained in:
aditya.chandel
2025-06-21 18:30:44 -06:00
committed by Aditya Chandel
parent 6f032153b8
commit ce96fcff6d
28 changed files with 358 additions and 126 deletions

View File

@@ -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();

View File

@@ -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));

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -29,6 +29,7 @@ public class BookLoreUser {
private boolean canEditMetadata;
private boolean canManipulateLibrary;
private boolean canEmailBook;
private boolean canDeleteBook;
}
@Data

View File

@@ -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;

View File

@@ -19,5 +19,6 @@ public class UserUpdateRequest {
private boolean canEditMetadata;
private boolean canManipulateLibrary;
private boolean canEmailBook;
private boolean canDeleteBook;
}
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -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()) {

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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),

View File

@@ -159,3 +159,8 @@ export interface BulkMetadataUpdateRequest {
publishedDate?: string;
genres?: string[];
}
export interface BookDeletionResponse {
deleted: number[];
failedFileDeletions: number[];
}

View File

@@ -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 {

View File

@@ -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'
}

View File

@@ -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) {

View File

@@ -90,6 +90,7 @@ export interface User {
canUpload: boolean;
canDownload: boolean;
canEmailBook: boolean;
canDeleteBook: boolean;
canEditMetadata: boolean;
canManipulateLibrary: boolean;
};