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
This commit is contained in:
aditya.chandel
2025-06-22 00:25:30 -06:00
committed by Aditya Chandel
parent cd4e14c8ad
commit 6bfbff5d34
13 changed files with 176 additions and 20 deletions

View File

@@ -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<List<BookRecommendation>> 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<Void> updateReadStatus(@PathVariable long bookId, @RequestBody @Valid ReadStatusUpdateRequest request) {
bookService.updateReadStatus(bookId, request.status());
return ResponseEntity.noContent().build();
}
}

View File

@@ -27,4 +27,5 @@ public class Book {
private EpubProgress epubProgress;
private CbxProgress cbxProgress;
private Set<Shelf> shelves;
private String readStatus;
}

View File

@@ -0,0 +1,5 @@
package com.adityachandel.booklore.model.dto.request;
import jakarta.validation.constraints.NotBlank;
public record ReadStatusUpdateRequest(@NotBlank String status) {}

View File

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

View File

@@ -0,0 +1,12 @@
package com.adityachandel.booklore.model.enums;
public enum ReadStatus {
UNREAD,
READING,
RE_READING,
READ,
PARTIALLY_READ,
PAUSED,
WONT_READ,
ABANDONED
}

View File

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

View File

@@ -0,0 +1,2 @@
ALTER TABLE book
ADD COLUMN read_status VARCHAR(20) DEFAULT 'UNREAD';

View File

@@ -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, string> = {
[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]: 'Wont 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'),

View File

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

View File

@@ -168,23 +168,37 @@
}
</div>
<div class="grid gap-x-20 gap-y-2 pt-6" style="grid-template-columns: repeat(5, max-content);">
<p class="whitespace-nowrap flex items-center gap-2">
<span class="font-bold">File Type:</span>
<span
class="inline-block px-2 py-0.5 rounded-full text-xs font-bold text-white"
[ngClass]="getFileTypeColorClass(getFileExtension(book?.filePath))">
{{ getFileExtension(book?.filePath) || '-' }}
</span>
</p>
<div>
<p class="whitespace-nowrap flex items-center gap-2">
<span class="font-bold">Read Status:</span>
<p-tag
class="cursor-pointer text-white"
[severity]="getStatusSeverity(selectedReadStatus)"
(click)="menu.toggle($event)">
{{ getStatusLabel(selectedReadStatus) }}
</p-tag>
<p-menu #menu [popup]="true" [model]="readStatusMenuItems"></p-menu>
</p>
</div>
<p class="whitespace-nowrap"><span class="font-bold">Publisher:</span> {{ book?.metadata!.publisher || '-' }}</p>
<p class="whitespace-nowrap"><span class="font-bold">Published:</span> {{ book?.metadata!.publishedDate || '-' }}</p>
<p class="whitespace-nowrap"><span class="font-bold">Language:</span> {{ book?.metadata!.language || '-' }}</p>
<p class="whitespace-nowrap"><span class="font-bold">ISBN 10:</span> {{ book?.metadata!.isbn10 || '-' }}</p>
<p class="whitespace-nowrap flex items-center gap-2">
<span class="font-bold">File Type:</span>
<span
class="inline-block px-2 py-0.5 rounded-lg text-xs font-bold text-gray-200"
[ngClass]="getFileTypeColorClass(getFileExtension(book?.filePath))">
{{ getFileExtension(book?.filePath) || '-' }}
</span>
</p>
<p class="whitespace-nowrap flex items-center gap-2">
<span class="font-bold">Metadata Score:</span>
@if (book?.metadataMatchScore != null) {
<span
class="inline-block px-2 py-0.5 rounded-full text-xs font-bold text-white border"
class="inline-block px-2 py-0.5 rounded-lg text-xs font-bold text-gray-200 border"
[ngClass]="getMatchScoreColorClass(book?.metadataMatchScore!)">
{{ (book?.metadataMatchScore! * 100) | number:'1.0-0' }}%
</span>
@@ -192,8 +206,6 @@
<span>-</span>
}
</p>
<p class="whitespace-nowrap"><span class="font-bold">Page Count:</span> {{ book?.metadata!.pageCount || '-' }}</p>
<p class="whitespace-nowrap"><span class="font-bold">File Size:</span> {{ getFileSizeInMB(book) }}</p>
<p class="whitespace-nowrap flex items-center gap-2">
<span class="font-bold">Progress:</span>
<span
@@ -202,6 +214,7 @@
{{ getProgressPercent(book) !== null ? getProgressPercent(book) + '%' : 'N/A' }}
</span>
</p>
<p class="whitespace-nowrap"><span class="font-bold">File Size:</span> {{ getFileSizeInMB(book) }}</p>
<p class="whitespace-nowrap"><span class="font-bold">ISBN 13:</span> {{ book?.metadata!.isbn13 || '-' }}</p>
</div>
<div class="pt-2 flex items-center gap-2 w-full">

View File

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

View File

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

View File

@@ -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<BookDeletionResponse>(this.url, { params }).pipe(
return this.http.delete<BookDeletionResponse>(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<any>(`${this.url}/${bookId}/metadata/restore`);
}
updateBookReadStatus(id: number, status: ReadStatus): Observable<void> {
return this.http.put<void>(`${this.url}/${id}/read-status`, {status});
}
}