Add support for updating read status of multiple books at once

This commit is contained in:
aditya.chandel
2025-07-14 10:30:58 -06:00
committed by Aditya Chandel
parent 77d68108ab
commit c0e993c37a
8 changed files with 166 additions and 88 deletions

View File

@@ -119,10 +119,9 @@ public class BookController {
return ResponseEntity.ok(bookRecommendationService.getRecommendations(id, limit));
}
@PutMapping("/{bookId}/read-status")
@CheckBookAccess(bookIdParam = "bookId")
public ResponseEntity<Void> updateReadStatus(@PathVariable long bookId, @RequestBody @Valid ReadStatusUpdateRequest request) {
bookService.updateReadStatus(bookId, request.status());
@PutMapping("/read-status")
public ResponseEntity<Void> updateReadStatus(@RequestBody @Valid ReadStatusUpdateRequest request) {
bookService.updateReadStatus(request.ids(), request.status());
return ResponseEntity.noContent().build();
}

View File

@@ -1,5 +1,6 @@
package com.adityachandel.booklore.model.dto.request;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
public record ReadStatusUpdateRequest(@NotBlank String status) {}
public record ReadStatusUpdateRequest(List<Long> ids, String status) {
}

View File

@@ -374,15 +374,26 @@ public class BookService {
}
@Transactional
public void updateReadStatus(Long bookId, String status) {
BookEntity book = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
public void updateReadStatus(List<Long> bookIds, String status) {
BookLoreUser user = authenticationService.getAuthenticatedUser();
UserBookProgressEntity userBookProgress = userBookProgressRepository.findByUserIdAndBookId(user.getId(), book.getId()).orElse(new UserBookProgressEntity());
userBookProgress.setUser(userRepository.findById(user.getId()).orElseThrow(() -> new UsernameNotFoundException("User not found")));
userBookProgress.setBook(book);
ReadStatus readStatus = EnumUtils.getEnumIgnoreCase(ReadStatus.class, status);
userBookProgress.setReadStatus(readStatus);
userBookProgressRepository.save(userBookProgress);
List<BookEntity> books = bookRepository.findAllById(bookIds);
if (books.size() != bookIds.size()) {
throw ApiError.BOOK_NOT_FOUND.createException("One or more books not found");
}
BookLoreUserEntity userEntity = userRepository.findById(user.getId()).orElseThrow(() -> new UsernameNotFoundException("User not found"));
for (BookEntity book : books) {
UserBookProgressEntity progress = userBookProgressRepository
.findByUserIdAndBookId(user.getId(), book.getId())
.orElse(new UserBookProgressEntity());
progress.setUser(userEntity);
progress.setBook(book);
progress.setReadStatus(readStatus);
userBookProgressRepository.save(progress);
}
}
public ResponseEntity<Resource> downloadBook(Long bookId) {

View File

@@ -343,15 +343,17 @@
tooltipPosition="top">
</p-button>
}
<p-menu #menuMore [model]="moreActionsMenuItems" [popup]="true" appendTo="body" class="hidden"/>
<p-button
(click)="menuMore.toggle($event)"
pTooltip="More actions"
tooltipPosition="top"
outlined="true"
severity="info"
icon="pi pi-ellipsis-v">
</p-button>
<div class="card flex justify-center">
<p-button
(click)="menu.toggle($event)"
pTooltip="More actions"
tooltipPosition="top"
outlined="true"
severity="info"
icon="pi pi-ellipsis-v">
</p-button>
<p-tieredMenu #menu [model]="tieredMenuItems" [popup]="true" appendTo="body"/>
</div>
</div>
}
<p-divider layout="vertical"></p-divider>

View File

@@ -12,7 +12,7 @@ import {Shelf} from '../../model/shelf.model';
import {SortService} from '../../service/sort.service';
import {SortDirection, SortOption} from '../../model/sort.model';
import {BookState} from '../../model/state/book-state.model';
import {Book} from '../../model/book.model';
import {Book, ReadStatus} from '../../model/book.model';
import {LibraryShelfMenuService} from '../../service/library-shelf-menu.service';
import {BookTableComponent} from './book-table/book-table.component';
import {animate, state, style, transition, trigger} from '@angular/animations';
@@ -24,7 +24,7 @@ import {ProgressSpinner} from 'primeng/progressspinner';
import {Menu} from 'primeng/menu';
import {InputText} from 'primeng/inputtext';
import {FormsModule} from '@angular/forms';
import {BookFilterComponent} from './book-filter/book-filter.component';
import {BookFilterComponent, readStatusLabels} from './book-filter/book-filter.component';
import {Tooltip} from 'primeng/tooltip';
import {EntityViewPreferences, UserService} from '../../../settings/user-management/user.service';
import {OverlayPanelModule} from 'primeng/overlaypanel';
@@ -43,6 +43,8 @@ import {FilterSortPreferenceService} from './filters/filter-sorting-preferences.
import {Divider} from 'primeng/divider';
import {MultiSelect} from 'primeng/multiselect';
import {TableColumnPreferenceService} from './table-column-preference-service';
import {TieredMenu} from 'primeng/tieredmenu';
import {BookMenuService} from '../../service/book-menu.service';
export enum EntityType {
LIBRARY = 'Library',
@@ -75,7 +77,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, PrimeTemplate, NgStyle, OverlayPanelModule, DropdownModule, Checkbox, Popover, Slider, Select, Divider, MultiSelect],
imports: [Button, VirtualScrollerModule, BookCardComponent, AsyncPipe, ProgressSpinner, Menu, InputText, FormsModule, BookTableComponent, BookFilterComponent, Tooltip, NgClass, PrimeTemplate, NgStyle, OverlayPanelModule, DropdownModule, Checkbox, Popover, Slider, Select, Divider, MultiSelect, TieredMenu],
providers: [SeriesCollapseFilter],
animations: [
trigger('slideInOut', [
@@ -134,6 +136,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
private bookService = inject(BookService);
private shelfService = inject(ShelfService);
private dialogHelperService = inject(BookDialogHelperService);
private bookMenuService = inject(BookMenuService);
private sortService = inject(SortService);
private router = inject(Router);
private changeDetectorRef = inject(ChangeDetectorRef);
@@ -149,10 +152,11 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
lastAppliedSort: SortOption | null = null;
private settingFiltersFromUrl = false;
protected metadataMenuItems: MenuItem[] | undefined;
protected moreActionsMenuItems: MenuItem[] | undefined;
protected tieredMenuItems: MenuItem[] | undefined;
currentBooks: Book[] = [];
lastSelectedIndex: number | null = null;
showFilter: boolean = false;
get currentCardSize() {
return this.coverScalePreferenceService.currentCardSize;
@@ -174,6 +178,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
}
ngOnInit(): void {
this.coverScalePreferenceService.scaleChange$.pipe(debounceTime(1000)).subscribe();
this.bookService.loadBooks();
@@ -210,45 +215,15 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
this.clearFilter();
});
this.metadataMenuItems = [
{
label: 'Refresh Metadata',
icon: 'pi pi-sync',
command: () => this.updateMetadata()
},
{
label: 'Bulk Metadata Editor',
icon: 'pi pi-table',
command: () => this.bulkEditMetadata()
},
{
label: 'Multi-Book Metadata Editor',
icon: 'pi pi-clone',
command: () => this.multiBookEditMetadata()
}
];
this.metadataMenuItems = this.bookMenuService.getMetadataMenuItems(
() => this.updateMetadata(),
() => this.bulkEditMetadata(),
() => this.multiBookEditMetadata()
);
this.moreActionsMenuItems = [
{
label: 'Reset Progress',
icon: 'pi pi-undo',
command: () => {
this.confirmationService.confirm({
message: 'Are you sure you want to reset progress for selected books?',
header: 'Confirm Reset',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Yes',
rejectLabel: 'No',
accept: () => {
this.resetProgress();
}
});
}
}
];
this.tieredMenuItems = this.bookMenuService.getTieredMenuItems(this.selectedBooks);
}
ngAfterViewInit() {
combineLatest({
@@ -709,26 +684,4 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
moveFiles() {
this.dialogHelperService.openFileMoverDialog(this.selectedBooks);
}
resetProgress() {
const bookIds = Array.from(this.selectedBooks);
this.bookService.resetProgress(bookIds).subscribe({
next: () => {
this.messageService.add({
severity: 'success',
summary: 'Progress Reset',
detail: 'Reading progress has been reset.',
life: 1500
});
},
error: () => {
this.messageService.add({
severity: 'error',
summary: 'Failed',
detail: 'Could not reset progress.',
life: 1500
});
}
});
}
}

View File

@@ -110,7 +110,7 @@ function getMatchScoreRangeFilters(score?: number | null): { id: string; name: s
return match ? [{id: match.id, name: match.label, sortIndex: match.sortIndex}] : [];
}
const readStatusLabels: Record<ReadStatus, string> = {
export const readStatusLabels: Record<ReadStatus, string> = {
[ReadStatus.UNREAD]: 'Unread',
[ReadStatus.READING]: 'Reading',
[ReadStatus.RE_READING]: 'Re-reading',

View File

@@ -0,0 +1,111 @@
import {inject, Injectable} from '@angular/core';
import {ConfirmationService, MessageService} from 'primeng/api';
import {MenuItem} from 'primeng/api';
import {BookService} from './book.service';
import {readStatusLabels} from '../components/book-browser/book-filter/book-filter.component';
import {ReadStatus} from '../model/book.model';
@Injectable({
providedIn: 'root'
})
export class BookMenuService {
confirmationService = inject(ConfirmationService);
messageService = inject(MessageService);
bookService = inject(BookService);
getMetadataMenuItems(updateMetadata: () => void, bulkEditMetadata: () => void, multiBookEditMetadata: () => void): MenuItem[] {
return [
{
label: 'Refresh Metadata',
icon: 'pi pi-sync',
command: updateMetadata
},
{
label: 'Bulk Metadata Editor',
icon: 'pi pi-table',
command: bulkEditMetadata
},
{
label: 'Multi-Book Metadata Editor',
icon: 'pi pi-clone',
command: multiBookEditMetadata
}
];
}
getTieredMenuItems(selectedBooks: Set<number>): MenuItem[] {
return [
{
label: 'Update Read Status',
icon: 'pi pi-book',
items: Object.entries(readStatusLabels).map(([status, label]) => ({
label,
command: () => {
this.confirmationService.confirm({
message: `Are you sure you want to mark selected books as "${label}"?`,
header: 'Confirm Read Status Update',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Yes',
rejectLabel: 'No',
accept: () => {
this.bookService.updateBookReadStatus(Array.from(selectedBooks), status as ReadStatus).subscribe({
next: () => {
this.messageService.add({
severity: 'success',
summary: 'Read Status Updated',
detail: `Marked as "${label}"`,
life: 2000
});
},
error: () => {
this.messageService.add({
severity: 'error',
summary: 'Update Failed',
detail: 'Could not update read status.',
life: 3000
});
}
});
}
});
}
}))
},
{
label: 'Reset Progress',
icon: 'pi pi-undo',
command: () => {
this.confirmationService.confirm({
message: 'Are you sure you want to reset progress for selected books?',
header: 'Confirm Reset',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Yes',
rejectLabel: 'No',
accept: () => {
this.bookService.resetProgress(Array.from(selectedBooks)).subscribe({
next: () => {
this.messageService.add({
severity: 'success',
summary: 'Progress Reset',
detail: 'Reading progress has been reset.',
life: 1500
});
},
error: () => {
this.messageService.add({
severity: 'error',
summary: 'Failed',
detail: 'Could not reset progress.',
life: 1500
});
}
});
}
});
}
}
];
}
}

View File

@@ -404,13 +404,14 @@ export class BookService {
);
}
updateBookReadStatus(id: number, status: ReadStatus): Observable<void> {
return this.http.put<void>(`${this.url}/${id}/read-status`, {status}).pipe(
updateBookReadStatus(bookIds: number | number[], status: ReadStatus): Observable<void> {
const ids = Array.isArray(bookIds) ? bookIds : [bookIds];
return this.http.put<void>(`${this.url}/read-status`, {ids, status}).pipe(
tap(() => {
const currentState = this.bookStateSubject.value;
if (!currentState.books) return;
const updatedBooks = currentState.books.map(book =>
book.id === id ? {...book, readStatus: status} : book
ids.includes(book.id) ? {...book, readStatus: status} : book
);
this.bookStateSubject.next({...currentState, books: updatedBooks});
})