diff --git a/Dockerfile b/Dockerfile
index 19748a334..43b66e65f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -14,7 +14,7 @@ COPY ./booklore-ui /angular-app/
RUN npm run build --configuration=production
# Stage 2: Build the Spring Boot app with Gradle
-FROM gradle:8-jdk21-alpine AS springboot-build
+FROM gradle:9.1-jdk25-alpine AS springboot-build
WORKDIR /springboot-app
@@ -29,7 +29,7 @@ RUN apk add --no-cache yq && \
RUN gradle clean build -x test
# Stage 3: Final image
-FROM eclipse-temurin:21-jre-alpine
+FROM eclipse-temurin:25-jre-alpine
RUN apk update && apk add nginx gettext su-exec
diff --git a/booklore-api/build.gradle b/booklore-api/build.gradle
index fe290f70d..464d0573c 100644
--- a/booklore-api/build.gradle
+++ b/booklore-api/build.gradle
@@ -2,8 +2,8 @@ plugins {
id 'java'
id 'org.springframework.boot' version '3.5.1'
id 'io.spring.dependency-management' version '1.1.7'
- id 'org.hibernate.orm' version '7.1.0.Final'
- id 'com.github.ben-manes.versions' version '0.52.0'
+ id 'org.hibernate.orm' version '7.1.3.Final'
+ id 'com.github.ben-manes.versions' version '0.53.0'
}
group = 'com.adityachandel'
@@ -11,7 +11,7 @@ version = '0.0.1-SNAPSHOT'
java {
toolchain {
- languageVersion = JavaLanguageVersion.of(21)
+ languageVersion = JavaLanguageVersion.of(25)
}
}
@@ -39,18 +39,18 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
// --- Database & Migration ---
- implementation 'org.mariadb.jdbc:mariadb-java-client:3.5.4'
- implementation 'org.flywaydb:flyway-mysql:11.11.0'
+ implementation 'org.mariadb.jdbc:mariadb-java-client:3.5.6'
+ implementation 'org.flywaydb:flyway-mysql:11.13.2'
implementation 'jakarta.persistence:jakarta.persistence-api:3.2.0'
// --- Security & Authentication ---
- implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
- runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
- runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
+ implementation 'io.jsonwebtoken:jjwt-api:0.13.0'
+ runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0'
+ runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0'
// --- Lombok (For Clean Code) ---
- compileOnly 'org.projectlombok:lombok:1.18.38'
- annotationProcessor 'org.projectlombok:lombok:1.18.38'
+ compileOnly 'org.projectlombok:lombok:1.18.42'
+ annotationProcessor 'org.projectlombok:lombok:1.18.42'
// --- Book & Image Processing ---
implementation 'org.apache.pdfbox:pdfbox:3.0.5'
@@ -64,8 +64,8 @@ dependencies {
implementation 'com.github.junrar:junrar:7.5.5'
// --- JSON & Web Scraping ---
- implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.19.2'
- implementation 'org.jsoup:jsoup:1.21.1'
+ implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.0'
+ implementation 'org.jsoup:jsoup:1.21.2'
// --- Mapping (DTOs & Entities) ---
implementation 'org.mapstruct:mapstruct:1.6.3'
@@ -80,8 +80,6 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.assertj:assertj-core:3.27.3'
testImplementation "org.mockito:mockito-inline:5.2.0"
- testImplementation 'org.testcontainers:junit-jupiter:1.20.4'
- testImplementation 'org.testcontainers:mariadb:1.20.4'
}
hibernate {
diff --git a/booklore-api/gradle/wrapper/gradle-wrapper.jar b/booklore-api/gradle/wrapper/gradle-wrapper.jar
index a4b76b953..1b33c55ba 100644
Binary files a/booklore-api/gradle/wrapper/gradle-wrapper.jar and b/booklore-api/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/booklore-api/gradle/wrapper/gradle-wrapper.properties b/booklore-api/gradle/wrapper/gradle-wrapper.properties
index 002b867c4..2e1113280 100644
--- a/booklore-api/gradle/wrapper/gradle-wrapper.properties
+++ b/booklore-api/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/booklore-api/gradlew b/booklore-api/gradlew
index f5feea6d6..23d15a936 100755
--- a/booklore-api/gradlew
+++ b/booklore-api/gradlew
@@ -86,8 +86,7 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
-APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
-' "$PWD" ) || exit
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -115,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
-CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -206,7 +205,7 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
-# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
@@ -214,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
- org.gradle.wrapper.GradleWrapperMain \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
diff --git a/booklore-api/gradlew.bat b/booklore-api/gradlew.bat
index 339847418..db3a6ac20 100644
--- a/booklore-api/gradlew.bat
+++ b/booklore-api/gradlew.bat
@@ -1,5 +1,5 @@
@rem
-@rem Copyright 2015 the original authorEntity or authorEntities.
+@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@@ -70,11 +70,11 @@ goto fail
:execute
@rem Setup the command line
-set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+set CLASSPATH=
@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java
index 8c4f25e19..3234c0b1c 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java
@@ -25,7 +25,6 @@ import java.io.IOException;
public class BookMediaController {
private final BookService bookService;
- private final BookMetadataService bookMetadataService;
private final PdfReaderService pdfReaderService;
private final CbxReaderService cbxReaderService;
private final BookDropService bookDropService;
@@ -40,18 +39,6 @@ public class BookMediaController {
return ResponseEntity.ok(bookService.getBookCover(bookId));
}
- @GetMapping("/book/{bookId}/backup-cover")
- public ResponseEntity getBackupBookCover(@PathVariable long bookId) {
- Resource file = bookMetadataService.getBackupCoverForBook(bookId);
- if (file == null) {
- return ResponseEntity.noContent().build();
- }
- return ResponseEntity.ok()
- .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=cover.jpg")
- .contentType(MediaType.IMAGE_JPEG)
- .body(file);
- }
-
@GetMapping("/book/{bookId}/pdf/pages/{pageNumber}")
public void getPdfPage(@PathVariable Long bookId, @PathVariable int pageNumber, HttpServletResponse response) throws IOException {
response.setContentType(MediaType.IMAGE_JPEG_VALUE);
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/MetadataController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/MetadataController.java
index fec26bc50..136ee50eb 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/MetadataController.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/MetadataController.java
@@ -118,22 +118,6 @@ public class MetadataController {
return ResponseEntity.noContent().build();
}
- @GetMapping("/{bookId}/metadata/restore")
- @PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
- @CheckBookAccess(bookIdParam = "bookId")
- public ResponseEntity getBackedUpMetadata(@PathVariable Long bookId) {
- BookMetadata restoredMetadata = bookMetadataService.getBackedUpMetadata(bookId);
- return ResponseEntity.ok(restoredMetadata);
- }
-
- @PostMapping("/{bookId}/metadata/restore")
- @PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
- @CheckBookAccess(bookIdParam = "bookId")
- public ResponseEntity restoreMetadata(@PathVariable Long bookId) throws IOException {
- BookMetadata restoredMetadata = bookMetadataService.restoreMetadataFromBackup(bookId);
- return ResponseEntity.ok(restoredMetadata);
- }
-
@PostMapping("/{bookId}/metadata/covers")
public ResponseEntity> getImages(@RequestBody CoverFetchRequest request) {
return ResponseEntity.ok(duckDuckGoCoverService.getCovers(request));
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMapper.java
index ecfc17cf5..31d682b6e 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMapper.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMapper.java
@@ -3,11 +3,7 @@ package com.adityachandel.booklore.mapper;
import com.adityachandel.booklore.model.dto.AdditionalFile;
import com.adityachandel.booklore.model.dto.Book;
import com.adityachandel.booklore.model.dto.LibraryPath;
-import com.adityachandel.booklore.model.entity.AuthorEntity;
-import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity;
-import com.adityachandel.booklore.model.entity.BookEntity;
-import com.adityachandel.booklore.model.entity.CategoryEntity;
-import com.adityachandel.booklore.model.entity.LibraryPathEntity;
+import com.adityachandel.booklore.model.entity.*;
import com.adityachandel.booklore.model.enums.AdditionalFileType;
import org.mapstruct.Context;
import org.mapstruct.Mapper;
@@ -53,6 +49,20 @@ public interface BookMapper {
.collect(Collectors.toSet());
}
+ default Set mapMoods(Set moods) {
+ if (moods == null) return null;
+ return moods.stream()
+ .map(MoodEntity::getName)
+ .collect(Collectors.toSet());
+ }
+
+ default Set mapTags(Set tags) {
+ if (tags == null) return null;
+ return tags.stream()
+ .map(TagEntity::getName)
+ .collect(Collectors.toSet());
+ }
+
@Named("mapLibraryPathIdOnly")
default LibraryPath mapLibraryPathIdOnly(LibraryPathEntity entity) {
if (entity == null) return null;
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMetadataMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMetadataMapper.java
index 45fe4d43f..be5c5953b 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMetadataMapper.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookMetadataMapper.java
@@ -4,7 +4,7 @@ import com.adityachandel.booklore.model.dto.BookMetadata;
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
import org.mapstruct.*;
-@Mapper(componentModel = "spring", uses = {AuthorMapper.class, CategoryMapper.class})
+@Mapper(componentModel = "spring", uses = {AuthorMapper.class, CategoryMapper.class, MoodMapper.class, TagMapper.class})
public interface BookMetadataMapper {
@AfterMapping
@@ -24,6 +24,8 @@ public interface BookMetadataMapper {
@Mapping(target = "description", ignore = true)
@Mapping(target = "authors", ignore = true)
@Mapping(target = "categories", ignore = true)
+ @Mapping(target = "moods", ignore = true)
+ @Mapping(target = "tags", ignore = true)
BookMetadata toBookMetadataWithoutRelations(BookMetadataEntity bookMetadataEntity, @Context boolean includeDescription);
}
\ No newline at end of file
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/MetadataClearFlagsMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/MetadataClearFlagsMapper.java
index 95544ec0d..6950dd142 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/MetadataClearFlagsMapper.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/MetadataClearFlagsMapper.java
@@ -8,36 +8,6 @@ import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
public interface MetadataClearFlagsMapper {
- /*@Mapping(target = "title", source = "clearTitle")
- @Mapping(target = "subtitle", source = "clearSubtitle")
- @Mapping(target = "publisher", source = "clearPublisher")
- @Mapping(target = "publishedDate", source = "clearPublishedDate")
- @Mapping(target = "description", source = "clearDescription")
- @Mapping(target = "seriesName", source = "clearSeriesName")
- @Mapping(target = "seriesNumber", source = "clearSeriesNumber")
- @Mapping(target = "seriesTotal", source = "clearSeriesTotal")
- @Mapping(target = "isbn13", source = "clearIsbn13")
- @Mapping(target = "isbn10", source = "clearIsbn10")
- @Mapping(target = "asin", source = "clearAsin")
- @Mapping(target = "goodreadsId", source = "clearGoodreadsId")
- @Mapping(target = "comicvineId", source = "clearComicvineId")
- @Mapping(target = "hardcoverId", source = "clearHardcoverId")
- @Mapping(target = "googleId", source = "clearGoogleId")
- @Mapping(target = "pageCount", source = "clearPageCount")
- @Mapping(target = "language", source = "clearLanguage")
- @Mapping(target = "amazonRating", source = "clearAmazonRating")
- @Mapping(target = "amazonReviewCount", source = "clearAmazonReviewCount")
- @Mapping(target = "goodreadsRating", source = "clearGoodreadsRating")
- @Mapping(target = "goodreadsReviewCount", source = "clearGoodreadsReviewCount")
- @Mapping(target = "hardcoverRating", source = "clearHardcoverRating")
- @Mapping(target = "hardcoverReviewCount", source = "clearHardcoverReviewCount")
- @Mapping(target = "personalRating", source = "clearPersonalRating")
- @Mapping(target = "authors", source = "clearAuthors")
- @Mapping(target = "categories", source = "clearGenres")
- @Mapping(target = "cover", source = "clearCover")
- MetadataClearFlags toClearFlags(BulkMetadataUpdateRequest request);*/
-
-
@Mapping(target = "publisher", source = "clearPublisher")
@Mapping(target = "publishedDate", source = "clearPublishedDate")
@Mapping(target = "seriesName", source = "clearSeriesName")
@@ -45,5 +15,7 @@ public interface MetadataClearFlagsMapper {
@Mapping(target = "language", source = "clearLanguage")
@Mapping(target = "authors", source = "clearAuthors")
@Mapping(target = "categories", source = "clearGenres")
+ @Mapping(target = "moods", source = "clearMoods")
+ @Mapping(target = "tags", source = "clearTags")
MetadataClearFlags toClearFlags(BulkMetadataUpdateRequest request);
}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/MoodMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/MoodMapper.java
new file mode 100644
index 000000000..c87e08152
--- /dev/null
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/MoodMapper.java
@@ -0,0 +1,23 @@
+package com.adityachandel.booklore.mapper;
+
+import com.adityachandel.booklore.model.entity.MoodEntity;
+import org.mapstruct.Mapper;
+
+import java.util.List;
+
+@Mapper(componentModel = "spring")
+public interface MoodMapper {
+
+ default String toMoodName(MoodEntity moodEntity) {
+ return moodEntity != null ? moodEntity.getName() : null;
+ }
+
+ default List toMoodNamesList(List moodEntities) {
+ if (moodEntities == null || moodEntities.isEmpty()) {
+ return List.of();
+ }
+ return moodEntities.stream()
+ .map(this::toMoodName)
+ .toList();
+ }
+}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/TagMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/TagMapper.java
new file mode 100644
index 000000000..f5315aae6
--- /dev/null
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/TagMapper.java
@@ -0,0 +1,23 @@
+package com.adityachandel.booklore.mapper;
+
+import com.adityachandel.booklore.model.entity.TagEntity;
+import org.mapstruct.Mapper;
+
+import java.util.List;
+
+@Mapper(componentModel = "spring")
+public interface TagMapper {
+
+ default String toTagName(TagEntity tagEntity) {
+ return tagEntity != null ? tagEntity.getName() : null;
+ }
+
+ default List toTagNamesList(List tagEntities) {
+ if (tagEntities == null || tagEntities.isEmpty()) {
+ return List.of();
+ }
+ return tagEntities.stream()
+ .map(this::toTagName)
+ .toList();
+ }
+}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/v2/BookMapperV2.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/v2/BookMapperV2.java
index 0d48421a1..32d5bad61 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/v2/BookMapperV2.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/v2/BookMapperV2.java
@@ -24,6 +24,8 @@ public interface BookMapperV2 {
@Named("mapMetadata")
@Mapping(target = "authors", source = "authors", qualifiedByName = "mapAuthors")
@Mapping(target = "categories", source = "categories", qualifiedByName = "mapCategories")
+ @Mapping(target = "moods", source = "moods", qualifiedByName = "mapMoods")
+ @Mapping(target = "tags", source = "tags", qualifiedByName = "mapTags")
BookMetadata mapMetadata(BookMetadataEntity metadataEntity);
@Named("mapAuthors")
@@ -38,6 +40,18 @@ public interface BookMapperV2 {
categories.stream().map(CategoryEntity::getName).collect(Collectors.toSet());
}
+ @Named("mapMoods")
+ default Set mapMoods(Set moods) {
+ return moods == null ? Set.of() :
+ moods.stream().map(MoodEntity::getName).collect(Collectors.toSet());
+ }
+
+ @Named("mapTags")
+ default Set mapTags(Set tags) {
+ return tags == null ? Set.of() :
+ tags.stream().map(TagEntity::getName).collect(Collectors.toSet());
+ }
+
@Named("mapLibraryPathIdOnly")
default LibraryPath mapLibraryPathIdOnly(LibraryPathEntity entity) {
if (entity == null) return null;
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/MetadataClearFlags.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/MetadataClearFlags.java
index 762acc33d..aabc82094 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/model/MetadataClearFlags.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/MetadataClearFlags.java
@@ -31,6 +31,8 @@ public class MetadataClearFlags {
private boolean personalRating;
private boolean authors;
private boolean categories;
+ private boolean moods;
+ private boolean tags;
private boolean cover;
private boolean reviews;
}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java
index 011bbc035..b8fe745bd 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java
@@ -113,6 +113,7 @@ public class BookLoreUser {
private Float letterSpacing;
private Float lineHeight;
private String flow;
+ private String spread;
}
@Data
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookMetadata.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookMetadata.java
index e681f3e53..3b2986ed0 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookMetadata.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookMetadata.java
@@ -48,6 +48,8 @@ public class BookMetadata {
private Instant coverUpdatedOn;
private Set authors;
private Set categories;
+ private Set moods;
+ private Set tags;
private MetadataProvider provider;
private String thumbnailUrl;
private List bookReviews;
@@ -82,5 +84,7 @@ public class BookMetadata {
private Boolean coverLocked;
private Boolean authorsLocked;
private Boolean categoriesLocked;
+ private Boolean moodsLocked;
+ private Boolean tagsLocked;
private Boolean reviewsLocked;
}
\ No newline at end of file
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/EpubViewerPreferences.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/EpubViewerPreferences.java
index f6360a2e1..258f67bf1 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/EpubViewerPreferences.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/EpubViewerPreferences.java
@@ -10,6 +10,7 @@ public class EpubViewerPreferences {
private String theme;
private String font;
private String flow;
+ private String spread;
private Integer fontSize;
private Float letterSpacing;
private Float lineHeight;
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/BulkMetadataUpdateRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/BulkMetadataUpdateRequest.java
index 6cd5d3502..a9c445564 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/BulkMetadataUpdateRequest.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/BulkMetadataUpdateRequest.java
@@ -29,4 +29,10 @@ public class BulkMetadataUpdateRequest {
private Set genres;
private boolean clearGenres;
+
+ private Set moods;
+ private boolean clearMoods;
+
+ private Set tags;
+ private boolean clearTags;
}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/MetadataRefreshOptions.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/MetadataRefreshOptions.java
index e4790b0ce..e0b385114 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/MetadataRefreshOptions.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/MetadataRefreshOptions.java
@@ -6,27 +6,28 @@ import lombok.Getter;
import lombok.Setter;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
+import lombok.Builder;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
+@Builder
public class MetadataRefreshOptions {
private Long libraryId;
- @NotNull(message = "Default Provider cannot be null")
- private MetadataProvider allP1;
- private MetadataProvider allP2;
- private MetadataProvider allP3;
- private MetadataProvider allP4;
private boolean refreshCovers;
private boolean mergeCategories;
private Boolean reviewBeforeApply;
+ @NotNull(message = "Field options cannot be null")
private FieldOptions fieldOptions;
+ @NotNull(message = "Enabled fields cannot be null")
+ private EnabledFields enabledFields;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
+ @Builder
public static class FieldOptions {
private FieldProvider title;
private FieldProvider subtitle;
@@ -42,16 +43,67 @@ public class MetadataRefreshOptions {
private FieldProvider language;
private FieldProvider categories;
private FieldProvider cover;
+ private FieldProvider pageCount;
+ private FieldProvider asin;
+ private FieldProvider goodreadsId;
+ private FieldProvider comicvineId;
+ private FieldProvider hardcoverId;
+ private FieldProvider googleId;
+ private FieldProvider amazonRating;
+ private FieldProvider amazonReviewCount;
+ private FieldProvider goodreadsRating;
+ private FieldProvider goodreadsReviewCount;
+ private FieldProvider hardcoverRating;
+ private FieldProvider hardcoverReviewCount;
+ private FieldProvider moods;
+ private FieldProvider tags;
}
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
+ @Builder
public static class FieldProvider {
- private MetadataProvider p4;
- private MetadataProvider p3;
- private MetadataProvider p2;
private MetadataProvider p1;
+ private MetadataProvider p2;
+ private MetadataProvider p3;
+ private MetadataProvider p4;
+ }
+
+ @Getter
+ @Setter
+ @NoArgsConstructor
+ @AllArgsConstructor
+ @Builder
+ public static class EnabledFields {
+ private boolean title;
+ private boolean subtitle;
+ private boolean description;
+ private boolean authors;
+ private boolean publisher;
+ private boolean publishedDate;
+ private boolean seriesName;
+ private boolean seriesNumber;
+ private boolean seriesTotal;
+ private boolean isbn13;
+ private boolean isbn10;
+ private boolean language;
+ private boolean categories;
+ private boolean cover;
+ private boolean pageCount;
+ private boolean asin;
+ private boolean goodreadsId;
+ private boolean comicvineId;
+ private boolean hardcoverId;
+ private boolean googleId;
+ private boolean amazonRating;
+ private boolean amazonReviewCount;
+ private boolean goodreadsRating;
+ private boolean goodreadsReviewCount;
+ private boolean hardcoverRating;
+ private boolean hardcoverReviewCount;
+ private boolean moods;
+ private boolean tags;
}
}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookMetadataEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookMetadataEntity.java
index f4afce2e3..feca02f4d 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookMetadataEntity.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookMetadataEntity.java
@@ -105,7 +105,6 @@ public class BookMetadataEntity {
@Column(name = "comicvine_id", length = 100)
private String comicvineId;
- // Locking fields
@Column(name = "title_locked")
private Boolean titleLocked = Boolean.FALSE;
@@ -176,6 +175,12 @@ public class BookMetadataEntity {
@Column(name = "categories_locked")
private Boolean categoriesLocked = Boolean.FALSE;
+ @Column(name = "moods_locked")
+ private Boolean moodsLocked = Boolean.FALSE;
+
+ @Column(name = "tags_locked")
+ private Boolean tagsLocked = Boolean.FALSE;
+
@Column(name = "goodreads_id_locked")
private Boolean goodreadsIdLocked = Boolean.FALSE;
@@ -214,6 +219,24 @@ public class BookMetadataEntity {
@Fetch(FetchMode.SUBSELECT)
private Set categories;
+ @ManyToMany(fetch = FetchType.LAZY)
+ @JoinTable(
+ name = "book_metadata_mood_mapping",
+ joinColumns = @JoinColumn(name = "book_id"),
+ inverseJoinColumns = @JoinColumn(name = "mood_id")
+ )
+ @Fetch(FetchMode.SUBSELECT)
+ private Set moods;
+
+ @ManyToMany(fetch = FetchType.LAZY)
+ @JoinTable(
+ name = "book_metadata_tag_mapping",
+ joinColumns = @JoinColumn(name = "book_id"),
+ inverseJoinColumns = @JoinColumn(name = "tag_id")
+ )
+ @Fetch(FetchMode.SUBSELECT)
+ private Set tags;
+
@OneToMany(mappedBy = "bookMetadata", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@Fetch(FetchMode.SUBSELECT)
private Set reviews = new HashSet<>();
@@ -235,6 +258,8 @@ public class BookMetadataEntity {
this.seriesTotalLocked = lock;
this.authorsLocked = lock;
this.categoriesLocked = lock;
+ this.moodsLocked = lock;
+ this.tagsLocked = lock;
this.amazonRatingLocked = lock;
this.amazonReviewCountLocked = lock;
this.goodreadsRatingLocked = lock;
@@ -266,6 +291,8 @@ public class BookMetadataEntity {
&& Boolean.TRUE.equals(this.seriesTotalLocked)
&& Boolean.TRUE.equals(this.authorsLocked)
&& Boolean.TRUE.equals(this.categoriesLocked)
+ && Boolean.TRUE.equals(this.moodsLocked)
+ && Boolean.TRUE.equals(this.tagsLocked)
&& Boolean.TRUE.equals(this.amazonRatingLocked)
&& Boolean.TRUE.equals(this.amazonReviewCountLocked)
&& Boolean.TRUE.equals(this.goodreadsRatingLocked)
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/CategoryEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/CategoryEntity.java
index 1477e7b14..f01760dc0 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/CategoryEntity.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/CategoryEntity.java
@@ -28,8 +28,7 @@ public class CategoryEntity {
@Override
public boolean equals(Object o) {
if (this == o) return true;
- if (!(o instanceof CategoryEntity)) return false;
- CategoryEntity that = (CategoryEntity) o;
+ if (!(o instanceof CategoryEntity that)) return false;
return name != null && name.equalsIgnoreCase(that.name);
}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/EpubViewerPreferencesEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/EpubViewerPreferencesEntity.java
index 412c987a2..5e6fb116d 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/EpubViewerPreferencesEntity.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/EpubViewerPreferencesEntity.java
@@ -41,4 +41,7 @@ public class EpubViewerPreferencesEntity {
@Column(name = "flow")
private String flow;
+
+ @Column(name = "spread")
+ private String spread;
}
\ No newline at end of file
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/MoodEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/MoodEntity.java
new file mode 100644
index 000000000..46f5619ec
--- /dev/null
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/MoodEntity.java
@@ -0,0 +1,40 @@
+package com.adityachandel.booklore.model.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.util.HashSet;
+import java.util.Set;
+
+@Entity
+@Getter
+@Setter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@Table(name = "mood")
+public class MoodEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "name", nullable = false, unique = true)
+ private String name;
+
+ @ManyToMany(mappedBy = "moods", fetch = FetchType.LAZY)
+ private Set bookMetadataEntityList = new HashSet<>();
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof MoodEntity that)) return false;
+ return name != null && name.equalsIgnoreCase(that.name);
+ }
+
+ @Override
+ public int hashCode() {
+ return name != null ? name.toLowerCase().hashCode() : 0;
+ }
+}
+
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/TagEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/TagEntity.java
new file mode 100644
index 000000000..cd2ad5005
--- /dev/null
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/TagEntity.java
@@ -0,0 +1,39 @@
+package com.adityachandel.booklore.model.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.util.HashSet;
+import java.util.Set;
+
+@Entity
+@Getter
+@Setter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@Table(name = "tag")
+public class TagEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "name", nullable = false, unique = true)
+ private String name;
+
+ @ManyToMany(mappedBy = "tags", fetch = FetchType.LAZY)
+ private Set bookMetadataEntityList = new HashSet<>();
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof TagEntity that)) return false;
+ return name != null && name.equalsIgnoreCase(that.name);
+ }
+
+ @Override
+ public int hashCode() {
+ return name != null ? name.toLowerCase().hashCode() : 0;
+ }
+}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/CategoryRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/CategoryRepository.java
index 00b655fed..61137b68e 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/repository/CategoryRepository.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/CategoryRepository.java
@@ -4,15 +4,11 @@ import com.adityachandel.booklore.model.entity.CategoryEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
-import java.util.List;
import java.util.Optional;
-import java.util.Set;
@Repository
public interface CategoryRepository extends JpaRepository {
Optional findByName(String categoryName);
-
- List findAllByIdIn(Set ids);
}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/MoodRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/MoodRepository.java
new file mode 100644
index 000000000..1e8bda0ec
--- /dev/null
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/MoodRepository.java
@@ -0,0 +1,14 @@
+package com.adityachandel.booklore.repository;
+
+import com.adityachandel.booklore.model.entity.MoodEntity;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface MoodRepository extends JpaRepository {
+
+ Optional findByName(String moodName);
+}
+
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/TagRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/TagRepository.java
new file mode 100644
index 000000000..69a729ed0
--- /dev/null
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/TagRepository.java
@@ -0,0 +1,14 @@
+package com.adityachandel.booklore.repository;
+
+import com.adityachandel.booklore.model.entity.TagEntity;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface TagRepository extends JpaRepository {
+
+ Optional findByName(String tagName);
+}
+
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java
index 9b02a4a7d..f8b5975b4 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java
@@ -157,10 +157,16 @@ public class BookService {
.percentage(userProgress.getEpubProgressPercent())
.build());
if (userProgress.getKoreaderProgressPercent() != null) {
- if (book.getKoreaderProgress() == null) {
- book.setKoreaderProgress(KoProgress.builder().build());
+ var userReadProgress = userProgress.getKoreaderProgressPercent() * 100;
+ book.getKoreaderProgress().setPercentage(userReadProgress);
+ if (userReadProgress >= 99.5f) {
+ book.setReadStatus(String.valueOf(ReadStatus.READ));
+ } else if (userReadProgress > 0.01f) {
+ book.setReadStatus(String.valueOf(ReadStatus.READING));
+ } else {
+ book.setReadStatus(String.valueOf(ReadStatus.UNREAD));
}
- book.getKoreaderProgress().setPercentage(userProgress.getKoreaderProgressPercent() * 100);
+
}
}
if (bookEntity.getBookType() == BookFileType.CBX) {
@@ -193,6 +199,7 @@ public class BookService {
.fontSize(epubPref.getFontSize())
.theme(epubPref.getTheme())
.flow(epubPref.getFlow())
+ .spread(epubPref.getSpread())
.letterSpacing(epubPref.getLetterSpacing())
.lineHeight(epubPref.getLineHeight())
.build()));
@@ -272,6 +279,7 @@ public class BookService {
epubPrefs.setFontSize(epubSettings.getFontSize());
epubPrefs.setTheme(epubSettings.getTheme());
epubPrefs.setFlow(epubSettings.getFlow());
+ epubPrefs.setSpread(epubSettings.getSpread());
epubPrefs.setLetterSpacing(epubSettings.getLetterSpacing());
epubPrefs.setLineHeight(epubSettings.getLineHeight());
epubViewerPreferencesRepository.save(epubPrefs);
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java
index 03d9f6728..53c9cffc9 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java
@@ -38,12 +38,12 @@ public class SettingPersistenceHelper {
public T getJsonSetting(Map settingsMap, AppSettingKey key, Class clazz, T defaultValue, boolean persistDefault) {
return getJsonSettingInternal(settingsMap, key, defaultValue, persistDefault,
- json -> objectMapper.readValue(json, clazz));
+ json -> objectMapper.readValue(json, clazz));
}
public T getJsonSetting(Map settingsMap, AppSettingKey key, TypeReference typeReference, T defaultValue, boolean persistDefault) {
return getJsonSettingInternal(settingsMap, key, defaultValue, persistDefault,
- json -> objectMapper.readValue(json, typeReference));
+ json -> objectMapper.readValue(json, typeReference));
}
private T getJsonSettingInternal(Map settingsMap, AppSettingKey key, T defaultValue, boolean persistDefault, JsonDeserializer deserializer) {
@@ -110,63 +110,83 @@ public class SettingPersistenceHelper {
}
MetadataRefreshOptions getDefaultMetadataRefreshOptions() {
- MetadataRefreshOptions.FieldProvider titleProviders =
- new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
- MetadataRefreshOptions.FieldProvider subtitleProviders =
- new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
- MetadataRefreshOptions.FieldProvider descriptionProviders =
- new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
- MetadataRefreshOptions.FieldProvider authorsProviders =
- new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
- MetadataRefreshOptions.FieldProvider publisherProviders =
- new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
- MetadataRefreshOptions.FieldProvider publishedDateProviders =
- new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
- MetadataRefreshOptions.FieldProvider seriesNameProviders =
- new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
- MetadataRefreshOptions.FieldProvider seriesNumberProviders =
- new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
- MetadataRefreshOptions.FieldProvider seriesTotalProviders =
- new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
- MetadataRefreshOptions.FieldProvider isbn13Providers =
- new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
- MetadataRefreshOptions.FieldProvider isbn10Providers =
- new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
- MetadataRefreshOptions.FieldProvider languageProviders =
- new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
- MetadataRefreshOptions.FieldProvider categoriesProviders =
- new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
- MetadataRefreshOptions.FieldProvider coverProviders =
- new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
+ MetadataRefreshOptions.FieldProvider amazonProvider = MetadataRefreshOptions.FieldProvider.builder()
+ .p1(MetadataProvider.Amazon)
+ .build();
- MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions(
- titleProviders,
- subtitleProviders,
- descriptionProviders,
- authorsProviders,
- publisherProviders,
- publishedDateProviders,
- seriesNameProviders,
- seriesNumberProviders,
- seriesTotalProviders,
- isbn13Providers,
- isbn10Providers,
- languageProviders,
- categoriesProviders,
- coverProviders
- );
+ MetadataRefreshOptions.FieldProvider nullProvider = MetadataRefreshOptions.FieldProvider.builder()
+ .build();
- return new MetadataRefreshOptions(
- null,
- MetadataProvider.GoodReads,
- MetadataProvider.Amazon,
- MetadataProvider.Google,
- null,
- false,
- true,
- false,
- fieldOptions
- );
+ MetadataRefreshOptions.FieldOptions fieldOptions = MetadataRefreshOptions.FieldOptions.builder()
+ .title(amazonProvider)
+ .subtitle(amazonProvider)
+ .description(amazonProvider)
+ .authors(amazonProvider)
+ .publisher(amazonProvider)
+ .publishedDate(amazonProvider)
+ .seriesName(amazonProvider)
+ .seriesNumber(amazonProvider)
+ .seriesTotal(amazonProvider)
+ .isbn13(amazonProvider)
+ .isbn10(amazonProvider)
+ .language(amazonProvider)
+ .categories(amazonProvider)
+ .cover(amazonProvider)
+ .pageCount(amazonProvider)
+ .asin(nullProvider)
+ .goodreadsId(nullProvider)
+ .comicvineId(nullProvider)
+ .hardcoverId(nullProvider)
+ .googleId(nullProvider)
+ .amazonRating(nullProvider)
+ .amazonReviewCount(nullProvider)
+ .goodreadsRating(nullProvider)
+ .goodreadsReviewCount(nullProvider)
+ .hardcoverRating(nullProvider)
+ .hardcoverReviewCount(nullProvider)
+ .moods(nullProvider)
+ .tags(nullProvider)
+ .build();
+
+ MetadataRefreshOptions.EnabledFields enabledFields = MetadataRefreshOptions.EnabledFields.builder()
+ .title(true)
+ .subtitle(true)
+ .description(true)
+ .authors(true)
+ .publisher(true)
+ .publishedDate(true)
+ .seriesName(true)
+ .seriesNumber(true)
+ .seriesTotal(true)
+ .isbn13(true)
+ .isbn10(true)
+ .language(true)
+ .categories(true)
+ .cover(true)
+ .pageCount(true)
+ .asin(true)
+ .goodreadsId(true)
+ .comicvineId(true)
+ .hardcoverId(true)
+ .googleId(true)
+ .amazonRating(true)
+ .amazonReviewCount(true)
+ .goodreadsRating(true)
+ .goodreadsReviewCount(true)
+ .hardcoverRating(true)
+ .hardcoverReviewCount(true)
+ .moods(true)
+ .tags(true)
+ .build();
+
+ return MetadataRefreshOptions.builder()
+ .libraryId(null)
+ .refreshCovers(false)
+ .mergeCategories(true)
+ .reviewBeforeApply(false)
+ .fieldOptions(fieldOptions)
+ .enabledFields(enabledFields)
+ .build();
}
public MetadataMatchWeights getDefaultMetadataMatchWeights() {
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java
index eb9ca8974..714a02886 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java
@@ -27,8 +27,6 @@ import com.adityachandel.booklore.util.SecurityContextVirtualThread;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry;
-import com.adityachandel.booklore.service.metadata.backuprestore.MetadataBackupRestore;
-import com.adityachandel.booklore.service.metadata.backuprestore.MetadataBackupRestoreFactory;
import com.adityachandel.booklore.service.metadata.extractor.CbxMetadataExtractor;
import com.adityachandel.booklore.service.metadata.parser.BookParser;
import com.adityachandel.booklore.service.metadata.writer.MetadataWriterFactory;
@@ -71,7 +69,6 @@ public class BookMetadataService {
private final BookFileProcessorRegistry processorRegistry;
private final BookQueryService bookQueryService;
private final Map parserMap;
- private final MetadataBackupRestoreFactory metadataBackupRestoreFactory;
private final CbxMetadataExtractor cbxMetadataExtractor;
private final MetadataWriterFactory metadataWriterFactory;
private final MetadataClearFlagsMapper metadataClearFlagsMapper;
@@ -238,18 +235,6 @@ public class BookMetadataService {
return cbxMetadataExtractor.extractMetadata(new File(FileUtils.getBookFullPath(bookEntity)));
}
- public BookMetadata restoreMetadataFromBackup(Long bookId) throws IOException {
- BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
- metadataBackupRestoreFactory.getService(bookEntity.getBookType()).restoreEmbeddedMetadata(bookEntity);
- bookRepository.saveAndFlush(bookEntity);
- return bookMetadataMapper.toBookMetadata(bookEntity.getMetadata(), true);
- }
-
- public BookMetadata getBackedUpMetadata(Long bookId) {
- BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
- return metadataBackupRestoreFactory.getService(bookEntity.getBookType()).getBackedUpMetadata(bookId);
- }
-
@Transactional
public List bulkUpdateMetadata(BulkMetadataUpdateRequest request, boolean mergeCategories) {
List books = bookRepository.findAllWithMetadataByIds(request.getBookIds());
@@ -265,6 +250,8 @@ public class BookMetadataService {
.seriesTotal(request.getSeriesTotal())
.publishedDate(request.getPublishedDate())
.categories(request.getGenres())
+ .moods(request.getMoods())
+ .tags(request.getTags())
.build();
MetadataUpdateWrapper metadataUpdateWrapper = MetadataUpdateWrapper.builder()
@@ -280,16 +267,5 @@ public class BookMetadataService {
.map(m -> bookMetadataMapper.toBookMetadata(m, false))
.toList();
}
-
- public Resource getBackupCoverForBook(long bookId) {
- BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
- MetadataBackupRestore backupRestore = metadataBackupRestoreFactory.getService(bookEntity.getBookType());
- try {
- return backupRestore.getBackupCover(bookId);
- } catch (UnsupportedOperationException e) {
- log.info("Cover backup not supported for file type: {}", bookEntity.getBookType());
- return null;
- }
- }
}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java
index dc22c87a1..3e32277aa 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java
@@ -4,18 +4,15 @@ import com.adityachandel.booklore.model.MetadataClearFlags;
import com.adityachandel.booklore.model.MetadataUpdateWrapper;
import com.adityachandel.booklore.model.dto.BookMetadata;
import com.adityachandel.booklore.model.dto.settings.MetadataPersistenceSettings;
-import com.adityachandel.booklore.model.entity.AuthorEntity;
-import com.adityachandel.booklore.model.entity.BookEntity;
-import com.adityachandel.booklore.model.entity.BookMetadataEntity;
-import com.adityachandel.booklore.model.entity.CategoryEntity;
+import com.adityachandel.booklore.model.entity.*;
import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.repository.AuthorRepository;
import com.adityachandel.booklore.repository.CategoryRepository;
+import com.adityachandel.booklore.repository.MoodRepository;
+import com.adityachandel.booklore.repository.TagRepository;
import com.adityachandel.booklore.service.FileFingerprint;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import com.adityachandel.booklore.service.file.UnifiedFileMoveService;
-import com.adityachandel.booklore.service.metadata.backuprestore.MetadataBackupRestore;
-import com.adityachandel.booklore.service.metadata.backuprestore.MetadataBackupRestoreFactory;
import com.adityachandel.booklore.service.metadata.writer.MetadataWriterFactory;
import com.adityachandel.booklore.util.FileService;
import com.adityachandel.booklore.util.MetadataChangeDetector;
@@ -44,11 +41,12 @@ public class BookMetadataUpdater {
private final AuthorRepository authorRepository;
private final CategoryRepository categoryRepository;
+ private final MoodRepository moodRepository;
+ private final TagRepository tagRepository;
private final FileService fileService;
private final MetadataMatchService metadataMatchService;
private final AppSettingService appSettingService;
private final MetadataWriterFactory metadataWriterFactory;
- private final MetadataBackupRestoreFactory metadataBackupRestoreFactory;
private final BookReviewUpdateService bookReviewUpdateService;
private final UnifiedFileMoveService unifiedFileMoveService;
@@ -82,6 +80,8 @@ public class BookMetadataUpdater {
updateBasicFields(newMetadata, metadata, clearFlags);
updateAuthorsIfNeeded(newMetadata, metadata, clearFlags);
updateCategoriesIfNeeded(newMetadata, metadata, clearFlags, mergeCategories);
+ updateMoodsIfNeeded(newMetadata, metadata, clearFlags, mergeCategories);
+ updateTagsIfNeeded(newMetadata, metadata, clearFlags, mergeCategories);
bookReviewUpdateService.updateBookReviews(newMetadata, metadata, clearFlags, mergeCategories);
updateThumbnailIfNeeded(bookId, newMetadata, metadata, setThumbnail);
@@ -233,6 +233,67 @@ public class BookMetadataUpdater {
}
}
+ private void updateMoodsIfNeeded(BookMetadata m, BookMetadataEntity e, MetadataClearFlags clear, boolean merge) {
+ if (Boolean.TRUE.equals(e.getMoodsLocked())) {
+ return;
+ }
+ if (e.getMoods() == null) {
+ e.setMoods(new HashSet<>());
+ }
+ if (clear.isMoods()) {
+ e.getMoods().clear();
+ } else if (shouldUpdateField(false, m.getMoods()) && m.getMoods() != null) {
+ if (merge) {
+ Set existing = e.getMoods();
+ for (String name : m.getMoods()) {
+ if (name == null || name.isBlank()) continue;
+ MoodEntity entity = moodRepository.findByName(name)
+ .orElseGet(() -> moodRepository.save(MoodEntity.builder().name(name).build()));
+ existing.add(entity);
+ }
+ } else {
+ Set existing = e.getMoods();
+ existing.clear();
+ Set result = m.getMoods().stream()
+ .filter(n -> n != null && !n.isBlank())
+ .map(name -> moodRepository.findByName(name)
+ .orElseGet(() -> moodRepository.save(MoodEntity.builder().name(name).build())))
+ .collect(Collectors.toSet());
+ existing.addAll(result);
+ }
+ }
+ }
+
+ private void updateTagsIfNeeded(BookMetadata m, BookMetadataEntity e, MetadataClearFlags clear, boolean merge) {
+ if (Boolean.TRUE.equals(e.getTagsLocked())) {
+ return;
+ }
+ if (e.getTags() == null) {
+ e.setTags(new HashSet<>());
+ }
+ if (clear.isTags()) {
+ e.getTags().clear();
+ } else if (shouldUpdateField(false, m.getTags()) && m.getTags() != null) {
+ if (merge) {
+ Set existing = e.getTags();
+ for (String name : m.getTags()) {
+ if (name == null || name.isBlank()) continue;
+ TagEntity entity = tagRepository.findByName(name)
+ .orElseGet(() -> tagRepository.save(TagEntity.builder().name(name).build()));
+ existing.add(entity);
+ }
+ } else {
+ Set existing = e.getTags();
+ existing.clear();
+ Set result = m.getTags().stream()
+ .filter(n -> n != null && !n.isBlank())
+ .map(name -> tagRepository.findByName(name)
+ .orElseGet(() -> tagRepository.save(TagEntity.builder().name(name).build())))
+ .collect(Collectors.toSet());
+ existing.addAll(result);
+ }
+ }
+ }
private void updateThumbnailIfNeeded(long bookId, BookMetadata m, BookMetadataEntity e, boolean set) {
if (Boolean.TRUE.equals(e.getCoverLocked())) {
@@ -273,6 +334,8 @@ public class BookMetadataUpdater {
Pair.of(m.getCoverLocked(), e::setCoverLocked),
Pair.of(m.getAuthorsLocked(), e::setAuthorsLocked),
Pair.of(m.getCategoriesLocked(), e::setCategoriesLocked),
+ Pair.of(m.getMoodsLocked(), e::setMoodsLocked),
+ Pair.of(m.getTagsLocked(), e::setTagsLocked),
Pair.of(m.getReviewsLocked(), e::setReviewsLocked)
);
lockMappings.forEach(pair -> {
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java
index cb1cf022c..a5010b098 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java
@@ -32,6 +32,9 @@ import java.time.Instant;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadLocalRandom;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
import java.util.stream.Collectors;
import static com.adityachandel.booklore.model.enums.MetadataProvider.*;
@@ -213,7 +216,7 @@ public class MetadataRefreshService {
));
}
- private CompletableFuture createInterruptibleMetadataFuture(java.util.function.Supplier metadataSupplier) {
+ private CompletableFuture createInterruptibleMetadataFuture(Supplier metadataSupplier) {
return CompletableFuture.supplyAsync(() -> {
if (Thread.currentThread().isInterrupted()) {
log.info("Skipping metadata fetch due to interruption");
@@ -323,10 +326,33 @@ public class MetadataRefreshService {
if (fieldOptions != null) {
addProviderToSet(fieldOptions.getTitle(), uniqueProviders);
+ addProviderToSet(fieldOptions.getSubtitle(), uniqueProviders);
addProviderToSet(fieldOptions.getDescription(), uniqueProviders);
addProviderToSet(fieldOptions.getAuthors(), uniqueProviders);
+ addProviderToSet(fieldOptions.getPublisher(), uniqueProviders);
+ addProviderToSet(fieldOptions.getPublishedDate(), uniqueProviders);
+ addProviderToSet(fieldOptions.getSeriesName(), uniqueProviders);
+ addProviderToSet(fieldOptions.getSeriesNumber(), uniqueProviders);
+ addProviderToSet(fieldOptions.getSeriesTotal(), uniqueProviders);
+ addProviderToSet(fieldOptions.getIsbn13(), uniqueProviders);
+ addProviderToSet(fieldOptions.getIsbn10(), uniqueProviders);
+ addProviderToSet(fieldOptions.getLanguage(), uniqueProviders);
addProviderToSet(fieldOptions.getCategories(), uniqueProviders);
addProviderToSet(fieldOptions.getCover(), uniqueProviders);
+ addProviderToSet(fieldOptions.getPageCount(), uniqueProviders);
+ addProviderToSet(fieldOptions.getAsin(), uniqueProviders);
+ addProviderToSet(fieldOptions.getGoodreadsId(), uniqueProviders);
+ addProviderToSet(fieldOptions.getComicvineId(), uniqueProviders);
+ addProviderToSet(fieldOptions.getHardcoverId(), uniqueProviders);
+ addProviderToSet(fieldOptions.getGoogleId(), uniqueProviders);
+ addProviderToSet(fieldOptions.getAmazonRating(), uniqueProviders);
+ addProviderToSet(fieldOptions.getAmazonReviewCount(), uniqueProviders);
+ addProviderToSet(fieldOptions.getGoodreadsRating(), uniqueProviders);
+ addProviderToSet(fieldOptions.getGoodreadsReviewCount(), uniqueProviders);
+ addProviderToSet(fieldOptions.getHardcoverRating(), uniqueProviders);
+ addProviderToSet(fieldOptions.getHardcoverReviewCount(), uniqueProviders);
+ addProviderToSet(fieldOptions.getMoods(), uniqueProviders);
+ addProviderToSet(fieldOptions.getTags(), uniqueProviders);
}
return uniqueProviders;
@@ -341,7 +367,6 @@ public class MetadataRefreshService {
}
}
-
public BookMetadata fetchTopMetadataFromAProvider(MetadataProvider provider, Book book) {
return getParser(provider).fetchTopMetadata(book, buildFetchMetadataRequestFromBook(book));
}
@@ -368,10 +393,122 @@ public class MetadataRefreshService {
public BookMetadata buildFetchMetadata(Long bookId, MetadataRefreshOptions refreshOptions, Map metadataMap) {
BookMetadata metadata = BookMetadata.builder().bookId(bookId).build();
MetadataRefreshOptions.FieldOptions fieldOptions = refreshOptions.getFieldOptions();
+ MetadataRefreshOptions.EnabledFields enabledFields = refreshOptions.getEnabledFields();
- metadata.setTitle(resolveFieldAsString(metadataMap, fieldOptions.getTitle(), BookMetadata::getTitle));
- metadata.setDescription(resolveFieldAsString(metadataMap, fieldOptions.getDescription(), BookMetadata::getDescription));
- metadata.setAuthors(resolveFieldAsList(metadataMap, fieldOptions.getAuthors(), BookMetadata::getAuthors));
+ if (enabledFields.isTitle()) {
+ metadata.setTitle(resolveFieldAsString(metadataMap, fieldOptions.getTitle(), BookMetadata::getTitle));
+ }
+ if (enabledFields.isSubtitle()) {
+ metadata.setSubtitle(resolveFieldAsString(metadataMap, fieldOptions.getSubtitle(), BookMetadata::getSubtitle));
+ }
+ if (enabledFields.isDescription()) {
+ metadata.setDescription(resolveFieldAsString(metadataMap, fieldOptions.getDescription(), BookMetadata::getDescription));
+ }
+ if (enabledFields.isAuthors()) {
+ metadata.setAuthors(resolveFieldAsList(metadataMap, fieldOptions.getAuthors(), BookMetadata::getAuthors));
+ }
+ if (enabledFields.isPublisher()) {
+ metadata.setPublisher(resolveFieldAsString(metadataMap, fieldOptions.getPublisher(), BookMetadata::getPublisher));
+ }
+ if (enabledFields.isPublishedDate()) {
+ metadata.setPublishedDate(resolveField(metadataMap, fieldOptions.getPublishedDate(), BookMetadata::getPublishedDate));
+ }
+ if (enabledFields.isSeriesName()) {
+ metadata.setSeriesName(resolveFieldAsString(metadataMap, fieldOptions.getSeriesName(), BookMetadata::getSeriesName));
+ }
+ if (enabledFields.isSeriesNumber()) {
+ metadata.setSeriesNumber(resolveField(metadataMap, fieldOptions.getSeriesNumber(), BookMetadata::getSeriesNumber));
+ }
+ if (enabledFields.isSeriesTotal()) {
+ metadata.setSeriesTotal(resolveFieldAsInteger(metadataMap, fieldOptions.getSeriesTotal(), BookMetadata::getSeriesTotal));
+ }
+ if (enabledFields.isIsbn13()) {
+ metadata.setIsbn13(resolveFieldAsString(metadataMap, fieldOptions.getIsbn13(), BookMetadata::getIsbn13));
+ }
+ if (enabledFields.isIsbn10()) {
+ metadata.setIsbn10(resolveFieldAsString(metadataMap, fieldOptions.getIsbn10(), BookMetadata::getIsbn10));
+ }
+ if (enabledFields.isLanguage()) {
+ metadata.setLanguage(resolveFieldAsString(metadataMap, fieldOptions.getLanguage(), BookMetadata::getLanguage));
+ }
+ if (enabledFields.isPageCount()) {
+ metadata.setPageCount(resolveFieldAsInteger(metadataMap, fieldOptions.getPageCount(), BookMetadata::getPageCount));
+ }
+ if (enabledFields.isCover()) {
+ metadata.setThumbnailUrl(resolveFieldAsString(metadataMap, fieldOptions.getCover(), BookMetadata::getThumbnailUrl));
+ }
+ if (enabledFields.isAmazonRating()) {
+ if (metadataMap.containsKey(Amazon)) {
+ metadata.setAmazonRating(metadataMap.get(Amazon).getAmazonRating());
+ }
+ }
+ if (enabledFields.isAmazonReviewCount()) {
+ if (metadataMap.containsKey(Amazon)) {
+ metadata.setAmazonReviewCount(metadataMap.get(Amazon).getAmazonReviewCount());
+ }
+ }
+ if (enabledFields.isGoodreadsRating()) {
+ if (metadataMap.containsKey(GoodReads)) {
+ metadata.setGoodreadsRating(metadataMap.get(GoodReads).getGoodreadsRating());
+ }
+ }
+ if (enabledFields.isGoodreadsReviewCount()) {
+ if (metadataMap.containsKey(GoodReads)) {
+ metadata.setGoodreadsReviewCount(metadataMap.get(GoodReads).getGoodreadsReviewCount());
+ }
+ }
+ if (enabledFields.isHardcoverRating()) {
+ if (metadataMap.containsKey(Hardcover)) {
+ metadata.setHardcoverRating(metadataMap.get(Hardcover).getHardcoverRating());
+ }
+ }
+ if (enabledFields.isHardcoverReviewCount()) {
+ if (metadataMap.containsKey(Hardcover)) {
+ metadata.setHardcoverReviewCount(metadataMap.get(Hardcover).getHardcoverReviewCount());
+ }
+ }
+ if (enabledFields.isAsin()) {
+ if (metadataMap.containsKey(Amazon)) {
+ metadata.setAsin(metadataMap.get(Amazon).getAsin());
+ }
+ }
+ if (enabledFields.isGoodreadsId()) {
+ if (metadataMap.containsKey(GoodReads)) {
+ metadata.setGoodreadsId(metadataMap.get(GoodReads).getGoodreadsId());
+ }
+ }
+ if (enabledFields.isHardcoverId()) {
+ if (metadataMap.containsKey(Hardcover)) {
+ metadata.setHardcoverId(metadataMap.get(Hardcover).getHardcoverId());
+ }
+ }
+ if (enabledFields.isGoogleId()) {
+ if (metadataMap.containsKey(Google)) {
+ metadata.setGoogleId(metadataMap.get(Google).getGoogleId());
+ }
+ }
+ if (enabledFields.isComicvineId()) {
+ if (metadataMap.containsKey(Comicvine)) {
+ metadata.setComicvineId(metadataMap.get(Comicvine).getComicvineId());
+ }
+ }
+ if (enabledFields.isMoods()) {
+ if (metadataMap.containsKey(Hardcover)) {
+ metadata.setMoods(metadataMap.get(Hardcover).getMoods());
+ }
+ }
+ if (enabledFields.isTags()) {
+ if (metadataMap.containsKey(Hardcover)) {
+ metadata.setTags(metadataMap.get(Hardcover).getTags());
+ }
+ }
+ if (enabledFields.isCategories()) {
+ if (refreshOptions.isMergeCategories()) {
+ metadata.setCategories(getAllCategories(metadataMap, fieldOptions.getCategories(), BookMetadata::getCategories));
+ } else {
+ metadata.setCategories(resolveFieldAsList(metadataMap, fieldOptions.getCategories(), BookMetadata::getCategories));
+ }
+ }
List allReviews = metadataMap.values().stream()
.filter(Objects::nonNull)
@@ -381,141 +518,70 @@ public class MetadataRefreshService {
metadata.setBookReviews(allReviews);
}
- if (metadataMap.containsKey(GoodReads)) {
- metadata.setGoodreadsId(metadataMap.get(GoodReads).getGoodreadsId());
- }
- if (metadataMap.containsKey(Hardcover)) {
- metadata.setHardcoverId(metadataMap.get(Hardcover).getHardcoverId());
- }
- if (metadataMap.containsKey(Google)) {
- metadata.setGoogleId(metadataMap.get(Google).getGoogleId());
- }
- if (metadataMap.containsKey(Comicvine)) {
- metadata.setComicvineId(metadataMap.get(Comicvine).getComicvineId());
- }
-
- if (refreshOptions.isMergeCategories()) {
- metadata.setCategories(getAllCategories(metadataMap, fieldOptions.getCategories(), BookMetadata::getCategories));
- } else {
- metadata.setCategories(resolveFieldAsList(metadataMap, fieldOptions.getCategories(), BookMetadata::getCategories));
- }
- metadata.setThumbnailUrl(resolveFieldAsString(metadataMap, fieldOptions.getCover(), BookMetadata::getThumbnailUrl));
-
- if (refreshOptions.getAllP4() != null) {
- setOtherUnspecifiedMetadata(metadataMap, metadata, refreshOptions.getAllP4());
- }
- if (refreshOptions.getAllP3() != null) {
- setOtherUnspecifiedMetadata(metadataMap, metadata, refreshOptions.getAllP3());
- }
- if (refreshOptions.getAllP2() != null) {
- setOtherUnspecifiedMetadata(metadataMap, metadata, refreshOptions.getAllP2());
- }
- if (refreshOptions.getAllP1() != null) {
- setOtherUnspecifiedMetadata(metadataMap, metadata, refreshOptions.getAllP1());
- }
-
return metadata;
}
- protected void setOtherUnspecifiedMetadata(Map metadataMap, BookMetadata metadataCombined, MetadataProvider provider) {
- if (metadataMap.containsKey(provider)) {
- BookMetadata metadata = metadataMap.get(provider);
- metadataCombined.setSubtitle(metadata.getSubtitle() != null ? metadata.getSubtitle() : metadataCombined.getSubtitle());
- metadataCombined.setPublisher(metadata.getPublisher() != null ? metadata.getPublisher() : metadataCombined.getPublisher());
- metadataCombined.setPublishedDate(metadata.getPublishedDate() != null ? metadata.getPublishedDate() : metadataCombined.getPublishedDate());
- metadataCombined.setIsbn10(metadata.getIsbn10() != null ? metadata.getIsbn10() : metadataCombined.getIsbn10());
- metadataCombined.setIsbn13(metadata.getIsbn13() != null ? metadata.getIsbn13() : metadataCombined.getIsbn13());
- metadataCombined.setAsin(metadata.getAsin() != null ? metadata.getAsin() : metadataCombined.getAsin());
- metadataCombined.setPageCount(metadata.getPageCount() != null ? metadata.getPageCount() : metadataCombined.getPageCount());
- metadataCombined.setLanguage(metadata.getLanguage() != null ? metadata.getLanguage() : metadataCombined.getLanguage());
- metadataCombined.setGoodreadsRating(metadata.getGoodreadsRating() != null ? metadata.getGoodreadsRating() : metadataCombined.getGoodreadsRating());
- metadataCombined.setGoodreadsReviewCount(metadata.getGoodreadsReviewCount() != null ? metadata.getGoodreadsReviewCount() : metadataCombined.getGoodreadsReviewCount());
- metadataCombined.setAmazonRating(metadata.getAmazonRating() != null ? metadata.getAmazonRating() : metadataCombined.getAmazonRating());
- metadataCombined.setAmazonReviewCount(metadata.getAmazonReviewCount() != null ? metadata.getAmazonReviewCount() : metadataCombined.getAmazonReviewCount());
- metadataCombined.setHardcoverRating(metadata.getHardcoverRating() != null ? metadata.getHardcoverRating() : metadataCombined.getHardcoverRating());
- metadataCombined.setHardcoverReviewCount(metadata.getHardcoverReviewCount() != null ? metadata.getHardcoverReviewCount() : metadataCombined.getHardcoverReviewCount());
- metadataCombined.setPersonalRating(metadata.getPersonalRating() != null ? metadata.getPersonalRating() : metadataCombined.getPersonalRating());
- metadataCombined.setSeriesName(metadata.getSeriesName() != null ? metadata.getSeriesName() : metadataCombined.getSeriesName());
- metadataCombined.setSeriesNumber(metadata.getSeriesNumber() != null ? metadata.getSeriesNumber() : metadataCombined.getSeriesNumber());
- metadataCombined.setSeriesTotal(metadata.getSeriesTotal() != null ? metadata.getSeriesTotal() : metadataCombined.getSeriesTotal());
- }
+ protected T resolveField(Map metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, Function extractor) {
+ return resolveFieldWithProviders(metadataMap, fieldProvider, extractor, (value) -> value != null);
}
- @FunctionalInterface
- public interface FieldValueExtractor {
- String extract(BookMetadata metadata);
+ protected Integer resolveFieldAsInteger(Map metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, Function fieldValueExtractor) {
+ return resolveFieldWithProviders(metadataMap, fieldProvider, fieldValueExtractor, (value) -> value != null);
}
- @FunctionalInterface
- public interface FieldValueExtractorList {
- Set extract(BookMetadata metadata);
- }
-
-
protected String resolveFieldAsString(Map metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractor fieldValueExtractor) {
- String value = null;
- if (fieldProvider.getP4() != null && metadataMap.containsKey(fieldProvider.getP4())) {
- String newValue = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP4()));
- if (newValue != null) value = newValue;
- }
- if (fieldProvider.getP3() != null && metadataMap.containsKey(fieldProvider.getP3())) {
- String newValue = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP3()));
- if (newValue != null) value = newValue;
- }
- if (fieldProvider.getP2() != null && metadataMap.containsKey(fieldProvider.getP2())) {
- String newValue = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP2()));
- if (newValue != null) value = newValue;
- }
- if (fieldProvider.getP1() != null && metadataMap.containsKey(fieldProvider.getP1())) {
- String newValue = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP1()));
- if (newValue != null) value = newValue;
- }
- return value;
+ return resolveFieldWithProviders(metadataMap, fieldProvider, fieldValueExtractor::extract, (value) -> value != null);
}
-
protected Set resolveFieldAsList(Map metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractorList fieldValueExtractor) {
- Set values = new HashSet<>();
- if (fieldProvider.getP4() != null && metadataMap.containsKey(fieldProvider.getP4())) {
- Set newValues = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP4()));
- if (newValues != null && !newValues.isEmpty()) values = newValues;
+ return resolveFieldWithProviders(metadataMap, fieldProvider, fieldValueExtractor::extract, (value) -> value != null && !value.isEmpty());
+ }
+
+ private T resolveFieldWithProviders(Map metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, Function extractor, Predicate isValidValue) {
+ if (fieldProvider == null) {
+ return null;
}
- if (fieldProvider.getP3() != null && metadataMap.containsKey(fieldProvider.getP3())) {
- Set newValues = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP3()));
- if (newValues != null && !newValues.isEmpty()) values = newValues;
+ MetadataProvider[] providers = {
+ fieldProvider.getP4(),
+ fieldProvider.getP3(),
+ fieldProvider.getP2(),
+ fieldProvider.getP1()
+ };
+ for (MetadataProvider provider : providers) {
+ if (provider != null && metadataMap.containsKey(provider)) {
+ T value = extractor.apply(metadataMap.get(provider));
+ if (isValidValue.test(value)) {
+ return value;
+ }
+ }
}
- if (values.isEmpty() && fieldProvider.getP2() != null && metadataMap.containsKey(fieldProvider.getP2())) {
- Set newValues = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP2()));
- if (newValues != null && !newValues.isEmpty()) values = newValues;
- }
- if (values.isEmpty() && fieldProvider.getP1() != null && metadataMap.containsKey(fieldProvider.getP1())) {
- Set newValues = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP1()));
- if (newValues != null && !newValues.isEmpty()) values = newValues;
- }
- return values;
+ return null;
}
Set getAllCategories(Map metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractorList fieldValueExtractor) {
Set uniqueCategories = new HashSet<>();
- if (fieldProvider.getP4() != null && metadataMap.containsKey(fieldProvider.getP4())) {
- Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP4()));
- if (extracted != null) uniqueCategories.addAll(extracted);
+ if (fieldProvider == null) {
+ return uniqueCategories;
}
- if (fieldProvider.getP3() != null && metadataMap.containsKey(fieldProvider.getP3())) {
- Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP3()));
- if (extracted != null) uniqueCategories.addAll(extracted);
- }
- if (fieldProvider.getP2() != null && metadataMap.containsKey(fieldProvider.getP2())) {
- Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP2()));
- if (extracted != null) uniqueCategories.addAll(extracted);
- }
- if (fieldProvider.getP1() != null && metadataMap.containsKey(fieldProvider.getP1())) {
- Set extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP1()));
- if (extracted != null) uniqueCategories.addAll(extracted);
- }
- return new HashSet<>(uniqueCategories);
- }
+ MetadataProvider[] providers = {
+ fieldProvider.getP4(),
+ fieldProvider.getP3(),
+ fieldProvider.getP2(),
+ fieldProvider.getP1()
+ };
+
+ for (MetadataProvider provider : providers) {
+ if (provider != null && metadataMap.containsKey(provider)) {
+ Set extracted = fieldValueExtractor.extract(metadataMap.get(provider));
+ if (extracted != null) {
+ uniqueCategories.addAll(extracted);
+ }
+ }
+ }
+
+ return uniqueCategories;
+ }
protected Set getBookEntities(MetadataRefreshRequest request) {
MetadataRefreshRequest.RefreshType refreshType = request.getRefreshType();
@@ -531,3 +597,4 @@ public class MetadataRefreshService {
};
}
}
+
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/AbstractMetadataBackupRestoreService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/AbstractMetadataBackupRestoreService.java
deleted file mode 100644
index b14730251..000000000
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/AbstractMetadataBackupRestoreService.java
+++ /dev/null
@@ -1,72 +0,0 @@
-package com.adityachandel.booklore.service.metadata.backuprestore;
-
-import com.adityachandel.booklore.exception.ApiError;
-import com.adityachandel.booklore.model.dto.BookMetadata;
-import com.adityachandel.booklore.model.entity.BookEntity;
-import com.adityachandel.booklore.repository.BookRepository;
-import com.adityachandel.booklore.util.FileService;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.ObjectReader;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.StandardOpenOption;
-
-@Slf4j
-@RequiredArgsConstructor
-public abstract class AbstractMetadataBackupRestoreService implements MetadataBackupRestore {
-
- protected final FileService fileService;
- protected final ObjectMapper objectMapper;
- protected final BookRepository bookRepository;
- protected final BookMetadataRestorer bookMetadataRestorer;
-
- protected Path resolveBackupDir(BookEntity bookEntity) {
- return Path.of(fileService.getMetadataBackupPath(), String.valueOf(bookEntity.getId()));
- }
-
- protected void writeMetadata(BookEntity bookEntity, BookMetadata metadata, Path backupDir) throws IOException {
- Path metadataFile = backupDir.resolve("metadata.json");
- Path filenameCheckFile = backupDir.resolve("original-filename.txt");
- String json = objectMapper.writer().writeValueAsString(metadata);
- Files.writeString(metadataFile, json, StandardOpenOption.CREATE_NEW);
- Files.writeString(filenameCheckFile, bookEntity.getFileName(), StandardOpenOption.CREATE_NEW);
- }
-
- protected void validateBackupIntegrity(BookEntity bookEntity, Path metadataFile, Path filenameCheckFile) throws IOException {
- if (Files.notExists(metadataFile)) {
- throw ApiError.INTERNAL_SERVER_ERROR.createException("Metadata backup file not found.");
- }
- if (Files.notExists(filenameCheckFile)) {
- throw ApiError.INTERNAL_SERVER_ERROR.createException("Filename check file is missing.");
- }
- String backedUpFilename = Files.readString(filenameCheckFile).trim();
- String currentFilename = bookEntity.getFileName().trim();
- if (!currentFilename.equals(backedUpFilename)) {
- throw ApiError.INTERNAL_SERVER_ERROR.createException("The backup is for a different file.");
- }
- }
-
- protected BookMetadata readMetadata(Path metadataFile, Long bookId) {
- try {
- ObjectReader reader = objectMapper.readerFor(BookMetadata.class);
- return reader.readValue(metadataFile.toFile());
- } catch (IOException e) {
- log.error("Failed to read metadata backup for book ID {}: {}", bookId, e.getMessage(), e);
- throw ApiError.INTERNAL_SERVER_ERROR.createException("Failed to read metadata backup file.");
- }
- }
-
- @Override
- public BookMetadata getBackedUpMetadata(Long bookId) {
- BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
- Path metadataFile = resolveBackupDir(bookEntity).resolve("metadata.json");
- if (Files.notExists(metadataFile)) {
- throw ApiError.INTERNAL_SERVER_ERROR.createException("Metadata backup file not found.");
- }
- return readMetadata(metadataFile, bookId);
- }
-}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/BookMetadataRestorer.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/BookMetadataRestorer.java
deleted file mode 100644
index f70c257ec..000000000
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/BookMetadataRestorer.java
+++ /dev/null
@@ -1,132 +0,0 @@
-package com.adityachandel.booklore.service.metadata.backuprestore;
-
-import com.adityachandel.booklore.model.MetadataClearFlags;
-import com.adityachandel.booklore.model.dto.BookMetadata;
-import com.adityachandel.booklore.model.dto.settings.MetadataPersistenceSettings;
-import com.adityachandel.booklore.model.enums.BookFileType;
-import com.adityachandel.booklore.model.entity.AuthorEntity;
-import com.adityachandel.booklore.model.entity.BookEntity;
-import com.adityachandel.booklore.model.entity.BookMetadataEntity;
-import com.adityachandel.booklore.model.entity.CategoryEntity;
-import com.adityachandel.booklore.repository.AuthorRepository;
-import com.adityachandel.booklore.repository.BookMetadataRepository;
-import com.adityachandel.booklore.repository.BookRepository;
-import com.adityachandel.booklore.repository.CategoryRepository;
-import com.adityachandel.booklore.service.appsettings.AppSettingService;
-import com.adityachandel.booklore.service.metadata.MetadataMatchService;
-import com.adityachandel.booklore.service.metadata.writer.MetadataWriterFactory;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.io.File;
-import java.util.HashSet;
-import java.util.Set;
-import java.util.stream.Collectors;
-
-@Slf4j
-@Service
-@RequiredArgsConstructor
-public class BookMetadataRestorer {
-
- private final AuthorRepository authorRepository;
- private final CategoryRepository categoryRepository;
- private final BookMetadataRepository bookMetadataRepository;
- private final BookRepository bookRepository;
- private final MetadataMatchService metadataMatchService;
- private final AppSettingService appSettingService;
- private final MetadataWriterFactory metadataWriterFactory;
-
- @Transactional
- public void restoreMetadata(BookEntity bookEntity, BookMetadata backup, String coverPath) {
- BookMetadataEntity metadata = bookEntity.getMetadata();
-
- if (!isLocked(metadata.getTitleLocked())) metadata.setTitle(backup.getTitle());
- if (!isLocked(metadata.getSubtitleLocked())) metadata.setSubtitle(backup.getSubtitle());
- if (!isLocked(metadata.getPublisherLocked())) metadata.setPublisher(backup.getPublisher());
- if (!isLocked(metadata.getPublishedDateLocked())) metadata.setPublishedDate(backup.getPublishedDate());
- if (!isLocked(metadata.getDescriptionLocked())) metadata.setDescription(backup.getDescription());
- if (!isLocked(metadata.getLanguageLocked())) metadata.setLanguage(backup.getLanguage());
- if (!isLocked(metadata.getPageCountLocked())) metadata.setPageCount(backup.getPageCount());
-
- if (!isLocked(metadata.getSeriesNameLocked())) metadata.setSeriesName(backup.getSeriesName());
- if (!isLocked(metadata.getSeriesNumberLocked())) metadata.setSeriesNumber(backup.getSeriesNumber());
- if (!isLocked(metadata.getSeriesTotalLocked())) metadata.setSeriesTotal(backup.getSeriesTotal());
-
- if (!isLocked(metadata.getIsbn13Locked())) metadata.setIsbn13(backup.getIsbn13());
- if (!isLocked(metadata.getIsbn10Locked())) metadata.setIsbn10(backup.getIsbn10());
- if (!isLocked(metadata.getAsinLocked())) metadata.setAsin(backup.getAsin());
- if (!isLocked(metadata.getGoodreadsIdLocked())) metadata.setGoodreadsId(backup.getGoodreadsId());
- if (!isLocked(metadata.getComicvineIdLocked())) metadata.setComicvineId(backup.getComicvineId());
- if (!isLocked(metadata.getHardcoverIdLocked())) metadata.setHardcoverId(backup.getHardcoverId());
- if (!isLocked(metadata.getGoogleIdLocked())) metadata.setGoogleId(backup.getGoogleId());
-
- if (!isLocked(metadata.getAuthorsLocked())) {
- Set authors = new HashSet<>();
- if (backup.getAuthors() != null) {
- authors = backup.getAuthors().stream()
- .map(name -> authorRepository.findByName(name)
- .orElseGet(() -> authorRepository.save(AuthorEntity.builder().name(name).build())))
- .collect(Collectors.toSet());
- }
- metadata.setAuthors(authors);
- }
-
- if (!isLocked(metadata.getCategoriesLocked())) {
- Set categories = new HashSet<>();
- if (backup.getCategories() != null) {
- categories = backup.getCategories().stream()
- .map(name -> categoryRepository.findByName(name)
- .orElseGet(() -> categoryRepository.save(CategoryEntity.builder().name(name).build())))
- .collect(Collectors.toSet());
- }
- metadata.setCategories(categories);
- }
-
- if (!isLocked(metadata.getPersonalRatingLocked())) metadata.setPersonalRating(backup.getPersonalRating());
- if (!isLocked(metadata.getAmazonRatingLocked())) metadata.setAmazonRating(backup.getAmazonRating());
- if (!isLocked(metadata.getAmazonReviewCountLocked())) metadata.setAmazonReviewCount(backup.getAmazonReviewCount());
-
- if (!isLocked(metadata.getGoodreadsRatingLocked())) metadata.setGoodreadsRating(backup.getGoodreadsRating());
- if (!isLocked(metadata.getGoodreadsReviewCountLocked())) metadata.setGoodreadsReviewCount(backup.getGoodreadsReviewCount());
-
- if (!isLocked(metadata.getHardcoverRatingLocked())) metadata.setHardcoverRating(backup.getHardcoverRating());
- if (!isLocked(metadata.getHardcoverReviewCountLocked())) metadata.setHardcoverReviewCount(backup.getHardcoverReviewCount());
-
- bookMetadataRepository.save(metadata);
-
- try {
- Float score = metadataMatchService.calculateMatchScore(bookEntity);
- bookEntity.setMetadataMatchScore(score);
- bookRepository.save(bookEntity);
- } catch (Exception e) {
- log.warn("Failed to calculate/save metadata match score for book ID {}: {}", bookEntity.getId(), e.getMessage());
- }
-
- try {
- MetadataPersistenceSettings settings = appSettingService.getAppSettings().getMetadataPersistenceSettings();
- boolean saveToOriginal = settings.isSaveToOriginalFile();
- boolean convertCbrCb7ToCbz = settings.isConvertCbrCb7ToCbz();
- if (saveToOriginal && (bookEntity.getBookType() != BookFileType.CBX || convertCbrCb7ToCbz)) {
- metadataWriterFactory.getWriter(bookEntity.getBookType()).ifPresent(writer -> {
- try {
- File file = new File(bookEntity.getFullFilePath().toUri());
- writer.writeMetadataToFile(file, metadata, coverPath, true, new MetadataClearFlags());
- log.info("Embedded metadata written to file for book ID {}", bookEntity.getId());
- } catch (Exception e) {
- log.warn("Failed to write metadata to file for book ID {}: {}", bookEntity.getId(), e.getMessage());
- }
- });
- }
- } catch (Exception e) {
- log.warn("Error during embedded metadata write: {}", e.getMessage());
- }
-
- log.info("Metadata fully restored from backup for book ID {}", bookEntity.getId());
- }
-
- private boolean isLocked(Boolean locked) {
- return locked != null && locked;
- }
-}
\ No newline at end of file
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/EpubMetadataBackupRestoreService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/EpubMetadataBackupRestoreService.java
deleted file mode 100644
index 6abab4819..000000000
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/EpubMetadataBackupRestoreService.java
+++ /dev/null
@@ -1,109 +0,0 @@
-package com.adityachandel.booklore.service.metadata.backuprestore;
-
-import com.adityachandel.booklore.exception.ApiError;
-import com.adityachandel.booklore.model.dto.BookMetadata;
-import com.adityachandel.booklore.model.entity.BookEntity;
-import com.adityachandel.booklore.model.entity.BookMetadataEntity;
-import com.adityachandel.booklore.model.enums.BookFileType;
-import com.adityachandel.booklore.repository.BookRepository;
-import com.adityachandel.booklore.service.metadata.extractor.FileMetadataExtractor;
-import com.adityachandel.booklore.util.FileService;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import io.documentnode.epub4j.domain.Book;
-import io.documentnode.epub4j.domain.Resource;
-import io.documentnode.epub4j.epub.EpubReader;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.core.io.FileSystemResource;
-import org.springframework.stereotype.Service;
-
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.StandardOpenOption;
-import java.time.Instant;
-
-@Slf4j
-@Service
-public class EpubMetadataBackupRestoreService extends AbstractMetadataBackupRestoreService {
-
- private final FileMetadataExtractor epubMetadataExtractor;
-
- public EpubMetadataBackupRestoreService(FileService fileService, ObjectMapper objectMapper, BookRepository bookRepository, BookMetadataRestorer bookMetadataRestorer, FileMetadataExtractor epubMetadataExtractor) {
- super(fileService, objectMapper, bookRepository, bookMetadataRestorer);
- this.epubMetadataExtractor = epubMetadataExtractor;
- }
-
- @Override
- public void backupEmbeddedMetadataIfNotExists(BookEntity bookEntity, boolean backupCover) {
- File bookFile = new File(bookEntity.getFullFilePath().toUri());
- Path backupDir = resolveBackupDir(bookEntity);
- Path metadataFile = backupDir.resolve("metadata.json");
- Path coverFile = backupDir.resolve("cover.jpg");
-
- if (Files.exists(metadataFile)) return;
-
- try {
- Files.createDirectories(backupDir);
- BookMetadata metadata = epubMetadataExtractor.extractMetadata(bookFile);
- writeMetadata(bookEntity, metadata, backupDir);
- if (backupCover) {
- try (FileInputStream fis = new FileInputStream(bookFile)) {
- Book epubBook = new EpubReader().readEpub(fis);
- Resource coverImage = epubBook.getCoverImage();
- if (coverImage != null) {
- Files.write(coverFile, coverImage.getData(), StandardOpenOption.CREATE_NEW);
- log.info("Backup cover image saved for book ID {} at {}", bookEntity.getId(), coverFile);
- } else {
- log.warn("No cover image found in EPUB for book ID {}", bookEntity.getId());
- }
- }
- }
- log.info("Created EPUB metadata backup for book ID {} at {}", bookEntity.getId(), backupDir);
- } catch (Exception e) {
- log.warn("Failed to backup EPUB metadata for book ID {}", bookEntity.getId(), e);
- }
- }
-
- @Override
- public void restoreEmbeddedMetadata(BookEntity bookEntity) throws IOException {
- Path backupDir = resolveBackupDir(bookEntity);
- Path metadataFile = backupDir.resolve("metadata.json");
- Path coverFile = backupDir.resolve("cover.jpg");
- Path filenameCheckFile = backupDir.resolve("original-filename.txt");
-
- validateBackupIntegrity(bookEntity, metadataFile, filenameCheckFile);
-
- BookMetadata backupMetadata = readMetadata(metadataFile, bookEntity.getId());
- bookMetadataRestorer.restoreMetadata(bookEntity, backupMetadata, coverFile.toString());
-
- updateThumbnailIfNeeded(bookEntity.getMetadata(), coverFile, bookEntity.getId());
-
- log.info("Successfully restored embedded metadata for EPUB book ID {}", bookEntity.getId());
- }
-
- @Override
- public org.springframework.core.io.Resource getBackupCover(long bookId) {
- BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
-
- Path coverPath = resolveBackupDir(bookEntity).resolve("cover.jpg");
-
- if (Files.notExists(coverPath)) {
- log.warn("No cover image found in backup for book ID {} at {}", bookId, coverPath);
- throw ApiError.INTERNAL_SERVER_ERROR.createException("Backup cover image not found.");
- }
-
- return new FileSystemResource(coverPath);
- }
-
- @Override
- public BookFileType getSupportedBookType() {
- return BookFileType.EPUB;
- }
-
- private void updateThumbnailIfNeeded(BookMetadataEntity metadata, Path coverFile, long bookId) throws IOException {
- /*String thumbnailPath = fileService.createThumbnailFromFile(bookId, coverFile.toString());*/
- metadata.setCoverUpdatedOn(Instant.now());
- }
-}
\ No newline at end of file
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/MetadataBackupRestore.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/MetadataBackupRestore.java
deleted file mode 100644
index 35265a9df..000000000
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/MetadataBackupRestore.java
+++ /dev/null
@@ -1,21 +0,0 @@
-package com.adityachandel.booklore.service.metadata.backuprestore;
-
-import com.adityachandel.booklore.model.dto.BookMetadata;
-import com.adityachandel.booklore.model.entity.BookEntity;
-import com.adityachandel.booklore.model.enums.BookFileType;
-import org.springframework.core.io.Resource;
-
-import java.io.IOException;
-
-public interface MetadataBackupRestore {
-
- void backupEmbeddedMetadataIfNotExists(BookEntity bookEntity, boolean backupCover);
-
- void restoreEmbeddedMetadata(BookEntity bookEntity) throws IOException;
-
- BookMetadata getBackedUpMetadata(Long bookId);
-
- Resource getBackupCover(long bookId);
-
- BookFileType getSupportedBookType();
-}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/MetadataBackupRestoreFactory.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/MetadataBackupRestoreFactory.java
deleted file mode 100644
index e4317c31f..000000000
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/MetadataBackupRestoreFactory.java
+++ /dev/null
@@ -1,29 +0,0 @@
-package com.adityachandel.booklore.service.metadata.backuprestore;
-
-import com.adityachandel.booklore.exception.ApiError;
-import com.adityachandel.booklore.model.enums.BookFileType;
-import org.springframework.stereotype.Component;
-
-import java.util.List;
-import java.util.Map;
-import java.util.function.Function;
-import java.util.stream.Collectors;
-
-@Component
-public class MetadataBackupRestoreFactory {
-
- private final Map serviceMap;
-
- public MetadataBackupRestoreFactory(List services) {
- serviceMap = services.stream()
- .collect(Collectors.toMap(MetadataBackupRestore::getSupportedBookType, Function.identity()));
- }
-
- public MetadataBackupRestore getService(BookFileType bookType) {
- MetadataBackupRestore service = serviceMap.get(bookType);
- if (service == null) {
- throw ApiError.UNSUPPORTED_FILE_TYPE.createException("No backup service for file type: " + bookType);
- }
- return service;
- }
-}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/PdfMetadataBackupRestoreService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/PdfMetadataBackupRestoreService.java
deleted file mode 100644
index 5bd624b22..000000000
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/PdfMetadataBackupRestoreService.java
+++ /dev/null
@@ -1,67 +0,0 @@
-package com.adityachandel.booklore.service.metadata.backuprestore;
-
-import com.adityachandel.booklore.model.dto.BookMetadata;
-import com.adityachandel.booklore.model.entity.BookEntity;
-import com.adityachandel.booklore.model.enums.BookFileType;
-import com.adityachandel.booklore.repository.BookRepository;
-import com.adityachandel.booklore.service.metadata.extractor.FileMetadataExtractor;
-import com.adityachandel.booklore.util.FileService;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.core.io.Resource;
-import org.springframework.stereotype.Service;
-
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-
-@Slf4j
-@Service
-public class PdfMetadataBackupRestoreService extends AbstractMetadataBackupRestoreService {
-
- private final FileMetadataExtractor pdfMetadataExtractor;
-
- public PdfMetadataBackupRestoreService(FileService fileService, ObjectMapper objectMapper, BookRepository bookRepository, BookMetadataRestorer bookMetadataRestorer, FileMetadataExtractor pdfMetadataExtractor) {
- super(fileService, objectMapper, bookRepository, bookMetadataRestorer);
- this.pdfMetadataExtractor = pdfMetadataExtractor;
- }
-
- @Override
- public void backupEmbeddedMetadataIfNotExists(BookEntity bookEntity, boolean backupCover) {
- Path backupDir = resolveBackupDir(bookEntity);
- Path metadataFile = backupDir.resolve("metadata.json");
- if (Files.exists(metadataFile)) return;
-
- try {
- Files.createDirectories(backupDir);
- BookMetadata metadata = pdfMetadataExtractor.extractMetadata(new File(bookEntity.getFullFilePath().toUri()));
- writeMetadata(bookEntity, metadata, backupDir);
- log.info("Created PDF metadata backup for book ID {}", bookEntity.getId());
- } catch (Exception e) {
- log.warn("Failed to backup metadata for PDF book ID {}", bookEntity.getId(), e);
- }
- }
-
- @Override
- public void restoreEmbeddedMetadata(BookEntity bookEntity) throws IOException {
- Path backupDir = resolveBackupDir(bookEntity);
- Path metadataFile = backupDir.resolve("metadata.json");
- Path filenameCheckFile = backupDir.resolve("original-filename.txt");
-
- validateBackupIntegrity(bookEntity, metadataFile, filenameCheckFile);
- BookMetadata backupMetadata = readMetadata(metadataFile, bookEntity.getId());
- bookMetadataRestorer.restoreMetadata(bookEntity, backupMetadata, null);
- log.info("Restored PDF metadata for book ID {}", bookEntity.getId());
- }
-
- @Override
- public Resource getBackupCover(long bookId) {
- throw new UnsupportedOperationException("Cover backup not supported for PDF files.");
- }
-
- @Override
- public BookFileType getSupportedBookType() {
- return BookFileType.PDF;
- }
-}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/HardcoverParser.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/HardcoverParser.java
index 5bb700683..40122fc1e 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/HardcoverParser.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/HardcoverParser.java
@@ -8,15 +8,18 @@ import com.adityachandel.booklore.service.metadata.parser.hardcover.GraphQLRespo
import com.adityachandel.booklore.service.metadata.parser.hardcover.HardcoverBookSearchService;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Service;
+import org.apache.commons.text.WordUtils;
import org.apache.commons.text.similarity.FuzzyScore;
-
-import java.util.Locale;
+import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
+import java.util.HashSet;
import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.stream.Collectors;
@Slf4j
@Service
@@ -48,10 +51,13 @@ public class HardcoverParser implements BookParser {
String searchAuthor = fetchMetadataRequest.getAuthor() != null ? fetchMetadataRequest.getAuthor() : "";
return hits.stream()
- .filter(hit -> {
+ .map(GraphQLResponse.Hit::getDocument)
+ .filter(doc -> {
if (searchByIsbn || searchAuthor.isBlank()) return true;
- List actualAuthorTokens = hit.getDocument().getAuthorNames().stream()
+ if (doc.getAuthorNames() == null || doc.getAuthorNames().isEmpty()) return false;
+
+ List actualAuthorTokens = doc.getAuthorNames().stream()
.flatMap(name -> List.of(name.toLowerCase().split("\\s+")).stream())
.toList();
List searchAuthorTokens = List.of(searchAuthor.toLowerCase().split("\\s+"));
@@ -66,13 +72,15 @@ public class HardcoverParser implements BookParser {
}
return false;
})
- .map(hit -> {
- GraphQLResponse.Document doc = hit.getDocument();
+ .map(doc -> {
BookMetadata metadata = new BookMetadata();
metadata.setHardcoverId(doc.getSlug());
metadata.setTitle(doc.getTitle());
+ metadata.setSubtitle(doc.getSubtitle());
metadata.setDescription(doc.getDescription());
- metadata.setAuthors(doc.getAuthorNames());
+ if (doc.getAuthorNames() != null) {
+ metadata.setAuthors(Set.copyOf(doc.getAuthorNames()));
+ }
if (doc.getFeaturedSeries() != null) {
if (doc.getFeaturedSeries().getSeries() != null) {
@@ -93,7 +101,22 @@ public class HardcoverParser implements BookParser {
metadata.setHardcoverReviewCount(doc.getRatingsCount());
metadata.setPageCount(doc.getPages());
metadata.setPublishedDate(doc.getReleaseDate() != null ? LocalDate.parse(doc.getReleaseDate()) : null);
- metadata.setCategories(doc.getGenres());
+
+ if (doc.getGenres() != null && !doc.getGenres().isEmpty()) {
+ metadata.setCategories(doc.getGenres().stream()
+ .map(WordUtils::capitalizeFully)
+ .collect(Collectors.toSet()));
+ }
+ if (doc.getMoods() != null && !doc.getMoods().isEmpty()) {
+ metadata.setMoods(doc.getMoods().stream()
+ .map(WordUtils::capitalizeFully)
+ .collect(Collectors.toSet()));
+ }
+ if (doc.getTags() != null && !doc.getTags().isEmpty()) {
+ metadata.setTags(doc.getTags().stream()
+ .map(WordUtils::capitalizeFully)
+ .collect(Collectors.toSet()));
+ }
if (doc.getIsbns() != null) {
String inputIsbn = fetchMetadataRequest.getIsbn();
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/GraphQLRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/GraphQLRequest.java
index e5c5ff940..dad099ea9 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/GraphQLRequest.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/GraphQLRequest.java
@@ -2,8 +2,11 @@ package com.adityachandel.booklore.service.metadata.parser.hardcover;
import lombok.Data;
+import java.util.Map;
+
@Data
public class GraphQLRequest {
private String query;
private String operationName;
+ private Map variables;
}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/GraphQLResponse.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/GraphQLResponse.java
index 8a54a47f4..11dfa52a6 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/GraphQLResponse.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/GraphQLResponse.java
@@ -2,46 +2,92 @@ package com.adityachandel.booklore.service.metadata.parser.hardcover;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
-import lombok.Data;
+import lombok.*;
import java.util.List;
+import java.util.Map;
import java.util.Set;
-@Data
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class GraphQLResponse {
- private DataWrapper data;
+ private Data data;
- @Data
+ @Getter
+ @Setter
+ @NoArgsConstructor
+ @AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
- public static class DataWrapper {
- private SearchWrapper search;
+ public static class Data {
+ private Search search;
}
- @Data
+ @Getter
+ @Setter
+ @NoArgsConstructor
+ @AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
- public static class SearchWrapper {
- private ResultsWrapper results;
+ public static class Search {
+ private Results results;
}
- @Data
+ @Getter
+ @Setter
+ @NoArgsConstructor
+ @AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
- public static class ResultsWrapper {
+ public static class Results {
+ @JsonProperty("facet_counts")
+ private List facetCounts;
+
+ private Integer found;
private List hits;
+
+ @JsonProperty("out_of")
+ private Integer outOf;
+
+ private Integer page;
+
+ @JsonProperty("request_params")
+ private Map requestParams;
+
+ @JsonProperty("search_cutoff")
+ private Boolean searchCutoff;
+
+ @JsonProperty("search_time_ms")
+ private Integer searchTimeMs;
}
- @Data
+ @Getter
+ @Setter
+ @NoArgsConstructor
+ @AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Hit {
private Document document;
+ private Map highlight;
+ private List> highlights;
+
+ @JsonProperty("text_match")
+ private Long textMatch;
+
+ @JsonProperty("text_match_info")
+ private Map textMatchInfo;
}
- @Data
+ @Getter
+ @Setter
+ @NoArgsConstructor
+ @AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Document {
private String id;
private String slug;
private String title;
+ private String subtitle;
@JsonProperty("author_names")
private Set authorNames;
@@ -53,33 +99,90 @@ public class GraphQLResponse {
@JsonProperty("ratings_count")
private Integer ratingsCount;
+ @JsonProperty("reviews_count")
+ private Integer reviewsCount;
+
private Integer pages;
@JsonProperty("release_date")
private String releaseDate;
- private Set genres;
+ @JsonProperty("release_year")
+ private Integer releaseYear;
+
+ private List genres;
+ private List moods;
+ private List tags;
@JsonProperty("featured_series")
private FeaturedSeries featuredSeries;
private Image image;
+
+ @JsonProperty("alternative_titles")
+ private List alternativeTitles;
+
+ @JsonProperty("activities_count")
+ private Integer activitiesCount;
+
+ private Boolean compilation;
+
+ @JsonProperty("content_warnings")
+ private List contentWarnings;
+
+ @JsonProperty("contribution_types")
+ private List contributionTypes;
+
+ private List> contributions;
+
+ @JsonProperty("cover_color")
+ private String coverColor;
+
+ @JsonProperty("has_audiobook")
+ private Boolean hasAudiobook;
+
+ @JsonProperty("has_ebook")
+ private Boolean hasEbook;
+
+ @JsonProperty("lists_count")
+ private Integer listsCount;
+
+ @JsonProperty("prompts_count")
+ private Integer promptsCount;
+
+ @JsonProperty("series_names")
+ private List seriesNames;
+
+ @JsonProperty("users_count")
+ private Integer usersCount;
+
+ @JsonProperty("users_read_count")
+ private Integer usersReadCount;
}
- @Data
+ @Getter
+ @Setter
+ @NoArgsConstructor
+ @AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Image {
private String url;
}
- @Data
+ @Getter
+ @Setter
+ @NoArgsConstructor
+ @AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public static class FeaturedSeries {
private Integer position;
private Series series;
}
- @Data
+ @Getter
+ @Setter
+ @NoArgsConstructor
+ @AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Series {
private String name;
@@ -87,4 +190,3 @@ public class GraphQLResponse {
private Integer booksCount;
}
}
-
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/HardcoverBookSearchService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/HardcoverBookSearchService.java
index f7b41793c..a7bcf2f5f 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/HardcoverBookSearchService.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/hardcover/HardcoverBookSearchService.java
@@ -35,22 +35,14 @@ public class HardcoverBookSearchService {
return Collections.emptyList();
}
- String graphqlQuery = """
- query SearchBooks {
- search(
- query: "%s",
- query_type: "Book",
- per_page: 5,
- page: 1
- ) {
- results
- }
- }
- """.formatted(query);
+ String graphqlQuery = String.format(
+ "query SearchBooks { search(query: \"%s\", query_type: \"Book\", per_page: 5, page: 1) { results } }",
+ query.replace("\"", "\\\"")
+ );
GraphQLRequest body = new GraphQLRequest();
body.setQuery(graphqlQuery);
- body.setOperationName("SearchBooks");
+ body.setVariables(Collections.emptyMap());
try {
GraphQLResponse response = restClient.post()
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/reader/CbxReaderService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/reader/CbxReaderService.java
index 530063ce1..7cf21605d 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/reader/CbxReaderService.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/reader/CbxReaderService.java
@@ -40,6 +40,7 @@ public class CbxReaderService {
private static final String CBZ_EXTENSION = ".cbz";
private static final String CBR_EXTENSION = ".cbr";
private static final String CB7_EXTENSION = ".cb7";
+ private static final String[] SUPPORTED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"};
private final BookRepository bookRepository;
private final AppSettingService appSettingService;
@@ -81,11 +82,13 @@ public class CbxReaderService {
}
try (var stream = Files.list(cacheDir)) {
- return stream
- .filter(p -> p.toString().endsWith(".jpg"))
+ List imageFiles = stream
+ .filter(p -> isImageFile(p.getFileName().toString()))
.sorted(Comparator.comparing(Path::getFileName))
- .map(p -> extractPageNumber(p.getFileName().toString()))
- .filter(p -> p != -1)
+ .toList();
+
+ return java.util.stream.IntStream.rangeClosed(1, imageFiles.size())
+ .boxed()
.collect(Collectors.toList());
} catch (IOException e) {
log.error("Failed to list pages for book {}", bookId, e);
@@ -94,8 +97,21 @@ public class CbxReaderService {
}
public void streamPageImage(Long bookId, int page, OutputStream outputStream) throws IOException {
- Path pagePath = Path.of(fileService.getCbxCachePath(), String.valueOf(bookId), String.format("%04d.jpg", page));
- if (!Files.exists(pagePath)) throw new FileNotFoundException("Page not found: " + page);
+ Path bookDir = Path.of(fileService.getCbxCachePath(), String.valueOf(bookId));
+ List images;
+ try (Stream files = Files.list(bookDir)) {
+ images = files
+ .filter(p -> isImageFile(p.getFileName().toString()))
+ .sorted(Comparator.comparing(p -> p.getFileName().toString()))
+ .toList();
+ }
+ if (images.isEmpty()) {
+ throw new FileNotFoundException("No image files found for book: " + bookId);
+ }
+ if (page < 1 || page > images.size()) {
+ throw new FileNotFoundException("Page out of range: " + page);
+ }
+ Path pagePath = images.get(page - 1);
try (InputStream in = Files.newInputStream(pagePath)) {
IOUtils.copy(in, outputStream);
}
@@ -119,12 +135,11 @@ public class CbxReaderService {
ZipInputStream zis = new ZipInputStream(fis)) {
ZipEntry entry;
- int index = 1;
while ((entry = zis.getNextEntry()) != null) {
- isImageFile(entry.getName());
if (!entry.isDirectory() && isImageFile(entry.getName())) {
- Path target = targetDir.resolve(String.format("%04d.jpg", index++));
+ String fileName = extractFileNameFromPath(entry.getName());
+ Path target = targetDir.resolve(fileName);
Files.copy(zis, target, StandardCopyOption.REPLACE_EXISTING);
}
zis.closeEntry();
@@ -135,10 +150,10 @@ public class CbxReaderService {
private void extract7zArchive(Path cb7Path, Path targetDir) throws IOException {
try (SevenZFile sevenZFile = new SevenZFile(cb7Path.toFile())) {
SevenZArchiveEntry entry;
- int index = 1;
while ((entry = sevenZFile.getNextEntry()) != null) {
if (!entry.isDirectory() && isImageFile(entry.getName())) {
- Path target = targetDir.resolve(String.format("%04d.jpg", index++));
+ String fileName = extractFileNameFromPath(entry.getName());
+ Path target = targetDir.resolve(fileName);
try (OutputStream out = Files.newOutputStream(target)) {
copySevenZEntry(sevenZFile, out, entry.getSize());
}
@@ -160,10 +175,10 @@ public class CbxReaderService {
private void extractRarArchive(Path cbrPath, Path targetDir) throws IOException {
try (Archive archive = new Archive(cbrPath.toFile())) {
List headers = archive.getFileHeaders();
- int index = 1;
for (FileHeader header : headers) {
if (!header.isDirectory() && isImageFile(header.getFileName())) {
- Path target = targetDir.resolve(String.format("%04d.jpg", index++));
+ String fileName = extractFileNameFromPath(header.getFileName());
+ Path target = targetDir.resolve(fileName);
try (OutputStream out = Files.newOutputStream(target)) {
archive.extractFile(header, out);
}
@@ -174,19 +189,23 @@ public class CbxReaderService {
}
}
- private boolean isImageFile(String name) {
- String lower = name.toLowerCase().replace("\\", "/");
- return lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png") || lower.endsWith(".webp");
+ private String extractFileNameFromPath(String fullPath) {
+ String normalizedPath = fullPath.replace("\\", "/");
+ int lastSlash = normalizedPath.lastIndexOf('/');
+ return lastSlash >= 0 ? normalizedPath.substring(lastSlash + 1) : normalizedPath;
}
- private int extractPageNumber(String filename) {
- try {
- return Integer.parseInt(filename.replaceAll("\\D+", ""));
- } catch (Exception e) {
- return -1;
+ private boolean isImageFile(String name) {
+ String lower = name.toLowerCase().replace("\\", "/");
+ for (String extension : SUPPORTED_IMAGE_EXTENSIONS) {
+ if (lower.endsWith(extension)) {
+ return true;
+ }
}
+ return false;
}
+
private boolean needsCacheRefresh(Path cbzPath, Path cacheInfoPath) throws IOException {
if (!Files.exists(cacheInfoPath)) return true;
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/DefaultUserSettingsProvider.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/DefaultUserSettingsProvider.java
index 5ccded2f1..75b18e0c5 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/DefaultUserSettingsProvider.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/DefaultUserSettingsProvider.java
@@ -69,6 +69,7 @@ public class DefaultUserSettingsProvider {
.letterSpacing(null)
.lineHeight(null)
.flow("paginated")
+ .spread("double")
.build();
}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/util/MetadataChangeDetector.java b/booklore-api/src/main/java/com/adityachandel/booklore/util/MetadataChangeDetector.java
index 2d90f3b5f..4d8e44a4e 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/util/MetadataChangeDetector.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/util/MetadataChangeDetector.java
@@ -47,6 +47,8 @@ public class MetadataChangeDetector {
compare(changes, "hardcoverReviewCount", clear.isHardcoverReviewCount(), newMeta.getHardcoverReviewCount(), existingMeta.getHardcoverReviewCount(), () -> !isTrue(existingMeta.getHardcoverReviewCountLocked()), newMeta.getHardcoverReviewCountLocked(), existingMeta.getHardcoverReviewCountLocked());
compare(changes, "authors", clear.isAuthors(), newMeta.getAuthors(), toNameSet(existingMeta.getAuthors()), () -> !isTrue(existingMeta.getAuthorsLocked()), newMeta.getAuthorsLocked(), existingMeta.getAuthorsLocked());
compare(changes, "categories", clear.isCategories(), newMeta.getCategories(), toNameSet(existingMeta.getCategories()), () -> !isTrue(existingMeta.getCategoriesLocked()), newMeta.getCategoriesLocked(), existingMeta.getCategoriesLocked());
+ compare(changes, "moods", clear.isMoods(), newMeta.getMoods(), toNameSet(existingMeta.getMoods()), () -> !isTrue(existingMeta.getMoodsLocked()), newMeta.getMoodsLocked(), existingMeta.getMoodsLocked());
+ compare(changes, "tags", clear.isTags(), newMeta.getTags(), toNameSet(existingMeta.getTags()), () -> !isTrue(existingMeta.getTagsLocked()), newMeta.getTagsLocked(), existingMeta.getTagsLocked());
Boolean coverLockedNew = newMeta.getCoverLocked();
Boolean coverLockedExisting = existingMeta.getCoverLocked();
@@ -90,6 +92,8 @@ public class MetadataChangeDetector {
compareValue(diffs, "hardcoverReviewCount", clear.isHardcoverReviewCount(), newMeta.getHardcoverReviewCount(), existingMeta.getHardcoverReviewCount(), () -> !isTrue(existingMeta.getHardcoverReviewCountLocked()));
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()));
+ compareValue(diffs, "moods", clear.isMoods(), newMeta.getMoods(), toNameSet(existingMeta.getMoods()), () -> !isTrue(existingMeta.getMoodsLocked()));
+ compareValue(diffs, "tags", clear.isTags(), newMeta.getTags(), toNameSet(existingMeta.getTags()), () -> !isTrue(existingMeta.getTagsLocked()));
return !diffs.isEmpty();
}
@@ -121,6 +125,8 @@ public class MetadataChangeDetector {
if (differsLock(newMeta.getCoverLocked(), existingMeta.getCoverLocked())) return true;
if (differsLock(newMeta.getAuthorsLocked(), existingMeta.getAuthorsLocked())) return true;
if (differsLock(newMeta.getCategoriesLocked(), existingMeta.getCategoriesLocked())) return true;
+ if (differsLock(newMeta.getMoodsLocked(), existingMeta.getMoodsLocked())) return true;
+ if (differsLock(newMeta.getTagsLocked(), existingMeta.getTagsLocked())) return true;
if (differsLock(newMeta.getReviewsLocked(), existingMeta.getReviewsLocked())) return true;
return false;
}
diff --git a/booklore-api/src/main/resources/db/migration/V53__Add_Mood_And_Tag_Tables.sql b/booklore-api/src/main/resources/db/migration/V53__Add_Mood_And_Tag_Tables.sql
new file mode 100644
index 000000000..da289cc64
--- /dev/null
+++ b/booklore-api/src/main/resources/db/migration/V53__Add_Mood_And_Tag_Tables.sql
@@ -0,0 +1,33 @@
+CREATE TABLE IF NOT EXISTS mood
+(
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(255) NOT NULL UNIQUE
+);
+
+CREATE TABLE IF NOT EXISTS tag
+(
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(255) NOT NULL UNIQUE
+);
+
+ALTER TABLE book_metadata
+ADD COLUMN moods_locked BOOLEAN DEFAULT FALSE,
+ADD COLUMN tags_locked BOOLEAN DEFAULT FALSE;
+
+CREATE TABLE IF NOT EXISTS book_metadata_mood_mapping
+(
+ book_id BIGINT NOT NULL,
+ mood_id BIGINT NOT NULL,
+ PRIMARY KEY (book_id, mood_id),
+ CONSTRAINT fk_book_metadata_mood_mapping_book FOREIGN KEY (book_id) REFERENCES book_metadata (book_id) ON DELETE CASCADE,
+ CONSTRAINT fk_book_metadata_mood_mapping_mood FOREIGN KEY (mood_id) REFERENCES mood (id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS book_metadata_tag_mapping
+(
+ book_id BIGINT NOT NULL,
+ tag_id BIGINT NOT NULL,
+ PRIMARY KEY (book_id, tag_id),
+ CONSTRAINT fk_book_metadata_tag_mapping_book FOREIGN KEY (book_id) REFERENCES book_metadata (book_id) ON DELETE CASCADE,
+ CONSTRAINT fk_book_metadata_tag_mapping_tag FOREIGN KEY (tag_id) REFERENCES tag (id) ON DELETE CASCADE
+);
\ No newline at end of file
diff --git a/booklore-api/src/main/resources/db/migration/V54__Add_Spread_Column_Epub.sql b/booklore-api/src/main/resources/db/migration/V54__Add_Spread_Column_Epub.sql
new file mode 100644
index 000000000..5ee28f020
--- /dev/null
+++ b/booklore-api/src/main/resources/db/migration/V54__Add_Spread_Column_Epub.sql
@@ -0,0 +1,2 @@
+ALTER TABLE epub_viewer_preference
+ ADD COLUMN IF NOT EXISTS spread VARCHAR(20) DEFAULT 'double';
\ No newline at end of file
diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/bookdrop/BookDropServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/bookdrop/BookDropServiceTest.java
index a742f7795..eea172eb0 100644
--- a/booklore-api/src/test/java/com/adityachandel/booklore/service/bookdrop/BookDropServiceTest.java
+++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/bookdrop/BookDropServiceTest.java
@@ -1,7 +1,6 @@
package com.adityachandel.booklore.service.bookdrop;
import com.adityachandel.booklore.config.AppProperties;
-import com.adityachandel.booklore.exception.APIException;
import com.adityachandel.booklore.mapper.BookdropFileMapper;
import com.adityachandel.booklore.model.FileProcessResult;
import com.adityachandel.booklore.model.dto.Book;
@@ -14,7 +13,6 @@ import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.BookdropFileEntity;
import com.adityachandel.booklore.model.entity.LibraryEntity;
import com.adityachandel.booklore.model.entity.LibraryPathEntity;
-import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.repository.BookdropFileRepository;
import com.adityachandel.booklore.repository.LibraryRepository;
@@ -25,7 +23,6 @@ import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistr
import com.adityachandel.booklore.service.metadata.MetadataRefreshService;
import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService;
import com.fasterxml.jackson.databind.ObjectMapper;
-import org.junit.Ignore;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
@@ -44,7 +41,6 @@ import org.springframework.data.domain.Pageable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
-import java.time.Instant;
import java.util.List;
import java.util.Optional;
diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/MetadataRefreshServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/MetadataRefreshServiceTest.java
index e5ce766c7..4344f023b 100644
--- a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/MetadataRefreshServiceTest.java
+++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/MetadataRefreshServiceTest.java
@@ -82,38 +82,103 @@ class MetadataRefreshServiceTest {
setupTestEntities();
}
+ private MetadataRefreshOptions.EnabledFields allEnabledFields() {
+ return MetadataRefreshOptions.EnabledFields.builder()
+ .title(true)
+ .subtitle(true)
+ .description(true)
+ .authors(true)
+ .publisher(true)
+ .publishedDate(true)
+ .seriesName(true)
+ .seriesNumber(true)
+ .seriesTotal(true)
+ .isbn13(true)
+ .isbn10(true)
+ .language(true)
+ .categories(true)
+ .cover(true)
+ .pageCount(true)
+ .asin(true)
+ .goodreadsId(true)
+ .comicvineId(true)
+ .hardcoverId(true)
+ .googleId(true)
+ .amazonRating(true)
+ .amazonReviewCount(true)
+ .goodreadsRating(true)
+ .goodreadsReviewCount(true)
+ .hardcoverRating(true)
+ .hardcoverReviewCount(true)
+ .moods(true)
+ .tags(true)
+ .build();
+ }
+
private void setupDefaultOptions() {
- MetadataRefreshOptions.FieldProvider titleProvider = new MetadataRefreshOptions.FieldProvider(
- null, null, MetadataProvider.Google, MetadataProvider.GoodReads);
- MetadataRefreshOptions.FieldProvider descriptionProvider = new MetadataRefreshOptions.FieldProvider(
- null, null, null, MetadataProvider.Google);
- MetadataRefreshOptions.FieldProvider authorsProvider = new MetadataRefreshOptions.FieldProvider(
- null, null, null, MetadataProvider.GoodReads);
- MetadataRefreshOptions.FieldProvider categoriesProvider = new MetadataRefreshOptions.FieldProvider(
- null, null, null, MetadataProvider.Google);
- MetadataRefreshOptions.FieldProvider coverProvider = new MetadataRefreshOptions.FieldProvider(
- null, null, null, MetadataProvider.GoodReads);
+ MetadataRefreshOptions.FieldProvider titleProvider = MetadataRefreshOptions.FieldProvider.builder()
+ .p3(MetadataProvider.Google)
+ .p1(MetadataProvider.GoodReads)
+ .build();
+ MetadataRefreshOptions.FieldProvider descriptionProvider = MetadataRefreshOptions.FieldProvider.builder()
+ .p1(MetadataProvider.Google)
+ .build();
+ MetadataRefreshOptions.FieldProvider authorsProvider = MetadataRefreshOptions.FieldProvider.builder()
+ .p1(MetadataProvider.GoodReads)
+ .build();
+ MetadataRefreshOptions.FieldProvider categoriesProvider = MetadataRefreshOptions.FieldProvider.builder()
+ .p1(MetadataProvider.Google)
+ .build();
+ MetadataRefreshOptions.FieldProvider moodProvider = MetadataRefreshOptions.FieldProvider.builder()
+ .p1(MetadataProvider.Google)
+ .build();
+ MetadataRefreshOptions.FieldProvider tagProvider = MetadataRefreshOptions.FieldProvider.builder()
+ .p1(MetadataProvider.Google)
+ .build();
+ MetadataRefreshOptions.FieldProvider coverProvider = MetadataRefreshOptions.FieldProvider.builder()
+ .p1(MetadataProvider.GoodReads)
+ .build();
- MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions(
- titleProvider, null, descriptionProvider, authorsProvider, null, null,
- null, null, null, null, null, null, categoriesProvider, coverProvider);
+ MetadataRefreshOptions.FieldOptions fieldOptions = MetadataRefreshOptions.FieldOptions.builder()
+ .title(titleProvider)
+ .description(descriptionProvider)
+ .authors(authorsProvider)
+ .categories(categoriesProvider)
+ .moods(moodProvider)
+ .tags(tagProvider)
+ .cover(coverProvider)
+ .build();
- defaultOptions = new MetadataRefreshOptions(
- null, MetadataProvider.GoodReads, MetadataProvider.Google, null, null,
- true, false, false, fieldOptions);
+ MetadataRefreshOptions.EnabledFields skipFields = allEnabledFields();
+
+ defaultOptions = MetadataRefreshOptions.builder()
+ .refreshCovers(true)
+ .mergeCategories(false)
+ .reviewBeforeApply(false)
+ .fieldOptions(fieldOptions)
+ .enabledFields(skipFields)
+ .build();
}
private void setupLibraryOptions() {
- MetadataRefreshOptions.FieldProvider titleProvider = new MetadataRefreshOptions.FieldProvider(
- null, null, null, MetadataProvider.Google);
+ MetadataRefreshOptions.FieldProvider titleProvider = MetadataRefreshOptions.FieldProvider.builder()
+ .p1(MetadataProvider.Google)
+ .build();
- MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions(
- titleProvider, null, null, null, null, null,
- null, null, null, null, null, null, null, null);
+ MetadataRefreshOptions.FieldOptions fieldOptions = MetadataRefreshOptions.FieldOptions.builder()
+ .title(titleProvider)
+ .build();
- libraryOptions = new MetadataRefreshOptions(
- 1L, MetadataProvider.Google, null, null, null,
- false, true, true, fieldOptions);
+ MetadataRefreshOptions.EnabledFields skipFields = allEnabledFields();
+
+ libraryOptions = MetadataRefreshOptions.builder()
+ .libraryId(1L)
+ .refreshCovers(false)
+ .mergeCategories(true)
+ .reviewBeforeApply(true)
+ .fieldOptions(fieldOptions)
+ .enabledFields(skipFields)
+ .build();
}
private void setupAppSettings() {
@@ -128,7 +193,6 @@ class MetadataRefreshServiceTest {
testLibrary.setId(1L);
testLibrary.setName("Test Library");
- // Create AuthorEntity for proper type compatibility
AuthorEntity authorEntity = new AuthorEntity();
authorEntity.setName("Test Author");
@@ -220,16 +284,21 @@ class MetadataRefreshServiceTest {
@Test
void testRefreshMetadata_WithRequestOptions_ShouldUseRequestOptions() {
- // Given
- MetadataRefreshOptions.FieldProvider titleProvider = new MetadataRefreshOptions.FieldProvider(
- null, null, null, MetadataProvider.Hardcover);
- MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions(
- titleProvider, null, null, null, null, null,
- null, null, null, null, null, null, null, null);
+ MetadataRefreshOptions.FieldProvider titleProvider = MetadataRefreshOptions.FieldProvider.builder()
+ .p1(MetadataProvider.Hardcover)
+ .build();
+ MetadataRefreshOptions.FieldOptions fieldOptions = MetadataRefreshOptions.FieldOptions.builder()
+ .title(titleProvider)
+ .build();
+ MetadataRefreshOptions.EnabledFields skipFields = allEnabledFields();
- MetadataRefreshOptions requestOptions = new MetadataRefreshOptions(
- null, MetadataProvider.Hardcover, null, null, null,
- true, false, false, fieldOptions);
+ MetadataRefreshOptions requestOptions = MetadataRefreshOptions.builder()
+ .refreshCovers(true)
+ .mergeCategories(false)
+ .reviewBeforeApply(false)
+ .fieldOptions(fieldOptions)
+ .enabledFields(skipFields)
+ .build();
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
.refreshType(MetadataRefreshRequest.RefreshType.BOOKS)
@@ -284,9 +353,15 @@ class MetadataRefreshServiceTest {
@Test
void testRefreshMetadata_WithReviewMode_ShouldCreateTaskAndProposals() throws JsonProcessingException {
- MetadataRefreshOptions reviewOptions = new MetadataRefreshOptions(
- null, MetadataProvider.GoodReads, MetadataProvider.Google, null, null,
- true, false, true, defaultOptions.getFieldOptions());
+ MetadataRefreshOptions.EnabledFields skipFields = allEnabledFields();
+
+ MetadataRefreshOptions reviewOptions = MetadataRefreshOptions.builder()
+ .refreshCovers(true)
+ .mergeCategories(false)
+ .reviewBeforeApply(true)
+ .fieldOptions(defaultOptions.getFieldOptions())
+ .enabledFields(skipFields)
+ .build();
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
.refreshType(MetadataRefreshRequest.RefreshType.BOOKS)
@@ -340,7 +415,7 @@ class MetadataRefreshServiceTest {
when(bookRepository.findAllWithMetadataByIds(Set.of(999L))).thenReturn(Collections.emptyList());
assertThrows(RuntimeException.class, () ->
- metadataRefreshService.refreshMetadata(request, 1L, "job-1"));
+ metadataRefreshService.refreshMetadata(request, 1L, "job-1"));
}
@Test
@@ -354,7 +429,7 @@ class MetadataRefreshServiceTest {
when(libraryRepository.findById(999L)).thenReturn(Optional.empty());
assertThrows(RuntimeException.class, () ->
- metadataRefreshService.refreshMetadata(request, 1L, "job-1"));
+ metadataRefreshService.refreshMetadata(request, 1L, "job-1"));
}
@Test
@@ -445,24 +520,48 @@ class MetadataRefreshServiceTest {
@Test
void testBuildFetchMetadata_WithMergeCategories_ShouldMergeAllCategories() {
- MetadataRefreshOptions.FieldProvider titleProvider = new MetadataRefreshOptions.FieldProvider(
- null, null, null, MetadataProvider.Google);
- MetadataRefreshOptions.FieldProvider descriptionProvider = new MetadataRefreshOptions.FieldProvider(
- null, null, null, MetadataProvider.Google);
- MetadataRefreshOptions.FieldProvider authorsProvider = new MetadataRefreshOptions.FieldProvider(
- null, null, null, MetadataProvider.Google);
- MetadataRefreshOptions.FieldProvider categoriesProvider = new MetadataRefreshOptions.FieldProvider(
- null, null, MetadataProvider.Google, MetadataProvider.GoodReads);
- MetadataRefreshOptions.FieldProvider coverProvider = new MetadataRefreshOptions.FieldProvider(
- null, null, null, MetadataProvider.Google);
+ MetadataRefreshOptions.FieldProvider titleProvider = MetadataRefreshOptions.FieldProvider.builder()
+ .p1(MetadataProvider.Google)
+ .build();
+ MetadataRefreshOptions.FieldProvider descriptionProvider = MetadataRefreshOptions.FieldProvider.builder()
+ .p1(MetadataProvider.Google)
+ .build();
+ MetadataRefreshOptions.FieldProvider authorsProvider = MetadataRefreshOptions.FieldProvider.builder()
+ .p1(MetadataProvider.Google)
+ .build();
+ MetadataRefreshOptions.FieldProvider moodProvider = MetadataRefreshOptions.FieldProvider.builder()
+ .p1(MetadataProvider.Google)
+ .build();
+ MetadataRefreshOptions.FieldProvider tagProvider = MetadataRefreshOptions.FieldProvider.builder()
+ .p1(MetadataProvider.Google)
+ .build();
+ MetadataRefreshOptions.FieldProvider categoriesProvider = MetadataRefreshOptions.FieldProvider.builder()
+ .p3(MetadataProvider.Google)
+ .p1(MetadataProvider.GoodReads)
+ .build();
+ MetadataRefreshOptions.FieldProvider coverProvider = MetadataRefreshOptions.FieldProvider.builder()
+ .p1(MetadataProvider.Google)
+ .build();
- MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions(
- titleProvider, null, descriptionProvider, authorsProvider, null, null,
- null, null, null, null, null, null, categoriesProvider, coverProvider);
+ MetadataRefreshOptions.FieldOptions fieldOptions = MetadataRefreshOptions.FieldOptions.builder()
+ .title(titleProvider)
+ .description(descriptionProvider)
+ .authors(authorsProvider)
+ .categories(categoriesProvider)
+ .moods(moodProvider)
+ .tags(tagProvider)
+ .cover(coverProvider)
+ .build();
- MetadataRefreshOptions mergeOptions = new MetadataRefreshOptions(
- null, MetadataProvider.GoodReads, MetadataProvider.Google, null, null,
- true, true, false, fieldOptions);
+ MetadataRefreshOptions.EnabledFields skipFields = allEnabledFields();
+
+ MetadataRefreshOptions mergeOptions = MetadataRefreshOptions.builder()
+ .refreshCovers(true)
+ .mergeCategories(true)
+ .reviewBeforeApply(false)
+ .fieldOptions(fieldOptions)
+ .enabledFields(skipFields)
+ .build();
Map metadataMap = new HashMap<>();
metadataMap.put(MetadataProvider.GoodReads, BookMetadata.builder()
diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/util/builder/LibraryTestBuilder.java b/booklore-api/src/test/java/com/adityachandel/booklore/util/builder/LibraryTestBuilder.java
index 325154e22..7e6f91dee 100644
--- a/booklore-api/src/test/java/com/adityachandel/booklore/util/builder/LibraryTestBuilder.java
+++ b/booklore-api/src/test/java/com/adityachandel/booklore/util/builder/LibraryTestBuilder.java
@@ -13,10 +13,9 @@ import com.adityachandel.booklore.service.FileFingerprint;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry;
import com.adityachandel.booklore.util.FileUtils;
+import jakarta.validation.constraints.NotNull;
import org.apache.commons.io.FilenameUtils;
-import org.jetbrains.annotations.NotNull;
import org.mockito.MockedStatic;
-import org.mockito.Mockito;
import java.nio.file.Path;
import java.security.MessageDigest;
@@ -28,8 +27,8 @@ import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
-import static org.mockito.Mockito.when;
import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.when;
/**
* Test builder for creating Library-related test objects.
diff --git a/booklore-ui/src/app/book/components/book-browser/book-browser.component.html b/booklore-ui/src/app/book/components/book-browser/book-browser.component.html
index 9eb3fd0ba..4ceb4b74d 100644
--- a/booklore-ui/src/app/book/components/book-browser/book-browser.component.html
+++ b/booklore-ui/src/app/book/components/book-browser/book-browser.component.html
@@ -117,26 +117,6 @@
{{ coverScalePreferenceService.scaleFactor.toFixed(2) }}x
-
-
-
Sidebar Filter Sort:
-
-
diff --git a/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts b/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts
index f6e0b2c6b..15265a9b5 100644
--- a/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts
+++ b/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts
@@ -36,8 +36,6 @@ import {BookDialogHelperService} from './BookDialogHelperService';
import {Checkbox} from 'primeng/checkbox';
import {Popover} from 'primeng/popover';
import {Slider} from 'primeng/slider';
-import {Select} from 'primeng/select';
-import {FilterSortPreferenceService} from './filters/filter-sorting-preferences.service';
import {Divider} from 'primeng/divider';
import {MultiSelect} from 'primeng/multiselect';
import {TableColumnPreferenceService} from './table-column-preference-service';
@@ -85,7 +83,7 @@ const SORT_DIRECTION = {
imports: [
Button, VirtualScrollerModule, BookCardComponent, AsyncPipe, ProgressSpinner, Menu, InputText, FormsModule,
BookTableComponent, BookFilterComponent, Tooltip, NgClass, PrimeTemplate, NgStyle, Popover,
- Checkbox, Slider, Select, Divider, MultiSelect, TieredMenu
+ Checkbox, Slider, Divider, MultiSelect, TieredMenu
],
providers: [SeriesCollapseFilter],
animations: [
@@ -100,7 +98,6 @@ const SORT_DIRECTION = {
export class BookBrowserComponent implements OnInit {
protected userService = inject(UserService);
protected coverScalePreferenceService = inject(CoverScalePreferenceService);
- protected filterSortPreferenceService = inject(FilterSortPreferenceService);
protected columnPreferenceService = inject(TableColumnPreferenceService);
protected sidebarFilterTogglePrefService = inject(SidebarFilterTogglePrefService);
private activatedRoute = inject(ActivatedRoute);
@@ -274,7 +271,6 @@ export class BookBrowserComponent implements OnInit {
const globalPrefs = this.entityViewPreferences?.global;
const currentEntityTypeStr = this.entityType ? this.entityType.toString().toUpperCase() : undefined;
this.coverScalePreferenceService.initScaleValue(this.coverScalePreferenceService.scaleFactor);
- this.filterSortPreferenceService.initValue(user.user?.userSettings?.filterSortingMode);
this.columnPreferenceService.initPreferences(user.user?.userSettings?.tableColumnPreference);
this.visibleColumns = this.columnPreferenceService.visibleColumns;
diff --git a/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.html b/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.html
index 0c71348cd..bf3af256c 100644
--- a/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.html
+++ b/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.html
@@ -41,7 +41,11 @@
(click)="handleFilterClick(filterType, filter.value?.id || filter.value)">
{{ filter.value.name || filter.value }}
-
+
+ }
+ @if (truncatedFilters[filterType]) {
+
+ Showing first 250 items
}
@@ -50,5 +54,9 @@
}
+
+
+ Note: Top 500 items are displayed per filter category
+
diff --git a/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.ts b/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.ts
index 4e342f22a..dc650016f 100644
--- a/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.ts
+++ b/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.ts
@@ -1,5 +1,5 @@
import {ChangeDetectionStrategy, Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output} from '@angular/core';
-import {combineLatest, distinctUntilChanged, Observable, of, shareReplay, Subject, takeUntil} from 'rxjs';
+import {combineLatest, Observable, of, shareReplay, Subject, takeUntil} from 'rxjs';
import {map} from 'rxjs/operators';
import {BookService} from '../../../service/book.service';
import {Library} from '../../../model/library.model';
@@ -12,7 +12,6 @@ import {Badge} from 'primeng/badge';
import {FormsModule} from '@angular/forms';
import {SelectButton} from 'primeng/selectbutton';
import {UserService} from '../../../../settings/user-management/user.service';
-import {FilterSortPreferenceService} from '../filters/filter-sorting-preferences.service';
import {MagicShelf} from '../../../../magic-shelf.service';
import {GroupRule} from '../../../../magic-shelf-component/magic-shelf-component';
import {BookRuleEvaluatorService} from '../../../../book-rule-evaluator.service';
@@ -161,6 +160,7 @@ export class BookFilterComponent implements OnInit, OnDestroy {
activeFilters: Record = {};
filterStreams: Record[]>> = {};
+ truncatedFilters: Record = {};
filterTypes: string[] = [];
filterModeOptions = [
{label: 'AND', value: 'and'},
@@ -190,38 +190,37 @@ export class BookFilterComponent implements OnInit, OnDestroy {
bookService = inject(BookService);
userService = inject(UserService);
- filterSortPreferenceService = inject(FilterSortPreferenceService);
bookRuleEvaluatorService = inject(BookRuleEvaluatorService);
ngOnInit(): void {
combineLatest([
- this.filterSortPreferenceService.sortMode$.pipe(distinctUntilChanged()),
this.entity$ ?? of(null),
this.entityType$ ?? of(EntityType.ALL_BOOKS)
])
.pipe(takeUntil(this.destroy$))
.subscribe(([sortMode]) => {
this.filterStreams = {
- // Temporarily disabled until we can optimize for large libraries
- /*author: this.getFilterStream((book: Book) => book.metadata?.authors!.map(name => ({id: name, name})) || [], 'id', 'name', sortMode),
- 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),
+ author: this.getFilterStream((book: Book) => book.metadata?.authors!.map(name => ({id: name, name})) || [], 'id', 'name'),
+ category: this.getFilterStream((book: Book) => book.metadata?.categories!.map(name => ({id: name, name})) || [], 'id', 'name'),
+ series: this.getFilterStream((book) => (book.metadata?.seriesName ? [{id: book.metadata.seriesName, name: book.metadata.seriesName}] : []), 'id', 'name'),
+ publisher: this.getFilterStream((book) => (book.metadata?.publisher ? [{id: book.metadata.publisher, name: book.metadata.publisher}] : []), 'id', 'name'),
readStatus: this.getFilterStream((book: Book) => {
let status = book.readStatus;
if (status == null || !(status in readStatusLabels)) {
status = ReadStatus.UNSET;
}
return [{id: status, name: getReadStatusName(status)}];
- }, 'id', 'name', sortMode),
+ }, 'id', 'name'),
+ mood: this.getFilterStream((book: Book) => book.metadata?.moods!.map(name => ({id: name, name})) || [], 'id', 'name'),
+ tag: this.getFilterStream((book: Book) => book.metadata?.tags!.map(name => ({id: name, name})) || [], 'id', 'name'),
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'),
goodreadsRating: this.getFilterStream((book: Book) => getRatingRangeFilters(book.metadata?.goodreadsRating!), 'id', 'name', 'sortIndex'),
hardcoverRating: this.getFilterStream((book: Book) => getRatingRangeFilters(book.metadata?.hardcoverRating!), 'id', 'name', 'sortIndex'),
- shelfStatus: this.getFilterStream(getShelfStatusFilter, 'id', 'name', sortMode),
- publishedDate: this.getFilterStream(extractPublishedYearFilter, 'id', 'name', sortMode),
- language: this.getFilterStream(getLanguageFilter, 'id', 'name', sortMode),
+ shelfStatus: this.getFilterStream(getShelfStatusFilter, 'id', 'name'),
+ publishedDate: this.getFilterStream(extractPublishedYearFilter, 'id', 'name'),
+ language: this.getFilterStream(getLanguageFilter, 'id', 'name'),
fileSize: this.getFilterStream((book: Book) => getFileSizeRangeFilters(book.fileSizeKb), 'id', 'name', 'sortIndex'),
pageCount: this.getFilterStream((book: Book) => getPageCountRangeFilters(book.metadata?.pageCount!), 'id', 'name', 'sortIndex'),
};
@@ -266,18 +265,31 @@ export class BookFilterComponent implements OnInit, OnDestroy {
const result = Array.from(filterMap.values());
- return result.sort((a, b) => {
+ const sorted = result.sort((a, b) => {
if (sortMode === 'sortIndex') {
return (a.value.sortIndex ?? 999) - (b.value.sortIndex ?? 999);
}
- if (sortMode === 'alphabetical') {
- return a.value[nameKey].toString().localeCompare(b.value[nameKey].toString());
- }
return (
b.bookCount - a.bookCount ||
a.value[nameKey].toString().localeCompare(b.value[nameKey].toString())
);
});
+
+ const isTruncated = sorted.length > 500;
+ const truncated = sorted.slice(0, 500);
+
+ return {items: truncated, isTruncated};
+ }),
+ map(({items, isTruncated}) => {
+ setTimeout(() => {
+ const filterType = Object.keys(this.filterStreams).find(key =>
+ this.filterStreams[key] === this.getFilterStream(extractor, idKey, nameKey)
+ );
+ if (filterType) {
+ this.truncatedFilters[filterType] = isTruncated;
+ }
+ });
+ return items;
}),
shareReplay({bufferSize: 1, refCount: true})
);
diff --git a/booklore-ui/src/app/book/components/book-browser/filters/SidebarFilter.ts b/booklore-ui/src/app/book/components/book-browser/filters/SidebarFilter.ts
index 2fa5ba94d..38dc226bf 100644
--- a/booklore-ui/src/app/book/components/book-browser/filters/SidebarFilter.ts
+++ b/booklore-ui/src/app/book/components/book-browser/filters/SidebarFilter.ts
@@ -66,6 +66,14 @@ export class SideBarFilter implements BookFilter {
return mode === 'and'
? filterValues.every(val => book.metadata?.categories?.includes(val))
: filterValues.some(val => book.metadata?.categories?.includes(val));
+ case 'mood':
+ return mode === 'and'
+ ? filterValues.every(val => book.metadata?.moods?.includes(val))
+ : filterValues.some(val => book.metadata?.moods?.includes(val));
+ case 'tag':
+ return mode === 'and'
+ ? filterValues.every(val => book.metadata?.tags?.includes(val))
+ : filterValues.some(val => book.metadata?.tags?.includes(val));
case 'publisher':
return mode === 'and'
? filterValues.every(val => book.metadata?.publisher === val)
diff --git a/booklore-ui/src/app/book/components/book-browser/filters/filter-sorting-preferences.service.ts b/booklore-ui/src/app/book/components/book-browser/filters/filter-sorting-preferences.service.ts
deleted file mode 100644
index e9fbb8510..000000000
--- a/booklore-ui/src/app/book/components/book-browser/filters/filter-sorting-preferences.service.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import {inject, Injectable} from '@angular/core';
-import {BehaviorSubject} from 'rxjs';
-import {UserService} from '../../../../settings/user-management/user.service';
-import {MessageService} from 'primeng/api';
-
-@Injectable({
- providedIn: 'root'
-})
-export class FilterSortPreferenceService {
-
- readonly filterSortingOptions = [
- { label: 'Alphabetical (A–Z)', value: 'alphabetical' },
- { label: 'Book Count (High to Low)', value: 'count' }
- ];
-
- private readonly userService = inject(UserService);
- private readonly messageService = inject(MessageService);
-
- private readonly sortModeSubject = new BehaviorSubject<'alphabetical' | 'count'>('count');
- readonly sortMode$ = this.sortModeSubject.asObservable();
-
- initValue(value: 'alphabetical' | 'count' | null | undefined): void {
- const resolved = value ?? 'count';
- this.sortModeSubject.next(resolved);
- }
-
- get selectedFilterSorting(): 'alphabetical' | 'count' {
- return this.sortModeSubject.value;
- }
-
- set selectedFilterSorting(value: 'alphabetical' | 'count') {
- if (this.sortModeSubject.value !== value) {
- this.sortModeSubject.next(value);
- this.savePreference(value);
- }
- }
-
- private savePreference(value: 'alphabetical' | 'count'): void {
- const user = this.userService.getCurrentUser();
- if (!user) return;
-
- user.userSettings.filterSortingMode = value;
- this.userService.updateUserSetting(user.id, 'filterSortingMode', value);
-
- this.messageService.add({
- severity: 'success',
- summary: 'Preferences Updated',
- detail: 'Your filter sorting preference has been saved.',
- life: 1500
- });
- }
-}
diff --git a/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.html b/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.html
index c76c78411..807eda4c8 100644
--- a/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.html
+++ b/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.html
@@ -109,6 +109,36 @@
+ @if (selectedFlow === 'paginated' && !isMobileDevice()) {
+
+
+
+ }
+
diff --git a/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.scss b/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.scss
index bd5f7f752..c51dd2920 100644
--- a/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.scss
+++ b/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.scss
@@ -61,6 +61,16 @@
height: 100%;
width: 100%;
overflow: hidden;
+
+ @media (max-width: 768px) {
+ touch-action: manipulation;
+ user-select: none;
+ }
+
+ iframe {
+ pointer-events: auto;
+ touch-action: manipulation;
+ }
}
.menu-toggle-button,
@@ -91,6 +101,10 @@
border: none;
padding: 10px;
transition: color 0.3s ease, transform 0.2s ease;
+
+ @media (max-width: 768px) {
+ display: none;
+ }
}
.epub-controls-left {
@@ -164,3 +178,4 @@
::ng-deep .p-divider.p-divider-horizontal {
margin: 0.25rem 0 0.5rem 0 !important;
}
+
diff --git a/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.ts b/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.ts
index ab7981794..755e8906b 100644
--- a/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.ts
+++ b/booklore-ui/src/app/book/components/epub-viewer/component/epub-viewer.component.ts
@@ -1,4 +1,4 @@
-import {Component, ElementRef, inject, OnDestroy, OnInit, ViewChild} from '@angular/core';
+import {Component, ElementRef, inject, OnDestroy, OnInit, ViewChild, AfterViewInit} from '@angular/core';
import ePub from 'epubjs';
import {Drawer} from 'primeng/drawer';
import {Button} from 'primeng/button';
@@ -49,6 +49,7 @@ export class EpubViewerComponent implements OnInit, OnDestroy {
selectedFlow?: string = 'paginated';
selectedTheme?: string = 'white';
selectedFontType?: string | null = null;
+ selectedSpread?: string = 'double';
lineHeight?: number;
letterSpacing?: number;
@@ -75,6 +76,11 @@ export class EpubViewerComponent implements OnInit, OnDestroy {
epub!: Book;
+ private touchStartX: number = 0;
+ private touchStartY: number = 0;
+ private minSwipeDistance: number = 50;
+ private maxVerticalDistance: number = 100;
+
ngOnInit(): void {
this.route.paramMap.subscribe((params) => {
this.isLoading = true;
@@ -110,6 +116,7 @@ export class EpubViewerComponent implements OnInit, OnDestroy {
const resolvedTheme = settingScope === 'Global' ? globalSettings.theme : individualSetting?.theme;
const resolvedLineHeight = settingScope === 'Global' ? globalSettings.lineHeight : individualSetting?.lineHeight;
const resolvedLetterSpacing = settingScope === 'Global' ? globalSettings.letterSpacing : individualSetting?.letterSpacing;
+ const resolvedSpread = settingScope === 'Global' ? globalSettings.spread || 'double' : individualSetting?.spread || 'double';
if (resolvedTheme != null) this.selectedTheme = resolvedTheme;
if (resolvedFontFamily != null) this.selectedFontType = resolvedFontFamily;
@@ -117,12 +124,14 @@ export class EpubViewerComponent implements OnInit, OnDestroy {
if (resolvedLineHeight != null) this.lineHeight = resolvedLineHeight;
if (resolvedLetterSpacing != null) this.letterSpacing = resolvedLetterSpacing;
if (resolvedFlow != null) this.selectedFlow = resolvedFlow;
+ if (resolvedSpread != null) this.selectedSpread = resolvedSpread;
this.rendition = this.book.renderTo(this.epubContainer.nativeElement, {
flow: this.selectedFlow ?? 'paginated',
manager: this.selectedFlow === 'scrolled' ? 'continuous' : 'default',
width: '100%',
height: '100%',
+ spread: this.selectedFlow === 'paginated' && !this.isMobileDevice() ? (this.selectedSpread === 'single' ? 'none' : this.selectedSpread) : 'none',
allowScriptedContent: true,
});
@@ -152,6 +161,7 @@ export class EpubViewerComponent implements OnInit, OnDestroy {
displayPromise.then(() => {
this.setupKeyListener();
+ this.setupTouchListeners();
this.trackProgress();
this.isLoading = false;
});
@@ -187,12 +197,37 @@ export class EpubViewerComponent implements OnInit, OnDestroy {
manager: this.selectedFlow === 'scrolled' ? 'continuous' : 'default',
width: '100%',
height: '100%',
+ spread: this.selectedFlow === 'paginated' && !this.isMobileDevice() ? (this.selectedSpread === 'single' ? 'none' : this.selectedSpread) : 'none',
allowScriptedContent: true,
});
+ this.rendition.themes.override('font-size', `${this.fontSize}%`);
this.applyCombinedTheme();
-
this.setupKeyListener();
+ this.setupTouchListeners();
+ this.rendition.display(cfi || undefined);
+ this.updateViewerSetting();
+ }
+
+ changeSpreadMode(): void {
+ if (!this.rendition || !this.book || this.selectedFlow === 'scrolled' || this.isMobileDevice()) return;
+
+ const cfi = this.rendition.currentLocation()?.start?.cfi;
+ this.rendition.destroy();
+
+ this.rendition = this.book.renderTo(this.epubContainer.nativeElement, {
+ flow: this.selectedFlow,
+ manager: 'default',
+ width: '100%',
+ height: '100%',
+ spread: this.selectedSpread === 'single' ? 'none' : this.selectedSpread,
+ allowScriptedContent: true,
+ });
+
+ this.rendition.themes.override('font-size', `${this.fontSize}%`);
+ this.applyCombinedTheme();
+ this.setupKeyListener();
+ this.setupTouchListeners();
this.rendition.display(cfi || undefined);
this.updateViewerSetting();
}
@@ -248,6 +283,7 @@ export class EpubViewerComponent implements OnInit, OnDestroy {
if (this.selectedFontType) epubSettings.font = this.selectedFontType;
if (this.fontSize) epubSettings.fontSize = this.fontSize;
if (this.selectedFlow) epubSettings.flow = this.selectedFlow;
+ if (this.selectedSpread === 'single' || this.selectedSpread === 'double') epubSettings.spread = this.selectedSpread;
if (this.lineHeight) epubSettings.lineHeight = this.lineHeight;
if (this.letterSpacing) epubSettings.letterSpacing = this.letterSpacing;
@@ -277,6 +313,56 @@ export class EpubViewerComponent implements OnInit, OnDestroy {
document.addEventListener('keyup', this.keyListener);
}
+ private setupTouchListeners(): void {
+ if (!this.isMobileDevice() || this.selectedFlow === 'scrolled') return;
+
+ const container = this.epubContainer.nativeElement;
+ container.removeEventListener('touchstart', this.onTouchStart.bind(this));
+ container.removeEventListener('touchend', this.onTouchEnd.bind(this));
+
+ container.addEventListener('touchstart', this.onTouchStart.bind(this), {passive: true});
+ container.addEventListener('touchend', this.onTouchEnd.bind(this), {passive: true});
+
+ setTimeout(() => {
+ const iframe = this.epubContainer.nativeElement.querySelector('iframe');
+ if (iframe && iframe.contentDocument) {
+ const iframeDoc = iframe.contentDocument;
+ iframeDoc.addEventListener('touchstart', this.onTouchStart.bind(this), {passive: true});
+ iframeDoc.addEventListener('touchend', this.onTouchEnd.bind(this), {passive: true});
+ }
+ }, 500);
+ }
+
+ onTouchStart(event: TouchEvent): void {
+ if (this.selectedFlow === 'scrolled') return;
+
+ this.touchStartX = event.touches[0].clientX;
+ this.touchStartY = event.touches[0].clientY;
+ }
+
+ onTouchEnd(event: TouchEvent): void {
+ if (this.selectedFlow === 'scrolled') return;
+
+ const touchEndX = event.changedTouches[0].clientX;
+ const touchEndY = event.changedTouches[0].clientY;
+
+ const deltaX = touchEndX - this.touchStartX;
+ const deltaY = Math.abs(touchEndY - this.touchStartY);
+
+ if (Math.abs(deltaX) > this.minSwipeDistance && deltaY < this.maxVerticalDistance) {
+ event.preventDefault();
+ if (deltaX > 0) {
+ this.prevPage();
+ } else {
+ this.nextPage();
+ }
+ }
+ }
+
+ public isMobileDevice(): boolean {
+ return window.innerWidth <= 768;
+ }
+
prevPage(): void {
if (this.rendition) {
this.rendition.prev();
@@ -333,7 +419,6 @@ export class EpubViewerComponent implements OnInit, OnDestroy {
return this.book.locations.generate(1600);
}).then(() => {
this.locationsReady = true;
- // Recalculate progress with new locations
if (this.rendition.currentLocation()) {
const location = this.rendition.currentLocation();
const cfi = location.end.cfi;
@@ -341,7 +426,6 @@ export class EpubViewerComponent implements OnInit, OnDestroy {
this.progressPercentage = Math.round(percentage * 1000) / 10;
}
}).catch(() => {
- // If location generation fails, keep using spine-based calculation
this.locationsReady = false;
});
}
diff --git a/booklore-ui/src/app/book/model/book.model.ts b/booklore-ui/src/app/book/model/book.model.ts
index 337504672..6d87e5aac 100644
--- a/booklore-ui/src/app/book/model/book.model.ts
+++ b/booklore-ui/src/app/book/model/book.model.ts
@@ -96,6 +96,8 @@ export interface BookMetadata {
coverUpdatedOn?: string;
authors?: string[];
categories?: string[];
+ moods?: string[];
+ tags?: string[];
provider?: string;
providerBookId?: string;
thumbnailUrl?: string | null;
@@ -128,6 +130,8 @@ export interface BookMetadata {
coverUpdatedOnLocked?: boolean;
authorsLocked?: boolean;
categoriesLocked?: boolean;
+ moodsLocked?: boolean;
+ tagsLocked?: boolean;
coverLocked?: boolean;
reviewsLocked?: boolean;
@@ -161,6 +165,8 @@ export interface MetadataClearFlags {
personalRating?: boolean;
authors?: boolean;
categories?: boolean;
+ moods?: boolean;
+ tags?: boolean;
cover?: boolean;
}
@@ -181,6 +187,7 @@ export interface EpubViewerSetting {
flow: string;
lineHeight: number;
letterSpacing: number;
+ spread: string;
}
export interface CbxViewerSetting {
diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.html b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.html
index f6cbaad9e..fd9c6044a 100644
--- a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.html
+++ b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.html
@@ -176,6 +176,61 @@
+
+
+
Moods
+
+
+ @if (!book.metadata!['moodsLocked']) {
+
+ }
+ @if (book.metadata!['moodsLocked']) {
+
+ }
+
+
+
+
+
+
Tags
+
+
+ @if (!book.metadata!['tagsLocked']) {
+
+ }
+ @if (book.metadata!['tagsLocked']) {
+
+ }
+
+
+
+
Series Name
diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.ts b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.ts
index 2ffb7ad0f..fc0498f89 100644
--- a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.ts
+++ b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.ts
@@ -1,42 +1,19 @@
-import {
- Component,
- DestroyRef,
- EventEmitter,
- inject,
- Input,
- OnInit,
- Output,
-} from "@angular/core";
+import {Component, DestroyRef, EventEmitter, inject, Input, OnInit, Output,} from "@angular/core";
import {InputText} from "primeng/inputtext";
import {Button} from "primeng/button";
import {Divider} from "primeng/divider";
-import {
- FormControl,
- FormGroup,
- FormsModule,
- ReactiveFormsModule,
-} from "@angular/forms";
+import {FormControl, FormGroup, FormsModule, ReactiveFormsModule,} from "@angular/forms";
import {Observable} from "rxjs";
import {AsyncPipe} from "@angular/common";
import {MessageService} from "primeng/api";
-import {
- Book,
- BookMetadata,
- MetadataClearFlags,
- MetadataUpdateWrapper,
-} from "../../../book/model/book.model";
+import {Book, BookMetadata, MetadataClearFlags, MetadataUpdateWrapper,} from "../../../book/model/book.model";
import {UrlHelperService} from "../../../utilities/service/url-helper.service";
-import {
- FileUpload,
- FileUploadErrorEvent,
- FileUploadEvent,
-} from "primeng/fileupload";
+import {FileUpload, FileUploadErrorEvent, FileUploadEvent,} from "primeng/fileupload";
import {HttpResponse} from "@angular/common/http";
import {BookService} from "../../../book/service/book.service";
import {ProgressSpinner} from "primeng/progressspinner";
import {Tooltip} from "primeng/tooltip";
import {filter, take} from "rxjs/operators";
-import {MetadataRestoreDialogComponent} from "../../../book/components/book-browser/metadata-restore-dialog-component/metadata-restore-dialog-component";
import {DialogService} from "primeng/dynamicdialog";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {MetadataRefreshRequest} from "../../model/request/metadata-refresh-request.model";
@@ -99,8 +76,12 @@ export class MetadataEditorComponent implements OnInit {
allAuthors!: string[];
allCategories!: string[];
+ allMoods!: string[];
+ allTags!: string[];
filteredCategories: string[] = [];
filteredAuthors: string[] = [];
+ filteredMoods: string[] = [];
+ filteredTags: string[] = [];
filterCategories(event: { query: string }) {
const query = event.query.toLowerCase();
@@ -116,12 +97,28 @@ export class MetadataEditorComponent implements OnInit {
);
}
+ filterMoods(event: { query: string }) {
+ const query = event.query.toLowerCase();
+ this.filteredMoods = this.allMoods.filter((mood) =>
+ mood.toLowerCase().includes(query)
+ );
+ }
+
+ filterTags(event: { query: string }) {
+ const query = event.query.toLowerCase();
+ this.filteredTags = this.allTags.filter((tag) =>
+ tag.toLowerCase().includes(query)
+ );
+ }
+
constructor() {
this.metadataForm = new FormGroup({
title: new FormControl(""),
subtitle: new FormControl(""),
authors: new FormControl(""),
categories: new FormControl(""),
+ moods: new FormControl(""),
+ tags: new FormControl(""),
publisher: new FormControl(""),
publishedDate: new FormControl(""),
isbn10: new FormControl(""),
@@ -150,6 +147,8 @@ export class MetadataEditorComponent implements OnInit {
subtitleLocked: new FormControl(false),
authorsLocked: new FormControl(false),
categoriesLocked: new FormControl(false),
+ moodsLocked: new FormControl(false),
+ tagsLocked: new FormControl(false),
publisherLocked: new FormControl(false),
publishedDateLocked: new FormControl(false),
isbn10Locked: new FormControl(false),
@@ -197,16 +196,22 @@ export class MetadataEditorComponent implements OnInit {
.subscribe((bookState) => {
const authors = new Set();
const categories = new Set();
+ const moods = new Set();
+ const tags = new Set();
(bookState.books ?? []).forEach((book) => {
book.metadata?.authors?.forEach((author) => authors.add(author));
book.metadata?.categories?.forEach((category) =>
categories.add(category)
);
+ book.metadata?.moods?.forEach((mood) => moods.add(mood));
+ book.metadata?.tags?.forEach((tag) => tags.add(tag));
});
this.allAuthors = Array.from(authors);
this.allCategories = Array.from(categories);
+ this.allMoods = Array.from(moods);
+ this.allTags = Array.from(tags);
});
}
@@ -216,6 +221,8 @@ export class MetadataEditorComponent implements OnInit {
subtitle: metadata.subtitle ?? null,
authors: [...(metadata.authors ?? [])].sort(),
categories: [...(metadata.categories ?? [])].sort(),
+ moods: [...(metadata.moods ?? [])].sort(),
+ tags: [...(metadata.tags ?? [])].sort(),
publisher: metadata.publisher ?? null,
publishedDate: metadata.publishedDate ?? null,
isbn10: metadata.isbn10 ?? null,
@@ -244,6 +251,8 @@ export class MetadataEditorComponent implements OnInit {
subtitleLocked: metadata.subtitleLocked ?? false,
authorsLocked: metadata.authorsLocked ?? false,
categoriesLocked: metadata.categoriesLocked ?? false,
+ moodsLocked: metadata.moodsLocked ?? false,
+ tagsLocked: metadata.tagsLocked ?? false,
publisherLocked: metadata.publisherLocked ?? false,
publishedDateLocked: metadata.publishedDateLocked ?? false,
isbn10Locked: metadata.isbn10Locked ?? false,
@@ -274,6 +283,8 @@ export class MetadataEditorComponent implements OnInit {
{key: "subtitleLocked", control: "subtitle"},
{key: "authorsLocked", control: "authors"},
{key: "categoriesLocked", control: "categories"},
+ {key: "moodsLocked", control: "moods"},
+ {key: "tagsLocked", control: "tags"},
{key: "publisherLocked", control: "publisher"},
{key: "publishedDateLocked", control: "publishedDate"},
{key: "languageLocked", control: "language"},
@@ -410,6 +421,8 @@ export class MetadataEditorComponent implements OnInit {
subtitle: form.get("subtitle")?.value,
authors: form.get("authors")?.value ?? [],
categories: form.get("categories")?.value ?? [],
+ moods: form.get("moods")?.value ?? [],
+ tags: form.get("tags")?.value ?? [],
publisher: form.get("publisher")?.value,
publishedDate: form.get("publishedDate")?.value,
isbn10: form.get("isbn10")?.value,
@@ -441,6 +454,8 @@ export class MetadataEditorComponent implements OnInit {
subtitleLocked: form.get("subtitleLocked")?.value,
authorsLocked: form.get("authorsLocked")?.value,
categoriesLocked: form.get("categoriesLocked")?.value,
+ moodsLocked: form.get("moodsLocked")?.value,
+ tagsLocked: form.get("tagsLocked")?.value,
publisherLocked: form.get("publisherLocked")?.value,
publishedDateLocked: form.get("publishedDateLocked")?.value,
isbn10Locked: form.get("isbn10Locked")?.value,
@@ -487,6 +502,8 @@ export class MetadataEditorComponent implements OnInit {
subtitle: wasCleared("subtitle"),
authors: wasCleared("authors"),
categories: wasCleared("categories"),
+ moods: wasCleared("moods"),
+ tags: wasCleared("tags"),
publisher: wasCleared("publisher"),
publishedDate: wasCleared("publishedDate"),
isbn10: wasCleared("isbn10"),
@@ -602,62 +619,6 @@ export class MetadataEditorComponent implements OnInit {
});
}
- // restoreCbxMetadata() {
- // this.isLoading = true;
- // this.bookService.getComicInfoMetadata(this.currentBookId).subscribe();
- // setTimeout(() => {
- // this.isLoading = false;
- // // this.refreshingBookIds.delete(bookId);
- // }, 10000);
- // }
- restoreCbxMetadata() {
- this.isLoading = true;
- console.log("LOADING CBX METADATA FOR BOOK ID:", this.currentBookId);
- this.bookService.getComicInfoMetadata(this.currentBookId).subscribe({
- next: (metadata) => {
- console.log("Retrieved ComicInfo.xml metadata:", metadata);
-
- if (metadata) {
- this.originalMetadata = structuredClone(metadata);
- this.populateFormFromMetadata(metadata);
- this.messageService.add({
- severity: "success",
- summary: "Restored",
- detail: "Metadata loaded from ComicInfo.xml",
- });
- } else {
- this.messageService.add({
- severity: "warn",
- summary: "No Data",
- detail: "ComicInfo.xml not found or empty.",
- });
- }
- this.isLoading = false;
- },
- error: (err) => {
- console.error("Error loading ComicInfo.xml metadata:", err);
- console.error(err.message);
- this.isLoading = false;
- this.messageService.add({
- severity: "error",
- summary: "Error",
- detail: err?.error?.message || "Failed to load ComicInfo.xml",
- });
- },
- });
- }
-
- restoreMetadata() {
- this.dialogService.open(MetadataRestoreDialogComponent, {
- header: "Restore Metadata from Backup",
- modal: true,
- closable: true,
- data: {
- bookId: [this.currentBookId],
- },
- });
- }
-
autoFetch(bookId: number) {
this.refreshingBookIds.add(bookId);
this.isAutoFetching = true;
diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-picker/metadata-picker.component.ts b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-picker/metadata-picker.component.ts
index 709d11e7b..19343aa3a 100644
--- a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-picker/metadata-picker.component.ts
+++ b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-picker/metadata-picker.component.ts
@@ -49,7 +49,9 @@ export class MetadataPickerComponent implements OnInit {
metadataChips = [
{label: 'Authors', controlName: 'authors', lockedKey: 'authorsLocked', fetchedKey: 'authors'},
- {label: 'Categories', controlName: 'categories', lockedKey: 'categoriesLocked', fetchedKey: 'categories'}
+ {label: 'Categories', controlName: 'categories', lockedKey: 'categoriesLocked', fetchedKey: 'categories'},
+ {label: 'Moods', controlName: 'moods', lockedKey: 'moodsLocked', fetchedKey: 'moods'},
+ {label: 'Tags', controlName: 'tags', lockedKey: 'tagsLocked', fetchedKey: 'tags'},
];
metadataDescription = [
@@ -84,11 +86,19 @@ export class MetadataPickerComponent implements OnInit {
allAuthors!: string[];
allCategories!: string[];
+ allMoods!: string[];
+ allTags!: string[];
filteredCategories: string[] = [];
filteredAuthors: string[] = [];
+ filteredMoods: string[] = [];
+ filteredTags: string[] = [];
getFiltered(controlName: string): string[] {
- return controlName === 'authors' ? this.filteredAuthors : this.filteredCategories;
+ if (controlName === 'authors') return this.filteredAuthors;
+ if (controlName === 'categories') return this.filteredCategories;
+ if (controlName === 'moods') return this.filteredMoods;
+ if (controlName === 'tags') return this.filteredTags;
+ return [];
}
filterItems(event: { query: string }, controlName: string) {
@@ -97,6 +107,10 @@ export class MetadataPickerComponent implements OnInit {
this.filteredAuthors = this.allAuthors.filter(a => a.toLowerCase().includes(query));
} else if (controlName === 'categories') {
this.filteredCategories = this.allCategories.filter(c => c.toLowerCase().includes(query));
+ } else if (controlName === 'moods') {
+ this.filteredMoods = this.allMoods.filter(m => m.toLowerCase().includes(query));
+ } else if (controlName === 'tags') {
+ this.filteredTags = this.allTags.filter(t => t.toLowerCase().includes(query));
}
}
@@ -118,6 +132,8 @@ export class MetadataPickerComponent implements OnInit {
subtitle: new FormControl(''),
authors: new FormControl(''),
categories: new FormControl(''),
+ moods: new FormControl(''),
+ tags: new FormControl(''),
publisher: new FormControl(''),
publishedDate: new FormControl(''),
isbn10: new FormControl(''),
@@ -145,6 +161,8 @@ export class MetadataPickerComponent implements OnInit {
subtitleLocked: new FormControl(false),
authorsLocked: new FormControl(false),
categoriesLocked: new FormControl(false),
+ moodsLocked: new FormControl(false),
+ tagsLocked: new FormControl(false),
publisherLocked: new FormControl(false),
publishedDateLocked: new FormControl(false),
isbn10Locked: new FormControl(false),
@@ -181,14 +199,20 @@ export class MetadataPickerComponent implements OnInit {
.subscribe(bookState => {
const authors = new Set();
const categories = new Set();
+ const moods = new Set();
+ const tags = new Set();
(bookState.books ?? []).forEach(book => {
book.metadata?.authors?.forEach(author => authors.add(author));
book.metadata?.categories?.forEach(category => categories.add(category));
+ book.metadata?.moods?.forEach(mood => moods.add(mood));
+ book.metadata?.tags?.forEach(tag => tags.add(tag));
});
this.allAuthors = Array.from(authors);
this.allCategories = Array.from(categories);
+ this.allMoods = Array.from(moods);
+ this.allTags = Array.from(tags);
});
this.book$
@@ -214,6 +238,8 @@ export class MetadataPickerComponent implements OnInit {
subtitle: metadata.subtitle || null,
authors: [...(metadata.authors ?? [])].sort(),
categories: [...(metadata.categories ?? [])].sort(),
+ moods: [...(metadata.moods ?? [])].sort(),
+ tags: [...(metadata.tags ?? [])].sort(),
publisher: metadata.publisher || null,
publishedDate: metadata.publishedDate || null,
isbn10: metadata.isbn10 || null,
@@ -241,6 +267,8 @@ export class MetadataPickerComponent implements OnInit {
subtitleLocked: metadata.subtitleLocked || false,
authorsLocked: metadata.authorsLocked || false,
categoriesLocked: metadata.categoriesLocked || false,
+ moodsLocked: metadata.moodsLocked || false,
+ tagsLocked: metadata.tagsLocked || false,
publisherLocked: metadata.publisherLocked || false,
publishedDateLocked: metadata.publishedDateLocked || false,
isbn10Locked: metadata.isbn10Locked || false,
@@ -277,6 +305,8 @@ export class MetadataPickerComponent implements OnInit {
if (metadata.subtitleLocked) this.metadataForm.get('subtitle')?.disable({emitEvent: false});
if (metadata.authorsLocked) this.metadataForm.get('authors')?.disable({emitEvent: false});
if (metadata.categoriesLocked) this.metadataForm.get('categories')?.disable({emitEvent: false});
+ if (metadata.moodsLocked) this.metadataForm.get('moods')?.disable({emitEvent: false});
+ if (metadata.tagsLocked) this.metadataForm.get('tags')?.disable({emitEvent: false});
if (metadata.publisherLocked) this.metadataForm.get('publisher')?.disable({emitEvent: false});
if (metadata.publishedDateLocked) this.metadataForm.get('publishedDate')?.disable({emitEvent: false});
if (metadata.languageLocked) this.metadataForm.get('language')?.disable({emitEvent: false});
@@ -353,6 +383,8 @@ export class MetadataPickerComponent implements OnInit {
subtitle: this.metadataForm.get('subtitle')?.value || this.copiedFields['subtitle'] ? this.getValueOrCopied('subtitle') : '',
authors: this.metadataForm.get('authors')?.value || this.copiedFields['authors'] ? this.getArrayFromFormField('authors', this.fetchedMetadata.authors) : [],
categories: this.metadataForm.get('categories')?.value || this.copiedFields['categories'] ? this.getArrayFromFormField('categories', this.fetchedMetadata.categories) : [],
+ moods: this.metadataForm.get('moods')?.value || this.copiedFields['moods'] ? this.getArrayFromFormField('moods', this.fetchedMetadata.moods) : [],
+ tags: this.metadataForm.get('tags')?.value || this.copiedFields['tags'] ? this.getArrayFromFormField('tags', this.fetchedMetadata.tags) : [],
publisher: this.metadataForm.get('publisher')?.value || this.copiedFields['publisher'] ? this.getValueOrCopied('publisher') : '',
publishedDate: this.metadataForm.get('publishedDate')?.value || this.copiedFields['publishedDate'] ? this.getValueOrCopied('publishedDate') : '',
isbn10: this.metadataForm.get('isbn10')?.value || this.copiedFields['isbn10'] ? this.getValueOrCopied('isbn10') : '',
@@ -381,6 +413,8 @@ export class MetadataPickerComponent implements OnInit {
subtitleLocked: this.metadataForm.get('subtitleLocked')?.value,
authorsLocked: this.metadataForm.get('authorsLocked')?.value,
categoriesLocked: this.metadataForm.get('categoriesLocked')?.value,
+ moodsLocked: this.metadataForm.get('moodsLocked')?.value,
+ tagsLocked: this.metadataForm.get('tagsLocked')?.value,
publisherLocked: this.metadataForm.get('publisherLocked')?.value,
publishedDateLocked: this.metadataForm.get('publishedDateLocked')?.value,
isbn10Locked: this.metadataForm.get('isbn10Locked')?.value,
@@ -422,6 +456,8 @@ export class MetadataPickerComponent implements OnInit {
subtitle: !current.subtitle && !!original.subtitle,
authors: current.authors?.length === 0 && original.authors?.length! > 0,
categories: current.categories?.length === 0 && original.categories?.length! > 0,
+ moods: current.moods?.length === 0 && original.moods?.length! > 0,
+ tags: current.tags?.length === 0 && original.tags?.length! > 0,
publisher: !current.publisher && !!original.publisher,
publishedDate: !current.publishedDate && !!original.publishedDate,
isbn10: !current.isbn10 && !!original.isbn10,
diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html
index c9cfcc484..ee1c7e18f 100644
--- a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html
+++ b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html
@@ -216,18 +216,52 @@
-
- @if (book?.metadata?.categories?.length) {
-
@@ -121,7 +129,7 @@
fluid
class="max-w-[60rem]"
[(ngModel)]="comicvineToken"
- (ngModelChange)="onComicTokenChange($event)" />
+ (ngModelChange)="onComicTokenChange($event)"/>
diff --git a/booklore-ui/src/app/settings/global-preferences/metadata-provider-settings/metadata-provider-settings.component.ts b/booklore-ui/src/app/settings/global-preferences/metadata-provider-settings/metadata-provider-settings.component.ts
index a887307f2..788070776 100644
--- a/booklore-ui/src/app/settings/global-preferences/metadata-provider-settings/metadata-provider-settings.component.ts
+++ b/booklore-ui/src/app/settings/global-preferences/metadata-provider-settings/metadata-provider-settings.component.ts
@@ -148,4 +148,8 @@ export class MetadataProviderSettingsComponent implements OnInit {
navigateToAmazonCookieDocumentation() {
window.open('https://booklore-app.github.io/booklore-docs/docs/metadata/amazon-cookie', '_blank');
}
+
+ navigateToHardcoverTokenDocumentation() {
+ window.open('https://booklore-app.github.io/booklore-docs/docs/metadata/hardcover-token', '_blank');
+ }
}
diff --git a/booklore-ui/src/app/settings/library-metadata-settings-component/library-metadata-settings.component.html b/booklore-ui/src/app/settings/library-metadata-settings-component/library-metadata-settings.component.html
index 35a34ff30..3f62426d1 100644
--- a/booklore-ui/src/app/settings/library-metadata-settings-component/library-metadata-settings.component.html
+++ b/booklore-ui/src/app/settings/library-metadata-settings-component/library-metadata-settings.component.html
@@ -22,6 +22,7 @@
The system checks your 1st priority provider first - if that provider doesn't have the specific field (like description or author),
it automatically moves to your 2nd priority, then 3rd, and finally 4th. Leave a priority empty to skip it entirely.
For example, if Amazon (1st) has no description but Google Books (2nd) does, Google's description will be used.
+ Use the Enable checkboxes to completely disable fetching for specific fields - disabled fields will be skipped entirely regardless of provider settings.
@@ -42,6 +43,7 @@
Override the default priority settings for specific libraries. For example, you might prefer Amazon for fiction but Google Books for technical books. Each library can have its own provider priority order while falling back to defaults for unspecified fields.
+ You can also enable or disable specific metadata fields per library - useful if certain libraries don't need specific data types like descriptions or covers.
diff --git a/booklore-ui/src/app/settings/library-metadata-settings-component/library-metadata-settings.component.ts b/booklore-ui/src/app/settings/library-metadata-settings-component/library-metadata-settings.component.ts
index 9a86741c5..3a0d82484 100644
--- a/booklore-ui/src/app/settings/library-metadata-settings-component/library-metadata-settings.component.ts
+++ b/booklore-ui/src/app/settings/library-metadata-settings-component/library-metadata-settings.component.ts
@@ -184,10 +184,6 @@ export class LibraryMetadataSettingsComponent implements OnInit {
private getDefaultMetadataOptions(): MetadataRefreshOptions {
return {
libraryId: null,
- allP1: null,
- allP2: null,
- allP3: null,
- allP4: null,
refreshCovers: false,
mergeCategories: false,
reviewBeforeApply: false,
@@ -205,9 +201,22 @@ export class LibraryMetadataSettingsComponent implements OnInit {
isbn10: {p1: null, p2: null, p3: null, p4: null},
language: {p1: null, p2: null, p3: null, p4: null},
categories: {p1: null, p2: null, p3: null, p4: null},
- cover: {p1: null, p2: null, p3: null, p4: null}
+ cover: {p1: null, p2: null, p3: null, p4: null},
+ pageCount: {p1: null, p2: null, p3: null, p4: null},
+ asin: {p1: null, p2: null, p3: null, p4: null},
+ goodreadsId: {p1: null, p2: null, p3: null, p4: null},
+ comicvineId: {p1: null, p2: null, p3: null, p4: null},
+ hardcoverId: {p1: null, p2: null, p3: null, p4: null},
+ googleId: {p1: null, p2: null, p3: null, p4: null},
+ amazonRating: {p1: null, p2: null, p3: null, p4: null},
+ amazonReviewCount: {p1: null, p2: null, p3: null, p4: null},
+ goodreadsRating: {p1: null, p2: null, p3: null, p4: null},
+ goodreadsReviewCount: {p1: null, p2: null, p3: null, p4: null},
+ hardcoverRating: {p1: null, p2: null, p3: null, p4: null},
+ hardcoverReviewCount: {p1: null, p2: null, p3: null, p4: null},
+ moods: {p1: null, p2: null, p3: null, p4: null},
+ tags: {p1: null, p2: null, p3: null, p4: null}
}
};
}
}
-
diff --git a/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.html b/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.html
index 502182df8..d7de68a05 100644
--- a/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.html
+++ b/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.html
@@ -3,6 +3,12 @@
OPDS Settings (New)
+
+
Manage your OPDS credentials and control how your book collection is shared with reading apps.
diff --git a/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.scss b/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.scss
index 1cf145cba..0675ec017 100644
--- a/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.scss
+++ b/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.scss
@@ -34,6 +34,11 @@
color: var(--p-primary-color);
font-size: 1.25rem;
}
+
+ .external-link-icon {
+ color: #0ea5e9 !important;
+ font-size: 0.875rem !important;
+ }
}
.settings-description {
diff --git a/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.ts b/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.ts
index d0b2e5f2f..29af37e58 100644
--- a/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.ts
+++ b/booklore-ui/src/app/settings/opds-settings-v2/opds-settings-v2.ts
@@ -203,6 +203,10 @@ export class OpdsSettingsV2 implements OnInit, OnDestroy {
this.messageService.add({severity, summary, detail});
}
+ navigateToOpdsDoc(): void {
+ window.open('https://booklore-app.github.io/booklore-docs/docs/integration/opds', '_blank');
+ }
+
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
diff --git a/booklore-ui/src/app/settings/reader-preferences/cbx-reader-preferences-component/cbx-reader-preferences-component.scss b/booklore-ui/src/app/settings/reader-preferences/cbx-reader-preferences-component/cbx-reader-preferences-component.scss
index 8e6450366..2e740666f 100644
--- a/booklore-ui/src/app/settings/reader-preferences/cbx-reader-preferences-component/cbx-reader-preferences-component.scss
+++ b/booklore-ui/src/app/settings/reader-preferences/cbx-reader-preferences-component/cbx-reader-preferences-component.scss
@@ -43,7 +43,7 @@
.setting-label {
margin-bottom: 0;
flex-shrink: 0;
- min-width: 120px;
+ min-width: 100px;
}
.radio-group {
diff --git a/booklore-ui/src/app/settings/reader-preferences/cbx-reader-preferences-component/cbx-reader-preferences-component.ts b/booklore-ui/src/app/settings/reader-preferences/cbx-reader-preferences-component/cbx-reader-preferences-component.ts
index 8ab7436bb..0b0bf286b 100644
--- a/booklore-ui/src/app/settings/reader-preferences/cbx-reader-preferences-component/cbx-reader-preferences-component.ts
+++ b/booklore-ui/src/app/settings/reader-preferences/cbx-reader-preferences-component/cbx-reader-preferences-component.ts
@@ -21,18 +21,19 @@ export class CbxReaderPreferencesComponent {
private readonly readerPreferencesService = inject(ReaderPreferencesService);
readonly cbxSpreads = [
- { name: 'Even', key: 'EVEN' },
- { name: 'Odd', key: 'ODD' }
+ {name: 'Even', key: 'EVEN'},
+ {name: 'Odd', key: 'ODD'}
];
readonly cbxViewModes = [
- { name: 'Single Page', key: 'SINGLE_PAGE' },
- { name: 'Two Page', key: 'TWO_PAGE' },
+ {name: 'Single Page', key: 'SINGLE_PAGE'},
+ {name: 'Two Page', key: 'TWO_PAGE'},
];
get selectedCbxSpread(): CbxPageSpread {
return this.userSettings.cbxReaderSetting.pageSpread;
}
+
set selectedCbxSpread(value: CbxPageSpread) {
this.userSettings.cbxReaderSetting.pageSpread = value;
this.readerPreferencesService.updatePreference(['cbxReaderSetting', 'pageSpread'], value);
@@ -41,6 +42,7 @@ export class CbxReaderPreferencesComponent {
get selectedCbxViewMode(): CbxPageViewMode {
return this.userSettings.cbxReaderSetting.pageViewMode;
}
+
set selectedCbxViewMode(value: CbxPageViewMode) {
this.userSettings.cbxReaderSetting.pageViewMode = value;
this.readerPreferencesService.updatePreference(['cbxReaderSetting', 'pageViewMode'], value);
diff --git a/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.html b/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.html
index 48c6cf11f..aff22c9ff 100644
--- a/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.html
+++ b/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.html
@@ -1,77 +1,115 @@
-
-
-
-
-
- Choose the visual theme for EPUB reading experience.
-
-
-
-
-
-
-
-
- Select the font family for text display.
-
-
-
-
-
-
-
-
- Configure text flow and reading direction.
-
-
-
-
-
-
-
-
Font Size
-
-
-
{{ fontSize }}%
-
+
+
+
+
+
+
Theme
+
+ @for (theme of themes; track theme) {
+
+ }
+
+
+ Choose the visual theme for EPUB reading experience.
+
+
+
+
+
+
+
+
Font
+
+ @for (font of fonts; track font) {
+
+ }
+
+
+
+ Select the font family for text display.
+
+
+
+
+
+
+
+
Flow
+
+ @for (flow of flowOptions; track flow) {
+
+ }
+
+
+
+ Configure text flow and reading direction.
+
+
+
+
+
+
+
+
Page Spread
+
+ @for (spread of spreadOptions; track spread) {
+
+
+
+
{{ spread.name }}
+
+ }
+
+
+
+ Choose between single page or double page spread view.
+
+
+
+
+
+
+
+
+ Adjust the text size for comfortable reading.
+
-
- Adjust the text size for comfortable reading.
-
diff --git a/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.scss b/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.scss
index a014fbbed..bd27e4209 100644
--- a/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.scss
+++ b/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.scss
@@ -1,7 +1,7 @@
.epub-preferences-container {
display: flex;
flex-direction: column;
- gap: 1rem;
+ gap: 1.5rem;
}
.setting-item {
@@ -46,26 +46,13 @@
min-width: 100px;
}
- p-select {
- flex: 1;
- min-width: 200px;
- max-width: 300px;
+ .radio-group {
+ display: flex;
+ gap: 1.5rem;
@media (max-width: 768px) {
- min-width: 180px;
- }
- }
-
- .font-size-controls {
- display: flex;
- align-items: center;
- gap: 0.75rem;
-
- .font-size-value {
- min-width: 3rem;
- text-align: center;
- font-weight: 500;
- color: var(--p-text-color);
+ flex-direction: column;
+ gap: 0.75rem;
}
}
}
@@ -77,3 +64,38 @@
margin: 0;
}
}
+
+.radio-group {
+ display: flex;
+ gap: 1.5rem;
+
+ @media (max-width: 768px) {
+ flex-direction: column;
+ gap: 0.75rem;
+ }
+}
+
+.radio-option {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+
+ label {
+ font-size: 0.875rem;
+ color: var(--p-text-color);
+ cursor: pointer;
+ }
+}
+
+.font-size-controls {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+
+ .font-size-value {
+ min-width: 3rem;
+ text-align: center;
+ font-weight: 500;
+ color: var(--p-text-color);
+ }
+}
diff --git a/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.ts b/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.ts
index f68ca3e08..10fe02518 100644
--- a/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.ts
+++ b/booklore-ui/src/app/settings/reader-preferences/epub-reader-preferences-component/epub-reader-preferences-component.ts
@@ -1,6 +1,6 @@
import {Component, inject, Input} from '@angular/core';
import {Button} from 'primeng/button';
-import {Select} from 'primeng/select';
+import {RadioButton} from 'primeng/radiobutton';
import {FormsModule} from '@angular/forms';
import {ReaderPreferencesService} from '../reader-preferences-service';
import {UserSettings} from '../../user-management/user.service';
@@ -9,7 +9,7 @@ import {UserSettings} from '../../user-management/user.service';
selector: 'app-epub-reader-preferences-component',
imports: [
Button,
- Select,
+ RadioButton,
FormsModule
],
templateUrl: './epub-reader-preferences-component.html',
@@ -31,13 +31,16 @@ export class EpubReaderPreferencesComponent {
];
readonly flowOptions = [
- {name: 'Book Default', key: null},
{name: 'Paginated', key: 'paginated'},
{name: 'Scrolled', key: 'scrolled'}
];
+ readonly spreadOptions = [
+ {name: 'Single Page', key: 'single'},
+ {name: 'Double Page', key: 'double'}
+ ];
+
readonly themes = [
- {name: 'Book Default', key: null},
{name: 'White', key: 'white'},
{name: 'Black', key: 'black'},
{name: 'Grey', key: 'grey'},
@@ -77,6 +80,17 @@ export class EpubReaderPreferencesComponent {
this.readerPreferencesService.updatePreference(['epubReaderSetting', 'flow'], value);
}
+ get selectedSpread(): string | null {
+ return this.userSettings.epubReaderSetting.spread;
+ }
+
+ set selectedSpread(value: string | null) {
+ if (typeof value === "string") {
+ this.userSettings.epubReaderSetting.spread = value;
+ }
+ this.readerPreferencesService.updatePreference(['epubReaderSetting', 'spread'], value);
+ }
+
get fontSize(): number {
return this.userSettings.epubReaderSetting.fontSize;
}
diff --git a/booklore-ui/src/app/settings/reader-preferences/pdf-reader-preferences-component/pdf-reader-preferences-component.scss b/booklore-ui/src/app/settings/reader-preferences/pdf-reader-preferences-component/pdf-reader-preferences-component.scss
index 88afb754f..212d8bd35 100644
--- a/booklore-ui/src/app/settings/reader-preferences/pdf-reader-preferences-component/pdf-reader-preferences-component.scss
+++ b/booklore-ui/src/app/settings/reader-preferences/pdf-reader-preferences-component/pdf-reader-preferences-component.scss
@@ -43,7 +43,7 @@
.setting-label {
margin-bottom: 0;
flex-shrink: 0;
- min-width: 120px;
+ min-width: 100px;
}
.radio-group {
diff --git a/booklore-ui/src/app/settings/user-management/user.service.ts b/booklore-ui/src/app/settings/user-management/user.service.ts
index e3c276f2b..05d0f7ea1 100644
--- a/booklore-ui/src/app/settings/user-management/user.service.ts
+++ b/booklore-ui/src/app/settings/user-management/user.service.ts
@@ -55,6 +55,7 @@ export interface EpubReaderSetting {
font: string;
fontSize: number;
flow: string;
+ spread: string;
lineHeight: number;
margin: number;
letterSpacing: number;
diff --git a/booklore-ui/src/app/shared/components/tag/tag.component.scss b/booklore-ui/src/app/shared/components/tag/tag.component.scss
new file mode 100644
index 000000000..01be5abc3
--- /dev/null
+++ b/booklore-ui/src/app/shared/components/tag/tag.component.scss
@@ -0,0 +1,190 @@
+.app-tag {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.375rem;
+ padding: 0.25rem 0.5rem;
+ font-size: 0.875rem;
+ font-weight: 700;
+ line-height: 1.25rem;
+ border-radius: 0.375rem;
+ white-space: nowrap;
+
+ &-5xs {
+ padding: 0.125rem 0.375rem;
+ font-size: 0.625rem;
+ line-height: 0.875rem;
+ gap: 0.25rem;
+ }
+
+ &-4xs {
+ padding: 0.15625rem 0.40625rem;
+ font-size: 0.6875rem;
+ line-height: 0.9375rem;
+ gap: 0.28125rem;
+ }
+
+ &-3xs {
+ padding: 0.1875rem 0.4375rem;
+ font-size: 0.75rem;
+ line-height: 1rem;
+ gap: 0.3125rem;
+ }
+
+ &-2xs {
+ padding: 0.203125rem 0.453125rem;
+ font-size: 0.78125rem;
+ line-height: 1.0625rem;
+ gap: 0.328125rem;
+ }
+
+ &-xs {
+ padding: 0.21875rem 0.46875rem;
+ font-size: 0.8125rem;
+ line-height: 1.125rem;
+ gap: 0.34375rem;
+ }
+
+ &-s {
+ padding: 0.234375rem 0.484375rem;
+ font-size: 0.84375rem;
+ line-height: 1.1875rem;
+ gap: 0.359375rem;
+ }
+
+ &-m {
+ padding: 0.25rem 0.5rem;
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+ gap: 0.375rem;
+ }
+
+ &-l {
+ padding: 0.265625rem 0.515625rem;
+ font-size: 0.90625rem;
+ line-height: 1.3125rem;
+ gap: 0.390625rem;
+ }
+
+ &-xl {
+ padding: 0.28125rem 0.53125rem;
+ font-size: 0.9375rem;
+ line-height: 1.375rem;
+ gap: 0.40625rem;
+ }
+
+ &-2xl {
+ padding: 0.296875rem 0.546875rem;
+ font-size: 0.96875rem;
+ line-height: 1.4375rem;
+ gap: 0.421875rem;
+ }
+
+ &-3xl {
+ padding: 0.3125rem 0.5625rem;
+ font-size: 1rem;
+ line-height: 1.5rem;
+ gap: 0.4375rem;
+ }
+
+ &-4xl {
+ padding: 0.34375rem 0.59375rem;
+ font-size: 1.0625rem;
+ line-height: 1.5625rem;
+ gap: 0.46875rem;
+ }
+
+ &-5xl {
+ padding: 0.375rem 0.625rem;
+ font-size: 1.125rem;
+ line-height: 1.625rem;
+ gap: 0.5rem;
+ }
+
+ &-rounded {
+ border-radius: 0.5rem;
+ }
+
+ &-pill {
+ border-radius: 9999px;
+ }
+
+ &-primary {
+ background-color: color-mix(in srgb, var(--p-primary-500), transparent 84%);
+ color: var(--p-primary-200);
+ }
+
+ $colors: (
+ 'secondary': 'slate',
+ 'success': 'green',
+ 'info': 'sky',
+ 'warning': 'orange',
+ 'danger': 'red',
+ 'blue': 'blue',
+ 'indigo': 'indigo',
+ 'purple': 'purple',
+ 'pink': 'pink',
+ 'red': 'red',
+ 'orange': 'orange',
+ 'yellow': 'yellow',
+ 'green': 'green',
+ 'teal': 'teal',
+ 'cyan': 'cyan',
+ 'gray': 'gray',
+ 'slate': 'slate',
+ 'zinc': 'zinc',
+ 'neutral': 'neutral',
+ 'stone': 'stone',
+ 'amber': 'amber',
+ 'lime': 'lime',
+ 'emerald': 'emerald',
+ 'sky': 'sky',
+ 'violet': 'violet',
+ 'fuchsia': 'fuchsia',
+ 'rose': 'rose'
+ );
+
+ @each $name, $color in $colors {
+ &-#{$name} {
+ background-color: color-mix(in srgb, var(--p-#{$color}-500), transparent 84%);
+ color: var(--p-#{$color}-200);
+ }
+ }
+
+ &-dark {
+ background-color: color-mix(in srgb, var(--p-gray-800), transparent 84%);
+ color: var(--p-gray-300);
+ }
+
+ &-light {
+ background-color: color-mix(in srgb, var(--p-gray-300), transparent 84%);
+ color: var(--p-gray-600);
+ }
+
+ &-variant-pill {
+ border-radius: 9999px;
+ padding: 0.125rem 0.375rem;
+
+ &.app-tag-primary {
+ background-color: var(--p-primary-600);
+ color: var(--p-primary-100);
+ }
+
+ @each $name, $color in $colors {
+ &.app-tag-#{$name} {
+ background-color: var(--p-#{$color}-600);
+ color: var(--p-#{$color}-100);
+ }
+ }
+
+ &.app-tag-dark {
+ background-color: var(--p-gray-800);
+ color: var(--p-gray-100);
+ }
+
+ &.app-tag-light {
+ background-color: var(--p-gray-300);
+ color: var(--p-gray-800);
+ }
+ }
+}
diff --git a/booklore-ui/src/app/shared/components/tag/tag.component.ts b/booklore-ui/src/app/shared/components/tag/tag.component.ts
new file mode 100644
index 000000000..16380e7a6
--- /dev/null
+++ b/booklore-ui/src/app/shared/components/tag/tag.component.ts
@@ -0,0 +1,44 @@
+import {Component, input, computed} from '@angular/core';
+
+export type TagColor =
+ | 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'danger'
+ | 'blue' | 'indigo' | 'purple' | 'pink' | 'red' | 'orange' | 'yellow'
+ | 'green' | 'teal' | 'cyan' | 'gray' | 'slate' | 'zinc' | 'neutral'
+ | 'stone' | 'amber' | 'lime' | 'emerald' | 'sky' | 'violet' | 'fuchsia'
+ | 'rose' | 'dark' | 'light';
+
+export type TagSize = '5xs' | '4xs' | '3xs' | '2xs' | 'xs' | 's' | 'm' | 'l' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl';
+
+export type TagVariant = 'label' | 'pill';
+
+@Component({
+ selector: 'app-tag',
+ standalone: true,
+ template: `
+
+
+
+ `,
+ styleUrls: ['./tag.component.scss']
+})
+export class TagComponent {
+ color = input
('primary');
+ size = input('m');
+ variant = input('label');
+ rounded = input(false);
+ pill = input(false);
+ customBgColor = input();
+ customTextColor = input();
+
+ protected tagClasses = computed(() => {
+ const classes = ['app-tag', `app-tag-${this.color()}`, `app-tag-${this.size()}`];
+ if (this.variant() === 'pill') classes.push('app-tag-variant-pill');
+ if (this.rounded()) classes.push('app-tag-rounded');
+ if (this.pill()) classes.push('app-tag-pill');
+ return classes.join(' ');
+ });
+}