mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,6 +5,9 @@ build/
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
## MacOS ###
|
||||
.DS_Store
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -29,5 +29,6 @@ public class Book {
|
||||
private CbxProgress cbxProgress;
|
||||
private Set<Shelf> shelves;
|
||||
private String readStatus;
|
||||
private Instant dateFinished;
|
||||
private LibraryPath libraryPath;
|
||||
}
|
||||
|
||||
@@ -51,4 +51,7 @@ public class UserBookProgressEntity {
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "read_status")
|
||||
private ReadStatus readStatus;
|
||||
|
||||
@Column(name = "date_finished")
|
||||
private Instant dateFinished;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`);
|
||||
5322
booklore-ui/package-lock.json
generated
5322
booklore-ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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':
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface Book {
|
||||
seriesCount?: number | null;
|
||||
metadataMatchScore?: number | null;
|
||||
readStatus?: ReadStatus;
|
||||
dateFinished?: string;
|
||||
libraryPath?: { id: number };
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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'},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user