From 6bfbff5d34a7c7ca6d3075fb063b2fc063caa603 Mon Sep 17 00:00:00 2001 From: "aditya.chandel" Date: Sun, 22 Jun 2025 00:25:30 -0600 Subject: [PATCH] feat(read-status): Add Read Status tracking and filtering support - Introduced `ReadStatus` enum and added `readStatus` field to `BookEntity` - Updated database schema with Flyway migration to store read status - Extended DTOs and backend API to handle read status updates - Added `updateBookReadStatus()` in backend service and controller - Integrated read status in Angular: display, update, and toast feedback - Added read status filters and labels across book listing, sidebar filter, and viewer - Refactored `MetadataViewerComponent` and extracted read status UI logic - Simplified read status label resolution using a mapping function --- .../booklore/controller/BookController.java | 8 ++ .../booklore/model/dto/Book.java | 1 + .../dto/request/ReadStatusUpdateRequest.java | 5 ++ .../booklore/model/entity/BookEntity.java | 5 ++ .../booklore/model/enums/ReadStatus.java | 12 +++ .../booklore/service/BookService.java | 10 +++ .../V33__Add_read_status_to_book.sql | 2 + .../book-filter/book-filter.component.ts | 19 ++++- .../book-browser/filters/SidebarFilter.ts | 2 + .../metadata-viewer.component.html | 35 ++++++--- .../metadata-viewer.component.ts | 77 +++++++++++++++++-- booklore-ui/src/app/book/model/book.model.ts | 12 +++ .../src/app/book/service/book.service.ts | 8 +- 13 files changed, 176 insertions(+), 20 deletions(-) create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/ReadStatusUpdateRequest.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/enums/ReadStatus.java create mode 100644 booklore-api/src/main/resources/db/migration/V33__Add_read_status_to_book.sql 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 0b23c61dc..cfb046759 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 @@ -5,6 +5,7 @@ import com.adityachandel.booklore.model.dto.Book; 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.ReadStatusUpdateRequest; import com.adityachandel.booklore.model.dto.request.ShelvesAssignmentRequest; import com.adityachandel.booklore.model.dto.response.BookDeletionResponse; import com.adityachandel.booklore.service.metadata.MetadataBackupRestoreService; @@ -112,4 +113,11 @@ public class BookController { public ResponseEntity> getRecommendations(@PathVariable Long id, @RequestParam(defaultValue = "25") @Max(25) @Min(1) int limit) { return ResponseEntity.ok(bookRecommendationService.getRecommendations(id, limit)); } + + @PutMapping("/{bookId}/read-status") + @CheckBookAccess(bookIdParam = "bookId") + public ResponseEntity updateReadStatus(@PathVariable long bookId, @RequestBody @Valid ReadStatusUpdateRequest request) { + bookService.updateReadStatus(bookId, request.status()); + return ResponseEntity.noContent().build(); + } } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/Book.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/Book.java index 5d5dda049..aabe7afe1 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/Book.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/Book.java @@ -27,4 +27,5 @@ public class Book { private EpubProgress epubProgress; private CbxProgress cbxProgress; private Set shelves; + private String readStatus; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/ReadStatusUpdateRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/ReadStatusUpdateRequest.java new file mode 100644 index 000000000..6a48039cb --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/ReadStatusUpdateRequest.java @@ -0,0 +1,5 @@ +package com.adityachandel.booklore.model.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record ReadStatusUpdateRequest(@NotBlank String status) {} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookEntity.java index bc06dbaee..f5d342d66 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookEntity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookEntity.java @@ -3,6 +3,7 @@ package com.adityachandel.booklore.model.entity; import com.adityachandel.booklore.convertor.BookRecommendationIdsListConverter; import com.adityachandel.booklore.model.dto.BookRecommendationLite; import com.adityachandel.booklore.model.enums.BookFileType; +import com.adityachandel.booklore.model.enums.ReadStatus; import jakarta.persistence.*; import lombok.*; @@ -40,6 +41,10 @@ public class BookEntity { @Column(name = "metadata_match_score") private Float metadataMatchScore; + @Enumerated(EnumType.STRING) + @Column(name = "read_status") + private ReadStatus readStatus; + @OneToOne(mappedBy = "book", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private BookMetadataEntity metadata; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/ReadStatus.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/ReadStatus.java new file mode 100644 index 000000000..f4e2859d2 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/ReadStatus.java @@ -0,0 +1,12 @@ +package com.adityachandel.booklore.model.enums; + +public enum ReadStatus { + UNREAD, + READING, + RE_READING, + READ, + PARTIALLY_READ, + PAUSED, + WONT_READ, + ABANDONED +} 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 142e7b606..4ae60f683 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 @@ -8,12 +8,15 @@ 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.model.enums.ReadStatus; 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 jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.EnumUtils; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; @@ -374,4 +377,11 @@ public class BookService { ? ResponseEntity.ok(response) : ResponseEntity.status(HttpStatus.MULTI_STATUS).body(response); } + + public void updateReadStatus(long bookId, @NotBlank String status) { + BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); + ReadStatus readStatus = EnumUtils.getEnumIgnoreCase(ReadStatus.class, status); + bookEntity.setReadStatus(readStatus); + bookRepository.save(bookEntity); + } } \ No newline at end of file diff --git a/booklore-api/src/main/resources/db/migration/V33__Add_read_status_to_book.sql b/booklore-api/src/main/resources/db/migration/V33__Add_read_status_to_book.sql new file mode 100644 index 000000000..8df7a01e2 --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V33__Add_read_status_to_book.sql @@ -0,0 +1,2 @@ +ALTER TABLE book + ADD COLUMN read_status VARCHAR(20) DEFAULT 'UNREAD'; \ No newline at end of file diff --git a/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.ts b/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.ts index 3afb0f24f..c80004c09 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.ts +++ b/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.ts @@ -5,7 +5,7 @@ import {BookService} from '../../../service/book.service'; import {Library} from '../../../model/library.model'; import {Shelf} from '../../../model/shelf.model'; import {EntityType} from '../book-browser.component'; -import {Book} from '../../../model/book.model'; +import {Book, ReadStatus} from '../../../model/book.model'; import {Accordion, AccordionContent, AccordionHeader, AccordionPanel} from 'primeng/accordion'; import {AsyncPipe, NgClass, TitleCasePipe} from '@angular/common'; import {Badge} from 'primeng/badge'; @@ -116,6 +116,21 @@ function getMatchScoreRangeFilters(score?: number | null): { id: string; name: s return match ? [{id: match.id, name: match.label, sortIndex: match.sortIndex}] : []; } +const readStatusLabels: Record = { + [ReadStatus.UNREAD]: 'Unread', + [ReadStatus.READING]: 'Reading', + [ReadStatus.RE_READING]: 'Re-reading', + [ReadStatus.PARTIALLY_READ]: 'Partially Read', + [ReadStatus.PAUSED]: 'Paused', + [ReadStatus.READ]: 'Read', + [ReadStatus.WONT_READ]: 'Won’t Read', + [ReadStatus.ABANDONED]: 'Abandoned', +}; + +function getReadStatusName(status?: ReadStatus | null): string { + return status != null ? readStatusLabels[status] ?? 'Unknown' : 'Unknown'; +} + @Component({ selector: 'app-book-filter', templateUrl: './book-filter.component.html', @@ -157,6 +172,7 @@ export class BookFilterComponent implements OnInit, OnDestroy { category: 'Category', series: 'Series', publisher: 'Publisher', + readStatus: 'Read Status', personalRating: 'Personal Rating', matchScore: 'Metadata Match Score', amazonRating: 'Amazon Rating', @@ -188,6 +204,7 @@ export class BookFilterComponent implements OnInit, OnDestroy { category: this.getFilterStream((book: Book) => book.metadata?.categories.map(name => ({id: name, name})) || [], 'id', 'name', sortMode), series: this.getFilterStream((book) => (book.metadata?.seriesName ? [{id: book.metadata.seriesName, name: book.metadata.seriesName}] : []), 'id', 'name', sortMode), publisher: this.getFilterStream((book) => (book.metadata?.publisher ? [{id: book.metadata.publisher, name: book.metadata.publisher}] : []), 'id', 'name', sortMode), + readStatus: this.getFilterStream((book: Book) => [{id: book.readStatus ?? ReadStatus.UNREAD, name: getReadStatusName(book.readStatus)}], 'id', 'name', sortMode), matchScore: this.getFilterStream((book: Book) => getMatchScoreRangeFilters(book.metadataMatchScore), 'id', 'name', 'sortIndex'), personalRating: this.getFilterStream((book: Book) => getRatingRangeFilters10(book.metadata?.personalRating!), 'id', 'name', 'sortIndex'), amazonRating: this.getFilterStream((book: Book) => getRatingRangeFilters(book.metadata?.amazonRating!), 'id', 'name', 'sortIndex'), diff --git a/booklore-ui/src/app/book/components/book-browser/filters/SidebarFilter.ts b/booklore-ui/src/app/book/components/book-browser/filters/SidebarFilter.ts index 87777252f..b8472aa03 100644 --- a/booklore-ui/src/app/book/components/book-browser/filters/SidebarFilter.ts +++ b/booklore-ui/src/app/book/components/book-browser/filters/SidebarFilter.ts @@ -70,6 +70,8 @@ export class SideBarFilter implements BookFilter { return mode === 'and' ? filterValues.every(val => book.metadata?.seriesName === val) : filterValues.some(val => book.metadata?.seriesName === val); + case 'readStatus': + return filterValues.some(val => book.readStatus === val); case 'amazonRating': return filterValues.some(range => isRatingInRange(book.metadata?.amazonRating, range)); case 'goodreadsRating': 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 339b347c4..59df4c52e 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 @@ -168,23 +168,37 @@ }
-

- File Type: - - {{ getFileExtension(book?.filePath) || '-' }} - -

+
+

+ Read Status: + + {{ getStatusLabel(selectedReadStatus) }} + + +

+

Publisher: {{ book?.metadata!.publisher || '-' }}

Published: {{ book?.metadata!.publishedDate || '-' }}

Language: {{ book?.metadata!.language || '-' }}

ISBN 10: {{ book?.metadata!.isbn10 || '-' }}

+ +

+ File Type: + + {{ getFileExtension(book?.filePath) || '-' }} + +

+

Metadata Score: @if (book?.metadataMatchScore != null) { {{ (book?.metadataMatchScore! * 100) | number:'1.0-0' }}% @@ -192,8 +206,6 @@ - }

-

Page Count: {{ book?.metadata!.pageCount || '-' }}

-

File Size: {{ getFileSizeInMB(book) }}

Progress:

+

File Size: {{ getFileSizeInMB(book) }}

ISBN 13: {{ book?.metadata!.isbn13 || '-' }}

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 a76d2b3fa..223115708 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 @@ -6,7 +6,7 @@ import {BookService} from '../../../service/book.service'; import {Rating, RatingRateEvent} from 'primeng/rating'; import {FormsModule} from '@angular/forms'; import {Tag} from 'primeng/tag'; -import {Book, BookMetadata, BookRecommendation} from '../../../model/book.model'; +import {Book, BookMetadata, BookRecommendation, ReadStatus} from '../../../model/book.model'; import {Divider} from 'primeng/divider'; import {UrlHelperService} from '../../../../utilities/service/url-helper.service'; import {UserService} from '../../../../settings/user-management/user.service'; @@ -64,6 +64,18 @@ export class MetadataViewerComponent implements OnInit, OnChanges { showFilePath = false; isAutoFetching = false; + readStatusOptions = [ + {label: 'Unread', value: ReadStatus.UNREAD}, + {label: 'Reading', value: ReadStatus.READING}, + {label: 'Re-reading', value: ReadStatus.RE_READING}, + {label: 'Partially Read', value: ReadStatus.PARTIALLY_READ}, + {label: 'Paused', value: ReadStatus.PAUSED}, + {label: 'Read', value: ReadStatus.READ}, + {label: 'Won’t Read', value: ReadStatus.WONT_READ}, + {label: 'Abandoned', value: ReadStatus.ABANDONED} + ]; + + selectedReadStatus: ReadStatus = ReadStatus.UNREAD; ngOnInit(): void { this.emailMenuItems$ = this.book$.pipe( @@ -148,15 +160,16 @@ export class MetadataViewerComponent implements OnInit, OnChanges { this.book$ .pipe( takeUntilDestroyed(this.destroyRef), - map(book => book?.metadata), - filter((metadata): metadata is BookMetadata => metadata != null) + filter((book): book is Book => book != null && book.metadata != null) ) - .subscribe(metadata => { + .subscribe(book => { + const metadata = book.metadata; this.isAutoFetching = false; - this.loadBooksInSeriesAndFilterRecommended(metadata.bookId); + this.loadBooksInSeriesAndFilterRecommended(metadata!.bookId); if (this.quillEditor?.quill) { - this.quillEditor.quill.root.innerHTML = metadata.description; + this.quillEditor.quill.root.innerHTML = metadata!.description; } + this.selectedReadStatus = book.readStatus ?? ReadStatus.UNREAD; }); } @@ -327,4 +340,56 @@ export class MetadataViewerComponent implements OnInit, OnChanges { } }); } + + getStatusSeverity(status: string): 'success' | 'secondary' | 'info' | 'warn' | 'danger' | undefined { + const normalized = status?.toUpperCase(); + if (['UNREAD', 'PAUSED'].includes(normalized)) return 'secondary'; + if (['READING', 'RE_READING'].includes(normalized)) return 'info'; + if (['READ'].includes(normalized)) return 'success'; + if (['PARTIALLY_READ'].includes(normalized)) return 'warn'; + if (['WONT_READ', 'ABANDONED'].includes(normalized)) return 'danger'; + return undefined; + } + + readStatusMenuItems = this.readStatusOptions.map(option => ({ + label: option.label, + command: () => this.updateReadStatus(option.value) + })); + + getStatusLabel(value: string): string { + return this.readStatusOptions.find(o => o.value === value)?.label ?? 'Unknown'; + } + + updateReadStatus(status: ReadStatus): void { + if (!status) { + return; + } + + this.book$.pipe(take(1)).subscribe(book => { + if (!book || !book.id) { + return; + } + + this.bookService.updateBookReadStatus(book.id, status).subscribe({ + next: () => { + this.selectedReadStatus = status; + this.messageService.add({ + severity: 'success', + summary: 'Read Status Updated', + detail: `Marked as "${this.getStatusLabel(status)}"`, + life: 2000 + }); + }, + error: (err) => { + console.error('Failed to update read status:', err); + this.messageService.add({ + severity: 'error', + summary: 'Update Failed', + detail: 'Could not update read status.', + life: 3000 + }); + } + }); + }); + } } diff --git a/booklore-ui/src/app/book/model/book.model.ts b/booklore-ui/src/app/book/model/book.model.ts index c00171302..72321d3e8 100644 --- a/booklore-ui/src/app/book/model/book.model.ts +++ b/booklore-ui/src/app/book/model/book.model.ts @@ -18,6 +18,7 @@ export interface Book { fileSizeKb?: number; seriesCount?: number | null; metadataMatchScore?: number | null; + readStatus?: ReadStatus; } export interface EpubProgress { @@ -165,3 +166,14 @@ export interface BookDeletionResponse { deleted: number[]; failedFileDeletions: number[]; } + +export enum ReadStatus { + UNREAD = 'UNREAD', + READING = 'READING', + RE_READING = 'RE_READING', + READ = 'READ', + PARTIALLY_READ = 'PARTIALLY_READ', + PAUSED = 'PAUSED', + WONT_READ = 'WONT_READ', + ABANDONED = 'ABANDONED' +} diff --git a/booklore-ui/src/app/book/service/book.service.ts b/booklore-ui/src/app/book/service/book.service.ts index bd97b149f..8406194da 100644 --- a/booklore-ui/src/app/book/service/book.service.ts +++ b/booklore-ui/src/app/book/service/book.service.ts @@ -2,7 +2,7 @@ import {inject, Injectable} from '@angular/core'; import {BehaviorSubject, first, Observable, of, throwError} from 'rxjs'; import {HttpClient, HttpParams} from '@angular/common/http'; import {catchError, filter, map, tap} from 'rxjs/operators'; -import {Book, BookDeletionResponse, BookMetadata, BookRecommendation, BookSetting, BulkMetadataUpdateRequest} from '../model/book.model'; +import {Book, BookDeletionResponse, BookMetadata, BookRecommendation, BookSetting, BulkMetadataUpdateRequest, ReadStatus} 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'; @@ -180,7 +180,7 @@ export class BookService { const idList = Array.from(ids); const params = new HttpParams().set('ids', idList.join(',')); - return this.http.delete(this.url, { params }).pipe( + return this.http.delete(this.url, {params}).pipe( tap(response => { const currentState = this.bookStateSubject.value; const remainingBooks = (currentState.books || []).filter( @@ -487,4 +487,8 @@ export class BookService { getBackupMetadata(bookId: number) { return this.http.get(`${this.url}/${bookId}/metadata/restore`); } + + updateBookReadStatus(id: number, status: ReadStatus): Observable { + return this.http.put(`${this.url}/${id}/read-status`, {status}); + } }