Merge pull request #1280 from booklore-app/develop

Merge develop into the master for release
This commit is contained in:
Aditya Chandel
2025-10-06 14:33:47 -06:00
committed by GitHub
88 changed files with 2160 additions and 1316 deletions

View File

@@ -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

View File

@@ -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 {

Binary file not shown.

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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<Resource> 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);

View File

@@ -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<BookMetadata> 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<BookMetadata> restoreMetadata(@PathVariable Long bookId) throws IOException {
BookMetadata restoredMetadata = bookMetadataService.restoreMetadataFromBackup(bookId);
return ResponseEntity.ok(restoredMetadata);
}
@PostMapping("/{bookId}/metadata/covers")
public ResponseEntity<List<CoverImage>> getImages(@RequestBody CoverFetchRequest request) {
return ResponseEntity.ok(duckDuckGoCoverService.getCovers(request));

View File

@@ -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<String> mapMoods(Set<MoodEntity> moods) {
if (moods == null) return null;
return moods.stream()
.map(MoodEntity::getName)
.collect(Collectors.toSet());
}
default Set<String> mapTags(Set<TagEntity> 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;

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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<String> toMoodNamesList(List<MoodEntity> moodEntities) {
if (moodEntities == null || moodEntities.isEmpty()) {
return List.of();
}
return moodEntities.stream()
.map(this::toMoodName)
.toList();
}
}

View File

@@ -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<String> toTagNamesList(List<TagEntity> tagEntities) {
if (tagEntities == null || tagEntities.isEmpty()) {
return List.of();
}
return tagEntities.stream()
.map(this::toTagName)
.toList();
}
}

View File

@@ -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<String> mapMoods(Set<MoodEntity> moods) {
return moods == null ? Set.of() :
moods.stream().map(MoodEntity::getName).collect(Collectors.toSet());
}
@Named("mapTags")
default Set<String> mapTags(Set<TagEntity> 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;

View File

@@ -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;
}

View File

@@ -113,6 +113,7 @@ public class BookLoreUser {
private Float letterSpacing;
private Float lineHeight;
private String flow;
private String spread;
}
@Data

View File

@@ -48,6 +48,8 @@ public class BookMetadata {
private Instant coverUpdatedOn;
private Set<String> authors;
private Set<String> categories;
private Set<String> moods;
private Set<String> tags;
private MetadataProvider provider;
private String thumbnailUrl;
private List<BookReview> 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;
}

View File

@@ -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;

View File

@@ -29,4 +29,10 @@ public class BulkMetadataUpdateRequest {
private Set<String> genres;
private boolean clearGenres;
private Set<String> moods;
private boolean clearMoods;
private Set<String> tags;
private boolean clearTags;
}

View File

@@ -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;
}
}

View File

@@ -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<CategoryEntity> 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<MoodEntity> 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<TagEntity> tags;
@OneToMany(mappedBy = "bookMetadata", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@Fetch(FetchMode.SUBSELECT)
private Set<BookReviewEntity> 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)

View File

@@ -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);
}

View File

@@ -41,4 +41,7 @@ public class EpubViewerPreferencesEntity {
@Column(name = "flow")
private String flow;
@Column(name = "spread")
private String spread;
}

View File

@@ -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<BookMetadataEntity> 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;
}
}

View File

@@ -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<BookMetadataEntity> 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;
}
}

View File

@@ -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<CategoryEntity, Long> {
Optional<CategoryEntity> findByName(String categoryName);
List<CategoryEntity> findAllByIdIn(Set<Long> ids);
}

View File

@@ -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<MoodEntity, Long> {
Optional<MoodEntity> findByName(String moodName);
}

View File

@@ -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<TagEntity, Long> {
Optional<TagEntity> findByName(String tagName);
}

View File

@@ -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);

View File

@@ -38,12 +38,12 @@ public class SettingPersistenceHelper {
public <T> T getJsonSetting(Map<String, String> settingsMap, AppSettingKey key, Class<T> clazz, T defaultValue, boolean persistDefault) {
return getJsonSettingInternal(settingsMap, key, defaultValue, persistDefault,
json -> objectMapper.readValue(json, clazz));
json -> objectMapper.readValue(json, clazz));
}
public <T> T getJsonSetting(Map<String, String> settingsMap, AppSettingKey key, TypeReference<T> typeReference, T defaultValue, boolean persistDefault) {
return getJsonSettingInternal(settingsMap, key, defaultValue, persistDefault,
json -> objectMapper.readValue(json, typeReference));
json -> objectMapper.readValue(json, typeReference));
}
private <T> T getJsonSettingInternal(Map<String, String> settingsMap, AppSettingKey key, T defaultValue, boolean persistDefault, JsonDeserializer<T> 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() {

View File

@@ -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<MetadataProvider, BookParser> 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<BookMetadata> bulkUpdateMetadata(BulkMetadataUpdateRequest request, boolean mergeCategories) {
List<BookEntity> 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;
}
}
}

View File

@@ -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<MoodEntity> 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<MoodEntity> existing = e.getMoods();
existing.clear();
Set<MoodEntity> 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<TagEntity> 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<TagEntity> existing = e.getTags();
existing.clear();
Set<TagEntity> 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 -> {

View File

@@ -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<BookMetadata> createInterruptibleMetadataFuture(java.util.function.Supplier<BookMetadata> metadataSupplier) {
private CompletableFuture<BookMetadata> createInterruptibleMetadataFuture(Supplier<BookMetadata> 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<MetadataProvider, BookMetadata> 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<BookReview> 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<MetadataProvider, BookMetadata> 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> T resolveField(Map<MetadataProvider, BookMetadata> metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, Function<BookMetadata, T> extractor) {
return resolveFieldWithProviders(metadataMap, fieldProvider, extractor, (value) -> value != null);
}
@FunctionalInterface
public interface FieldValueExtractor {
String extract(BookMetadata metadata);
protected Integer resolveFieldAsInteger(Map<MetadataProvider, BookMetadata> metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, Function<BookMetadata, Integer> fieldValueExtractor) {
return resolveFieldWithProviders(metadataMap, fieldProvider, fieldValueExtractor, (value) -> value != null);
}
@FunctionalInterface
public interface FieldValueExtractorList {
Set<String> extract(BookMetadata metadata);
}
protected String resolveFieldAsString(Map<MetadataProvider, BookMetadata> 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<String> resolveFieldAsList(Map<MetadataProvider, BookMetadata> metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractorList fieldValueExtractor) {
Set<String> values = new HashSet<>();
if (fieldProvider.getP4() != null && metadataMap.containsKey(fieldProvider.getP4())) {
Set<String> 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> T resolveFieldWithProviders(Map<MetadataProvider, BookMetadata> metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, Function<BookMetadata, T> extractor, Predicate<T> isValidValue) {
if (fieldProvider == null) {
return null;
}
if (fieldProvider.getP3() != null && metadataMap.containsKey(fieldProvider.getP3())) {
Set<String> 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<String> 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<String> newValues = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP1()));
if (newValues != null && !newValues.isEmpty()) values = newValues;
}
return values;
return null;
}
Set<String> getAllCategories(Map<MetadataProvider, BookMetadata> metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractorList fieldValueExtractor) {
Set<String> uniqueCategories = new HashSet<>();
if (fieldProvider.getP4() != null && metadataMap.containsKey(fieldProvider.getP4())) {
Set<String> 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<String> extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP3()));
if (extracted != null) uniqueCategories.addAll(extracted);
}
if (fieldProvider.getP2() != null && metadataMap.containsKey(fieldProvider.getP2())) {
Set<String> extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP2()));
if (extracted != null) uniqueCategories.addAll(extracted);
}
if (fieldProvider.getP1() != null && metadataMap.containsKey(fieldProvider.getP1())) {
Set<String> 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<String> extracted = fieldValueExtractor.extract(metadataMap.get(provider));
if (extracted != null) {
uniqueCategories.addAll(extracted);
}
}
}
return uniqueCategories;
}
protected Set<Long> getBookEntities(MetadataRefreshRequest request) {
MetadataRefreshRequest.RefreshType refreshType = request.getRefreshType();
@@ -531,3 +597,4 @@ public class MetadataRefreshService {
};
}
}

