Feature: Add support for recording when a book is finished, so that users can create Magic Shelves based on this information. (#803)

* Added migration for new field in user_book_progress table
* Added ability to set date finished when book status changes to read, and reset date finished when book status changes to another value
* Added ability to use date published in a Magic Shelf
This commit is contained in:
astrodad
2025-08-04 23:12:19 -04:00
committed by GitHub
parent 95bc88feea
commit 21f23ca6a2
15 changed files with 3048 additions and 2374 deletions

3
.gitignore vendored
View File

@@ -5,6 +5,9 @@ build/
!**/src/main/**/build/
!**/src/test/**/build/
## MacOS ###
.DS_Store
### STS ###
.apt_generated
.classpath

View File

@@ -120,9 +120,9 @@ public class BookController {
}
@PutMapping("/read-status")
public ResponseEntity<Void> updateReadStatus(@RequestBody @Valid ReadStatusUpdateRequest request) {
bookService.updateReadStatus(request.ids(), request.status());
return ResponseEntity.noContent().build();
public ResponseEntity<List<Book>> updateReadStatus(@RequestBody @Valid ReadStatusUpdateRequest request) {
List<Book> updatedBooks = bookService.updateReadStatus(request.ids(), request.status());
return ResponseEntity.ok(updatedBooks);
}
@PostMapping("/reset-progress")

View File

@@ -29,5 +29,6 @@ public class Book {
private CbxProgress cbxProgress;
private Set<Shelf> shelves;
private String readStatus;
private Instant dateFinished;
private LibraryPath libraryPath;
}

View File

@@ -51,4 +51,7 @@ public class UserBookProgressEntity {
@Enumerated(EnumType.STRING)
@Column(name = "read_status")
private ReadStatus readStatus;
@Column(name = "date_finished")
private Instant dateFinished;
}

View File

@@ -80,6 +80,7 @@ public class BookService {
setBookProgress(book, progress);
book.setLastReadTime(progress.getLastReadTime());
book.setReadStatus(String.valueOf(progress.getReadStatus()));
book.setDateFinished(progress.getDateFinished());
}
});
@@ -108,6 +109,8 @@ public class BookService {
if (progress != null) {
setBookProgress(book, progress);
book.setLastReadTime(progress.getLastReadTime());
book.setReadStatus(String.valueOf(progress.getReadStatus()));
book.setDateFinished(progress.getDateFinished());
}
return book;
@@ -277,6 +280,7 @@ public class BookService {
}
book.setFilePath(FileUtils.getBookFullPath(bookEntity));
book.setReadStatus(String.valueOf(userProgress.getReadStatus()));
book.setDateFinished(userProgress.getDateFinished());
if (!withDescription) {
book.getMetadata().setDescription(null);
@@ -307,6 +311,7 @@ public class BookService {
progress.setEpubProgressPercent(null);
progress.setCbxProgress(null);
progress.setCbxProgressPercent(null);
progress.setDateFinished(null);
userBookProgressRepository.save(progress);
updatedBooks.add(bookMapper.toBook(bookEntity));
@@ -374,7 +379,7 @@ public class BookService {
}
@Transactional
public void updateReadStatus(List<Long> bookIds, String status) {
public List<Book> updateReadStatus(List<Long> bookIds, String status) {
BookLoreUser user = authenticationService.getAuthenticatedUser();
ReadStatus readStatus = EnumUtils.getEnumIgnoreCase(ReadStatus.class, status);
@@ -392,8 +397,37 @@ public class BookService {
progress.setUser(userEntity);
progress.setBook(book);
progress.setReadStatus(readStatus);
// Set dateFinished when status is READ, clear it otherwise
if (readStatus == ReadStatus.READ) {
progress.setDateFinished(Instant.now());
} else {
progress.setDateFinished(null);
}
userBookProgressRepository.save(progress);
}
// Return updated books with the latest data
return books.stream()
.map(bookEntity -> {
Book book = bookMapper.toBook(bookEntity);
book.setFilePath(FileUtils.getBookFullPath(bookEntity));
UserBookProgressEntity progress = userBookProgressRepository
.findByUserIdAndBookId(user.getId(), bookEntity.getId())
.orElse(null);
if (progress != null) {
setBookProgress(book, progress);
book.setLastReadTime(progress.getLastReadTime());
book.setReadStatus(String.valueOf(progress.getReadStatus()));
book.setDateFinished(progress.getDateFinished());
}
return book;
})
.collect(Collectors.toList());
}
public ResponseEntity<Resource> downloadBook(Long bookId) {

View File

@@ -0,0 +1,3 @@
ALTER TABLE `user_book_progress`
ADD COLUMN `date_finished` timestamp NULL DEFAULT NULL,
ADD INDEX `idx_user_book_progress_date_finished` (`date_finished`);

File diff suppressed because it is too large Load Diff

View File

@@ -193,6 +193,8 @@ export class BookRuleEvaluatorService {
return book.metadata?.publisher?.toLowerCase() ?? null;
case 'publishedDate':
return book.metadata?.publishedDate ? new Date(book.metadata.publishedDate) : null;
case 'dateFinished':
return book.dateFinished ? new Date(book.dateFinished) : null;
case 'seriesName':
return book.metadata?.seriesName?.toLowerCase() ?? null;
case 'seriesNumber':

View File

@@ -21,6 +21,7 @@ export interface Book {
seriesCount?: number | null;
metadataMatchScore?: number | null;
readStatus?: ReadStatus;
dateFinished?: string;
libraryPath?: { id: number };
}

View File

@@ -408,16 +408,12 @@ export class BookService {
);
}
updateBookReadStatus(bookIds: number | number[], status: ReadStatus): Observable<void> {
updateBookReadStatus(bookIds: number | number[], status: ReadStatus): Observable<Book[]> {
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 =>
ids.includes(book.id) ? {...book, readStatus: status} : book
);
this.bookStateSubject.next({...currentState, books: updatedBooks});
return this.http.put<Book[]>(`${this.url}/read-status`, {ids, status}).pipe(
tap(updatedBooks => {
// Update the books in the state with the actual response from the API
updatedBooks.forEach(updatedBook => this.handleBookUpdate(updatedBook));
})
);
}

View File

@@ -62,6 +62,9 @@
@if (ruleCtrl.get('field')?.value === 'publishedDate') {
<p-datepicker [formControl]="ruleCtrl.get('valueStart')" dateFormat="dd-M-yy" placeholder="Start Year" appendTo="body" fluid class="w-full"></p-datepicker>
<p-datepicker [formControl]="ruleCtrl.get('valueEnd')" dateFormat="dd-M-yy" placeholder="End Year" appendTo="body" fluid class="w-full"></p-datepicker>
} @else if (ruleCtrl.get('field')?.value === 'dateFinished') {
<p-datepicker [formControl]="ruleCtrl.get('valueStart')" dateFormat="dd-M-yy" placeholder="Start Date" appendTo="body" fluid class="w-full"></p-datepicker>
<p-datepicker [formControl]="ruleCtrl.get('valueEnd')" dateFormat="dd-M-yy" placeholder="End Date" appendTo="body" fluid class="w-full"></p-datepicker>
} @else if (numericFieldConfigMap.get(ruleCtrl.get('field')?.value)?.type === 'number') {
<p-inputNumber [formControl]="ruleCtrl.get('valueStart')" [min]="0" class="w-full" placeholder="Start Value" [showButtons]="true"></p-inputNumber>
<p-inputNumber [formControl]="ruleCtrl.get('valueEnd')" [min]="0" class="w-full" placeholder="End Value" [showButtons]="true"></p-inputNumber>
@@ -75,6 +78,8 @@
} @else {
@if (ruleCtrl.get('field')?.value === 'publishedDate') {
<p-datepicker formControlName="value" dateFormat="dd-M-yy" appendTo="body" fluid class="w-full"></p-datepicker>
} @else if (ruleCtrl.get('field')?.value === 'dateFinished') {
<p-datepicker formControlName="value" dateFormat="dd-M-yy" appendTo="body" fluid class="w-full"></p-datepicker>
} @else if (numericFieldConfigMap.get(ruleCtrl.get('field')?.value)?.type === 'number') {
<p-inputNumber formControlName="value" class="w-full" mode="decimal" placeholder="Value" [showButtons]="true"></p-inputNumber>
} @else if (numericFieldConfigMap.get(ruleCtrl.get('field')?.value)?.type === 'decimal') {

View File

@@ -59,6 +59,7 @@ export type RuleField =
| 'fileType'
| 'fileSize'
| 'readStatus'
| 'dateFinished'
| 'metadataScore';
@@ -107,6 +108,7 @@ export type GroupFormGroup = FormGroup<{
const FIELD_CONFIGS: Record<RuleField, FullFieldConfig> = {
library: {label: 'Library'},
readStatus: {label: 'Read Status'},
dateFinished: {label: 'Date Finished', type: 'date'},
metadataScore: {label: 'Metadata Score', type: 'decimal', max: 100},
title: {label: 'Title'},
authors: {label: 'Authors'},

View File

@@ -49,7 +49,7 @@ export function serializeDateRules(ruleOrGroup: any): any {
};
}
const isDateField = ruleOrGroup.field === 'publishedDate';
const isDateField = ruleOrGroup.field === 'publishedDate' || ruleOrGroup.field === 'dateFinished';
const serialize = (val: any) => (val instanceof Date ? val.toISOString().split('T')[0] : val);
return {

View File

@@ -231,6 +231,11 @@
{{ getStatusLabel(selectedReadStatus) }}
</span>
<p-menu #menu [popup]="true" [model]="readStatusMenuItems"></p-menu>
@if (selectedReadStatus === 'READ' && book?.dateFinished) {
<span class="text-xs text-gray-400 ml-2">
Finished on {{ formatDate(book.dateFinished) }}
</span>
}
</p>
<p class="whitespace-nowrap flex items-center">
<span class="font-bold mr-2">Progress:</span>

View File

@@ -1,6 +1,6 @@
import {Component, DestroyRef, inject, Input, OnChanges, OnInit, Optional, SimpleChanges, ViewChild} from '@angular/core';
import {Button} from 'primeng/button';
import {AsyncPipe, DecimalPipe, NgClass} from '@angular/common';
import {AsyncPipe, DatePipe, DecimalPipe, NgClass} from '@angular/common';
import {Observable} from 'rxjs';
import {BookService} from '../../../book/service/book.service';
import {Rating, RatingRateEvent} from 'primeng/rating';
@@ -34,7 +34,7 @@ import {BookCardLiteComponent} from '../../../book/components/book-card-lite/boo
standalone: true,
templateUrl: './metadata-viewer.component.html',
styleUrl: './metadata-viewer.component.scss',
imports: [Button, AsyncPipe, Rating, FormsModule, Tag, Divider, SplitButton, NgClass, Tooltip, DecimalPipe, Editor, ProgressBar, Menu, InfiniteScrollDirective, BookCardLiteComponent]
imports: [Button, AsyncPipe, Rating, FormsModule, Tag, Divider, SplitButton, NgClass, Tooltip, DecimalPipe, DatePipe, Editor, ProgressBar, Menu, InfiniteScrollDirective, BookCardLiteComponent]
})
export class MetadataViewerComponent implements OnInit, OnChanges {
@@ -480,8 +480,9 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
}
this.bookService.updateBookReadStatus(book.id, status).subscribe({
next: () => {
next: (updatedBooks) => {
this.selectedReadStatus = status;
// The book state will be updated automatically by the service
this.messageService.add({
severity: 'success',
summary: 'Read Status Updated',
@@ -532,4 +533,14 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
}
});
}
formatDate(dateString: string | undefined): string {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
}