mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
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:
committed by
Aditya Chandel
parent
cd4e14c8ad
commit
6bfbff5d34
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -27,4 +27,5 @@ public class Book {
|
||||
private EpubProgress epubProgress;
|
||||
private CbxProgress cbxProgress;
|
||||
private Set<Shelf> shelves;
|
||||
private String readStatus;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.adityachandel.booklore.model.dto.request;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record ReadStatusUpdateRequest(@NotBlank String status) {}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.adityachandel.booklore.model.enums;
|
||||
|
||||
public enum ReadStatus {
|
||||
UNREAD,
|
||||
READING,
|
||||
RE_READING,
|
||||
READ,
|
||||
PARTIALLY_READ,
|
||||
PAUSED,
|
||||
WONT_READ,
|
||||
ABANDONED
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE book
|
||||
ADD COLUMN read_status VARCHAR(20) DEFAULT 'UNREAD';
|
||||
@@ -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]: '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'),
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user