View File

@@ -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);
}
}

View File

@@ -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<AuthorEntity> 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<CategoryEntity> 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;
}
}

View File

@@ -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());
}
}

View File

@@ -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();
}

View File

@@ -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<BookFileType, MetadataBackupRestore> serviceMap;
public MetadataBackupRestoreFactory(List<MetadataBackupRestore> 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<String> actualAuthorTokens = hit.getDocument().getAuthorNames().stream()
if (doc.getAuthorNames() == null || doc.getAuthorNames().isEmpty()) return false;
List<String> actualAuthorTokens = doc.getAuthorNames().stream()
.flatMap(name -> List.of(name.toLowerCase().split("\\s+")).stream())
.toList();
List<String> 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();

View File

@@ -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<String, Object> variables;
}

View File

@@ -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<Object> facetCounts;
private Integer found;
private List<Hit> hits;
@JsonProperty("out_of")
private Integer outOf;
private Integer page;
@JsonProperty("request_params")
private Map<String, Object> 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<String, Object> highlight;
private List<Map<String, Object>> highlights;
@JsonProperty("text_match")
private Long textMatch;
@JsonProperty("text_match_info")
private Map<String, Object> 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<String> 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<String> genres;
@JsonProperty("release_year")
private Integer releaseYear;
private List<String> genres;
private List<String> moods;
private List<String> tags;
@JsonProperty("featured_series")
private FeaturedSeries featuredSeries;
private Image image;
@JsonProperty("alternative_titles")
private List<String> alternativeTitles;
@JsonProperty("activities_count")
private Integer activitiesCount;
private Boolean compilation;
@JsonProperty("content_warnings")
private List<String> contentWarnings;
@JsonProperty("contribution_types")
private List<String> contributionTypes;
private List<Map<String, Object>> 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<String> 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;
}
}

View File

@@ -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()

View File

@@ -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<Path> 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<Path> images;
try (Stream<Path> 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<FileHeader> 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;

View File

@@ -69,6 +69,7 @@ public class DefaultUserSettingsProvider {
.letterSpacing(null)
.lineHeight(null)
.flow("paginated")
.spread("double")
.build();
}

View File

@@ -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;
}

View File

@@ -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
);

View File

@@ -0,0 +1,2 @@
ALTER TABLE epub_viewer_preference
ADD COLUMN IF NOT EXISTS spread VARCHAR(20) DEFAULT 'double';

View File

@@ -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;

View File

@@ -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<MetadataProvider, BookMetadata> metadataMap = new HashMap<>();
metadataMap.put(MetadataProvider.GoodReads, BookMetadata.builder()

View File

@@ -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.

View File

@@ -117,26 +117,6 @@
{{ coverScalePreferenceService.scaleFactor.toFixed(2) }}x
</div>
</div>
<!--<div class="flex flex-col gap-2">
<label class="font-medium text-sm">Show Sidebar Filters:</label>
<div class="flex gap-4 justify-start">
<p-toggle-switch
[(ngModel)]="sidebarFilterTogglePrefService.selectedShowFilter"
(onChange)="sidebarFilterTogglePrefService.selectedShowFilter = $event.checked">
</p-toggle-switch>
</div>
</div>-->
<div class="flex flex-col gap-2">
<label class="font-medium text-sm">Sidebar Filter Sort:</label>
<div class="flex gap-4 justify-start">
<p-select
size="small"
[options]="filterSortPreferenceService.filterSortingOptions"
[(ngModel)]="filterSortPreferenceService.selectedFilterSorting"
placeholder="Select Sorting">
</p-select>
</div>
</div>
</div>
</p-popover>
</div>

View File

@@ -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;

View File

@@ -41,7 +41,11 @@
(click)="handleFilterClick(filterType, filter.value?.id || filter.value)">
{{ filter.value.name || filter.value }}
<p-badge class="filter-value-badge" [value]="filter.bookCount"></p-badge>
<!-- <p class="text-[var(--primary-color)]"> {{ filter.bookCount }}</p> -->
</div>
}
@if (truncatedFilters[filterType]) {
<div class="text-xs text-gray-500 text-center pt-2 border-t border-gray-200">
Showing first 250 items
</div>
}
</div>
@@ -50,5 +54,9 @@
</p-accordion-panel>
}
</p-accordion>
<div class="text-xs text-gray-500 text-center pt-4 px-4 mt-2">
Note: Top 500 items are displayed per filter category
</div>
</div>
</div>

View File

@@ -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<string, any> = {};
filterStreams: Record<string, Observable<Filter<any>[]>> = {};
truncatedFilters: Record<string, boolean> = {};
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})
);

View File

@@ -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)

View File

@@ -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 (AZ)', 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
});
}
}

View File

