Per user personal ratings (#1820)

* Change personal ratings to be saved per user

* Only copy ratings to users with existing progress records
This commit is contained in:
Muppetteer
2025-12-12 12:37:21 +11:00
committed by GitHub
parent 73b27944e2
commit 8ee53ff7ba
39 changed files with 147 additions and 109 deletions

View File

@@ -5,6 +5,7 @@ import com.adityachandel.booklore.exception.ApiError;
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.PersonalRatingUpdateRequest;
import com.adityachandel.booklore.model.dto.request.ReadProgressRequest;
import com.adityachandel.booklore.model.dto.request.ReadStatusUpdateRequest;
import com.adityachandel.booklore.model.dto.request.ShelvesAssignmentRequest;
@@ -186,4 +187,28 @@ public class BookController {
List<Book> updatedBooks = bookService.resetProgress(bookIds, type);
return ResponseEntity.ok(updatedBooks);
}
@Operation(summary = "Update personal rating", description = "Update the personal rating for one or more books.")
@ApiResponse(responseCode = "200", description = "Personal rating updated successfully")
@PutMapping("/personal-rating")
public ResponseEntity<List<Book>> updatePersonalRating(
@Parameter(description = "Personal rating update request") @RequestBody @Valid PersonalRatingUpdateRequest request) {
List<Book> updatedBooks = bookService.updatePersonalRating(request.ids(), request.rating());
return ResponseEntity.ok(updatedBooks);
}
@Operation(summary = "Reset personal rating", description = "Reset the personal rating for one or more books.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Personal rating reset successfully"),
@ApiResponse(responseCode = "400", description = "No book IDs provided")
})
@PostMapping("/reset-personal-rating")
public ResponseEntity<List<Book>> resetPersonalRating(
@Parameter(description = "List of book IDs to reset personal rating for") @RequestBody List<Long> bookIds) {
if (bookIds == null || bookIds.isEmpty()) {
throw ApiError.GENERIC_BAD_REQUEST.createException("No book IDs provided");
}
List<Book> updatedBooks = bookService.resetPersonalRating(bookIds);
return ResponseEntity.ok(updatedBooks);
}
}

View File