@@ -109,6 +109,36 @@
</div>
</div>
@if (selectedFlow === 'paginated' && !isMobileDevice()) {
<p-divider></p-divider>
<div class="flex items-center gap-2">
<label id="spread-label" class="block font-semibold text-gray-200">Page Spead:</label>
<div class="flex gap-4" role="radiogroup" aria-labelledby="spread-label">
<div class="flex items-center gap-1.5">
<p-radioButton
name="spread"
[value]="'single'"
[(ngModel)]="selectedSpread"
(onClick)="changeSpreadMode()"
inputId="spread-single">
</p-radioButton>
<label for="spread-single" class="cursor-pointer select-none text-gray-200">Single</label>
</div>
<div class="flex items-center gap-1.5">
<p-radioButton
name="spread"
[value]="'double'"
[(ngModel)]="selectedSpread"
(onClick)="changeSpreadMode()"
inputId="spread-auto">
</p-radioButton>
<label for="spread-auto" class="cursor-pointer select-none text-gray-200">Double</label>
</div>
</div>
</div>
}
<p-divider></p-divider>
<div>

View File

@@ -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;
}

View File

@@ -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;
});
}

View File

@@ -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 {

View File

@@ -176,6 +176,61 @@
</div>
</div>
<div class="flex flex-col md:flex-row w-full gap-4 mt-2 pb-1">
<div class="flex flex-col gap-1 w-full">
<label class="text-sm" for="moods">Moods</label>
<div class="flex justify-between items-center gap-2">
<div class="w-full">
<p-autoComplete
class="w-full"
formControlName="moods"
[multiple]="true"
[dropdown]="false"
[suggestions]="filteredMoods"
[forceSelection]="false"
[showClear]="true"
(completeMethod)="filterMoods($event)"
(onKeyUp)="onAutoCompleteKeyUp('moods', $event)"
(onSelect)="onAutoCompleteSelect('moods', $event)">
</p-autoComplete>
</div>
@if (!book.metadata!['moodsLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('moods')" severity="success"></p-button>
}
@if (book.metadata!['moodsLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('moods')" severity="warn"></p-button>
}
</div>
</div>
</div>
<div class="flex flex-col md:flex-row w-full gap-4 mt-2 pb-1">
<div class="flex flex-col gap-1 w-full">
<label class="text-sm" for="tags">Tags</label>
<div class="flex justify-between items-center gap-2">
<div class="w-full">
<p-autoComplete
class="w-full"
formControlName="tags"
[multiple]="true"
[dropdown]="false"
[suggestions]="filteredTags"
[forceSelection]="false"
[showClear]="true"
(completeMethod)="filterTags($event)"
(onKeyUp)="onAutoCompleteKeyUp('tags', $event)"
(onSelect)="onAutoCompleteSelect('tags', $event)">
</p-autoComplete>
</div>
@if (!book.metadata!['tagsLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('tags')" severity="success"></p-button>
}
@if (book.metadata!['tagsLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('tags')" severity="warn"></p-button>
}
</div>
</div>
</div>
<div class="flex flex-col md:flex-row w-full gap-4 mt-2 pb-1">
<div class="flex flex-col gap-1 md:basis-[75%]">
<label class="text-sm" for="seriesName">Series Name</label>

View File

@@ -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<string>();
const categories = new Set<string>();
const moods = new Set<string>();
const tags = new Set<string>();
(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;

View File

@@ -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<string>();
const categories = new Set<string>();
const moods = new Set<string>();
const tags = new Set<string>();
(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,

View File

@@ -216,18 +216,52 @@
</div>
</div>
@if (book?.metadata?.categories?.length) {
<div class="overflow-x-auto scrollbar-hide max-w-7xl pt-2 md:pt-0">
<div class="flex gap-2 w-max max-w-[1150px]">
@for (category of book.metadata!.categories; track category) {
<a (click)="goToCategory(category)" class="shrink-0 no-underline cursor-pointer">
<p-tag [value]="category"></p-tag>
</a>
}
<div class="flex flex-col space-y-2">
@if (book?.metadata?.categories?.length) {
<div class="flex items-center gap-1">
<p class="font-bold text-sm w-[50px]">Genres:</p>
<div class="overflow-x-auto scrollbar-hide max-w-7xl">
<div class="flex gap-2 w-max max-w-[1150px]">
@for (category of book.metadata!.categories; track category) {
<a (click)="goToCategory(category)" class="shrink-0 no-underline cursor-pointer">
<app-tag color="rose" size="xs">{{ category }}</app-tag>
</a>
}
</div>
</div>
</div>
</div>
}
}
@if (book?.metadata?.moods?.length) {
<div class="flex items-center gap-1">
<p class="font-bold text-sm w-[50px]">Moods:</p>
<div class="overflow-x-auto scrollbar-hide max-w-7xl">
<div class="flex gap-2 w-max max-w-[1150px]">
@for (mood of book.metadata!.moods; track mood) {
<a (click)="goToMood(mood)" class="shrink-0 no-underline cursor-pointer">
<app-tag color="indigo" size="xs">{{ mood }}</app-tag>
</a>
}
</div>
</div>
</div>
}
@if (book?.metadata?.tags?.length) {
<div class="flex items-center gap-1">
<p class="font-bold text-sm w-[50px]">Tags:</p>
<div class="overflow-x-auto scrollbar-hide max-w-7xl">
<div class="flex gap-2 w-max max-w-[1150px]">
@for (tag of book.metadata!.tags; track tag) {
<a (click)="goToTag(tag)" class="shrink-0 no-underline cursor-pointer">
<app-tag color="cyan" size="xs">{{ tag }}</app-tag>
</a>
}
</div>
</div>
</div>
}
</div>
<div class="px-1 md:px-0">
<div class="grid md:grid-cols-4 gap-y-2.5 text-sm pt-2 md:pt-4 pb-2 text-gray-300 md:min-w-[60rem] md:max-w-[100rem]">
@@ -251,20 +285,16 @@
<p><strong>Language:</strong> {{ book?.metadata!.language || '-' }}</p>
<p class="whitespace-nowrap flex items-center gap-2">
<span class="font-bold">File Type:</span>
<span
class="inline-block px-2 py-0.5 rounded-lg text-xs font-bold text-gray-200"
[ngClass]="getFileTypeColorClass(getFileExtension(book?.filePath))">
{{ getFileExtension(book?.filePath) || '-' }}
</span>
<app-tag size="3xs" variant="pill" [color]="getFileTypeColor(getFileExtension(book?.filePath))">
{{ (getFileExtension(book?.filePath) | uppercase) || '-' }}
</app-tag>
</p>
<p class="whitespace-nowrap flex items-center">
<span class="font-bold mr-2">BookLore Progress:</span>
<span class="inline-flex items-center">
<span
class="inline-block px-2 py-0.5 rounded-full text-xs font-bold text-white"
[ngClass]="getProgressColorClass(getProgressPercent(book))">
<app-tag size="3xs" variant="pill" [color]="getProgressColor(getProgressPercent(book))">
{{ getProgressPercent(book) !== null ? getProgressPercent(book) + '%' : 'N/A' }}
</span>
</app-tag>
@if (getProgressPercent(book) !== null) {
<p-button
pTooltip="Reset progress"
@@ -282,11 +312,9 @@
<p class="whitespace-nowrap flex items-center gap-2">
<span class="font-bold">Metadata Match:</span>
@if (book?.metadataMatchScore != null) {
<span
class="inline-block px-2 py-0.5 rounded-lg text-xs font-bold text-gray-200 border"
[ngClass]="getMatchScoreColorClass(book?.metadataMatchScore!)">
<app-tag size="3xs" variant="pill" [color]="getMatchScoreColor(book?.metadataMatchScore!)">
{{ (book?.metadataMatchScore!) | number:'1.0-0' }}%
</span>
</app-tag>
} @else {
<span>-</span>
}
@@ -305,23 +333,22 @@
</p>
<p class="whitespace-nowrap flex items-center gap-2">
<span class="font-bold">Read Status:</span>
<span
class="inline-block px-2 py-0.5 rounded-full text-xs font-bold text-white cursor-pointer"
[ngClass]="getStatusSeverityClass(selectedReadStatus)"
(click)="menu.toggle($event)">
<app-tag size="3xs" variant="pill" [color]="getStatusColor(selectedReadStatus)">
{{ getStatusLabel(selectedReadStatus) }}
</span>
</app-tag>
<i
class="pi pi-pencil cursor-pointer text-[cornflowerblue] hover:text-[darkturquoise] transition-colors"
(click)="menu.toggle($event)">
</i>
<p-menu #menu [popup]="true" [model]="readStatusMenuItems"></p-menu>
</p>
@if (book?.koreaderProgress && book.koreaderProgress?.percentage != null) {
<p class="whitespace-nowrap flex items-center">
<span class="font-bold mr-2">KOReader Progress:</span>
<span class="inline-flex items-center">
<span
class="inline-block px-2 py-0.5 rounded-full text-xs font-bold text-white"
[ngClass]="getKoProgressColorClass(getKOReaderPercentage(book))">
{{ getKOReaderPercentage(book) + '%' }}
</span>
<app-tag size="3xs" variant="pill" [color]="getKoProgressColor(getKOReaderPercentage(book))">
{{ getKOReaderPercentage(book) + '%' }}
</app-tag>
@if (getKOReaderPercentage(book) !== null) {
<p-button
pTooltip="Reset progress"

View File

@@ -1,11 +1,10 @@
import {Component, DestroyRef, inject, Input, OnChanges, OnInit, SimpleChanges, ViewChild} from '@angular/core';
import {Button} from 'primeng/button';
import {AsyncPipe, DecimalPipe, NgClass} from '@angular/common';
import {AsyncPipe, DecimalPipe, NgClass, UpperCasePipe} from '@angular/common';
import {Observable} from 'rxjs';
import {BookService} from '../../../book/service/book.service';
import {Rating, RatingRateEvent} from 'primeng/rating';
import {FormsModule} from '@angular/forms';
import {Tag} from 'primeng/tag';
import {Book, BookMetadata, BookRecommendation, FileInfo, ReadStatus} from '../../../book/model/book.model';
import {UrlHelperService} from '../../../utilities/service/url-helper.service';
import {UserService} from '../../../settings/user-management/user.service';
@@ -38,13 +37,14 @@ import {TieredMenu} from 'primeng/tieredmenu';
import {AdditionalFileUploaderComponent} from '../../../book/components/additional-file-uploader/additional-file-uploader.component';
import {Image} from 'primeng/image';
import {BookDialogHelperService} from '../../../book/components/book-browser/BookDialogHelperService';
import {TagColor, TagComponent} from '../../../shared/components/tag/tag.component';
@Component({
selector: 'app-metadata-viewer',
standalone: true,
templateUrl: './metadata-viewer.component.html',
styleUrl: './metadata-viewer.component.scss',
imports: [Button, AsyncPipe, Rating, FormsModule, Tag, SplitButton, NgClass, Tooltip, DecimalPipe, Editor, ProgressBar, Menu, InfiniteScrollDirective, BookCardLiteComponent, DatePicker, Tab, TabList, TabPanel, TabPanels, Tabs, BookReviewsComponent, BookNotesComponent, ProgressSpinner, TieredMenu, Image]
imports: [Button, AsyncPipe, Rating, FormsModule, SplitButton, NgClass, Tooltip, DecimalPipe, Editor, ProgressBar, Menu, InfiniteScrollDirective, BookCardLiteComponent, DatePicker, Tab, TabList, TabPanel, TabPanels, Tabs, BookReviewsComponent, BookNotesComponent, ProgressSpinner, TieredMenu, Image, TagComponent, UpperCasePipe]
})
export class MetadataViewerComponent implements OnInit, OnChanges {
@Input() book$!: Observable<Book | null>;
@@ -551,6 +551,14 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
this.handleMetadataClick('category', category);
}
goToMood(mood: string): void {
this.handleMetadataClick('mood', mood);
}
goToTag(tag: string): void {
this.handleMetadataClick('tag', tag);
}
goToSeries(seriesName: string): void {
const encodedSeriesName = encodeURIComponent(seriesName);
this.router.navigate(['/series', encodedSeriesName]);
@@ -636,21 +644,21 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
}
}
getFileTypeColorClass(fileType: string | null | undefined): string {
if (!fileType) return 'bg-gray-600 text-white';
getFileTypeColor(fileType: string | null | undefined): TagColor {
if (!fileType) return 'gray';
switch (fileType.toLowerCase()) {
case 'pdf':
return 'bg-pink-700 text-white';
return 'pink';
case 'epub':
return 'bg-indigo-600 text-white';
return 'indigo';
case 'cbz':
return 'bg-teal-600 text-white';
return 'teal';
case 'cbr':
return 'bg-purple-700 text-white';
return 'purple';
case 'cb7':
return 'bg-blue-700 text-white';
return 'blue';
default:
return 'bg-gray-600 text-white';
return 'gray';
}
}
@@ -672,50 +680,51 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
}
}
getMatchScoreColorClass(score: number): string {
if (score >= 0.95) return 'bg-green-800 border-green-900';
if (score >= 0.90) return 'bg-green-700 border-green-800';
if (score >= 0.80) return 'bg-green-600 border-green-700';
if (score >= 0.70) return 'bg-yellow-600 border-yellow-700';
if (score >= 0.60) return 'bg-yellow-500 border-yellow-600';
if (score >= 0.50) return 'bg-yellow-400 border-yellow-500';
if (score >= 0.40) return 'bg-red-400 border-red-500';
if (score >= 0.30) return 'bg-red-500 border-red-600';
return 'bg-red-600 border-red-700';
getMatchScoreColor(score: number): TagColor {
if (score >= 0.95) return 'emerald';
if (score >= 0.90) return 'green';
if (score >= 0.80) return 'lime';
if (score >= 0.70) return 'yellow';
if (score >= 0.60) return 'amber';
if (score >= 0.50) return 'orange';
if (score >= 0.40) return 'red';
if (score >= 0.30) return 'rose';
return 'pink';
}
getStatusSeverityClass(status: string): string {
const normalized = status?.toUpperCase();
getStatusColor(status: string | null | undefined): TagColor {
const normalized = status?.toUpperCase() ?? '';
switch (normalized) {
case 'UNREAD':
return 'bg-gray-500';
return 'gray';
case 'PAUSED':
return 'bg-zinc-600';
return 'zinc';
case 'READING':
return 'bg-blue-600';
return 'blue';
case 'RE_READING':
return 'bg-indigo-600';
return 'indigo';
case 'READ':
return 'bg-green-600';
return 'green';
case 'PARTIALLY_READ':
return 'bg-yellow-600';
return 'yellow';
case 'ABANDONED':
return 'bg-red-600';
return 'red';
case 'WONT_READ':
return 'bg-pink-700';
return 'pink';
default:
return 'bg-gray-600';
return 'gray';
}
}
getProgressColorClass(progress: number | null | undefined): string {
if (progress == null) return 'bg-gray-600';
return 'bg-blue-500';
getProgressColor(progress: number | null | undefined): TagColor {
if (progress == null) return 'gray';
return 'blue';
}
getKoProgressColorClass(progress: number | null | undefined): string {
if (progress == null) return 'bg-gray-600';
return 'bg-amber-500';
getKoProgressColor(progress: number | null | undefined): TagColor {
if (progress == null) return 'gray';
return 'amber';
}
getKOReaderPercentage(book: Book): number | null {

View File

@@ -2,95 +2,99 @@
<table class="min-w-full table-auto border-collapse custom-table">
<thead>
<tr>
<th class="px-4 py-1.5 text-left font-semibold text-gray-300">Book Field</th>
<th class="px-4 py-1.5 text-left font-semibold text-gray-300">
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300" style="max-width: 70px; width: 70px;">Enabled</th>
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300" style="max-width: 127px; width: 127px;">Field</th>
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300">
4th Priority
<i class="pi pi-question-circle ml-1 text-xs"
pTooltip="Last fallback option - only used if 1st, 2nd, and 3rd priorities fail or are empty"
tooltipPosition="top"></i>
</th>
<th class="px-4 py-1.5 text-left font-semibold text-gray-300">
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300">
3rd Priority
<i class="pi pi-question-circle ml-1 text-xs"
pTooltip="Third choice - used if 1st and 2nd priorities don't have data"
tooltipPosition="top"></i>
</th>
<th class="px-4 py-1.5 text-left font-semibold text-gray-300">
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300">
2nd Priority
<i class="pi pi-question-circle ml-1 text-xs"
pTooltip="Second choice - used if 1st priority doesn't have data"
tooltipPosition="top"></i>
</th>
<th class="px-4 py-1.5 text-left font-semibold text-gray-300">
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300">
1st Priority
<i class="pi pi-question-circle ml-1 text-xs"
pTooltip="First choice - always tried first for this field"
tooltipPosition="top"></i>
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-sm px-4 py-1.5 text-gray-300">
All Other Fields
<i class="pi pi-question-circle ml-1 text-xs"
pTooltip="Quick way to set the same provider priority for all fields at once"
tooltipPosition="right"></i>
</td>
<td class="px-4 py-1.5">
<p-select [options]="providers" [(ngModel)]="allP4.value"
(onChange)="syncProvider($event, 'p4')"
placeholder="Select All" appendTo="body"
<td class="px-4 py-2" style="max-width: 70px; width: 70px;"></td>
<td class="px-4 py-2 text-sm text-zinc-400 italic" style="max-width: 127px; width: 127px;">Set All:</td>
<td class="px-4 py-2">
<p-select [options]="providersWithClear" [(ngModel)]="bulkP4"
(ngModelChange)="setBulkProvider('p4', $event)"
placeholder="Set all P4" appendTo="body"
class="w-full" size="small">
</p-select>
</td>
<td class="px-4 py-1.5">
<p-select [options]="providers" [(ngModel)]="allP3.value"
(onChange)="syncProvider($event, 'p3')"
placeholder="Select All" appendTo="body"
<td class="px-4 py-2">
<p-select [options]="providersWithClear" [(ngModel)]="bulkP3"
(ngModelChange)="setBulkProvider('p3', $event)"
placeholder="Set all P3" appendTo="body"
class="w-full" size="small">
</p-select>
</td>
<td class="px-4 py-1.5">
<p-select [options]="providers" [(ngModel)]="allP2.value"
(onChange)="syncProvider($event, 'p2')"
placeholder="Select All" appendTo="body"
<td class="px-4 py-2">
<p-select [options]="providersWithClear" [(ngModel)]="bulkP2"
(ngModelChange)="setBulkProvider('p2', $event)"
placeholder="Set all P2" appendTo="body"
class="w-full" size="small">
</p-select>
</td>
<td class="px-4 py-1.5">
<p-select [options]="providers" [(ngModel)]="allP1.value"
(onChange)="syncProvider($event, 'p1')"
placeholder="Select All" appendTo="body"
<td class="px-4 py-2">
<p-select [options]="providersWithClear" [(ngModel)]="bulkP1"
(ngModelChange)="setBulkProvider('p1', $event)"
placeholder="Set all P1" appendTo="body"
class="w-full" size="small">
</p-select>
</td>
</tr>
@for (field of fields; track field) {
<tr [hidden]="field === 'cover' && !refreshCovers">
<td class="text-sm px-4 py-1.5 text-gray-300">{{ formatLabel(field) }}</td>
</thead>
<tbody>
@for (field of nonProviderSpecificFields; track field) {
<tr [hidden]="field === 'cover' && !refreshCovers" [class.opacity-50]="!enabledFields[field]">
<td class="px-4 py-1.5" style="max-width: 70px; width: 70px;">
<p-checkbox [(ngModel)]="enabledFields[field]" [binary]="true"
pTooltip="Enable this field during metadata fetch"
tooltipPosition="top"></p-checkbox>
</td>
<td class="px-4 py-1.5 text-zinc-200" style="max-width: 127px; width: 127px;">{{ formatLabel(field) }}</td>
<td class="px-4 py-1.5">
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p4"
[disabled]="!enabledFields[field]"
placeholder="Unset" appendTo="body"
class="w-full" size="small">
</p-select>
</td>
<td class="px-4 py-1.5">
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p3"
[disabled]="!enabledFields[field]"
placeholder="Unset" appendTo="body"
class="w-full" size="small">
</p-select>
</td>
<td class="px-4 py-1.5">
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p2"
[disabled]="!enabledFields[field]"
placeholder="Unset" appendTo="body"
class="w-full" size="small">
</p-select>
</td>
<td class="px-4 py-1.5">
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p1"
[disabled]="!enabledFields[field]"
placeholder="Unset" appendTo="body"
class="w-full" size="small">
</p-select>
@@ -100,6 +104,20 @@
</tbody>
</table>
<div class="space-y-4">
<h3 class="text-lg font-semibold text-zinc-300">Provider-Specific Fields</h3>
<p class="text-sm text-zinc-400">These fields are unique to specific providers and cannot have custom priority settings. Use the checkboxes to enable/disable fetching these fields.</p>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
@for (field of providerSpecificFields; track field) {
<div class="flex items-center space-x-3 p-3 border border-zinc-600 rounded-lg">
<p-checkbox [(ngModel)]="enabledFields[field]" [binary]="true"
pTooltip="Enable this field during metadata fetch"
tooltipPosition="top"></p-checkbox>
<span class="text-sm text-zinc-300">{{ formatLabel(field) }}</span>
</div>
}
</div>
</div>
<div class="flex flex-row items-center justify-between gap-4 w-full">

View File

@@ -1,17 +1,11 @@
import {
Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges
} from '@angular/core';
import {Select, SelectChangeEvent} from 'primeng/select';
import {Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges} from '@angular/core';
import {Select} from 'primeng/select';
import {FormsModule} from '@angular/forms';
import {Checkbox} from 'primeng/checkbox';
import {Button} from 'primeng/button';
import {MessageService} from 'primeng/api';
import {
FieldOptions,
FieldProvider,
MetadataRefreshOptions
} from '../../model/request/metadata-refresh-options.model';
import {FieldOptions, MetadataRefreshOptions} from '../../model/request/metadata-refresh-options.model';
import {Tooltip} from 'primeng/tooltip';
@Component({
@@ -30,25 +24,49 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
fields: (keyof FieldOptions)[] = [
'title', 'subtitle', 'description', 'authors', 'publisher', 'publishedDate',
'seriesName', 'seriesNumber', 'seriesTotal', 'isbn13', 'isbn10',
'language', 'categories', 'cover'
'language', 'categories', 'cover', 'pageCount',
'asin', 'goodreadsId', 'comicvineId', 'hardcoverId', 'googleId',
'amazonRating', 'amazonReviewCount', 'goodreadsRating', 'goodreadsReviewCount',
'hardcoverRating', 'hardcoverReviewCount', 'moods', 'tags'
];
providerSpecificFields: (keyof FieldOptions)[] = [
'asin', 'goodreadsId', 'comicvineId', 'hardcoverId', 'googleId',
'amazonRating', 'amazonReviewCount', 'goodreadsRating', 'goodreadsReviewCount',
'hardcoverRating', 'hardcoverReviewCount', 'moods', 'tags'
];
nonProviderSpecificFields: (keyof FieldOptions)[] = [
'title', 'subtitle', 'description', 'authors', 'publisher', 'publishedDate',
'seriesName', 'seriesNumber', 'seriesTotal', 'isbn13', 'isbn10',
'language', 'categories', 'cover', 'pageCount',
];
providers: string[] = ['Amazon', 'Google', 'GoodReads', 'Hardcover', 'Comicvine', 'Douban'];
providersWithClear: string[] = ['Clear All', 'Amazon', 'Google', 'GoodReads', 'Hardcover', 'Comicvine', 'Douban'];
refreshCovers: boolean = false;
mergeCategories: boolean = false;
reviewBeforeApply: boolean = false;
allP1 = {placeholder: 'Set All', value: null as string | null};
allP2 = {placeholder: 'Set All', value: null as string | null};
allP3 = {placeholder: 'Set All', value: null as string | null};
allP4 = {placeholder: 'Set All', value: null as string | null};
fieldOptions: FieldOptions = this.initializeFieldOptions();
enabledFields: Record<keyof FieldOptions, boolean> = this.initializeEnabledFields();
bulkP1: string | null = null;
bulkP2: string | null = null;
bulkP3: string | null = null;
bulkP4: string | null = null;
private messageService = inject(MessageService);
private justSubmitted = false;
private providerSpecificFieldsList = [
'asin', 'goodreadsId', 'comicvineId', 'hardcoverId', 'googleId',
'amazonRating', 'amazonReviewCount', 'goodreadsRating', 'goodreadsReviewCount',
'hardcoverRating', 'hardcoverReviewCount', 'moods', 'tags'
];
private initializeFieldOptions(): FieldOptions {
return this.fields.reduce((acc, field) => {
acc[field] = {p1: null, p2: null, p3: null, p4: null};
@@ -56,6 +74,13 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
}, {} as FieldOptions);
}
private initializeEnabledFields(): Record<keyof FieldOptions, boolean> {
return this.fields.reduce((acc, field) => {
acc[field] = true;
return acc;
}, {} as Record<keyof FieldOptions, boolean>);
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['currentMetadataOptions'] && this.currentMetadataOptions && !this.justSubmitted) {
this.refreshCovers = this.currentMetadataOptions.refreshCovers || false;
@@ -72,10 +97,11 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
}
this.fieldOptions = backendFieldOptions;
this.allP1 = {placeholder: 'Set All', value: this.currentMetadataOptions.allP1 || null};
this.allP2 = {placeholder: 'Set All', value: this.currentMetadataOptions.allP2 || null};
this.allP3 = {placeholder: 'Set All', value: this.currentMetadataOptions.allP3 || null};
this.allP4 = {placeholder: 'Set All', value: this.currentMetadataOptions.allP4 || null};
if (this.currentMetadataOptions.enabledFields) {
this.enabledFields = {...this.enabledFields, ...this.currentMetadataOptions.enabledFields};
} else {
this.enabledFields = this.initializeEnabledFields();
}
}
}
@@ -92,14 +118,10 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
return cloned;
}
syncProvider(event: SelectChangeEvent, providerType: keyof FieldProvider) {
for (const field of Object.keys(this.fieldOptions)) {
this.fieldOptions[field as keyof FieldOptions][providerType] = event.value;
}
}
submit() {
const allFieldsHaveProvider = Object.values(this.fieldOptions).every(opt =>
const allFieldsHaveProvider = Object.entries(this.fieldOptions).every(([field, opt]) =>
!this.enabledFields[field as keyof FieldOptions] ||
this.isProviderSpecificField(field as keyof FieldOptions) ||
opt.p1 !== null || opt.p2 !== null || opt.p3 !== null || opt.p4 !== null
);
@@ -108,14 +130,11 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
const metadataRefreshOptions: MetadataRefreshOptions = {
libraryId: null,
allP1: this.allP1.value,
allP2: this.allP2.value,
allP3: this.allP3.value,
allP4: this.allP4.value,
refreshCovers: this.refreshCovers,
mergeCategories: this.mergeCategories,
reviewBeforeApply: this.reviewBeforeApply,
fieldOptions: this.fieldOptions
fieldOptions: this.fieldOptions,
enabledFields: this.enabledFields
};
this.metadataOptionsSubmitted.emit(metadataRefreshOptions);
@@ -127,18 +146,41 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
this.messageService.add({
severity: 'error',
summary: 'Error',
detail: 'At least one provider (P1P4) must be selected for each book field.',
detail: 'At least one provider (P1P4) must be selected for each enabled book field.',
life: 5000
});
}
}
setBulkProvider(priority: 'p1' | 'p2' | 'p3' | 'p4', provider: string | null): void {
if (!provider) return;
const value = provider === 'Clear All' ? null : provider;
for (const field of this.nonProviderSpecificFields) {
if (this.enabledFields[field]) {
this.fieldOptions[field][priority] = value;
}
}
switch (priority) {
case 'p1':
this.bulkP1 = null;
break;
case 'p2':
this.bulkP2 = null;
break;
case 'p3':
this.bulkP3 = null;
break;
case 'p4':
this.bulkP4 = null;
break;
}
}
reset() {
this.justSubmitted = false;
this.allP1.value = null;
this.allP2.value = null;
this.allP3.value = null;
this.allP4.value = null;
for (const field of Object.keys(this.fieldOptions)) {
this.fieldOptions[field as keyof FieldOptions] = {
p1: null,
@@ -147,9 +189,53 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
p4: null
};
}
this.enabledFields = this.initializeEnabledFields();
// Reset bulk selectors
this.bulkP1 = null;
this.bulkP2 = null;
this.bulkP3 = null;
this.bulkP4 = null;
}
formatLabel(field: string): string {
return field.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase()).trim();
const fieldLabels: Record<string, string> = {
'title': 'Title',
'subtitle': 'Subtitle',
'description': 'Description',
'authors': 'Authors',
'publisher': 'Publisher',
'publishedDate': 'Published Date',
'seriesName': 'Series Name',
'seriesNumber': 'Series Number',
'seriesTotal': 'Series Total',
'isbn13': 'ISBN-13',
'isbn10': 'ISBN-10',
'language': 'Language',
'categories': 'Genres',
'cover': 'Cover Image',
'pageCount': 'Page Count',
'rating': 'Rating',
'reviewCount': 'Review Count',
'asin': 'Amazon ASIN',
'goodreadsId': 'Goodreads ID',
'comicvineId': 'Comicvine ID',
'hardcoverId': 'Hardcover ID',
'googleId': 'Google Books ID',
'amazonRating': 'Amazon Rating',
'amazonReviewCount': 'Amazon Review Count',
'goodreadsRating': 'Goodreads Rating',
'goodreadsReviewCount': 'Goodreads Review Count',
'hardcoverRating': 'Hardcover Rating',
'hardcoverReviewCount': 'Hardcover Review Count',
'moods': 'Moods (Hardcover)',
'tags': 'Tags (Hardcover)'
};
return fieldLabels[field] || field.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase()).trim();
}
isProviderSpecificField(field: keyof FieldOptions): boolean {
return this.providerSpecificFieldsList.includes(field as string);
}
}

View File

@@ -1,13 +1,10 @@
export interface MetadataRefreshOptions {
libraryId: number | null;
allP4: string | null;
allP3: string | null;
allP2: string | null;
allP1: string | null;
refreshCovers: boolean;
mergeCategories: boolean;
reviewBeforeApply: boolean;
fieldOptions?: FieldOptions;
enabledFields?: Record<keyof FieldOptions, boolean>;
}
export interface FieldProvider {
@@ -32,4 +29,18 @@ export interface FieldOptions {
isbn13: FieldProvider;
isbn10: FieldProvider;
language: FieldProvider;
pageCount: FieldProvider;
asin: FieldProvider;
goodreadsId: FieldProvider;
comicvineId: FieldProvider;
hardcoverId: FieldProvider;
googleId: FieldProvider;
amazonRating: FieldProvider;
amazonReviewCount: FieldProvider;
goodreadsRating: FieldProvider;
goodreadsReviewCount: FieldProvider;
hardcoverRating: FieldProvider;
hardcoverReviewCount: FieldProvider;
moods: FieldProvider;
tags: FieldProvider;
}

View File

@@ -3,6 +3,12 @@
<h2 class="settings-title">
<i class="pi pi-envelope"></i>
Email Providers
<i class="pi pi-external-link external-link-icon"
pTooltip="Click to view email setup documentation"
tooltipPosition="right"
(click)="navigateToEmailDocumentation()"
style="cursor: pointer;">
</i>
</h2>
<p class="settings-description">
Configure email-sending services like Gmail, Outlook, or custom SMTP servers for sending books via email. The default email provider will be used for 'Quick Book Send' located in the Book Card menu.

View File

@@ -20,6 +20,11 @@
color: var(--p-primary-color);
font-size: 1.25rem;
}
.external-link-icon {
color: #0ea5e9 !important;
font-size: 0.875rem !important;
}
}
.settings-description {

View File

@@ -23,7 +23,7 @@ import {CreateEmailProviderDialogComponent} from '../create-email-provider-dialo
TableModule,
Tooltip,
FormsModule
],
],
templateUrl: './email-provider.component.html',
styleUrl: './email-provider.component.scss'
})
@@ -117,7 +117,7 @@ export class EmailProviderComponent implements OnInit {
header: 'Create Email Provider',
modal: true,
closable: true,
style: { position: 'absolute', top: '15%' },
style: {position: 'absolute', top: '15%'},
});
this.ref.onClose.subscribe((result) => {
if (result) {
@@ -136,4 +136,8 @@ export class EmailProviderComponent implements OnInit {
});
});
}
navigateToEmailDocumentation() {
window.open('https://booklore-app.github.io/booklore-docs/docs/email-setup', '_blank');
}
}

View File

@@ -49,7 +49,7 @@
placeholder="Paste your Amazon cookie"
fluid
class="max-w-[60rem]"
[(ngModel)]="amazonCookie" />
[(ngModel)]="amazonCookie"/>
</div>
</div>
</div>
@@ -86,7 +86,15 @@
<div class="provider-config">
<div class="config-field">
<label class="config-label">API Token</label>
<div class="config-label-row">
<label class="config-label">API Token</label>
<i class="pi pi-external-link external-link-icon"
pTooltip="Click to view Hardcover API documentation"
tooltipPosition="right"
(click)="navigateToHardcoverTokenDocumentation()"
style="cursor: pointer;">
</i>
</div>
<input
type="text"
pInputText
@@ -94,7 +102,7 @@
fluid
class="max-w-[60rem]"
[(ngModel)]="hardcoverToken"
(ngModelChange)="onTokenChange($event)" />
(ngModelChange)="onTokenChange($event)"/>
</div>
</div>
</div>
@@ -121,7 +129,7 @@
fluid
class="max-w-[60rem]"
[(ngModel)]="comicvineToken"
(ngModelChange)="onComicTokenChange($event)" />
(ngModelChange)="onComicTokenChange($event)"/>
</div>
</div>
</div>

View File

@@ -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');
}
}

View File

@@ -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.
</p>
</div>
@@ -42,6 +43,7 @@
</h3>
<p class="section-description">
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.
</p>
</div>

View File

@@ -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}
}
};
}
}

View File

@@ -3,6 +3,12 @@
<h2 class="settings-title">
<i class="pi pi-server"></i>
OPDS Settings (New)
<i class="pi pi-external-link external-link-icon"
pTooltip="Click to view OPDS setup documentation"
tooltipPosition="right"
(click)="navigateToOpdsDoc()"
style="cursor: pointer;">
</i>
</h2>
<p class="settings-description">
Manage your OPDS credentials and control how your book collection is shared with reading apps.

View File

@@ -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 {

View File

@@ -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();

View File

@@ -43,7 +43,7 @@
.setting-label {
margin-bottom: 0;
flex-shrink: 0;
min-width: 120px;
min-width: 100px;
}
.radio-group {

View File

@@ -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);

View File

@@ -1,77 +1,115 @@
<div class="epub-preferences-container">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Theme</label>
<p-select
size="small"
[options]="themes"
[(ngModel)]="selectedTheme"
optionLabel="name"
optionValue="key"
placeholder="Select a Theme"
class="w-full md:w-60">
</p-select>
</div>
<p class="setting-description">
Choose the visual theme for EPUB reading experience.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Font</label>
<p-select
size="small"
[options]="fonts"
[(ngModel)]="selectedFont"
optionLabel="name"
optionValue="key"
placeholder="Select a Font"
class="w-full md:w-60">
</p-select>
</div>
<p class="setting-description">
Select the font family for text display.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Flow</label>
<p-select
size="small"
[options]="flowOptions"
[(ngModel)]="selectedFlow"
optionLabel="name"
optionValue="key"
placeholder="Select a Flow"
class="w-full md:w-60">
</p-select>
</div>
<p class="setting-description">
Configure text flow and reading direction.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Font Size</label>
<div class="font-size-controls">
<p-button icon="pi pi-minus" size="small" rounded (click)="decreaseFontSize()"></p-button>
<span class="font-size-value">{{ fontSize }}%</span>
<p-button icon="pi pi-plus" size="small" rounded (click)="increaseFontSize()"></p-button>
<div class="p-4">
<div class="epub-preferences-container">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Theme</label>
<div class="radio-group">
@for (theme of themes; track theme) {
<div class="radio-option">
<p-radiobutton
[inputId]="'theme-' + theme.key"
name="theme"
[value]="theme.key"
[(ngModel)]="selectedTheme">
</p-radiobutton>
<label [for]="'theme-' + theme.key">{{ theme.name }}</label>
</div>
}
</div>
</div>
<p class="setting-description">
Choose the visual theme for EPUB reading experience.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Font</label>
<div class="radio-group">
@for (font of fonts; track font) {
<div class="radio-option">
<p-radiobutton
[inputId]="'font-' + font.key"
name="font"
[value]="font.key"
[(ngModel)]="selectedFont">
</p-radiobutton>
<label [for]="'font-' + font.key">{{ font.name }}</label>
</div>
}
</div>
</div>
<p class="setting-description">
Select the font family for text display.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Flow</label>
<div class="radio-group">
@for (flow of flowOptions; track flow) {
<div class="radio-option">
<p-radiobutton
[inputId]="'flow-' + flow.key"
name="flow"
[value]="flow.key"
[(ngModel)]="selectedFlow">
</p-radiobutton>
<label [for]="'flow-' + flow.key">{{ flow.name }}</label>
</div>
}
</div>
</div>
<p class="setting-description">
Configure text flow and reading direction.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Page Spread</label>
<div class="radio-group">
@for (spread of spreadOptions; track spread) {
<div class="radio-option">
<p-radiobutton
[inputId]="'spread-' + spread.key"
name="spread"
[value]="spread.key"
[(ngModel)]="selectedSpread">
</p-radiobutton>
<label [for]="'spread-' + spread.key">{{ spread.name }}</label>
</div>
}
</div>
</div>
<p class="setting-description">
Choose between single page or double page spread view.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Font Size</label>
<div class="font-size-controls">
<p-button icon="pi pi-minus" size="small" rounded (click)="decreaseFontSize()"></p-button>
<span class="font-size-value">{{ fontSize }}%</span>
<p-button icon="pi pi-plus" size="small" rounded (click)="increaseFontSize()"></p-button>
</div>
</div>
<p class="setting-description">
Adjust the text size for comfortable reading.
</p>
</div>
<p class="setting-description">
Adjust the text size for comfortable reading.
</p>
</div>
</div>
</div>

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -43,7 +43,7 @@
.setting-label {
margin-bottom: 0;
flex-shrink: 0;
min-width: 120px;
min-width: 100px;
}
.radio-group {

View File

@@ -55,6 +55,7 @@ export interface EpubReaderSetting {
font: string;
fontSize: number;
flow: string;
spread: string;
lineHeight: number;
margin: number;
letterSpacing: number;

View File

@@ -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);
}
}
}

View File

@@ -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: `
<span
[class]="tagClasses()"
[style.background-color]="customBgColor()"
[style.color]="customTextColor()"
>
<ng-content></ng-content>
</span>
`,
styleUrls: ['./tag.component.scss']
})
export class TagComponent {
color = input<TagColor>('primary');
size = input<TagSize>('m');
variant = input<TagVariant>('label');
rounded = input(false);
pill = input(false);
customBgColor = input<string>();
customTextColor = input<string>();
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(' ');
});
}