@@ -28,7 +28,6 @@ public class MetadataClearFlags {
private boolean goodreadsReviewCount;
private boolean hardcoverRating;
private boolean hardcoverReviewCount;
private boolean personalRating;
private boolean authors;
private boolean categories;
private boolean moods;

View File

@@ -32,6 +32,7 @@ public class Book {
private CbxProgress cbxProgress;
private KoProgress koreaderProgress;
private KoboProgress koboProgress;
private Integer personalRating;
private Set<Shelf> shelves;
private String readStatus;
private Instant dateFinished;

View File

@@ -29,7 +29,6 @@ public class BookMetadata {
private String isbn10;
private Integer pageCount;
private String language;
private Double rating;
private String asin;
private Double amazonRating;
private Integer amazonReviewCount;
@@ -43,7 +42,6 @@ public class BookMetadata {
private String doubanId;
private Double doubanRating;
private Integer doubanReviewCount;
private Double personalRating;
private String googleId;
private Instant coverUpdatedOn;
private Set<String> authors;
@@ -72,7 +70,6 @@ public class BookMetadata {
private Boolean googleIdLocked;
private Boolean pageCountLocked;
private Boolean languageLocked;
private Boolean personalRatingLocked;
private Boolean amazonRatingLocked;
private Boolean amazonReviewCountLocked;
private Boolean goodreadsRatingLocked;

View File

@@ -31,7 +31,6 @@ public class EpubMetadata {
private Integer pageCount;
private String language;
private String asin;
private Double personalRating;
private Double amazonRating;
private Integer amazonReviewCount;
private String goodreadsId;

View File

@@ -0,0 +1,6 @@
package com.adityachandel.booklore.model.dto.request;
import java.util.List;
public record PersonalRatingUpdateRequest(List<Long> ids, Integer rating) {
}

View File

@@ -88,9 +88,6 @@ public class BookMetadataEntity {
@Column(name = "hardcover_review_count")
private Integer hardcoverReviewCount;
@Column(name = "personal_rating")
private Double personalRating;
@Column(name = "asin", length = 10)
private String asin;
@@ -171,10 +168,6 @@ public class BookMetadataEntity {
@Builder.Default
private Boolean hardcoverReviewCountLocked = Boolean.FALSE;
@Column(name = "personal_rating_locked")
@Builder.Default
private Boolean personalRatingLocked = Boolean.FALSE;
@Column(name = "cover_locked")
@Builder.Default
private Boolean coverLocked = Boolean.FALSE;
@@ -314,7 +307,6 @@ public class BookMetadataEntity {
this.hardcoverRatingLocked = lock;
this.hardcoverReviewCountLocked = lock;
this.comicvineIdLocked = lock;
this.personalRatingLocked = lock;
this.goodreadsIdLocked = lock;
this.hardcoverIdLocked = lock;
this.googleIdLocked = lock;
@@ -346,7 +338,6 @@ public class BookMetadataEntity {
&& Boolean.TRUE.equals(this.goodreadsReviewCountLocked)
&& Boolean.TRUE.equals(this.hardcoverRatingLocked)
&& Boolean.TRUE.equals(this.hardcoverReviewCountLocked)
&& Boolean.TRUE.equals(this.personalRatingLocked)
&& Boolean.TRUE.equals(this.goodreadsIdLocked)
&& Boolean.TRUE.equals(this.comicvineIdLocked)
&& Boolean.TRUE.equals(this.hardcoverIdLocked)

View File

@@ -93,4 +93,7 @@ public class UserBookProgressEntity {
@Column(name = "read_status_modified_time")
private Instant readStatusModifiedTime;
@Column(name = "personal_rating")
private Integer personalRating;
}

View File

@@ -312,13 +312,13 @@ public class BookRuleEvaluatorService {
case READ_STATUS -> progressJoin.get("readStatus");
case DATE_FINISHED -> progressJoin.get("dateFinished");
case LAST_READ_TIME -> progressJoin.get("lastReadTime");
case PERSONAL_RATING -> progressJoin.get("personalRating");
case FILE_SIZE -> root.get("fileSizeKb");
case METADATA_SCORE -> root.get("metadataMatchScore");
case TITLE -> root.get("metadata").get("title");
case SUBTITLE -> root.get("metadata").get("subtitle");
case PUBLISHER -> root.get("metadata").get("publisher");
case PUBLISHED_DATE -> root.get("metadata").get("publishedDate");
case PERSONAL_RATING -> root.get("metadata").get("personalRating");
case PAGE_COUNT -> root.get("metadata").get("pageCount");
case LANGUAGE -> root.get("metadata").get("language");
case SERIES_NAME -> root.get("metadata").get("seriesName");

View File

@@ -100,6 +100,7 @@ public class BookService {
book.setLastReadTime(progress.getLastReadTime());
book.setReadStatus(progress.getReadStatus() == null ? String.valueOf(ReadStatus.UNSET) : String.valueOf(progress.getReadStatus()));
book.setDateFinished(progress.getDateFinished());
book.setPersonalRating(progress.getPersonalRating());
}
}
@@ -191,6 +192,7 @@ public class BookService {
book.setFilePath(FileUtils.getBookFullPath(bookEntity));
book.setReadStatus(userProgress.getReadStatus() == null ? String.valueOf(ReadStatus.UNSET) : String.valueOf(userProgress.getReadStatus()));
book.setDateFinished(userProgress.getDateFinished());
book.setPersonalRating(userProgress.getPersonalRating());
if (!withDescription) {
book.getMetadata().setDescription(null);
@@ -429,13 +431,7 @@ public class BookService {
.findByUserIdAndBookId(user.getId(), bookEntity.getId())
.orElse(null);
if (progress != null) {
setBookProgress(book, progress);
book.setLastReadTime(progress.getLastReadTime());
book.setReadStatus(progress.getReadStatus() == null ? String.valueOf(ReadStatus.UNSET) : String.valueOf(progress.getReadStatus()));
book.setDateFinished(progress.getDateFinished());
}
this.enrichBookWithProgress(book, progress);
return book;
})
.collect(Collectors.toList());
@@ -491,6 +487,65 @@ public class BookService {
return updatedBooks;
}
@Transactional
public List<Book> updatePersonalRating(List<Long> bookIds, Integer rating) {
BookLoreUser user = authenticationService.getAuthenticatedUser();
List<BookEntity> books = bookRepository.findAllById(bookIds);
if (books.size() != bookIds.size()) {
throw ApiError.BOOK_NOT_FOUND.createException("One or more books not found");
}
BookLoreUserEntity userEntity = userRepository.findById(user.getId()).orElseThrow(() -> new UsernameNotFoundException("User not found"));
for (BookEntity book : books) {
UserBookProgressEntity progress = userBookProgressRepository
.findByUserIdAndBookId(user.getId(), book.getId())
.orElse(new UserBookProgressEntity());
progress.setUser(userEntity);
progress.setBook(book);
progress.setPersonalRating(rating);
userBookProgressRepository.save(progress);
}
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);
this.enrichBookWithProgress(book, progress);
return book;
})
.collect(Collectors.toList());
}
public List<Book> resetPersonalRating(List<Long> bookIds) {
BookLoreUser user = authenticationService.getAuthenticatedUser();
List<Book> updatedBooks = new ArrayList<>();
Optional<BookLoreUserEntity> userEntity = userRepository.findById(user.getId());
for (Long bookId : bookIds) {
BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
UserBookProgressEntity progress = userBookProgressRepository
.findByUserIdAndBookId(user.getId(), bookId)
.orElse(new UserBookProgressEntity());
progress.setBook(bookEntity);
progress.setUser(userEntity.orElseThrow());
progress.setPersonalRating(null);
userBookProgressRepository.save(progress);
updatedBooks.add(bookMapper.toBook(bookEntity));
}
return updatedBooks;
}
@Transactional
public List<Book> assignShelvesToBooks(Set<Long> bookIds, Set<Long> shelfIdsToAssign, Set<Long> shelfIdsToUnassign) {
BookLoreUser user = authenticationService.getAuthenticatedUser();

View File

@@ -120,7 +120,6 @@ public class EpubProcessor extends AbstractFileProcessor implements BookFileProc
metadata.setLanguage(truncate((lang == null || "UND".equalsIgnoreCase(lang)) ? "en" : lang, 1000));
metadata.setAsin(truncate(epubMetadata.getAsin(), 20));
metadata.setPersonalRating(epubMetadata.getPersonalRating());
metadata.setAmazonRating(epubMetadata.getAmazonRating());
metadata.setAmazonReviewCount(epubMetadata.getAmazonReviewCount());
metadata.setGoodreadsId(truncate(epubMetadata.getGoodreadsId(), 100));

View File

@@ -139,9 +139,6 @@ public class PdfProcessor extends AbstractFileProcessor implements BookFileProce
if (StringUtils.isNotBlank(extracted.getIsbn13())) {
bookEntity.getMetadata().setIsbn13(extracted.getIsbn13());
}
if (extracted.getPersonalRating() != null) {
bookEntity.getMetadata().setPersonalRating(extracted.getPersonalRating());
}
if (extracted.getCategories() != null) {
bookCreatorService.addCategoriesToBook(extracted.getCategories(), bookEntity);
}

View File

@@ -163,7 +163,6 @@ public class BookMetadataUpdater {
handleFieldUpdate(e.getGoogleIdLocked(), clear.isGoogleId(), m.getGoogleId(), v -> e.setGoogleId(nullIfBlank(v)), e::getGoogleId, replaceMode);
handleFieldUpdate(e.getPageCountLocked(), clear.isPageCount(), m.getPageCount(), e::setPageCount, e::getPageCount, replaceMode);
handleFieldUpdate(e.getLanguageLocked(), clear.isLanguage(), m.getLanguage(), v -> e.setLanguage(nullIfBlank(v)), e::getLanguage, replaceMode);
handleFieldUpdate(e.getPersonalRatingLocked(), clear.isPersonalRating(), m.getPersonalRating(), e::setPersonalRating, e::getPersonalRating, replaceMode);
handleFieldUpdate(e.getAmazonRatingLocked(), clear.isAmazonRating(), m.getAmazonRating(), e::setAmazonRating, e::getAmazonRating, replaceMode);
handleFieldUpdate(e.getAmazonReviewCountLocked(), clear.isAmazonReviewCount(), m.getAmazonReviewCount(), e::setAmazonReviewCount, e::getAmazonReviewCount, replaceMode);
handleFieldUpdate(e.getGoodreadsRatingLocked(), clear.isGoodreadsRating(), m.getGoodreadsRating(), e::setGoodreadsRating, e::getGoodreadsRating, replaceMode);
@@ -379,7 +378,6 @@ public class BookMetadataUpdater {
Pair.of(m.getGoogleIdLocked(), e::setGoogleIdLocked),
Pair.of(m.getPageCountLocked(), e::setPageCountLocked),
Pair.of(m.getLanguageLocked(), e::setLanguageLocked),
Pair.of(m.getPersonalRatingLocked(), e::setPersonalRatingLocked),
Pair.of(m.getAmazonRatingLocked(), e::setAmazonRatingLocked),
Pair.of(m.getAmazonReviewCountLocked(), e::setAmazonReviewCountLocked),
Pair.of(m.getGoodreadsRatingLocked(), e::setGoodreadsRatingLocked),

View File

@@ -165,10 +165,6 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
}
}
if ("calibre:rating".equals(name) || "booklore:personal_rating".equals(prop)) {
safeParseDouble(content, builderMeta::personalRating);
}
switch (prop) {
case "booklore:asin" -> builderMeta.asin(content);
case "booklore:goodreads_id" -> builderMeta.goodreadsId(content);

View File

@@ -234,11 +234,6 @@ public class PdfMetadataExtractor implements FileMetadataExtractor {
private void extractCalibreMetadata(XPath xpath, Document doc, BookMetadata.BookMetadataBuilder builder) {
try {
String rating = xpath.evaluate("//calibre:rating/text()", doc);
if (StringUtils.isNotBlank(rating)) {
builder.personalRating(Double.valueOf(rating));
}
String series = xpath.evaluate("//calibre:series/rdf:value/text()", doc);
if (StringUtils.isNotBlank(series)) {
builder.seriesName(series);

View File

@@ -114,11 +114,6 @@ public class EpubMetadataWriter implements MetadataWriter {
replaceMetaElement(metadataElement, opfDoc, "calibre:series_index", formatted, hasChanges);
});
helper.copyPersonalRating(clear != null && clear.isPersonalRating(), val -> {
String formatted = val != null ? String.format("%.1f", val) : null;
replaceMetaElement(metadataElement, opfDoc, "calibre:rating", formatted, hasChanges);
});
List<String> schemes = List.of("AMAZON", "GOOGLE", "GOODREADS", "HARDCOVER", "ISBN");
for (String scheme : schemes) {

View File

@@ -112,13 +112,6 @@ public class MetadataCopyHelper {
}
}
public void copyPersonalRating(boolean clear, Consumer<Double> consumer) {
if (!isLocked(metadata.getPersonalRatingLocked())) {
if (clear) consumer.accept(null);
else if (metadata.getPersonalRating() != null) consumer.accept(metadata.getPersonalRating());
}
}
public void copyGoodreadsId(boolean clear, Consumer<String> consumer) {
if (!isLocked(metadata.getGoodreadsIdLocked())) {
if (clear) consumer.accept(null);

View File

@@ -200,11 +200,6 @@ public class PdfMetadataWriter implements MetadataWriter {
calibreDescription.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:calibreSI", "http://calibre-ebook.com/xmp-namespace-series-index");
calibreDescription.setAttributeNS("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf:about", "");
helper.copyPersonalRating(clear != null && clear.isPersonalRating(), rating -> {
String value = (rating != null) ? String.valueOf((int) Math.round(rating)) : "";
calibreDescription.appendChild(createSimpleElement(doc, "calibre:rating", value));
});
helper.copySeriesName(clear != null && clear.isSeriesName(), series -> {
Element seriesElem = doc.createElementNS("http://calibre-ebook.com/xmp-namespace", "calibre:series");
seriesElem.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:calibreSI", "http://calibre-ebook.com/xmp-namespace-series-index");

View File

@@ -38,7 +38,6 @@ public class MetadataChangeDetector {
compare(changes, "googleId", clear.isGoogleId(), newMeta.getGoogleId(), existingMeta.getGoogleId(), () -> !isTrue(existingMeta.getGoogleIdLocked()), newMeta.getGoogleIdLocked(), existingMeta.getGoogleIdLocked());
compare(changes, "pageCount", clear.isPageCount(), newMeta.getPageCount(), existingMeta.getPageCount(), () -> !isTrue(existingMeta.getPageCountLocked()), newMeta.getPageCountLocked(), existingMeta.getPageCountLocked());
compare(changes, "language", clear.isLanguage(), newMeta.getLanguage(), existingMeta.getLanguage(), () -> !isTrue(existingMeta.getLanguageLocked()), newMeta.getLanguageLocked(), existingMeta.getLanguageLocked());
compare(changes, "personalRating", clear.isPersonalRating(), newMeta.getPersonalRating(), existingMeta.getPersonalRating(), () -> !isTrue(existingMeta.getPersonalRatingLocked()), newMeta.getPersonalRatingLocked(), existingMeta.getPersonalRatingLocked());
compare(changes, "amazonRating", clear.isAmazonRating(), newMeta.getAmazonRating(), existingMeta.getAmazonRating(), () -> !isTrue(existingMeta.getAmazonRatingLocked()), newMeta.getAmazonRatingLocked(), existingMeta.getAmazonRatingLocked());
compare(changes, "amazonReviewCount", clear.isAmazonReviewCount(), newMeta.getAmazonReviewCount(), existingMeta.getAmazonReviewCount(), () -> !isTrue(existingMeta.getAmazonReviewCountLocked()), newMeta.getAmazonReviewCountLocked(), existingMeta.getAmazonReviewCountLocked());
compare(changes, "goodreadsRating", clear.isGoodreadsRating(), newMeta.getGoodreadsRating(), existingMeta.getGoodreadsRating(), () -> !isTrue(existingMeta.getGoodreadsRatingLocked()), newMeta.getGoodreadsRatingLocked(), existingMeta.getGoodreadsRatingLocked());
@@ -79,7 +78,6 @@ public class MetadataChangeDetector {
compareValue(diffs, "googleId", clear.isGoogleId(), newMeta.getGoogleId(), existingMeta.getGoogleId(), () -> !isTrue(existingMeta.getGoogleIdLocked()));
compareValue(diffs, "pageCount", clear.isPageCount(), newMeta.getPageCount(), existingMeta.getPageCount(), () -> !isTrue(existingMeta.getPageCountLocked()));
compareValue(diffs, "language", clear.isLanguage(), newMeta.getLanguage(), existingMeta.getLanguage(), () -> !isTrue(existingMeta.getLanguageLocked()));
compareValue(diffs, "personalRating", clear.isPersonalRating(), newMeta.getPersonalRating(), existingMeta.getPersonalRating(), () -> !isTrue(existingMeta.getPersonalRatingLocked()));
compareValue(diffs, "amazonRating", clear.isAmazonRating(), newMeta.getAmazonRating(), existingMeta.getAmazonRating(), () -> !isTrue(existingMeta.getAmazonRatingLocked()));
compareValue(diffs, "amazonReviewCount", clear.isAmazonReviewCount(), newMeta.getAmazonReviewCount(), existingMeta.getAmazonReviewCount(), () -> !isTrue(existingMeta.getAmazonReviewCountLocked()));
compareValue(diffs, "goodreadsRating", clear.isGoodreadsRating(), newMeta.getGoodreadsRating(), existingMeta.getGoodreadsRating(), () -> !isTrue(existingMeta.getGoodreadsRatingLocked()));
@@ -111,7 +109,6 @@ public class MetadataChangeDetector {
compareValue(diffs, "hardcoverId", clear.isHardcoverId(), newMeta.getHardcoverId(), existingMeta.getHardcoverId(), () -> !isTrue(existingMeta.getHardcoverIdLocked()));
compareValue(diffs, "googleId", clear.isGoogleId(), newMeta.getGoogleId(), existingMeta.getGoogleId(), () -> !isTrue(existingMeta.getGoogleIdLocked()));
compareValue(diffs, "language", clear.isLanguage(), newMeta.getLanguage(), existingMeta.getLanguage(), () -> !isTrue(existingMeta.getLanguageLocked()));
compareValue(diffs, "personalRating", clear.isPersonalRating(), newMeta.getPersonalRating(), existingMeta.getPersonalRating(), () -> !isTrue(existingMeta.getPersonalRatingLocked()));
compareValue(diffs, "authors", clear.isAuthors(), newMeta.getAuthors(), toNameSet(existingMeta.getAuthors()), () -> !isTrue(existingMeta.getAuthorsLocked()));
compareValue(diffs, "categories", clear.isCategories(), newMeta.getCategories(), toNameSet(existingMeta.getCategories()), () -> !isTrue(existingMeta.getCategoriesLocked()));
return !diffs.isEmpty();

View File

@@ -0,0 +1,11 @@
ALTER TABLE user_book_progress ADD COLUMN IF NOT EXISTS personal_rating TINYINT;
-- Copies existing personal ratings to all users with progress records for matching books
UPDATE user_book_progress ubp
JOIN book_metadata bm ON ubp.book_id = bm.book_id
SET ubp.personal_rating = bm.personal_rating
WHERE bm.personal_rating IS NOT NULL;
-- Drops obsolete columns
ALTER TABLE book_metadata DROP COLUMN personal_rating;
ALTER TABLE book_metadata DROP COLUMN personal_rating_locked;

View File

@@ -48,7 +48,6 @@ public class MetadataChangeDetectorTest {
.googleId("google123")
.pageCount(300)
.language("en")
.personalRating(4.5)
.amazonRating(4.2)
.amazonReviewCount(1500)
.goodreadsRating(4.1)
@@ -72,7 +71,6 @@ public class MetadataChangeDetectorTest {
.googleIdLocked(false)
.pageCountLocked(false)
.languageLocked(false)
.personalRatingLocked(false)
.amazonRatingLocked(false)
.amazonReviewCountLocked(false)
.goodreadsRatingLocked(false)
@@ -121,7 +119,6 @@ public class MetadataChangeDetectorTest {
.googleId("google123")
.pageCount(300)
.language("en")
.personalRating(4.5)
.amazonRating(4.2)
.amazonReviewCount(1500)
.goodreadsRating(4.1)
@@ -145,7 +142,6 @@ public class MetadataChangeDetectorTest {
.googleIdLocked(false)
.pageCountLocked(false)
.languageLocked(false)
.personalRatingLocked(false)
.amazonRatingLocked(false)
.amazonReviewCountLocked(false)
.goodreadsRatingLocked(false)
@@ -200,7 +196,6 @@ public class MetadataChangeDetectorTest {
Arguments.of("googleId", (Consumer<BookMetadata>) m -> m.setGoogleId("google456")),
Arguments.of("pageCount", (Consumer<BookMetadata>) m -> m.setPageCount(350)),
Arguments.of("language", (Consumer<BookMetadata>) m -> m.setLanguage("fr")),
Arguments.of("personalRating", (Consumer<BookMetadata>) m -> m.setPersonalRating(4.8)),
Arguments.of("amazonRating", (Consumer<BookMetadata>) m -> m.setAmazonRating(4.5)),
Arguments.of("amazonReviewCount", (Consumer<BookMetadata>) m -> m.setAmazonReviewCount(2000)),
Arguments.of("goodreadsRating", (Consumer<BookMetadata>) m -> m.setGoodreadsRating(4.3)),

View File

@@ -239,7 +239,7 @@ export class BookFilterComponent implements OnInit, OnDestroy {
}
return [{id: status, name: getReadStatusName(status)}];
}, 'id', 'name'),
personalRating: this.getFilterStream((book: Book) => getRatingRangeFilters10(book.metadata?.personalRating!), 'id', 'name', 'sortIndex'),
personalRating: this.getFilterStream((book: Book) => getRatingRangeFilters10(book.personalRating!), 'id', 'name', 'sortIndex'),
publishedDate: this.getFilterStream(extractPublishedYearFilter, 'id', 'name'),
matchScore: this.getFilterStream((book: Book) => getMatchScoreRangeFilters(book.metadataMatchScore), 'id', 'name', 'sortIndex'),
mood: this.getFilterStream(

View File

@@ -93,7 +93,7 @@ export class SideBarFilter implements BookFilter {
case 'hardcoverRating':
return filterValues.some(range => isRatingInRange(book.metadata?.hardcoverRating, range));
case 'personalRating':
return filterValues.some(range => isRatingInRange10(book.metadata?.personalRating, range));
return filterValues.some(range => isRatingInRange10(book.personalRating, range));
case 'publishedDate':
const bookYear = book.metadata?.publishedDate
? new Date(book.metadata.publishedDate).getFullYear()

View File

@@ -10,6 +10,7 @@ export class BookSorter {
{ label: 'Author', field: 'author', direction: SortDirection.ASCENDING },
{ label: 'Author + Series', field: 'authorSeries', direction: SortDirection.ASCENDING },
{ label: 'Last Read', field: 'lastReadTime', direction: SortDirection.ASCENDING },
{ label: 'Personal Rating', field: 'personalRating', direction: SortDirection.ASCENDING },
{ label: 'Added On', field: 'addedOn', direction: SortDirection.ASCENDING },
{ label: 'File Size', field: 'fileSizeKb', direction: SortDirection.ASCENDING },
{ label: 'Locked', field: 'locked', direction: SortDirection.ASCENDING },

View File

@@ -41,6 +41,7 @@ export interface Book extends FileInfo {
koboProgress?: KoboProgress;
seriesCount?: number | null;
metadataMatchScore?: number | null;
personalRating?: number | null;
readStatus?: ReadStatus;
dateFinished?: string;
libraryPath?: { id: number };
@@ -98,7 +99,6 @@ export interface BookMetadata {
goodreadsReviewCount?: number | null;
hardcoverRating?: number | null;
hardcoverReviewCount?: number | null;
personalRating?: number | null;
coverUpdatedOn?: string;
authors?: string[];
categories?: string[];
@@ -125,7 +125,6 @@ export interface BookMetadata {
googleIdLocked?: boolean;
pageCountLocked?: boolean;
languageLocked?: boolean;
personalRatingLocked?: boolean;
amazonRatingLocked?: boolean;
amazonReviewCountLocked?: boolean;
goodreadsRatingLocked?: boolean;
@@ -166,7 +165,6 @@ export interface MetadataClearFlags {
goodreadsReviewCount?: boolean;
hardcoverRating?: boolean;
hardcoverReviewCount?: boolean;
personalRating?: boolean;
authors?: boolean;
categories?: boolean;
moods?: boolean;

View File

@@ -531,6 +531,22 @@ export class BookService {
);
}
resetPersonalRating(bookIds: number | number[]): Observable<Book[]> {
const ids = Array.isArray(bookIds) ? bookIds : [bookIds];
return this.http.post<Book[]>(`${this.url}/reset-personal-rating`, ids).pipe(
tap(updatedBooks => updatedBooks.forEach(book => this.handleBookUpdate(book)))
);
}
updatePersonalRating(bookIds: number | number[], rating: number): Observable<Book[]> {
const ids = Array.isArray(bookIds) ? bookIds : [bookIds];
return this.http.put<Book[]>(`${this.url}/personal-rating`, {ids, rating}).pipe(
tap(updatedBooks => {
updatedBooks.forEach(updatedBook => this.handleBookUpdate(updatedBook));
})
);
}
consolidateMetadata(metadataType: 'authors' | 'categories' | 'moods' | 'tags' | 'series' | 'publishers' | 'languages', targetValues: string[], valuesToMerge: string[]): Observable<any> {
const payload = {metadataType, targetValues, valuesToMerge};
return this.http.post(`${this.url}/metadata/manage/consolidate`, payload).pipe(

View File

@@ -79,7 +79,7 @@ export class SortService {
publisher: (book) => book.metadata?.publisher || null,
pageCount: (book) => book.metadata?.pageCount || null,
rating: (book) => book.metadata?.rating || null,
personalRating: (book) => book.metadata?.personalRating || null,
personalRating: (book) => book.personalRating || null,
reviewCount: (book) => book.metadata?.reviewCount || null,
amazonRating: (book) => book.metadata?.amazonRating || null,
amazonReviewCount: (book) => book.metadata?.amazonReviewCount || null,

View File

@@ -201,7 +201,7 @@ export class BookRuleEvaluatorService {
case 'metadataScore':
return book.metadataMatchScore;
case 'personalRating':
return book.metadata?.personalRating;
return book.personalRating;
case 'title':
return book.metadata?.title?.toLowerCase() ?? null;
case 'subtitle':

View File

@@ -146,7 +146,6 @@ export class MetadataEditorComponent implements OnInit {
pageCount: new FormControl(""),
language: new FormControl(""),
asin: new FormControl(""),
personalRating: new FormControl(""),
amazonRating: new FormControl(""),
amazonReviewCount: new FormControl(""),
goodreadsId: new FormControl(""),
@@ -176,7 +175,6 @@ export class MetadataEditorComponent implements OnInit {
pageCountLocked: new FormControl(false),
languageLocked: new FormControl(false),
asinLocked: new FormControl(false),
personalRatingLocked: new FormControl(false),
amazonRatingLocked: new FormControl(false),
amazonReviewCountLocked: new FormControl(false),
goodreadsIdLocked: new FormControl(""),
@@ -267,7 +265,6 @@ export class MetadataEditorComponent implements OnInit {
rating: metadata.rating ?? null,
reviewCount: metadata.reviewCount ?? null,
asin: metadata.asin ?? null,
personalRating: metadata.personalRating ?? null,
amazonRating: metadata.amazonRating ?? null,
amazonReviewCount: metadata.amazonReviewCount ?? null,
goodreadsId: metadata.goodreadsId ?? null,
@@ -295,7 +292,6 @@ export class MetadataEditorComponent implements OnInit {
pageCountLocked: metadata.pageCountLocked ?? false,
languageLocked: metadata.languageLocked ?? false,
asinLocked: metadata.asinLocked ?? false,
personalRatingLocked: metadata.personalRatingLocked ?? false,
amazonRatingLocked: metadata.amazonRatingLocked ?? false,
amazonReviewCountLocked: metadata.amazonReviewCountLocked ?? false,
goodreadsIdLocked: metadata.goodreadsIdLocked ?? false,
@@ -328,7 +324,6 @@ export class MetadataEditorComponent implements OnInit {
{key: "asinLocked", control: "asin"},
{key: "amazonReviewCountLocked", control: "amazonReviewCount"},
{key: "amazonRatingLocked", control: "amazonRating"},
{key: "personalRatingLocked", control: "personalRating"},
{key: "goodreadsIdLocked", control: "goodreadsId"},
{key: "comicvineIdLocked", control: "comicvineId"},
{key: "goodreadsReviewCountLocked", control: "goodreadsReviewCount"},
@@ -465,7 +460,6 @@ export class MetadataEditorComponent implements OnInit {
rating: form.get("rating")?.value,
reviewCount: form.get("reviewCount")?.value,
asin: form.get("asin")?.value,
personalRating: form.get("personalRating")?.value,
amazonRating: form.get("amazonRating")?.value,
amazonReviewCount: form.get("amazonReviewCount")?.value,
goodreadsId: form.get("goodreadsId")?.value,
@@ -498,7 +492,6 @@ export class MetadataEditorComponent implements OnInit {
languageLocked: form.get("languageLocked")?.value,
asinLocked: form.get("asinLocked")?.value,
amazonRatingLocked: form.get("amazonRatingLocked")?.value,
personalRatingLocked: form.get("personalRatingLocked")?.value,
amazonReviewCountLocked: form.get("amazonReviewCountLocked")?.value,
goodreadsIdLocked: form.get("goodreadsIdLocked")?.value,
comicvineIdLocked: form.get("comicvineIdLocked")?.value,
@@ -546,7 +539,6 @@ export class MetadataEditorComponent implements OnInit {
pageCount: wasCleared("pageCount"),
language: wasCleared("language"),
asin: wasCleared("asin"),
personalRating: wasCleared("personalRating"),
amazonRating: wasCleared("amazonRating"),
amazonReviewCount: wasCleared("amazonReviewCount"),
goodreadsId: wasCleared("goodreadsId"),

View File

@@ -171,7 +171,6 @@ export class MetadataPickerComponent implements OnInit {
pageCountLocked: new FormControl(false),
languageLocked: new FormControl(false),
asinLocked: new FormControl(false),
personalRatingLocked: new FormControl(false),
amazonRatingLocked: new FormControl(false),
amazonReviewCountLocked: new FormControl(false),
goodreadsIdLocked: new FormControl(false),
@@ -277,7 +276,6 @@ export class MetadataPickerComponent implements OnInit {
pageCountLocked: metadata.pageCountLocked || false,
languageLocked: metadata.languageLocked || false,
asinLocked: metadata.asinLocked || false,
personalRatingLocked: metadata.personalRatingLocked || false,
amazonRatingLocked: metadata.amazonRatingLocked || false,
amazonReviewCountLocked: metadata.amazonReviewCountLocked || false,
goodreadsIdLocked: metadata.goodreadsIdLocked || false,
@@ -313,7 +311,6 @@ export class MetadataPickerComponent implements OnInit {
if (metadata.isbn10Locked) this.metadataForm.get('isbn10')?.disable({emitEvent: false});
if (metadata.isbn13Locked) this.metadataForm.get('isbn13')?.disable({emitEvent: false});
if (metadata.asinLocked) this.metadataForm.get('asin')?.disable({emitEvent: false});
if (metadata.personalRatingLocked) this.metadataForm.get('personalRating')?.disable({emitEvent: false});
if (metadata.amazonReviewCountLocked) this.metadataForm.get('amazonReviewCount')?.disable({emitEvent: false});
if (metadata.amazonRatingLocked) this.metadataForm.get('amazonRating')?.disable({emitEvent: false});
if (metadata.googleIdLocked) this.metadataForm.get('googleIdCount')?.disable({emitEvent: false});
@@ -393,7 +390,6 @@ export class MetadataPickerComponent implements OnInit {
pageCount: this.metadataForm.get('pageCount')?.value || this.copiedFields['pageCount'] ? this.getPageCountOrCopied() : null,
language: this.metadataForm.get('language')?.value || this.copiedFields['language'] ? this.getValueOrCopied('language') : '',
asin: this.metadataForm.get('asin')?.value || this.copiedFields['asin'] ? this.getValueOrCopied('asin') : '',
personalRating: this.metadataForm.get('personalRating')?.value || this.copiedFields['personalRating'] ? this.getNumberOrCopied('personalRating') : null,
amazonRating: this.metadataForm.get('amazonRating')?.value || this.copiedFields['amazonRating'] ? this.getNumberOrCopied('amazonRating') : null,
amazonReviewCount: this.metadataForm.get('amazonReviewCount')?.value || this.copiedFields['amazonReviewCount'] ? this.getNumberOrCopied('amazonReviewCount') : null,
goodreadsId: this.metadataForm.get('goodreadsId')?.value || this.copiedFields['goodreadsId'] ? this.getValueOrCopied('goodreadsId') : '',
@@ -423,7 +419,6 @@ export class MetadataPickerComponent implements OnInit {
pageCountLocked: this.metadataForm.get('pageCountLocked')?.value,
languageLocked: this.metadataForm.get('languageLocked')?.value,
asinLocked: this.metadataForm.get('asinLocked')?.value,
personalRatingLocked: this.metadataForm.get('personalRatingLocked')?.value,
amazonRatingLocked: this.metadataForm.get('amazonRatingLocked')?.value,
amazonReviewCountLocked: this.metadataForm.get('amazonReviewCountLocked')?.value,
goodreadsIdLocked: this.metadataForm.get('goodreadsIdLocked')?.value,
@@ -480,7 +475,6 @@ export class MetadataPickerComponent implements OnInit {
seriesNumber: current.seriesNumber === null && original.seriesNumber !== null,
seriesTotal: current.seriesTotal === null && original.seriesTotal !== null,
cover: !current.thumbnailUrl && !!original.thumbnailUrl,
personalRating: current.personalRating === null && original.personalRating !== null,
};
}

View File

@@ -93,10 +93,10 @@
<div class="rating-group">
<i class="pi pi-thumbs-up-fill rating-icon" pTooltip="Personal Rating" tooltipPosition="top"></i>
<p-rating
[ngModel]="book?.metadata!.personalRating"
[ngModel]="book?.personalRating"
(onRate)="onPersonalRatingChange(book, $event)"
stars="10"
[style.--p-rating-icon-active-color]="getStarColorScaled(book?.metadata!.personalRating, 10)">
[style.--p-rating-icon-active-color]="getStarColorScaled(book?.personalRating, 10)">
</p-rating>
</div>
<p-button

View File

@@ -502,12 +502,7 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
}
onPersonalRatingChange(book: Book, {value: personalRating}: RatingRateEvent): void {
if (!book?.metadata) return;
const updatedMetadata = {...book.metadata, personalRating};
this.bookService.updateBookMetadata(book.id, {
metadata: updatedMetadata,
clearFlags: {personalRating: false}
}, false).subscribe({
this.bookService.updatePersonalRating(book.id, personalRating).subscribe({
next: () => {
this.messageService.add({
severity: 'success',
@@ -527,12 +522,7 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
}
resetPersonalRating(book: Book): void {
if (!book?.metadata) return;
const updatedMetadata = {...book.metadata, personalRating: null};
this.bookService.updateBookMetadata(book.id, {
metadata: updatedMetadata,
clearFlags: {personalRating: true}
}, false).subscribe({
this.bookService.resetPersonalRating(book.id).subscribe({
next: () => {
this.messageService.add({
severity: 'info',

View File

@@ -271,7 +271,7 @@ export class AuthorPopularityChartService implements OnDestroy {
if (book.metadata?.goodreadsRating) ratings.push(book.metadata.goodreadsRating);
if (book.metadata?.amazonRating) ratings.push(book.metadata.amazonRating);
if (book.metadata?.hardcoverRating) ratings.push(book.metadata.hardcoverRating);
if (book.metadata?.personalRating) ratings.push(book.metadata.personalRating);
if (book.personalRating) ratings.push(book.personalRating);
if (ratings.length > 0) {
const avgRating = ratings.reduce((sum, rating) => sum + rating, 0) / ratings.length;

View File

@@ -208,7 +208,7 @@ export class PersonalRatingChartService implements OnDestroy {
RATING_RANGES.forEach(range => rangeCounts.set(range.range, {count: 0, totalRating: 0}));
books.forEach(book => {
const personalRating = book.metadata?.personalRating;
const personalRating = book.personalRating;
if (!personalRating || personalRating === 0) {
const noRatingData = rangeCounts.get('No Rating')!;

View File

@@ -296,7 +296,7 @@ export class ReadingDNAChartService implements OnDestroy {
if (!metadata) return false;
return (metadata.goodreadsRating && metadata.goodreadsRating >= 4.0) ||
(metadata.amazonRating && metadata.amazonRating >= 4.0) ||
(metadata.personalRating && metadata.personalRating >= 4);
(book.personalRating && book.personalRating >= 4);
});
const qualityRate = qualityBooks.length / books.length;
@@ -366,7 +366,7 @@ export class ReadingDNAChartService implements OnDestroy {
});
// Personal rating engagement shows emotional connection
const personallyRatedBooks = books.filter(book => book.metadata?.personalRating);
const personallyRatedBooks = books.filter(book => book.personalRating);
const emotionalRate = emotionalBooks.length / books.length;
const ratingEngagement = personallyRatedBooks.length / books.length;

View File

@@ -394,7 +394,7 @@ export class ReadingHabitsChartService implements OnDestroy {
const metadataScore = (wellOrganizedBooks.length / books.length) * 35; // Max 35
// Personal ratings suggest systematic tracking
const ratedBooks = books.filter(book => book.metadata?.personalRating);
const ratedBooks = books.filter(book => book.personalRating);
const ratingScore = (ratedBooks.length / books.length) * 25; // Max 25
return Math.min(100, seriesScore + metadataScore + ratingScore);

View File

@@ -271,7 +271,7 @@ export class ReadingVelocityChartService implements OnDestroy {
const goodreadsRating = metadata.goodreadsRating || 0;
const amazonRating = metadata.amazonRating || 0;
const personalRating = metadata.personalRating || 0;
const personalRating = book.personalRating || 0;
return goodreadsRating >= 4.0 || amazonRating >= 4.0 || personalRating >= 4;
}
@@ -290,14 +290,14 @@ export class ReadingVelocityChartService implements OnDestroy {
private calculateAverageRating(books: Book[]): number {
const ratingsBooks = books.filter(book => {
const metadata = book.metadata;
return metadata && (metadata.goodreadsRating || metadata.amazonRating || metadata.personalRating);
return metadata && (metadata.goodreadsRating || metadata.amazonRating || book.personalRating);
});
if (ratingsBooks.length === 0) return 0;
const totalRating = ratingsBooks.reduce((sum, book) => {
const metadata = book.metadata!;
const rating = metadata.goodreadsRating || metadata.amazonRating || metadata.personalRating || 0;
const rating = metadata.goodreadsRating || metadata.amazonRating || book.personalRating || 0;
return sum + rating;
}, 0);

View File

@@ -318,9 +318,9 @@ export class ReadingVelocityTimelineChartService implements OnDestroy {
const averagePages = books.length > 0 ? Math.round(totalPages / books.length) : 0;
// Calculate average rating
const ratedBooks = books.filter(book => book.metadata?.personalRating || book.metadata?.goodreadsRating);
const ratedBooks = books.filter(book => book.personalRating || book.metadata?.goodreadsRating);
const totalRating = ratedBooks.reduce((sum, book) => {
const rating = book.metadata?.personalRating || book.metadata?.goodreadsRating || 0;
const rating = book.personalRating || book.metadata?.goodreadsRating || 0;
return sum + rating;
}, 0);
const averageRating = ratedBooks.length > 0 ? Number((totalRating / ratedBooks.length).toFixed(1)) : 0;

View File

@@ -275,13 +275,13 @@ export class SeriesCompletionProgressChartService implements OnDestroy {
// Calculate average rating
const ratedBooks = books.filter(book => {
const rating = book.metadata?.personalRating || book.metadata?.goodreadsRating;
const rating = book.personalRating || book.metadata?.goodreadsRating;
return rating && rating > 0;
});
const averageRating = ratedBooks.length > 0
? Number((ratedBooks.reduce((sum, book) => {
const rating = book.metadata?.personalRating || book.metadata?.goodreadsRating || 0;
const rating = book.personalRating || book.metadata?.goodreadsRating || 0;
return sum + rating;
}, 0) / ratedBooks.length).toFixed(1))
: 0;