mirror of
https://github.com/booklore-app/booklore.git
synced 2025-12-23 22:28:11 -05:00
Merge pull request #1593 from booklore-app/develop
Merge develop into master for the release
This commit is contained in:
@@ -6,7 +6,6 @@ # BookLore
|
||||

|
||||
[](https://discord.gg/Ee5hd458Uz)
|
||||
[](https://opencollective.com/booklore)
|
||||
[](https://venmo.com/AdityaChandel)
|
||||
> 🚨 **Important Announcement:**
|
||||
> Docker images have moved to new repositories:
|
||||
> - Docker Hub: `https://hub.docker.com/r/booklore/booklore`
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
|
||||
@Slf4j
|
||||
@@ -25,9 +26,9 @@ public class JwtUtils {
|
||||
|
||||
private final JwtSecretService jwtSecretService;
|
||||
@Getter
|
||||
public final long accessTokenExpirationMs = 1000L * 60 * 60 * 10; // 10 hours
|
||||
public static final long accessTokenExpirationMs = 1000L * 60 * 60 * 10; // 10 hours
|
||||
@Getter
|
||||
public final long refreshTokenExpirationMs = 1000L * 60 * 60 * 24 * 30; // 30 days
|
||||
public static final long refreshTokenExpirationMs = 1000L * 60 * 60 * 24 * 30; // 30 days
|
||||
|
||||
private SecretKey getSigningKey() {
|
||||
String secretKey = jwtSecretService.getSecret();
|
||||
@@ -36,12 +37,13 @@ public class JwtUtils {
|
||||
|
||||
public String generateToken(BookLoreUserEntity user, boolean isRefreshToken) {
|
||||
long expirationTime = isRefreshToken ? refreshTokenExpirationMs : accessTokenExpirationMs;
|
||||
Instant now = Instant.now();
|
||||
return Jwts.builder()
|
||||
.subject(user.getUsername())
|
||||
.claim("userId", user.getId())
|
||||
.claim("isDefaultPassword", user.isDefaultPassword())
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + expirationTime))
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(now.plusMillis(expirationTime)))
|
||||
.signWith(getSigningKey(), Jwts.SIG.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
@@ -154,17 +152,10 @@ public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
|
||||
return http.getSharedObject(AuthenticationManagerBuilder.class)
|
||||
.authenticationProvider(authenticationProvider())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationProvider authenticationProvider() {
|
||||
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
|
||||
provider.setUserDetailsService(opdsUserDetailsService);
|
||||
provider.setPasswordEncoder(passwordEncoder());
|
||||
return provider;
|
||||
// Configure the shared AuthenticationManagerBuilder with the UserDetailsService and PasswordEncoder
|
||||
AuthenticationManagerBuilder auth = http.getSharedObject(AuthenticationManagerBuilder.class);
|
||||
auth.userDetailsService(opdsUserDetailsService).passwordEncoder(passwordEncoder());
|
||||
return auth.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
@@ -21,6 +21,7 @@ import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
|
||||
@AllArgsConstructor
|
||||
@Component
|
||||
@@ -75,7 +76,7 @@ public class CoverJwtFilter extends OncePerRequestFilter {
|
||||
var processor = dynamicOidcJwtProcessor.getProcessor();
|
||||
var claimsSet = processor.process(token, null);
|
||||
|
||||
if (claimsSet.getExpirationTime() == null || claimsSet.getExpirationTime().before(new java.util.Date())) {
|
||||
if (claimsSet.getExpirationTime() == null || claimsSet.getExpirationTime().toInstant().isBefore(Instant.now())) {
|
||||
throw new RuntimeException("OIDC token expired or invalid");
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Date;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
@@ -101,8 +101,7 @@ public class DualJwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
OidcProviderDetails providerDetails = appSettingService.getAppSettings().getOidcProviderDetails();
|
||||
JWTClaimsSet claimsSet = dynamicOidcJwtProcessor.getProcessor().process(token, null);
|
||||
|
||||
Date expirationTime = claimsSet.getExpirationTime();
|
||||
if (expirationTime == null || expirationTime.before(new Date())) {
|
||||
if (claimsSet.getExpirationTime() == null || claimsSet.getExpirationTime().toInstant().isBefore(Instant.now())) {
|
||||
log.warn("OIDC token is expired or missing exp claim");
|
||||
throw ApiError.GENERIC_UNAUTHORIZED.createException("Token has expired or is invalid.");
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Date;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -109,7 +109,7 @@ public class AuthenticationService {
|
||||
|
||||
Optional<BookLoreUserEntity> user = userRepository.findByUsername(username);
|
||||
if (user.isEmpty() && appProperties.getRemoteAuth().isCreateNewUsers()) {
|
||||
user = Optional.of(userProvisioningService.provisionRemoteUser(name, username, email, groups));
|
||||
user = Optional.of(userProvisioningService.provisionRemoteUserFromHeaders(name, username, email, groups));
|
||||
}
|
||||
|
||||
if (user.isEmpty()) {
|
||||
@@ -126,7 +126,7 @@ public class AuthenticationService {
|
||||
RefreshTokenEntity refreshTokenEntity = RefreshTokenEntity.builder()
|
||||
.user(user)
|
||||
.token(refreshToken)
|
||||
.expiryDate(new Date(System.currentTimeMillis() + jwtUtils.getRefreshTokenExpirationMs()))
|
||||
.expiryDate(Instant.now().plusMillis(jwtUtils.getRefreshTokenExpirationMs()))
|
||||
.revoked(false)
|
||||
.build();
|
||||
|
||||
@@ -142,21 +142,21 @@ public class AuthenticationService {
|
||||
public ResponseEntity<Map<String, String>> refreshToken(String token) {
|
||||
RefreshTokenEntity storedToken = refreshTokenRepository.findByToken(token).orElseThrow(() -> ApiError.INVALID_CREDENTIALS.createException("Refresh token not found"));
|
||||
|
||||
if (storedToken.isRevoked() || storedToken.getExpiryDate().before(new Date()) || !jwtUtils.validateToken(token)) {
|
||||
if (storedToken.isRevoked() || storedToken.getExpiryDate().isBefore(Instant.now()) || !jwtUtils.validateToken(token)) {
|
||||
throw ApiError.INVALID_CREDENTIALS.createException("Invalid or expired refresh token");
|
||||
}
|
||||
|
||||
BookLoreUserEntity user = storedToken.getUser();
|
||||
|
||||
storedToken.setRevoked(true);
|
||||
storedToken.setRevocationDate(new Date());
|
||||
storedToken.setRevocationDate(Instant.now());
|
||||
refreshTokenRepository.save(storedToken);
|
||||
|
||||
String newRefreshToken = jwtUtils.generateRefreshToken(user);
|
||||
RefreshTokenEntity newRefreshTokenEntity = RefreshTokenEntity.builder()
|
||||
.user(user)
|
||||
.token(newRefreshToken)
|
||||
.expiryDate(new Date(System.currentTimeMillis() + jwtUtils.getRefreshTokenExpirationMs()))
|
||||
.expiryDate(Instant.now().plusMillis(jwtUtils.getRefreshTokenExpirationMs()))
|
||||
.revoked(false)
|
||||
.build();
|
||||
|
||||
|
||||
@@ -3,23 +3,19 @@ package com.adityachandel.booklore.config.security.service;
|
||||
import com.adityachandel.booklore.model.dto.settings.OidcProviderDetails;
|
||||
import com.adityachandel.booklore.service.appsettings.AppSettingService;
|
||||
import com.nimbusds.jose.JWSAlgorithm;
|
||||
import com.nimbusds.jose.jwk.source.DefaultJWKSetCache;
|
||||
import com.nimbusds.jose.jwk.source.JWKSetCache;
|
||||
import com.nimbusds.jose.jwk.source.JWKSource;
|
||||
import com.nimbusds.jose.jwk.source.RemoteJWKSet;
|
||||
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
|
||||
import com.nimbusds.jose.proc.JWSKeySelector;
|
||||
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
|
||||
import com.nimbusds.jose.proc.SecurityContext;
|
||||
import com.nimbusds.jose.util.DefaultResourceRetriever;
|
||||
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
|
||||
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.URL;
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -49,15 +45,14 @@ public class DynamicOidcJwtProcessor {
|
||||
String discoveryUri = providerDetails.getIssuerUri() + "/.well-known/openid-configuration";
|
||||
log.info("Fetching OIDC discovery document from {}", discoveryUri);
|
||||
|
||||
URL jwksUrl = fetchJwksUri(discoveryUri);
|
||||
|
||||
DefaultResourceRetriever resourceRetriever = new DefaultResourceRetriever(2000, 2000);
|
||||
URI jwksUri = fetchJwksUri(discoveryUri);
|
||||
|
||||
Duration ttl = Duration.ofHours(6);
|
||||
Duration refresh = Duration.ofHours(1);
|
||||
JWKSetCache jwkSetCache = new DefaultJWKSetCache(ttl.toMillis(), refresh.toMillis(), TimeUnit.MILLISECONDS);
|
||||
|
||||
JWKSource<SecurityContext> jwkSource = new RemoteJWKSet<>(jwksUrl, resourceRetriever, jwkSetCache);
|
||||
|
||||
JWKSource<SecurityContext> jwkSource = JWKSourceBuilder.create(jwksUri.toURL())
|
||||
.cache(ttl.toMillis(), refresh.toMillis())
|
||||
.build();
|
||||
|
||||
JWSKeySelector<SecurityContext> keySelector = new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, jwkSource);
|
||||
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
|
||||
@@ -66,17 +61,21 @@ public class DynamicOidcJwtProcessor {
|
||||
return jwtProcessor;
|
||||
}
|
||||
|
||||
private URL fetchJwksUri(String discoveryUri) throws Exception {
|
||||
private URI fetchJwksUri(String discoveryUri) throws Exception {
|
||||
var restClient = org.springframework.web.client.RestClient.create();
|
||||
var discoveryDoc = restClient.get()
|
||||
.uri(discoveryUri)
|
||||
.retrieve()
|
||||
.body(new org.springframework.core.ParameterizedTypeReference<java.util.Map<String, Object>>() {});
|
||||
|
||||
String jwksUri = (String) discoveryDoc.get("jwks_uri");
|
||||
if (jwksUri == null || jwksUri.isEmpty()) {
|
||||
if (discoveryDoc == null) {
|
||||
throw new IllegalStateException("Failed to fetch OIDC discovery document.");
|
||||
}
|
||||
|
||||
String jwksUriStr = (String) discoveryDoc.get("jwks_uri");
|
||||
if (jwksUriStr == null || jwksUriStr.isEmpty()) {
|
||||
throw new IllegalStateException("jwks_uri not found in OIDC discovery document.");
|
||||
}
|
||||
return new URL(jwksUri);
|
||||
return new URI(jwksUriStr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -21,7 +20,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Tag(name = "Book Media", description = "Endpoints for retrieving book media such as covers, thumbnails, and pages")
|
||||
@AllArgsConstructor
|
||||
@@ -29,6 +30,8 @@ import java.nio.charset.StandardCharsets;
|
||||
@RequestMapping("/api/v1/media")
|
||||
public class BookMediaController {
|
||||
|
||||
private static final Pattern NON_ASCII_PATTERN = Pattern.compile("[^\\x00-\\x7F]");
|
||||
|
||||
private final BookService bookService;
|
||||
private final PdfReaderService pdfReaderService;
|
||||
private final CbxReaderService cbxReaderService;
|
||||
@@ -78,10 +81,7 @@ public class BookMediaController {
|
||||
public ResponseEntity<Resource> getBookdropCover(
|
||||
@Parameter(description = "ID of the bookdrop file") @PathVariable long bookdropId) {
|
||||
Resource file = bookDropService.getBookdropCover(bookdropId);
|
||||
String contentDisposition = ContentDisposition.builder("inline")
|
||||
.filename("cover.jpg", StandardCharsets.UTF_8)
|
||||
.build()
|
||||
.toString();
|
||||
String contentDisposition = "inline; filename=\"cover.jpg\"; filename*=UTF-8''cover.jpg";
|
||||
return (file != null)
|
||||
? ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
|
||||
@@ -105,11 +105,10 @@ public class BookMediaController {
|
||||
? MediaType.IMAGE_PNG
|
||||
: MediaType.IMAGE_JPEG;
|
||||
|
||||
String contentDisposition = ContentDisposition.builder("inline")
|
||||
.filename(filename, StandardCharsets.UTF_8)
|
||||
.build()
|
||||
.toString();
|
||||
|
||||
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
String fallbackFilename = NON_ASCII_PATTERN.matcher(filename).replaceAll("_");
|
||||
String contentDisposition = String.format("inline; filename=\"%s\"; filename*=UTF-8''%s",
|
||||
fallbackFilename, encodedFilename);
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
|
||||
.contentType(mediaType)
|
||||
|
||||
@@ -47,4 +47,15 @@ public class KoboSettingsController {
|
||||
koboService.setSyncEnabled(enabled);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@Operation(summary = "Update progress thresholds", description = "Update the progress thresholds for marking books as reading or finished. Requires sync permission or admin.")
|
||||
@ApiResponse(responseCode = "200", description = "Thresholds updated successfully")
|
||||
@PutMapping("/progress-thresholds")
|
||||
@PreAuthorize("@securityUtil.canSyncKobo() or @securityUtil.isAdmin()")
|
||||
public ResponseEntity<KoboSyncSettings> updateProgressThresholds(
|
||||
@Parameter(description = "Progress percentage to mark as reading (0-100)") @RequestParam(required = false) Float readingThreshold,
|
||||
@Parameter(description = "Progress percentage to mark as finished (0-100)") @RequestParam(required = false) Float finishedThreshold) {
|
||||
KoboSyncSettings updated = koboService.updateProgressThresholds(readingThreshold, finishedThreshold);
|
||||
return ResponseEntity.ok(updated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ public class MetadataController {
|
||||
.updateThumbnail(true)
|
||||
.mergeCategories(mergeCategories)
|
||||
.replaceMode(MetadataReplaceMode.REPLACE_ALL)
|
||||
.mergeMoods(true)
|
||||
.mergeTags(true)
|
||||
.build();
|
||||
|
||||
bookMetadataUpdater.setBookMetadata(context);
|
||||
|
||||
@@ -10,12 +10,12 @@ import java.util.Map;
|
||||
|
||||
@Converter
|
||||
@Slf4j
|
||||
public class JpaJsonConverter implements AttributeConverter<Map<String, Object>, String> {
|
||||
public class JpaJsonConverter implements AttributeConverter<Map, String> {
|
||||
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(Map<String, Object> attribute) {
|
||||
public String convertToDatabaseColumn(Map attribute) {
|
||||
if (attribute == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -28,7 +28,7 @@ public class JpaJsonConverter implements AttributeConverter<Map<String, Object>,
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> convertToEntityAttribute(String dbData) {
|
||||
public Map convertToEntityAttribute(String dbData) {
|
||||
if (dbData == null || dbData.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ import java.io.IOException;
|
||||
import java.util.Map;
|
||||
|
||||
@Converter(autoApply = true)
|
||||
public class MapToStringConverter implements AttributeConverter<Map<String, String>, String> {
|
||||
public class MapToStringConverter implements AttributeConverter<Map, String> {
|
||||
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(Map<String, String> attribute) {
|
||||
public String convertToDatabaseColumn(Map attribute) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(attribute);
|
||||
} catch (IOException e) {
|
||||
@@ -22,7 +22,7 @@ public class MapToStringConverter implements AttributeConverter<Map<String, Stri
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> convertToEntityAttribute(String dbData) {
|
||||
public Map convertToEntityAttribute(String dbData) {
|
||||
try {
|
||||
return objectMapper.readValue(dbData, Map.class);
|
||||
} catch (IOException e) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import java.util.regex.Pattern;
|
||||
public interface KoboReadingStateMapper {
|
||||
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
Pattern PATTERN = Pattern.compile("^\"|\"$");
|
||||
Pattern SURROUNDING_DOUBLE_QUOTES_PATTERN = Pattern.compile("^\"|\"$");
|
||||
|
||||
@Mapping(target = "currentBookmarkJson", expression = "java(toJson(dto.getCurrentBookmark()))")
|
||||
@Mapping(target = "statisticsJson", expression = "java(toJson(dto.getStatistics()))")
|
||||
@@ -51,6 +51,6 @@ public interface KoboReadingStateMapper {
|
||||
|
||||
default String cleanString(String value) {
|
||||
if (value == null) return null;
|
||||
return PATTERN.matcher(value).replaceAll("");
|
||||
return SURROUNDING_DOUBLE_QUOTES_PATTERN.matcher(value).replaceAll("");
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ public class Book {
|
||||
private EpubProgress epubProgress;
|
||||
private CbxProgress cbxProgress;
|
||||
private KoProgress koreaderProgress;
|
||||
private KoboProgress koboProgress;
|
||||
private Set<Shelf> shelves;
|
||||
private String readStatus;
|
||||
private Instant dateFinished;
|
||||
|
||||
@@ -9,4 +9,6 @@ public class KoboSyncSettings {
|
||||
private String userId;
|
||||
private String token;
|
||||
private boolean syncEnabled;
|
||||
private Float progressMarkAsReadingThreshold;
|
||||
private Float progressMarkAsFinishedThreshold;
|
||||
}
|
||||
|
||||
@@ -21,4 +21,6 @@ public class MagicShelf {
|
||||
@NotNull(message = "Filter JSON must not be null")
|
||||
@Size(min = 2, message = "Filter JSON must not be empty")
|
||||
private String filterJson;
|
||||
|
||||
private Boolean isPublic = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.adityachandel.booklore.model.dto.progress;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
public class KoboProgress {
|
||||
private Float percentage;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import lombok.ToString;
|
||||
@Builder
|
||||
@ToString
|
||||
public class KoreaderProgress {
|
||||
private Long timestamp;
|
||||
private String document;
|
||||
private Float percentage;
|
||||
private String progress;
|
||||
|
||||
@@ -21,6 +21,7 @@ public class MetadataProviderSettings {
|
||||
@Data
|
||||
public static class Google {
|
||||
private boolean enabled;
|
||||
private String language;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -19,8 +19,7 @@ public class BookMetadataCategoryKey implements Serializable {
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof BookMetadataCategoryKey)) return false;
|
||||
BookMetadataCategoryKey that = (BookMetadataCategoryKey) o;
|
||||
if (!(o instanceof BookMetadataCategoryKey that)) return false;
|
||||
return Objects.equals(bookId, that.bookId) && Objects.equals(categoryId, that.categoryId);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,4 +24,10 @@ public class KoboUserSettingsEntity {
|
||||
|
||||
@Column(name = "sync_enabled")
|
||||
private boolean syncEnabled = true;
|
||||
|
||||
@Column(name = "progress_mark_as_reading_threshold")
|
||||
private Float progressMarkAsReadingThreshold = 1f;
|
||||
|
||||
@Column(name = "progress_mark_as_finished_threshold")
|
||||
private Float progressMarkAsFinishedThreshold = 99f;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ public class MagicShelfEntity {
|
||||
@Column(name = "filter_json", columnDefinition = "json", nullable = false)
|
||||
private String filterJson;
|
||||
|
||||
@Column(name = "is_public", nullable = false)
|
||||
@lombok.Builder.Default
|
||||
private boolean isPublic = false;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt = LocalDateTime.now();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.adityachandel.booklore.model.entity;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.util.Date;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@@ -26,11 +26,11 @@ public class RefreshTokenEntity {
|
||||
private BookLoreUserEntity user;
|
||||
|
||||
@Column(name = "expiry_date", nullable = false)
|
||||
private Date expiryDate;
|
||||
private Instant expiryDate;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean revoked = false;
|
||||
|
||||
@Column(name = "revocation_date")
|
||||
private Date revocationDate;
|
||||
private Instant revocationDate;
|
||||
}
|
||||
@@ -60,6 +60,18 @@ public class UserBookProgressEntity {
|
||||
@Column(name = "koreader_device_id", length = 100)
|
||||
private String koreaderDeviceId;
|
||||
|
||||
@Column(name = "kobo_progress_percent")
|
||||
private Float koboProgressPercent;
|
||||
|
||||
@Column(name = "kobo_location", length = 1000)
|
||||
private String koboLocation;
|
||||
|
||||
@Column(name = "kobo_location_type", length = 50)
|
||||
private String koboLocationType;
|
||||
|
||||
@Column(name = "kobo_location_source", length = 50)
|
||||
private String koboLocationSource;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "read_status")
|
||||
private ReadStatus readStatus;
|
||||
@@ -69,4 +81,7 @@ public class UserBookProgressEntity {
|
||||
|
||||
@Column(name = "koreader_last_sync_time")
|
||||
private Instant koreaderLastSyncTime;
|
||||
|
||||
@Column(name = "kobo_last_sync_time")
|
||||
private Instant koboLastSyncTime;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
package com.adityachandel.booklore.model.enums;
|
||||
|
||||
public enum ResetProgressType {
|
||||
BOOKLORE, KOREADER
|
||||
BOOKLORE, KOREADER, KOBO
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ public interface MagicShelfRepository extends JpaRepository<MagicShelfEntity, Lo
|
||||
|
||||
List<MagicShelfEntity> findAllByUserId(Long userId);
|
||||
|
||||
List<MagicShelfEntity> findAllByIsPublicIsTrue();
|
||||
|
||||
Optional<MagicShelfEntity> findByUserIdAndName(Long userId, String name);
|
||||
|
||||
boolean existsByUserIdAndName(Long userId, String name);
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@AllArgsConstructor
|
||||
@Service
|
||||
@@ -19,9 +20,20 @@ public class MagicShelfService {
|
||||
|
||||
public List<MagicShelf> getUserShelves() {
|
||||
Long userId = authenticationService.getAuthenticatedUser().getId();
|
||||
return repository.findAllByUserId(userId).stream()
|
||||
|
||||
List<MagicShelf> shelves = repository.findAllByUserId(userId).stream()
|
||||
.map(this::toDto)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<Long> userShelfIds = shelves.stream().map(MagicShelf::getId).toList();
|
||||
|
||||
List<MagicShelf> publicShelves = repository.findAllByIsPublicIsTrue().stream()
|
||||
.map(this::toDto)
|
||||
.filter(shelf -> !userShelfIds.contains(shelf.getId()))
|
||||
.toList();
|
||||
|
||||
shelves.addAll(publicShelves);
|
||||
return shelves;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -32,9 +44,13 @@ public class MagicShelfService {
|
||||
if (!existing.getUserId().equals(userId)) {
|
||||
throw new SecurityException("You are not authorized to update this shelf");
|
||||
}
|
||||
if (existing.isPublic() && !authenticationService.getAuthenticatedUser().getPermissions().isAdmin()) {
|
||||
throw new SecurityException("You are not authorized to update a public shelf");
|
||||
}
|
||||
existing.setName(dto.getName());
|
||||
existing.setIcon(dto.getIcon());
|
||||
existing.setFilterJson(dto.getFilterJson());
|
||||
existing.setPublic(dto.getIsPublic());
|
||||
return toDto(repository.save(existing));
|
||||
}
|
||||
if (repository.existsByUserIdAndName(userId, dto.getName())) {
|
||||
@@ -59,6 +75,7 @@ public class MagicShelfService {
|
||||
dto.setName(entity.getName());
|
||||
dto.setIcon(entity.getIcon());
|
||||
dto.setFilterJson(entity.getFilterJson());
|
||||
dto.setIsPublic(entity.isPublic());
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -68,6 +85,7 @@ public class MagicShelfService {
|
||||
entity.setName(dto.getName());
|
||||
entity.setIcon(dto.getIcon());
|
||||
entity.setFilterJson(dto.getFilterJson());
|
||||
entity.setPublic(dto.getIsPublic());
|
||||
entity.setUserId(userId);
|
||||
return entity;
|
||||
}
|
||||
@@ -76,4 +94,4 @@ public class MagicShelfService {
|
||||
MagicShelfEntity shelf = repository.findById(id).orElseThrow(() -> new IllegalArgumentException("Shelf not found"));
|
||||
return toDto(shelf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -24,16 +23,20 @@ import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Slf4j
|
||||
@AllArgsConstructor
|
||||
@Service
|
||||
public class BookDownloadService {
|
||||
|
||||
private static final Pattern NON_ASCII_PATTERN = Pattern.compile("[^\\x00-\\x7F]");
|
||||
|
||||
private final BookRepository bookRepository;
|
||||
private final KepubConversionService kepubConversionService;
|
||||
private final AppSettingService appSettingService;
|
||||
@@ -53,11 +56,11 @@ public class BookDownloadService {
|
||||
InputStream inputStream = new FileInputStream(bookFile);
|
||||
InputStreamResource resource = new InputStreamResource(inputStream);
|
||||
|
||||
String contentDisposition = ContentDisposition.builder("attachment")
|
||||
.filename(file.getFileName().toString(), StandardCharsets.UTF_8)
|
||||
.build()
|
||||
.toString();
|
||||
|
||||
String encodedFilename = URLEncoder.encode(file.getFileName().toString(), StandardCharsets.UTF_8)
|
||||
.replace("+", "%20");
|
||||
String fallbackFilename = NON_ASCII_PATTERN.matcher(file.getFileName().toString()).replaceAll("_");
|
||||
String contentDisposition = String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s",
|
||||
fallbackFilename, encodedFilename);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.contentLength(bookFile.length())
|
||||
@@ -113,10 +116,10 @@ public class BookDownloadService {
|
||||
private void setResponseHeaders(HttpServletResponse response, File file) {
|
||||
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
|
||||
response.setContentLengthLong(file.length());
|
||||
String contentDisposition = ContentDisposition.builder("attachment")
|
||||
.filename(file.getName(), StandardCharsets.UTF_8)
|
||||
.build()
|
||||
.toString();
|
||||
String encodedFilename = URLEncoder.encode(file.getName(), StandardCharsets.UTF_8).replace("+", "%20");
|
||||
String fallbackFilename = NON_ASCII_PATTERN.matcher(file.getName()).replaceAll("_");
|
||||
String contentDisposition = String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s",
|
||||
fallbackFilename, encodedFilename);
|
||||
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, contentDisposition);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.adityachandel.booklore.model.dto.*;
|
||||
import com.adityachandel.booklore.model.dto.progress.CbxProgress;
|
||||
import com.adityachandel.booklore.model.dto.progress.EpubProgress;
|
||||
import com.adityachandel.booklore.model.dto.progress.KoProgress;
|
||||
import com.adityachandel.booklore.model.dto.progress.KoboProgress;
|
||||
import com.adityachandel.booklore.model.dto.progress.PdfProgress;
|
||||
import com.adityachandel.booklore.model.dto.request.ReadProgressRequest;
|
||||
import com.adityachandel.booklore.model.dto.response.BookDeletionResponse;
|
||||
@@ -64,6 +65,12 @@ public class BookService {
|
||||
|
||||
|
||||
private void setBookProgress(Book book, UserBookProgressEntity progress) {
|
||||
if (progress.getKoboProgressPercent() != null) {
|
||||
book.setKoboProgress(KoboProgress.builder()
|
||||
.percentage(progress.getKoboProgressPercent())
|
||||
.build());
|
||||
}
|
||||
|
||||
switch (book.getBookType()) {
|
||||
case EPUB -> {
|
||||
book.setEpubProgress(EpubProgress.builder()
|
||||
@@ -149,6 +156,12 @@ public class BookService {
|
||||
book.setShelves(filterShelvesByUserId(book.getShelves(), user.getId()));
|
||||
book.setLastReadTime(userProgress.getLastReadTime());
|
||||
|
||||
if (userProgress.getKoboProgressPercent() != null) {
|
||||
book.setKoboProgress(KoboProgress.builder()
|
||||
.percentage(userProgress.getKoboProgressPercent())
|
||||
.build());
|
||||
}
|
||||
|
||||
if (bookEntity.getBookType() == BookFileType.PDF) {
|
||||
book.setPdfProgress(PdfProgress.builder()
|
||||
.page(userProgress.getPdfProgress())
|
||||
@@ -456,6 +469,12 @@ public class BookService {
|
||||
progress.setKoreaderDeviceId(null);
|
||||
progress.setKoreaderDevice(null);
|
||||
progress.setKoreaderLastSyncTime(null);
|
||||
} else if (type == ResetProgressType.KOBO) {
|
||||
progress.setKoboProgressPercent(null);
|
||||
progress.setKoboLocation(null);
|
||||
progress.setKoboLocationType(null);
|
||||
progress.setKoboLocationSource(null);
|
||||
progress.setKoboLastSyncTime(null);
|
||||
}
|
||||
userBookProgressRepository.save(progress);
|
||||
updatedBooks.add(bookMapper.toBook(bookEntity));
|
||||
@@ -587,35 +606,36 @@ public class BookService {
|
||||
}
|
||||
|
||||
public void deleteEmptyParentDirsUpToLibraryFolders(Path currentDir, Set<Path> libraryRoots) throws IOException {
|
||||
Path dir = currentDir;
|
||||
Set<String> ignoredFilenames = Set.of(".DS_Store", "Thumbs.db");
|
||||
currentDir = currentDir.toAbsolutePath().normalize();
|
||||
dir = dir.toAbsolutePath().normalize();
|
||||
|
||||
Set<Path> normalizedRoots = new HashSet<>();
|
||||
for (Path root : libraryRoots) {
|
||||
normalizedRoots.add(root.toAbsolutePath().normalize());
|
||||
}
|
||||
|
||||
while (currentDir != null) {
|
||||
while (dir != null) {
|
||||
boolean isLibraryRoot = false;
|
||||
for (Path root : normalizedRoots) {
|
||||
try {
|
||||
if (Files.isSameFile(root, currentDir)) {
|
||||
if (Files.isSameFile(root, dir)) {
|
||||
isLibraryRoot = true;
|
||||
break;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to compare paths: {} and {}", root, currentDir);
|
||||
log.warn("Failed to compare paths: {} and {}", root, dir);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLibraryRoot) {
|
||||
log.debug("Reached library root: {}. Stopping cleanup.", currentDir);
|
||||
log.debug("Reached library root: {}. Stopping cleanup.", dir);
|
||||
break;
|
||||
}
|
||||
|
||||
File[] files = currentDir.toFile().listFiles();
|
||||
File[] files = dir.toFile().listFiles();
|
||||
if (files == null) {
|
||||
log.warn("Cannot read directory: {}. Stopping cleanup.", currentDir);
|
||||
log.warn("Cannot read directory: {}. Stopping cleanup.", dir);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -637,15 +657,15 @@ public class BookService {
|
||||
}
|
||||
}
|
||||
try {
|
||||
Files.delete(currentDir);
|
||||
log.info("Deleted empty directory: {}", currentDir);
|
||||
Files.delete(dir);
|
||||
log.info("Deleted empty directory: {}", dir);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete directory: {}", currentDir, e);
|
||||
log.warn("Failed to delete directory: {}", dir, e);
|
||||
break;
|
||||
}
|
||||
currentDir = currentDir.getParent();
|
||||
dir = dir.getParent();
|
||||
} else {
|
||||
log.debug("Directory {} contains important files. Stopping cleanup.", currentDir);
|
||||
log.debug("Directory {} contains important files. Stopping cleanup.", dir);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,9 @@ public class BookDropService {
|
||||
private BookdropFinalizeResult processFinalizationRequest(BookdropFinalizeRequest request) {
|
||||
BookdropFinalizeResult results = BookdropFinalizeResult.builder()
|
||||
.processedAt(Instant.now())
|
||||
.totalFiles(0)
|
||||
.successfullyImported(0)
|
||||
.failed(0)
|
||||
.build();
|
||||
|
||||
Long defaultLibraryId = request.getDefaultLibraryId();
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.adityachandel.booklore.model.dto.request.SendBookByEmailRequest;
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.model.entity.EmailProviderV2Entity;
|
||||
import com.adityachandel.booklore.model.entity.EmailRecipientV2Entity;
|
||||
import com.adityachandel.booklore.model.entity.UserEmailProviderPreferenceEntity;
|
||||
import com.adityachandel.booklore.model.websocket.LogNotification;
|
||||
import com.adityachandel.booklore.model.websocket.Topic;
|
||||
import com.adityachandel.booklore.repository.BookRepository;
|
||||
@@ -179,7 +180,7 @@ public class SendEmailV2Service {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
|
||||
Long defaultProviderId = preferenceRepository.findByUserId(user.getId())
|
||||
.map(pref -> pref.getDefaultProviderId())
|
||||
.map(UserEmailProviderPreferenceEntity::getDefaultProviderId)
|
||||
.orElseThrow(ApiError.DEFAULT_EMAIL_PROVIDER_NOT_FOUND::createException);
|
||||
|
||||
return emailProviderRepository.findAccessibleProvider(defaultProviderId, user.getId())
|
||||
|
||||
@@ -10,7 +10,6 @@ import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.UrlResource;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -18,17 +17,21 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Slf4j
|
||||
@AllArgsConstructor
|
||||
@Service
|
||||
public class AdditionalFileService {
|
||||
|
||||
private static final Pattern NON_ASCII = Pattern.compile("[^\\x00-\\x7F]");
|
||||
|
||||
private final BookAdditionalFileRepository additionalFileRepository;
|
||||
private final AdditionalFileMapper additionalFileMapper;
|
||||
private final MonitoringRegistrationService monitoringRegistrationService;
|
||||
@@ -80,11 +83,10 @@ public class AdditionalFileService {
|
||||
|
||||
Resource resource = new UrlResource(filePath.toUri());
|
||||
|
||||
String contentDisposition = ContentDisposition.builder("attachment")
|
||||
.filename(file.getFileName(), StandardCharsets.UTF_8)
|
||||
.build()
|
||||
.toString();
|
||||
|
||||
String encodedFilename = URLEncoder.encode(file.getFileName(), StandardCharsets.UTF_8).replace("+", "%20");
|
||||
String fallbackFilename = NON_ASCII.matcher(file.getFileName()).replaceAll("_");
|
||||
String contentDisposition = String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s",
|
||||
fallbackFilename, encodedFilename);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
|
||||
|
||||
@@ -80,24 +80,25 @@ public class FileMoveHelper {
|
||||
}
|
||||
|
||||
public void deleteEmptyParentDirsUpToLibraryFolders(Path currentDir, Set<Path> libraryRoots) throws IOException {
|
||||
Path dir = currentDir;
|
||||
Set<String> ignoredFilenames = Set.of(".DS_Store", "Thumbs.db");
|
||||
currentDir = currentDir.toAbsolutePath().normalize();
|
||||
dir = dir.toAbsolutePath().normalize();
|
||||
Set<Path> normalizedRoots = new HashSet<>();
|
||||
for (Path root : libraryRoots) {
|
||||
normalizedRoots.add(root.toAbsolutePath().normalize());
|
||||
}
|
||||
while (currentDir != null) {
|
||||
if (isLibraryRoot(currentDir, normalizedRoots)) {
|
||||
while (dir != null) {
|
||||
if (isLibraryRoot(dir, normalizedRoots)) {
|
||||
break;
|
||||
}
|
||||
File[] files = currentDir.toFile().listFiles();
|
||||
File[] files = dir.toFile().listFiles();
|
||||
if (files == null) {
|
||||
log.warn("Cannot read directory: {}. Stopping cleanup.", currentDir);
|
||||
log.warn("Cannot read directory: {}. Stopping cleanup.", dir);
|
||||
break;
|
||||
}
|
||||
if (hasOnlyIgnoredFiles(files, ignoredFilenames)) {
|
||||
deleteIgnoredFilesAndDirectory(files, currentDir);
|
||||
currentDir = currentDir.getParent();
|
||||
deleteIgnoredFilesAndDirectory(files, dir);
|
||||
dir = dir.getParent();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -66,10 +66,7 @@ public class FileMoveService {
|
||||
LibraryPathEntity libraryPathEntity = optionalLibraryPathEntity.get();
|
||||
|
||||
LibraryEntity sourceLibrary = bookEntity.getLibrary();
|
||||
if (sourceLibrary.getId().equals(targetLibrary.getId())) {
|
||||
continue;
|
||||
}
|
||||
if (!sourceLibraryIds.contains(sourceLibrary.getId())) {
|
||||
if (!sourceLibrary.getId().equals(targetLibrary.getId()) && !sourceLibraryIds.contains(sourceLibrary.getId())) {
|
||||
monitoringRegistrationService.unregisterLibraries(Collections.singleton(sourceLibrary.getId()));
|
||||
sourceLibraryIds.add(sourceLibrary.getId());
|
||||
}
|
||||
@@ -101,15 +98,11 @@ public class FileMoveService {
|
||||
|
||||
for (Long libraryId : targetLibraryIds) {
|
||||
Optional<LibraryEntity> optionalLibrary = libraryRepository.findById(libraryId);
|
||||
optionalLibrary.ifPresent(library -> {
|
||||
monitoringRegistrationService.registerLibrary(libraryMapper.toLibrary(library));
|
||||
});
|
||||
optionalLibrary.ifPresent(library -> monitoringRegistrationService.registerLibrary(libraryMapper.toLibrary(library)));
|
||||
}
|
||||
for (Long libraryId : sourceLibraryIds) {
|
||||
Optional<LibraryEntity> optionalLibrary = libraryRepository.findById(libraryId);
|
||||
optionalLibrary.ifPresent(library -> {
|
||||
monitoringRegistrationService.registerLibrary(libraryMapper.toLibrary(library));
|
||||
});
|
||||
optionalLibrary.ifPresent(library -> monitoringRegistrationService.registerLibrary(libraryMapper.toLibrary(library)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce
|
||||
}
|
||||
|
||||
private Optional<BufferedImage> extractFirstImageFromZip(File file) {
|
||||
try (ZipFile zipFile = new ZipFile(file)) {
|
||||
try (ZipFile zipFile = ZipFile.builder().setFile(file).get()) {
|
||||
return Collections.list(zipFile.getEntries()).stream()
|
||||
.filter(e -> !e.isDirectory() && IMAGE_EXTENSION_CASE_INSENSITIVE_PATTERN.matcher(e.getName()).matches())
|
||||
.min(Comparator.comparing(ZipArchiveEntry::getName))
|
||||
@@ -126,7 +126,7 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce
|
||||
}
|
||||
|
||||
private Optional<BufferedImage> extractFirstImageFrom7z(File file) {
|
||||
try (SevenZFile sevenZFile = new SevenZFile(file)) {
|
||||
try (SevenZFile sevenZFile = SevenZFile.builder().setFile(file).get()) {
|
||||
List<SevenZArchiveEntry> imageEntries = new ArrayList<>();
|
||||
SevenZArchiveEntry entry;
|
||||
while ((entry = sevenZFile.getNextEntry()) != null) {
|
||||
@@ -136,7 +136,7 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce
|
||||
}
|
||||
imageEntries.sort(Comparator.comparing(SevenZArchiveEntry::getName));
|
||||
|
||||
try (SevenZFile sevenZFileReset = new SevenZFile(file)) {
|
||||
try (SevenZFile sevenZFileReset = SevenZFile.builder().setFile(file).get()) {
|
||||
for (SevenZArchiveEntry imgEntry : imageEntries) {
|
||||
SevenZArchiveEntry current;
|
||||
while ((current = sevenZFileReset.getNextEntry()) != null) {
|
||||
@@ -162,8 +162,8 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce
|
||||
private Optional<BufferedImage> extractFirstImageFromRar(File file) {
|
||||
try (Archive archive = new Archive(file)) {
|
||||
List<FileHeader> imageHeaders = archive.getFileHeaders().stream()
|
||||
.filter(h -> !h.isDirectory() && IMAGE_EXTENSION_PATTERN.matcher(h.getFileNameString().toLowerCase()).matches())
|
||||
.sorted(Comparator.comparing(FileHeader::getFileNameString))
|
||||
.filter(h -> !h.isDirectory() && IMAGE_EXTENSION_PATTERN.matcher(h.getFileName().toLowerCase()).matches())
|
||||
.sorted(Comparator.comparing(FileHeader::getFileName))
|
||||
.toList();
|
||||
|
||||
for (FileHeader header : imageHeaders) {
|
||||
@@ -171,7 +171,7 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce
|
||||
archive.extractFile(header, baos);
|
||||
return Optional.ofNullable(ImageIO.read(new ByteArrayInputStream(baos.toByteArray())));
|
||||
} catch (Exception e) {
|
||||
log.warn("Error reading RAR entry {}: {}", header.getFileNameString(), e.getMessage());
|
||||
log.warn("Error reading RAR entry {}: {}", header.getFileName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -121,7 +121,7 @@ public class EpubProcessor extends AbstractFileProcessor implements BookFileProc
|
||||
metadata.setPageCount(epubMetadata.getPageCount());
|
||||
|
||||
String lang = epubMetadata.getLanguage();
|
||||
metadata.setLanguage(truncate((lang == null || lang.equalsIgnoreCase("UND")) ? "en" : lang, 1000));
|
||||
metadata.setLanguage(truncate((lang == null || "UND".equalsIgnoreCase(lang)) ? "en" : lang, 1000));
|
||||
|
||||
metadata.setAsin(truncate(epubMetadata.getAsin(), 20));
|
||||
metadata.setPersonalRating(epubMetadata.getPersonalRating());
|
||||
|
||||
@@ -28,8 +28,8 @@ public class KoboDeviceAuthService {
|
||||
log.info("Kobo device authentication request received: {}", requestBody);
|
||||
|
||||
KoboAuthentication auth = new KoboAuthentication();
|
||||
auth.setAccessToken(RandomStringUtils.randomAlphanumeric(24));
|
||||
auth.setRefreshToken(RandomStringUtils.randomAlphanumeric(24));
|
||||
auth.setAccessToken(RandomStringUtils.secure().nextAlphanumeric(24));
|
||||
auth.setRefreshToken(RandomStringUtils.secure().nextAlphanumeric(24));
|
||||
auth.setTrackingId(UUID.randomUUID().toString());
|
||||
auth.setUserKey(requestBody.get("UserKey").asText());
|
||||
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
package com.adityachandel.booklore.service.kobo;
|
||||
|
||||
import com.adityachandel.booklore.config.security.service.AuthenticationService;
|
||||
import com.adityachandel.booklore.mapper.KoboReadingStateMapper;
|
||||
import com.adityachandel.booklore.model.dto.BookLoreUser;
|
||||
import com.adityachandel.booklore.model.dto.kobo.*;
|
||||
import com.adityachandel.booklore.model.dto.settings.KoboSettings;
|
||||
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.UserBookProgressEntity;
|
||||
import com.adityachandel.booklore.model.enums.BookFileType;
|
||||
import com.adityachandel.booklore.model.enums.KoboBookFormat;
|
||||
import com.adityachandel.booklore.model.enums.KoboReadStatus;
|
||||
import com.adityachandel.booklore.repository.KoboReadingStateRepository;
|
||||
import com.adityachandel.booklore.repository.UserBookProgressRepository;
|
||||
import com.adityachandel.booklore.service.book.BookQueryService;
|
||||
import com.adityachandel.booklore.service.appsettings.AppSettingService;
|
||||
import com.adityachandel.booklore.util.kobo.KoboUrlBuilder;
|
||||
@@ -32,6 +38,11 @@ public class KoboEntitlementService {
|
||||
private final KoboUrlBuilder koboUrlBuilder;
|
||||
private final BookQueryService bookQueryService;
|
||||
private final AppSettingService appSettingService;
|
||||
private final UserBookProgressRepository progressRepository;
|
||||
private final KoboReadingStateRepository readingStateRepository;
|
||||
private final KoboReadingStateMapper readingStateMapper;
|
||||
private final AuthenticationService authenticationService;
|
||||
private final KoboReadingStateBuilder readingStateBuilder;
|
||||
|
||||
public List<NewEntitlement> generateNewEntitlements(Set<Long> bookIds, String token, boolean removed) {
|
||||
List<BookEntity> books = bookQueryService.findAllWithMetadataByIds(bookIds);
|
||||
@@ -79,9 +90,25 @@ public class KoboEntitlementService {
|
||||
private KoboReadingState createInitialReadingState(BookEntity book) {
|
||||
OffsetDateTime now = getCurrentUtc();
|
||||
OffsetDateTime createdOn = getCreatedOn(book);
|
||||
String entitlementId = String.valueOf(book.getId());
|
||||
|
||||
KoboReadingState existingState = readingStateRepository.findByEntitlementId(entitlementId)
|
||||
.map(readingStateMapper::toDto)
|
||||
.orElse(null);
|
||||
|
||||
KoboReadingState.CurrentBookmark bookmark;
|
||||
if (existingState != null && existingState.getCurrentBookmark() != null) {
|
||||
bookmark = existingState.getCurrentBookmark();
|
||||
} else {
|
||||
bookmark = progressRepository
|
||||
.findByUserIdAndBookId(authenticationService.getAuthenticatedUser().getId(), book.getId())
|
||||
.filter(progress -> progress.getKoboProgressPercent() != null)
|
||||
.map(progress -> readingStateBuilder.buildBookmarkFromProgress(progress, now))
|
||||
.orElseGet(() -> readingStateBuilder.buildEmptyBookmark(now));
|
||||
}
|
||||
|
||||
return KoboReadingState.builder()
|
||||
.entitlementId(String.valueOf(book.getId()))
|
||||
.entitlementId(entitlementId)
|
||||
.created(createdOn.toString())
|
||||
.lastModified(now.toString())
|
||||
.statusInfo(KoboReadingState.StatusInfo.builder()
|
||||
@@ -89,9 +116,7 @@ public class KoboEntitlementService {
|
||||
.status(KoboReadStatus.READY_TO_READ)
|
||||
.timesStartedReading(0)
|
||||
.build())
|
||||
.currentBookmark(KoboReadingState.CurrentBookmark.builder()
|
||||
.lastModified(now.toString())
|
||||
.build())
|
||||
.currentBookmark(bookmark)
|
||||
.statistics(KoboReadingState.Statistics.builder()
|
||||
.lastModified(now.toString())
|
||||
.build())
|
||||
|
||||
@@ -47,13 +47,12 @@ public class KoboLibrarySyncService {
|
||||
|
||||
if (prevSnapshot.isPresent()) {
|
||||
int maxRemaining = 5;
|
||||
List<KoboSnapshotBookEntity> addedAll = new ArrayList<>();
|
||||
List<KoboSnapshotBookEntity> removedAll = new ArrayList<>();
|
||||
|
||||
koboLibrarySnapshotService.updateSyncedStatusForExistingBooks(prevSnapshot.get().getId(), currSnapshot.getId());
|
||||
|
||||
Page<KoboSnapshotBookEntity> addedPage = koboLibrarySnapshotService.getNewlyAddedBooks(prevSnapshot.get().getId(), currSnapshot.getId(), PageRequest.of(0, maxRemaining), user.getId());
|
||||
addedAll.addAll(addedPage.getContent());
|
||||
List<KoboSnapshotBookEntity> addedAll = new ArrayList<>(addedPage.getContent());
|
||||
maxRemaining -= addedPage.getNumberOfElements();
|
||||
shouldContinueSync = addedPage.hasNext();
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.adityachandel.booklore.service.kobo;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.kobo.KoboReadingState;
|
||||
import com.adityachandel.booklore.model.entity.UserBookProgressEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Optional;
|
||||
|
||||
@Component
|
||||
public class KoboReadingStateBuilder {
|
||||
|
||||
public KoboReadingState.CurrentBookmark buildEmptyBookmark(OffsetDateTime timestamp) {
|
||||
return KoboReadingState.CurrentBookmark.builder()
|
||||
.lastModified(timestamp.toString())
|
||||
.build();
|
||||
}
|
||||
|
||||
public KoboReadingState.CurrentBookmark buildBookmarkFromProgress(UserBookProgressEntity progress) {
|
||||
return buildBookmarkFromProgress(progress, null);
|
||||
}
|
||||
|
||||
public KoboReadingState.CurrentBookmark buildBookmarkFromProgress(UserBookProgressEntity progress, OffsetDateTime defaultTime) {
|
||||
KoboReadingState.CurrentBookmark.Location location = Optional.ofNullable(progress.getKoboLocation())
|
||||
.map(loc -> KoboReadingState.CurrentBookmark.Location.builder()
|
||||
.value(loc)
|
||||
.type(progress.getKoboLocationType())
|
||||
.source(progress.getKoboLocationSource())
|
||||
.build())
|
||||
.orElse(null);
|
||||
|
||||
String lastModified = Optional.ofNullable(progress.getKoboLastSyncTime())
|
||||
.map(this::formatTimestamp)
|
||||
.or(() -> Optional.ofNullable(defaultTime).map(OffsetDateTime::toString))
|
||||
.orElse(null);
|
||||
|
||||
return KoboReadingState.CurrentBookmark.builder()
|
||||
.progressPercent(Optional.ofNullable(progress.getKoboProgressPercent())
|
||||
.map(Math::round)
|
||||
.orElse(null))
|
||||
.location(location)
|
||||
.lastModified(lastModified)
|
||||
.build();
|
||||
}
|
||||
|
||||
public KoboReadingState buildReadingStateFromProgress(String entitlementId, UserBookProgressEntity progress) {
|
||||
KoboReadingState.CurrentBookmark bookmark = buildBookmarkFromProgress(progress);
|
||||
String lastModified = bookmark.getLastModified();
|
||||
|
||||
return KoboReadingState.builder()
|
||||
.entitlementId(entitlementId)
|
||||
.currentBookmark(bookmark)
|
||||
.created(lastModified)
|
||||
.lastModified(lastModified)
|
||||
.build();
|
||||
}
|
||||
|
||||
private String formatTimestamp(Instant instant) {
|
||||
return instant.atOffset(ZoneOffset.UTC).toString();
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,46 @@
|
||||
package com.adityachandel.booklore.service.kobo;
|
||||
|
||||
import com.adityachandel.booklore.config.security.service.AuthenticationService;
|
||||
import com.adityachandel.booklore.mapper.KoboReadingStateMapper;
|
||||
import com.adityachandel.booklore.model.dto.BookLoreUser;
|
||||
import com.adityachandel.booklore.model.dto.KoboSyncSettings;
|
||||
import com.adityachandel.booklore.model.dto.kobo.KoboReadingState;
|
||||
import com.adityachandel.booklore.model.dto.kobo.KoboReadingStateWrapper;
|
||||
import com.adityachandel.booklore.model.dto.response.kobo.KoboReadingStateResponse;
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
|
||||
import com.adityachandel.booklore.model.entity.KoboReadingStateEntity;
|
||||
import com.adityachandel.booklore.model.entity.UserBookProgressEntity;
|
||||
import com.adityachandel.booklore.model.enums.ReadStatus;
|
||||
import com.adityachandel.booklore.repository.BookRepository;
|
||||
import com.adityachandel.booklore.repository.KoboReadingStateRepository;
|
||||
import com.adityachandel.booklore.repository.UserBookProgressRepository;
|
||||
import com.adityachandel.booklore.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class KoboReadingStateService {
|
||||
|
||||
private final KoboReadingStateRepository repository;
|
||||
private final KoboReadingStateMapper mapper;
|
||||
private final UserBookProgressRepository progressRepository;
|
||||
private final BookRepository bookRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final AuthenticationService authenticationService;
|
||||
private final KoboSettingsService koboSettingsService;
|
||||
private final KoboReadingStateBuilder readingStateBuilder;
|
||||
|
||||
@Transactional
|
||||
public KoboReadingStateResponse saveReadingState(List<KoboReadingState> readingStates) {
|
||||
List<KoboReadingState> koboReadingStates = saveAll(readingStates);
|
||||
|
||||
@@ -39,6 +60,8 @@ public class KoboReadingStateService {
|
||||
}
|
||||
|
||||
private List<KoboReadingState> saveAll(List<KoboReadingState> dtos) {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
|
||||
return dtos.stream()
|
||||
.map(dto -> {
|
||||
KoboReadingStateEntity entity = repository.findByEntitlementId(dto.getEntitlementId())
|
||||
@@ -55,16 +78,118 @@ public class KoboReadingStateService {
|
||||
return newEntity;
|
||||
});
|
||||
|
||||
return repository.save(entity);
|
||||
KoboReadingStateEntity savedEntity = repository.save(entity);
|
||||
|
||||
syncKoboProgressToUserBookProgress(dto, user.getId());
|
||||
|
||||
return savedEntity;
|
||||
})
|
||||
.map(mapper::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public KoboReadingStateWrapper getReadingState(String entitlementId) {
|
||||
Optional<KoboReadingState> readingState = repository.findByEntitlementId(entitlementId).map(mapper::toDto);
|
||||
Optional<KoboReadingState> readingState = repository.findByEntitlementId(entitlementId)
|
||||
.map(mapper::toDto)
|
||||
.or(() -> constructReadingStateFromProgress(entitlementId));
|
||||
|
||||
return readingState.map(state -> KoboReadingStateWrapper.builder()
|
||||
.readingStates(List.of(state))
|
||||
.build()).orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<KoboReadingState> constructReadingStateFromProgress(String entitlementId) {
|
||||
try {
|
||||
Long bookId = Long.parseLong(entitlementId);
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
|
||||
return progressRepository.findByUserIdAndBookId(user.getId(), bookId)
|
||||
.filter(progress -> progress.getKoboProgressPercent() != null || progress.getKoboLocation() != null)
|
||||
.map(progress -> {
|
||||
log.info("Constructed reading state from UserBookProgress for book: {}, progress: {}%",
|
||||
entitlementId, progress.getKoboProgressPercent());
|
||||
return readingStateBuilder.buildReadingStateFromProgress(entitlementId, progress);
|
||||
});
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("Invalid entitlement ID format when constructing reading state: {}", entitlementId);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private void syncKoboProgressToUserBookProgress(KoboReadingState readingState, Long userId) {
|
||||
try {
|
||||
Long bookId = Long.parseLong(readingState.getEntitlementId());
|
||||
|
||||
Optional<BookEntity> bookOpt = bookRepository.findById(bookId);
|
||||
if (bookOpt.isEmpty()) {
|
||||
log.warn("Book not found for entitlement ID: {}", readingState.getEntitlementId());
|
||||
return;
|
||||
}
|
||||
|
||||
BookEntity book = bookOpt.get();
|
||||
Optional<BookLoreUserEntity> userOpt = userRepository.findById(userId);
|
||||
if (userOpt.isEmpty()) {
|
||||
log.warn("User not found: {}", userId);
|
||||
return;
|
||||
}
|
||||
|
||||
UserBookProgressEntity progress = progressRepository.findByUserIdAndBookId(userId, bookId)
|
||||
.orElseGet(() -> {
|
||||
UserBookProgressEntity newProgress = new UserBookProgressEntity();
|
||||
newProgress.setUser(userOpt.get());
|
||||
newProgress.setBook(book);
|
||||
return newProgress;
|
||||
});
|
||||
|
||||
KoboReadingState.CurrentBookmark bookmark = readingState.getCurrentBookmark();
|
||||
if (bookmark != null) {
|
||||
if (bookmark.getProgressPercent() != null) {
|
||||
progress.setKoboProgressPercent(bookmark.getProgressPercent().floatValue());
|
||||
}
|
||||
|
||||
KoboReadingState.CurrentBookmark.Location location = bookmark.getLocation();
|
||||
if (location != null) {
|
||||
progress.setKoboLocation(location.getValue());
|
||||
progress.setKoboLocationType(location.getType());
|
||||
progress.setKoboLocationSource(location.getSource());
|
||||
}
|
||||
}
|
||||
|
||||
progress.setKoboLastSyncTime(Instant.now());
|
||||
progress.setLastReadTime(Instant.now());
|
||||
|
||||
if (progress.getKoboProgressPercent() != null) {
|
||||
updateKoboReadStatus(progress, progress.getKoboProgressPercent() / 100.0);
|
||||
}
|
||||
|
||||
progressRepository.save(progress);
|
||||
|
||||
log.info("Synced Kobo progress to BookLore: userId={}, bookId={}, progress={}%",
|
||||
userId, bookId, progress.getKoboProgressPercent());
|
||||
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("Invalid entitlement ID format: {}", readingState.getEntitlementId());
|
||||
}
|
||||
}
|
||||
|
||||
private void updateKoboReadStatus(UserBookProgressEntity userProgress, double progressFraction) {
|
||||
KoboSyncSettings settings = koboSettingsService.getCurrentUserSettings();
|
||||
|
||||
double progressPercent = progressFraction * 100.0;
|
||||
float finishedThreshold = settings.getProgressMarkAsFinishedThreshold() != null
|
||||
? settings.getProgressMarkAsFinishedThreshold()
|
||||
: 99f;
|
||||
float readingThreshold = settings.getProgressMarkAsReadingThreshold() != null
|
||||
? settings.getProgressMarkAsReadingThreshold()
|
||||
: 1f;
|
||||
|
||||
if (progressPercent >= finishedThreshold) {
|
||||
userProgress.setReadStatus(ReadStatus.READ);
|
||||
userProgress.setDateFinished(Instant.now());
|
||||
} else if (progressPercent >= readingThreshold) {
|
||||
userProgress.setReadStatus(ReadStatus.READING);
|
||||
} else {
|
||||
userProgress.setReadStatus(ReadStatus.UNREAD);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,23 @@ public class KoboSettingsService {
|
||||
repository.save(entity);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public KoboSyncSettings updateProgressThresholds(Float readingThreshold, Float finishedThreshold) {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
KoboUserSettingsEntity entity = repository.findByUserId(user.getId())
|
||||
.orElseGet(() -> initDefaultSettings(user.getId()));
|
||||
|
||||
if (readingThreshold != null) {
|
||||
entity.setProgressMarkAsReadingThreshold(readingThreshold);
|
||||
}
|
||||
if (finishedThreshold != null) {
|
||||
entity.setProgressMarkAsFinishedThreshold(finishedThreshold);
|
||||
}
|
||||
|
||||
repository.save(entity);
|
||||
return mapToDto(entity);
|
||||
}
|
||||
|
||||
private KoboUserSettingsEntity initDefaultSettings(Long userId) {
|
||||
ensureKoboShelfExists(userId);
|
||||
KoboUserSettingsEntity entity = KoboUserSettingsEntity.builder()
|
||||
@@ -103,6 +120,8 @@ public class KoboSettingsService {
|
||||
dto.setUserId(entity.getUserId().toString());
|
||||
dto.setToken(entity.getToken());
|
||||
dto.setSyncEnabled(entity.isSyncEnabled());
|
||||
dto.setProgressMarkAsReadingThreshold(entity.getProgressMarkAsReadingThreshold());
|
||||
dto.setProgressMarkAsFinishedThreshold(entity.getProgressMarkAsFinishedThreshold());
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,12 @@ public class KoreaderService {
|
||||
progress.getKoreaderProgress(), progress.getKoreaderProgressPercent(),
|
||||
authDetails.getBookLoreUserId(), bookHash);
|
||||
|
||||
Long timestamp = progress.getKoreaderLastSyncTime() != null
|
||||
? progress.getKoreaderLastSyncTime().getEpochSecond()
|
||||
: null;
|
||||
|
||||
return KoreaderProgress.builder()
|
||||
.timestamp(timestamp)
|
||||
.document(bookHash)
|
||||
.progress(progress.getKoreaderProgress())
|
||||
.percentage(progress.getKoreaderProgressPercent())
|
||||
|
||||
@@ -90,6 +90,8 @@ public class LibraryRescanHelper {
|
||||
.replaceMode(context.getOptions().getMetadataReplaceMode())
|
||||
.updateThumbnail(false)
|
||||
.mergeCategories(false)
|
||||
.mergeMoods(true)
|
||||
.mergeTags(true)
|
||||
.build();
|
||||
bookMetadataUpdater.setBookMetadata(metadataUpdateContext);
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -29,7 +29,7 @@ import org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URL;
|
||||
import java.net.URI;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
@@ -94,6 +94,8 @@ public class BookMetadataUpdater {
|
||||
boolean convertCbrCb7ToCbz = settings.isConvertCbrCb7ToCbz();
|
||||
BookFileType bookType = bookEntity.getBookType();
|
||||
|
||||
boolean hasValueChangesForFileWrite = MetadataChangeDetector.hasValueChangesForFileWrite(newMetadata, metadata, clearFlags);
|
||||
|
||||
updateBasicFields(newMetadata, metadata, clearFlags, replaceMode);
|
||||
updateAuthorsIfNeeded(newMetadata, metadata, clearFlags, mergeCategories, replaceMode);
|
||||
updateCategoriesIfNeeded(newMetadata, metadata, clearFlags, mergeCategories, replaceMode);
|
||||
@@ -111,7 +113,6 @@ public class BookMetadataUpdater {
|
||||
log.warn("Failed to calculate metadata match score for book ID {}: {}", bookId, e.getMessage());
|
||||
}
|
||||
|
||||
boolean hasValueChangesForFileWrite = MetadataChangeDetector.hasValueChangesForFileWrite(newMetadata, metadata, clearFlags);
|
||||
if ((writeToFile && hasValueChangesForFileWrite) || thumbnailRequiresUpdate) {
|
||||
metadataWriterFactory.getWriter(bookType).ifPresent(writer -> {
|
||||
try {
|
||||
@@ -401,8 +402,8 @@ public class BookMetadataUpdater {
|
||||
|
||||
private boolean isLocalOrPrivateUrl(String url) {
|
||||
try {
|
||||
URL parsed = new URL(url);
|
||||
String host = parsed.getHost();
|
||||
URI uri = new URI(url);
|
||||
String host = uri.getHost();
|
||||
if ("localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host)) return true;
|
||||
InetAddress addr = InetAddress.getByName(host);
|
||||
return addr.isLoopbackAddress() || addr.isSiteLocalAddress();
|
||||
|
||||
@@ -266,7 +266,7 @@ public class MetadataManagementService {
|
||||
if (targetValues.size() != 1) {
|
||||
throw new IllegalArgumentException("Series merge requires exactly one target value");
|
||||
}
|
||||
String targetSeriesName = targetValues.get(0);
|
||||
String targetSeriesName = targetValues.getFirst();
|
||||
|
||||
for (String oldSeriesName : valuesToMerge) {
|
||||
List<BookMetadataEntity> booksWithOldSeries = bookMetadataRepository.findAllBySeriesNameIgnoreCase(oldSeriesName);
|
||||
@@ -289,7 +289,7 @@ public class MetadataManagementService {
|
||||
if (targetValues.size() != 1) {
|
||||
throw new IllegalArgumentException("Publisher merge requires exactly one target value");
|
||||
}
|
||||
String targetPublisher = targetValues.get(0);
|
||||
String targetPublisher = targetValues.getFirst();
|
||||
|
||||
for (String oldPublisher : valuesToMerge) {
|
||||
List<BookMetadataEntity> booksWithOldPublisher = bookMetadataRepository.findAllByPublisherIgnoreCase(oldPublisher);
|
||||
@@ -312,7 +312,7 @@ public class MetadataManagementService {
|
||||
if (targetValues.size() != 1) {
|
||||
throw new IllegalArgumentException("Language merge requires exactly one target value");
|
||||
}
|
||||
String targetLanguage = targetValues.get(0);
|
||||
String targetLanguage = targetValues.getFirst();
|
||||
|
||||
for (String oldLanguage : valuesToMerge) {
|
||||
List<BookMetadataEntity> booksWithOldLanguage = bookMetadataRepository.findAllByLanguageIgnoreCase(oldLanguage);
|
||||
|
||||
@@ -283,6 +283,8 @@ public class MetadataRefreshService {
|
||||
.updateThumbnail(replaceCover)
|
||||
.mergeCategories(mergeCategories)
|
||||
.replaceMode(MetadataReplaceMode.REPLACE_MISSING)
|
||||
.mergeMoods(true)
|
||||
.mergeTags(true)
|
||||
.build();
|
||||
|
||||
bookMetadataUpdater.setBookMetadata(context);
|
||||
@@ -293,13 +295,14 @@ public class MetadataRefreshService {
|
||||
}
|
||||
|
||||
public List<MetadataProvider> prepareProviders(MetadataRefreshOptions refreshOptions) {
|
||||
Set<MetadataProvider> allProviders = new HashSet<>(getAllProvidersUsingIndividualFields(refreshOptions));
|
||||
Set<MetadataProvider> allProviders = EnumSet.noneOf(MetadataProvider.class);
|
||||
allProviders.addAll(getAllProvidersUsingIndividualFields(refreshOptions));
|
||||
return new ArrayList<>(allProviders);
|
||||
}
|
||||
|
||||
protected Set<MetadataProvider> getAllProvidersUsingIndividualFields(MetadataRefreshOptions refreshOptions) {
|
||||
MetadataRefreshOptions.FieldOptions fieldOptions = refreshOptions.getFieldOptions();
|
||||
Set<MetadataProvider> uniqueProviders = new HashSet<>();
|
||||
Set<MetadataProvider> uniqueProviders = EnumSet.noneOf(MetadataProvider.class);
|
||||
|
||||
if (fieldOptions != null) {
|
||||
addProviderToSet(fieldOptions.getTitle(), uniqueProviders);
|
||||
@@ -499,15 +502,15 @@ public class MetadataRefreshService {
|
||||
}
|
||||
|
||||
protected <T> T resolveField(Map<MetadataProvider, BookMetadata> metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, Function<BookMetadata, T> extractor) {
|
||||
return resolveFieldWithProviders(metadataMap, fieldProvider, extractor, (value) -> value != null);
|
||||
return resolveFieldWithProviders(metadataMap, fieldProvider, extractor, Objects::nonNull);
|
||||
}
|
||||
|
||||
protected Integer resolveFieldAsInteger(Map<MetadataProvider, BookMetadata> metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, Function<BookMetadata, Integer> fieldValueExtractor) {
|
||||
return resolveFieldWithProviders(metadataMap, fieldProvider, fieldValueExtractor, (value) -> value != null);
|
||||
return resolveFieldWithProviders(metadataMap, fieldProvider, fieldValueExtractor, Objects::nonNull);
|
||||
}
|
||||
|
||||
protected String resolveFieldAsString(Map<MetadataProvider, BookMetadata> metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractor fieldValueExtractor) {
|
||||
return resolveFieldWithProviders(metadataMap, fieldProvider, fieldValueExtractor::extract, (value) -> value != null);
|
||||
return resolveFieldWithProviders(metadataMap, fieldProvider, fieldValueExtractor::extract, Objects::nonNull);
|
||||
}
|
||||
|
||||
protected Set<String> resolveFieldAsList(Map<MetadataProvider, BookMetadata> metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractorList fieldValueExtractor) {
|
||||
|
||||
@@ -72,7 +72,7 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
|
||||
|
||||
// CB7 path (7z)
|
||||
if (lowerName.endsWith(".cb7")) {
|
||||
try (SevenZFile sevenZ = new SevenZFile(file)) {
|
||||
try (SevenZFile sevenZ = SevenZFile.builder().setFile(file).get()) {
|
||||
SevenZArchiveEntry entry = findSevenZComicInfoEntry(sevenZ);
|
||||
if (entry == null) {
|
||||
return BookMetadata.builder().title(baseName).build();
|
||||
@@ -92,27 +92,27 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
|
||||
}
|
||||
|
||||
// CBR path (RAR)
|
||||
Archive archive = null;
|
||||
try {
|
||||
archive = new Archive(file);
|
||||
FileHeader header = findComicInfoHeader(archive);
|
||||
if (header == null) {
|
||||
try (Archive archive = new Archive(file)) {
|
||||
try {
|
||||
FileHeader header = findComicInfoHeader(archive);
|
||||
if (header == null) {
|
||||
return BookMetadata.builder().title(baseName).build();
|
||||
}
|
||||
byte[] xmlBytes = readRarEntryBytes(archive, header);
|
||||
if (xmlBytes == null) {
|
||||
return BookMetadata.builder().title(baseName).build();
|
||||
}
|
||||
try (InputStream is = new ByteArrayInputStream(xmlBytes)) {
|
||||
Document document = buildSecureDocument(is);
|
||||
return mapDocumentToMetadata(document, baseName);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract metadata from CBR", e);
|
||||
return BookMetadata.builder().title(baseName).build();
|
||||
}
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
return BookMetadata.builder().title(baseName).build();
|
||||
}
|
||||
byte[] xmlBytes = readRarEntryBytes(archive, header);
|
||||
if (xmlBytes == null) {
|
||||
return BookMetadata.builder().title(baseName).build();
|
||||
}
|
||||
try (InputStream is = new ByteArrayInputStream(xmlBytes)) {
|
||||
Document document = buildSecureDocument(is);
|
||||
return mapDocumentToMetadata(document, baseName);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract metadata from CBR", e);
|
||||
return BookMetadata.builder().title(baseName).build();
|
||||
} finally {
|
||||
try { if (archive != null) archive.close(); } catch (Exception ignore) {}
|
||||
}
|
||||
}
|
||||
|
||||
private ZipEntry findComicInfoEntry(ZipFile zipFile) {
|
||||
@@ -321,7 +321,7 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
|
||||
|
||||
// CB7 path
|
||||
if (lowerName.endsWith(".cb7")) {
|
||||
try (SevenZFile sevenZ = new SevenZFile(file)) {
|
||||
try (SevenZFile sevenZ = SevenZFile.builder().setFile(file).get()) {
|
||||
// Try via ComicInfo.xml first
|
||||
SevenZArchiveEntry ci = findSevenZComicInfoEntry(sevenZ);
|
||||
if (ci != null) {
|
||||
@@ -374,61 +374,60 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
|
||||
}
|
||||
|
||||
// CBR path
|
||||
Archive archive = null;
|
||||
try {
|
||||
archive = new Archive(file);
|
||||
try (Archive archive = new Archive(file)) {
|
||||
try {
|
||||
|
||||
// Try via ComicInfo.xml first
|
||||
FileHeader comicInfo = findComicInfoHeader(archive);
|
||||
if (comicInfo != null) {
|
||||
byte[] xmlBytes = readRarEntryBytes(archive, comicInfo);
|
||||
if (xmlBytes != null) {
|
||||
try (InputStream is = new ByteArrayInputStream(xmlBytes)) {
|
||||
Document document = buildSecureDocument(is);
|
||||
String imageName = findFrontCoverImageName(document);
|
||||
if (imageName != null) {
|
||||
FileHeader byName = findRarHeaderByName(archive, imageName);
|
||||
if (byName != null) {
|
||||
byte[] bytes = readRarEntryBytes(archive, byName);
|
||||
if (canDecode(bytes)) return bytes;
|
||||
}
|
||||
try {
|
||||
int index = Integer.parseInt(imageName);
|
||||
FileHeader byIndex = findRarImageHeaderByIndex(archive, index);
|
||||
if (byIndex != null) {
|
||||
byte[] bytes = readRarEntryBytes(archive, byIndex);
|
||||
if (canDecode(bytes)) return bytes;
|
||||
}
|
||||
if (index > 0) {
|
||||
FileHeader offByOne = findRarImageHeaderByIndex(archive, index - 1);
|
||||
if (offByOne != null) {
|
||||
byte[] bytes = readRarEntryBytes(archive, offByOne);
|
||||
if (canDecode(bytes)) return bytes;
|
||||
// Try via ComicInfo.xml first
|
||||
FileHeader comicInfo = findComicInfoHeader(archive);
|
||||
if (comicInfo != null) {
|
||||
byte[] xmlBytes = readRarEntryBytes(archive, comicInfo);
|
||||
if (xmlBytes != null) {
|
||||
try (InputStream is = new ByteArrayInputStream(xmlBytes)) {
|
||||
Document document = buildSecureDocument(is);
|
||||
String imageName = findFrontCoverImageName(document);
|
||||
if (imageName != null) {
|
||||
FileHeader byName = findRarHeaderByName(archive, imageName);
|
||||
if (byName != null) {
|
||||
byte[] bytes = readRarEntryBytes(archive, byName);
|
||||
if (canDecode(bytes)) return bytes;
|
||||
}
|
||||
try {
|
||||
int index = Integer.parseInt(imageName);
|
||||
FileHeader byIndex = findRarImageHeaderByIndex(archive, index);
|
||||
if (byIndex != null) {
|
||||
byte[] bytes = readRarEntryBytes(archive, byIndex);
|
||||
if (canDecode(bytes)) return bytes;
|
||||
}
|
||||
if (index > 0) {
|
||||
FileHeader offByOne = findRarImageHeaderByIndex(archive, index - 1);
|
||||
if (offByOne != null) {
|
||||
byte[] bytes = readRarEntryBytes(archive, offByOne);
|
||||
if (canDecode(bytes)) return bytes;
|
||||
}
|
||||
}
|
||||
} catch (NumberFormatException ignore) {
|
||||
// ignore and continue fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (NumberFormatException ignore) {
|
||||
// ignore and continue fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: iterate images alphabetically until a decodable one is found
|
||||
FileHeader firstImage = findFirstAlphabeticalImageHeader(archive);
|
||||
if (firstImage != null) {
|
||||
java.util.List<FileHeader> images = listRarImageHeaders(archive);
|
||||
for (FileHeader fh : images) {
|
||||
byte[] bytes = readRarEntryBytes(archive, fh);
|
||||
if (canDecode(bytes)) return bytes;
|
||||
}
|
||||
// Fallback: iterate images alphabetically until a decodable one is found
|
||||
FileHeader firstImage = findFirstAlphabeticalImageHeader(archive);
|
||||
if (firstImage != null) {
|
||||
List<FileHeader> images = listRarImageHeaders(archive);
|
||||
for (FileHeader fh : images) {
|
||||
byte[] bytes = readRarEntryBytes(archive, fh);
|
||||
if (canDecode(bytes)) return bytes;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract cover image from CBR", e);
|
||||
return generatePlaceholderCover(250, 350);
|
||||
}
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract cover image from CBR", e);
|
||||
return generatePlaceholderCover(250, 350);
|
||||
} finally {
|
||||
try { if (archive != null) archive.close(); } catch (Exception ignore) {}
|
||||
}
|
||||
|
||||
return generatePlaceholderCover(250, 350);
|
||||
}
|
||||
@@ -508,10 +507,9 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
|
||||
NodeList pages = document.getElementsByTagName("Page");
|
||||
for (int i = 0; i < pages.getLength(); i++) {
|
||||
org.w3c.dom.Node node = pages.item(i);
|
||||
if (node instanceof org.w3c.dom.Element) {
|
||||
org.w3c.dom.Element page = (org.w3c.dom.Element) node;
|
||||
String type = page.getAttribute("Type");
|
||||
if (type != null && type.equalsIgnoreCase("FrontCover")) {
|
||||
if (node instanceof org.w3c.dom.Element page) {
|
||||
String type = page.getAttribute("Type");
|
||||
if (type != null && "FrontCover".equalsIgnoreCase(type)) {
|
||||
String imageFile = page.getAttribute("ImageFile");
|
||||
if (imageFile != null && !imageFile.isBlank()) {
|
||||
return imageFile.trim();
|
||||
@@ -545,7 +543,7 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
|
||||
if (norm.startsWith("__MACOSX/") || norm.contains("/__MACOSX/")) return false;
|
||||
String base = baseName(norm);
|
||||
if (base.startsWith(".")) return false;
|
||||
if (base.equalsIgnoreCase(".ds_store")) return false;
|
||||
if (".ds_store".equalsIgnoreCase(base)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -656,7 +654,7 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
|
||||
}
|
||||
if (images.isEmpty()) return null;
|
||||
images.sort((a, b) -> naturalCompare(a.getFileName(), b.getFileName()));
|
||||
return images.get(0);
|
||||
return images.getFirst();
|
||||
}
|
||||
|
||||
private ZipEntry findFirstAlphabeticalImageEntry(ZipFile zipFile) {
|
||||
@@ -670,7 +668,7 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
|
||||
}
|
||||
if (images.isEmpty()) return null;
|
||||
images.sort((a, b) -> naturalCompare(a.getName(), b.getName()));
|
||||
return images.get(0);
|
||||
return images.getFirst();
|
||||
}
|
||||
|
||||
// ==== 7z (.cb7) helpers ====
|
||||
@@ -678,7 +676,7 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
|
||||
for (SevenZArchiveEntry e : sevenZ.getEntries()) {
|
||||
if (e == null || e.isDirectory()) continue;
|
||||
String name = e.getName();
|
||||
if (name != null && name.equalsIgnoreCase("ComicInfo.xml")) {
|
||||
if (name != null && "ComicInfo.xml".equalsIgnoreCase(name)) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@@ -718,7 +716,7 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
|
||||
}
|
||||
if (images.isEmpty()) return null;
|
||||
images.sort((a, b) -> naturalCompare(a.getName(), b.getName()));
|
||||
return images.get(0);
|
||||
return images.getFirst();
|
||||
}
|
||||
|
||||
private byte[] readSevenZEntryBytes(SevenZFile sevenZ, SevenZArchiveEntry entry) throws IOException {
|
||||
@@ -766,7 +764,7 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
|
||||
private boolean likelyCoverName(String base) {
|
||||
if (base == null) return false;
|
||||
String n = base.toLowerCase();
|
||||
return n.startsWith("cover") || n.equals("folder") || n.startsWith("front");
|
||||
return n.startsWith("cover") || "folder".equals(n) || n.startsWith("front");
|
||||
}
|
||||
|
||||
private int naturalCompare(String a, String b) {
|
||||
|
||||
@@ -7,6 +7,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import net.lingala.zip4j.ZipFile;
|
||||
import net.lingala.zip4j.model.FileHeader;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.boot.configurationprocessor.json.JSONException;
|
||||
import org.springframework.boot.configurationprocessor.json.JSONObject;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
@@ -103,18 +105,25 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
|
||||
String scheme = el.getAttributeNS("http://www.idpf.org/2007/opf", "scheme").toUpperCase();
|
||||
String value = text.toLowerCase().startsWith("isbn:") ? text.substring(5) : text;
|
||||
|
||||
switch (scheme) {
|
||||
case "ISBN" -> {
|
||||
if (!scheme.isEmpty()) {
|
||||
switch (scheme) {
|
||||
case "ISBN" -> {
|
||||
if (value.length() == 13) builderMeta.isbn13(value);
|
||||
else if (value.length() == 10) builderMeta.isbn10(value);
|
||||
}
|
||||
case "GOODREADS" -> builderMeta.goodreadsId(value);
|
||||
case "COMICVINE" -> builderMeta.comicvineId(value);
|
||||
case "GOOGLE" -> builderMeta.googleId(value);
|
||||
case "AMAZON" -> builderMeta.asin(value);
|
||||
case "HARDCOVER" -> builderMeta.hardcoverId(value);
|
||||
}
|
||||
} else {
|
||||
if (text.toLowerCase().startsWith("isbn:")) {
|
||||
if (value.length() == 13) builderMeta.isbn13(value);
|
||||
else if (value.length() == 10) builderMeta.isbn10(value);
|
||||
}
|
||||
case "GOODREADS" -> builderMeta.goodreadsId(value);
|
||||
case "COMICVINE" -> builderMeta.comicvineId(value);
|
||||
case "GOOGLE" -> builderMeta.googleId(value);
|
||||
case "AMAZON" -> builderMeta.asin(value);
|
||||
case "HARDCOVER" -> builderMeta.hardcoverId(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
case "date" -> {
|
||||
LocalDate parsed = parseDate(text);
|
||||
if (parsed != null) builderMeta.publishedDate(parsed);
|
||||
@@ -125,12 +134,12 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
|
||||
String content = el.hasAttribute("content") ? el.getAttribute("content").trim() : text;
|
||||
if (StringUtils.isBlank(content)) continue;
|
||||
|
||||
if (!seriesFound && (prop.equals("booklore:series") || name.equals("calibre:series") || prop.equals("calibre:series") || prop.equals("belongs-to-collection"))) {
|
||||
if (!seriesFound && ("booklore:series".equals(prop) || "calibre:series".equals(name) || "calibre:series".equals(prop) || "belongs-to-collection".equals(prop))) {
|
||||
builderMeta.seriesName(content);
|
||||
seriesFound = true;
|
||||
}
|
||||
|
||||
if (!seriesIndexFound && (prop.equals("booklore:series_index") || name.equals("calibre:series_index") || prop.equals("calibre:series_index") || prop.equals("group-position"))) {
|
||||
if (!seriesIndexFound && ("booklore:series_index".equals(prop) || "calibre:series_index".equals(name) || "calibre:series_index".equals(prop) || "group-position".equals(prop))) {
|
||||
try {
|
||||
builderMeta.seriesNumber(Float.parseFloat(content));
|
||||
seriesIndexFound = true;
|
||||
@@ -138,11 +147,26 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
if (name.equals("calibre:pages") || name.equals("pagecount") || prop.equals("schema:pagecount") || prop.equals("media:pagecount") || prop.equals("booklore:page_count")) {
|
||||
if ("calibre:pages".equals(name) || "pagecount".equals(name) || "schema:pagecount".equals(prop) || "media:pagecount".equals(prop) || "booklore:page_count".equals(prop)) {
|
||||
safeParseInt(content, builderMeta::pageCount);
|
||||
} else if (name.equals("calibre:user_metadata:#pagecount")) {
|
||||
try {
|
||||
JSONObject jsonroot = new JSONObject(content);
|
||||
Object value = jsonroot.opt("#value#");
|
||||
safeParseInt(String.valueOf(value), builderMeta::pageCount);
|
||||
} catch (JSONException ignored) {
|
||||
}
|
||||
} else if (prop.equals("calibre:user_metadata")) {
|
||||
try {
|
||||
JSONObject jsonroot = new JSONObject(content);
|
||||
JSONObject pages = jsonroot.getJSONObject("#pagecount");
|
||||
Object value = pages.opt("#value#");
|
||||
safeParseInt(String.valueOf(value), builderMeta::pageCount);
|
||||
} catch (JSONException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
if (name.equals("calibre:rating") || prop.equals("booklore:personal_rating")) {
|
||||
if ("calibre:rating".equals(name) || "booklore:personal_rating".equals(prop)) {
|
||||
safeParseDouble(content, builderMeta::personalRating);
|
||||
}
|
||||
|
||||
@@ -223,4 +247,4 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
|
||||
log.warn("Failed to parse date from string: {}", value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,8 +40,39 @@ public class AmazonBookParser implements BookParser {
|
||||
private static final Pattern PARENTHESES_WITH_WHITESPACE_PATTERN = Pattern.compile("\\s*\\(.*?\\)");
|
||||
private static final Pattern NON_ALPHANUMERIC_PATTERN = Pattern.compile("[^a-zA-Z0-9]");
|
||||
private static final Pattern DP_SEPARATOR_PATTERN = Pattern.compile("/dp/");
|
||||
// Pattern to extract country and date from strings like "Reviewed in ... on ..."
|
||||
private static final Pattern REVIEWED_IN_ON_PATTERN = Pattern.compile("Reviewed in (.+?) on (.+)");
|
||||
|
||||
private static final Map<String, LocaleInfo> DOMAIN_LOCALE_MAP = Map.ofEntries(
|
||||
Map.entry("com", new LocaleInfo("en-US,en;q=0.9", Locale.US)),
|
||||
Map.entry("co.uk", new LocaleInfo("en-GB,en;q=0.9", Locale.UK)),
|
||||
Map.entry("de", new LocaleInfo("en-GB,en;q=0.9,de;q=0.8", Locale.GERMANY)),
|
||||
Map.entry("fr", new LocaleInfo("en-GB,en;q=0.9,fr;q=0.8", Locale.FRANCE)),
|
||||
Map.entry("it", new LocaleInfo("en-GB,en;q=0.9,it;q=0.8", Locale.ITALY)),
|
||||
Map.entry("es", new LocaleInfo("en-GB,en;q=0.9,es;q=0.8", new Locale.Builder().setLanguage("es").setRegion("ES").build())),
|
||||
Map.entry("ca", new LocaleInfo("en-US,en;q=0.9", Locale.CANADA)),
|
||||
Map.entry("com.au", new LocaleInfo("en-GB,en;q=0.9", new Locale.Builder().setLanguage("en").setRegion("AU").build())),
|
||||
Map.entry("co.jp", new LocaleInfo("en-GB,en;q=0.9,ja;q=0.8", Locale.JAPAN)),
|
||||
Map.entry("in", new LocaleInfo("en-GB,en;q=0.9", new Locale.Builder().setLanguage("en").setRegion("IN").build())),
|
||||
Map.entry("com.br", new LocaleInfo("en-GB,en;q=0.9,pt;q=0.8", new Locale.Builder().setLanguage("pt").setRegion("BR").build())),
|
||||
Map.entry("com.mx", new LocaleInfo("en-US,en;q=0.9,es;q=0.8", new Locale.Builder().setLanguage("es").setRegion("MX").build())),
|
||||
Map.entry("nl", new LocaleInfo("en-GB,en;q=0.9,nl;q=0.8", new Locale.Builder().setLanguage("nl").setRegion("NL").build())),
|
||||
Map.entry("se", new LocaleInfo("en-GB,en;q=0.9,sv;q=0.8", new Locale.Builder().setLanguage("sv").setRegion("SE").build())),
|
||||
Map.entry("pl", new LocaleInfo("en-GB,en;q=0.9,pl;q=0.8", new Locale.Builder().setLanguage("pl").setRegion("PL").build()))
|
||||
);
|
||||
|
||||
private final AppSettingService appSettingService;
|
||||
|
||||
private static class LocaleInfo {
|
||||
final String acceptLanguage;
|
||||
final Locale locale;
|
||||
|
||||
LocaleInfo(String acceptLanguage, Locale locale) {
|
||||
this.acceptLanguage = acceptLanguage;
|
||||
this.locale = locale;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public BookMetadata fetchTopMetadata(Book book, FetchMetadataRequest fetchMetadataRequest) {
|
||||
LinkedList<String> amazonBookIds = getAmazonBookIds(book, fetchMetadataRequest);
|
||||
@@ -362,7 +393,12 @@ public class AmazonBookParser implements BookParser {
|
||||
try {
|
||||
Element publicationDateElement = doc.select("#rpi-attribute-book_details-publication_date .rpi-attribute-value span").first();
|
||||
if (publicationDateElement != null) {
|
||||
return parseAmazonDate(publicationDateElement.text());
|
||||
String dateText = publicationDateElement.text();
|
||||
LocalDate parsedDate = parseAmazonDate(dateText);
|
||||
if (parsedDate == null) {
|
||||
log.warn("Failed to parse publication date: '{}', returning null", dateText);
|
||||
}
|
||||
return parsedDate;
|
||||
}
|
||||
log.warn("Failed to parse publishedDate: Element not found.");
|
||||
} catch (Exception e) {
|
||||
@@ -463,7 +499,8 @@ public class AmazonBookParser implements BookParser {
|
||||
if (ratingSpan != null) {
|
||||
String text = ratingSpan.text().trim();
|
||||
if (!text.isEmpty()) {
|
||||
return Double.parseDouble(text);
|
||||
String normalizedText = text.replace(',', '.');
|
||||
return Double.parseDouble(normalizedText);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -475,7 +512,9 @@ public class AmazonBookParser implements BookParser {
|
||||
|
||||
private List<BookReview> getReviews(Document doc, int maxReviews) {
|
||||
List<BookReview> reviews = new ArrayList<>();
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMMM d, yyyy", Locale.ENGLISH);
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH);
|
||||
String domain = appSettingService.getAppSettings().getMetadataProviderSettings().getAmazon().getDomain();
|
||||
LocaleInfo localeInfo = getLocaleInfoForDomain(domain);
|
||||
|
||||
try {
|
||||
Elements reviewElements = doc.select("li[data-hook=review]");
|
||||
@@ -501,10 +540,12 @@ public class AmazonBookParser implements BookParser {
|
||||
String ratingText = !ratingElements.isEmpty() ? ratingElements.first().text() : "";
|
||||
if (!ratingText.isEmpty()) {
|
||||
try {
|
||||
Pattern ratingPattern = Pattern.compile("^([0-9]+(\\.[0-9]+)?)");
|
||||
// Support both comma and dot as decimal separator
|
||||
Pattern ratingPattern = Pattern.compile("^([0-9]+([.,][0-9]+)?)");
|
||||
Matcher ratingMatcher = ratingPattern.matcher(ratingText);
|
||||
if (ratingMatcher.find()) {
|
||||
ratingValue = Float.parseFloat(ratingMatcher.group(1));
|
||||
String ratingStr = ratingMatcher.group(1).replace(',', '.');
|
||||
ratingValue = Float.parseFloat(ratingStr);
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("Failed to parse rating '{}': {}", ratingText, e.getMessage());
|
||||
@@ -518,8 +559,7 @@ public class AmazonBookParser implements BookParser {
|
||||
|
||||
if (!fullDateText.isEmpty()) {
|
||||
try {
|
||||
Pattern pattern = Pattern.compile("Reviewed in (.+?) on (.+)");
|
||||
Matcher matcher = pattern.matcher(fullDateText);
|
||||
Matcher matcher = REVIEWED_IN_ON_PATTERN.matcher(fullDateText);
|
||||
String datePart = fullDateText;
|
||||
|
||||
if (matcher.find() && matcher.groupCount() == 2) {
|
||||
@@ -530,11 +570,11 @@ public class AmazonBookParser implements BookParser {
|
||||
datePart = matcher.group(2).trim();
|
||||
}
|
||||
|
||||
LocalDate localDate = LocalDate.parse(datePart, formatter);
|
||||
dateInstant = localDate.atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||
LocalDate localDate = parseReviewDate(datePart, localeInfo);
|
||||
if (localDate != null) {
|
||||
dateInstant = localDate.atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||
}
|
||||
|
||||
} catch (DateTimeParseException e) {
|
||||
log.warn("Failed to parse date '{}' in review: {}", fullDateText, e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.warn("Error parsing date string '{}': {}", fullDateText, e.getMessage());
|
||||
}
|
||||
@@ -633,9 +673,12 @@ public class AmazonBookParser implements BookParser {
|
||||
try {
|
||||
String domain = appSettingService.getAppSettings().getMetadataProviderSettings().getAmazon().getDomain();
|
||||
String amazonCookie = appSettingService.getAppSettings().getMetadataProviderSettings().getAmazon().getCookie();
|
||||
|
||||
LocaleInfo localeInfo = getLocaleInfoForDomain(domain);
|
||||
|
||||
Connection connection = Jsoup.connect(url)
|
||||
.header("accept", "text/html, application/json")
|
||||
.header("accept-language", "en-US,en;q=0.9")
|
||||
.header("accept-language", localeInfo.acceptLanguage)
|
||||
.header("content-type", "application/json")
|
||||
.header("device-memory", "8")
|
||||
.header("downlink", "10")
|
||||
@@ -671,9 +714,60 @@ public class AmazonBookParser implements BookParser {
|
||||
}
|
||||
}
|
||||
|
||||
private static LocaleInfo getLocaleInfoForDomain(String domain) {
|
||||
return DOMAIN_LOCALE_MAP.getOrDefault(domain,
|
||||
new LocaleInfo("en-US,en;q=0.9", Locale.US));
|
||||
}
|
||||
|
||||
|
||||
private static LocalDate parseDate(String dateString, LocaleInfo localeInfo) {
|
||||
if (dateString == null || dateString.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String trimmedDate = dateString.trim();
|
||||
|
||||
String[] patterns = {
|
||||
"MMMM d, yyyy",
|
||||
"d MMMM yyyy",
|
||||
"d. MMMM yyyy",
|
||||
"MMM d, yyyy",
|
||||
"MMM. d, yyyy",
|
||||
"d MMM yyyy",
|
||||
"d MMM. yyyy",
|
||||
"d. MMM yyyy"
|
||||
};
|
||||
|
||||
for (String pattern : patterns) {
|
||||
try {
|
||||
return LocalDate.parse(trimmedDate, DateTimeFormatter.ofPattern(pattern, Locale.ENGLISH));
|
||||
} catch (DateTimeParseException e) {
|
||||
log.debug("Date '{}' did not match pattern '{}' for locale ENGLISH", trimmedDate, pattern);
|
||||
}
|
||||
}
|
||||
|
||||
if (!"en".equals(localeInfo.locale.getLanguage())) {
|
||||
for (String pattern : patterns) {
|
||||
try {
|
||||
return LocalDate.parse(trimmedDate, DateTimeFormatter.ofPattern(pattern, localeInfo.locale));
|
||||
} catch (DateTimeParseException e) {
|
||||
log.debug("Date '{}' did not match pattern '{}' for locale {}", trimmedDate, pattern, localeInfo.locale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.warn("Failed to parse date '{}' with any known format for locale {}", dateString, localeInfo.locale);
|
||||
return null;
|
||||
}
|
||||
|
||||
private LocalDate parseAmazonDate(String dateString) {
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMMM d, yyyy");
|
||||
return LocalDate.parse(dateString, formatter);
|
||||
String domain = appSettingService.getAppSettings().getMetadataProviderSettings().getAmazon().getDomain();
|
||||
LocaleInfo localeInfo = getLocaleInfoForDomain(domain);
|
||||
return parseDate(dateString, localeInfo);
|
||||
}
|
||||
|
||||
private static LocalDate parseReviewDate(String dateString, LocaleInfo localeInfo) {
|
||||
return parseDate(dateString, localeInfo);
|
||||
}
|
||||
|
||||
private String cleanDescriptionHtml(String html) {
|
||||
|
||||
@@ -41,6 +41,14 @@ public class DoubanBookParser implements BookParser {
|
||||
private static final Pattern NON_DIGIT_PATTERN = Pattern.compile("[^\\d]");
|
||||
private static final Pattern NON_ALPHANUMERIC_CJK_PATTERN = Pattern.compile("[^a-zA-Z0-9\\u4e00-\\u9fff]");
|
||||
private static final Pattern SLASH_SEPARATOR_PATTERN = Pattern.compile(" / ");
|
||||
// Regex to extract JSON assigned to window.__DATA__ in Douban pages (DOTALL to capture across lines)
|
||||
private static final Pattern WINDOW_DATA_JSON_PATTERN = Pattern.compile("window\\.__DATA__\\s*=\\s*(\\{.*\\});", Pattern.DOTALL);
|
||||
// Pattern to extract numeric Douban subject id from URLs like /subject/123456/
|
||||
private static final Pattern SUBJECT_ID_PATTERN = Pattern.compile("/subject/(\\d+)/");
|
||||
// Pattern to extract rating number from class names like 'rating40' -> 40
|
||||
private static final Pattern RATING_NUMBER_PATTERN = Pattern.compile("rating(\\d+)");
|
||||
// Pattern for yyyy-MM-dd (or yyyy/M/d) date formats
|
||||
private static final Pattern DATE_YMD_PATTERN = Pattern.compile("(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})");
|
||||
private final AppSettingService appSettingService;
|
||||
|
||||
@Override
|
||||
@@ -49,7 +57,7 @@ public class DoubanBookParser implements BookParser {
|
||||
if (searchResults == null || searchResults.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
BookMetadata topResult = searchResults.get(0);
|
||||
BookMetadata topResult = searchResults.getFirst();
|
||||
|
||||
// If Douban reviews are enabled, fetch detailed metadata including reviews
|
||||
if (topResult.getDoubanId() != null && areDoubanReviewsEnabled()) {
|
||||
@@ -123,12 +131,11 @@ public class DoubanBookParser implements BookParser {
|
||||
String jsonData = null;
|
||||
|
||||
// Use regex to find the JSON object in window.__DATA__
|
||||
Pattern pattern = Pattern.compile("window\\.__DATA__\\s*=\\s*(\\{.*\\});", Pattern.DOTALL);
|
||||
Matcher matcher = pattern.matcher(htmlContent);
|
||||
if (matcher.find()) {
|
||||
jsonData = matcher.group(1);
|
||||
log.debug("Successfully extracted JSON data, length: {}", jsonData.length());
|
||||
}
|
||||
Matcher matcher = WINDOW_DATA_JSON_PATTERN.matcher(htmlContent);
|
||||
if (matcher.find()) {
|
||||
jsonData = matcher.group(1);
|
||||
log.debug("Successfully extracted JSON data, length: {}", jsonData.length());
|
||||
}
|
||||
|
||||
if (jsonData == null) {
|
||||
log.warn("No JSON data found in Douban search response");
|
||||
@@ -156,7 +163,7 @@ public class DoubanBookParser implements BookParser {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (itemsNode.size() == 0) {
|
||||
if (itemsNode.isEmpty()) {
|
||||
log.info("No books found for the search query");
|
||||
return null;
|
||||
}
|
||||
@@ -244,12 +251,11 @@ public class DoubanBookParser implements BookParser {
|
||||
if (url == null || url.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
Pattern pattern = Pattern.compile("/subject/(\\d+)/");
|
||||
Matcher matcher = pattern.matcher(url);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
return null;
|
||||
Matcher matcher = SUBJECT_ID_PATTERN.matcher(url);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private BookMetadata getBookMetadata(String doubanBookId) {
|
||||
@@ -580,11 +586,10 @@ public class DoubanBookParser implements BookParser {
|
||||
Float ratingValue = null;
|
||||
if (!ratingElements.isEmpty()) {
|
||||
String ratingClass = ratingElements.first().className();
|
||||
Pattern pattern = Pattern.compile("rating(\\d+)");
|
||||
Matcher matcher = pattern.matcher(ratingClass);
|
||||
if (matcher.find()) {
|
||||
ratingValue = Float.parseFloat(matcher.group(1)) / 2.0f; // Convert 5-star scale to 10-star scale
|
||||
}
|
||||
Matcher matcher = RATING_NUMBER_PATTERN.matcher(ratingClass);
|
||||
if (matcher.find()) {
|
||||
ratingValue = Float.parseFloat(matcher.group(1)) / 2.0f; // Convert 5-star scale to 10-star scale
|
||||
}
|
||||
}
|
||||
|
||||
Elements dateElements = reviewElement.select(".comment-info .comment-time");
|
||||
@@ -715,13 +720,12 @@ public class DoubanBookParser implements BookParser {
|
||||
if (dateString == null || dateString.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
dateString = dateString.trim();
|
||||
String trim = dateString.trim();
|
||||
|
||||
// Try regex patterns first for more flexibility
|
||||
try {
|
||||
// Pattern for yyyy-MM-dd or yyyy-M-d or yyyy-MM-d or yyyy-M-dd
|
||||
Pattern pattern = Pattern.compile("(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})");
|
||||
Matcher matcher = pattern.matcher(dateString);
|
||||
Matcher matcher = DATE_YMD_PATTERN.matcher(trim);
|
||||
if (matcher.matches()) {
|
||||
int year = Integer.parseInt(matcher.group(1));
|
||||
int month = Integer.parseInt(matcher.group(2));
|
||||
@@ -730,8 +734,8 @@ public class DoubanBookParser implements BookParser {
|
||||
}
|
||||
|
||||
// Pattern for yyyy-MM or yyyy-M
|
||||
pattern = Pattern.compile("(\\d{4})[-/](\\d{1,2})");
|
||||
matcher = pattern.matcher(dateString);
|
||||
Pattern pattern = Pattern.compile("(\\d{4})[-/](\\d{1,2})");
|
||||
matcher = pattern.matcher(trim);
|
||||
if (matcher.matches()) {
|
||||
int year = Integer.parseInt(matcher.group(1));
|
||||
int month = Integer.parseInt(matcher.group(2));
|
||||
@@ -740,7 +744,7 @@ public class DoubanBookParser implements BookParser {
|
||||
|
||||
// Pattern for yyyy
|
||||
pattern = Pattern.compile("(\\d{4})");
|
||||
matcher = pattern.matcher(dateString);
|
||||
matcher = pattern.matcher(trim);
|
||||
if (matcher.matches()) {
|
||||
int year = Integer.parseInt(matcher.group(1));
|
||||
return LocalDate.of(year, 1, 1);
|
||||
@@ -749,7 +753,7 @@ public class DoubanBookParser implements BookParser {
|
||||
// Chinese patterns
|
||||
// Pattern for 年月日: 2011年1月10日
|
||||
pattern = Pattern.compile("(\\d{4})年(\\d{1,2})月(\\d{1,2})日");
|
||||
matcher = pattern.matcher(dateString);
|
||||
matcher = pattern.matcher(trim);
|
||||
if (matcher.matches()) {
|
||||
int year = Integer.parseInt(matcher.group(1));
|
||||
int month = Integer.parseInt(matcher.group(2));
|
||||
@@ -759,7 +763,7 @@ public class DoubanBookParser implements BookParser {
|
||||
|
||||
// Pattern for 年月: 2111年12月
|
||||
pattern = Pattern.compile("(\\d{4})年(\\d{1,2})月");
|
||||
matcher = pattern.matcher(dateString);
|
||||
matcher = pattern.matcher(trim);
|
||||
if (matcher.matches()) {
|
||||
int year = Integer.parseInt(matcher.group(1));
|
||||
int month = Integer.parseInt(matcher.group(2));
|
||||
@@ -768,14 +772,14 @@ public class DoubanBookParser implements BookParser {
|
||||
|
||||
// Pattern for 年: 2011年
|
||||
pattern = Pattern.compile("(\\d{4})年");
|
||||
matcher = pattern.matcher(dateString);
|
||||
matcher = pattern.matcher(trim);
|
||||
if (matcher.matches()) {
|
||||
int year = Integer.parseInt(matcher.group(1));
|
||||
return LocalDate.of(year, 1, 1);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Exception while parsing date '{}': {}", dateString, e.getMessage());
|
||||
log.warn("Exception while parsing date '{}': {}", trim, e.getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -40,6 +40,8 @@ public class GoodReadsParser implements BookParser {
|
||||
private static final String BASE_ISBN_URL = "https://www.goodreads.com/book/isbn/";
|
||||
private static final int COUNT_DETAILED_METADATA_TO_GET = 3;
|
||||
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
|
||||
// Pattern to extract numeric Goodreads id from book URL like /book/show/12345
|
||||
private static final Pattern BOOK_SHOW_ID_PATTERN = Pattern.compile("/book/show/(\\d+)");
|
||||
private final AppSettingService appSettingService;
|
||||
|
||||
@Override
|
||||
@@ -63,7 +65,7 @@ public class GoodReadsParser implements BookParser {
|
||||
.orElse(null);
|
||||
|
||||
if (ogUrl != null && !ogUrl.isBlank()) {
|
||||
String goodreadsId = ogUrl.substring(ogUrl.lastIndexOf("/") + 1);
|
||||
String goodreadsId = ogUrl.substring(ogUrl.lastIndexOf('/') + 1);
|
||||
if (!goodreadsId.isBlank()) {
|
||||
BookMetadata metadata = parseBookDetails(doc, goodreadsId);
|
||||
if (metadata != null) {
|
||||
@@ -309,7 +311,7 @@ public class GoodReadsParser implements BookParser {
|
||||
}
|
||||
|
||||
private String handleStringNull(String s) {
|
||||
if (s != null && s.equals("null")) {
|
||||
if (s != null && "null".equals(s)) {
|
||||
return null;
|
||||
}
|
||||
return s;
|
||||
@@ -495,9 +497,11 @@ public class GoodReadsParser implements BookParser {
|
||||
private Integer extractGoodReadsIdPreview(Element book) {
|
||||
try {
|
||||
Element bookTitle = book.select("a.bookTitle").first();
|
||||
if (bookTitle == null) {
|
||||
return null;
|
||||
}
|
||||
String href = bookTitle.attr("href");
|
||||
Pattern pattern = Pattern.compile("/book/show/(\\d+)");
|
||||
Matcher matcher = pattern.matcher(href);
|
||||
Matcher matcher = BOOK_SHOW_ID_PATTERN.matcher(href);
|
||||
if (matcher.find()) {
|
||||
return Integer.valueOf(matcher.group(1));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.adityachandel.booklore.model.dto.BookMetadata;
|
||||
import com.adityachandel.booklore.model.dto.response.GoogleBooksApiResponse;
|
||||
import com.adityachandel.booklore.model.dto.request.FetchMetadataRequest;
|
||||
import com.adityachandel.booklore.model.enums.MetadataProvider;
|
||||
import com.adityachandel.booklore.service.appsettings.AppSettingService;
|
||||
import com.adityachandel.booklore.util.BookUtils;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -35,6 +36,7 @@ public class GoogleParser implements BookParser {
|
||||
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
|
||||
private static final Pattern SPECIAL_CHARACTERS_PATTERN = Pattern.compile("[.,\\-\\[\\]{}()!@#$%^&*_=+|~`<>?/\";:]");
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AppSettingService appSettingService;
|
||||
private final HttpClient httpClient = HttpClient.newHttpClient();
|
||||
private static final String GOOGLE_BOOKS_API_URL = "https://www.googleapis.com/books/v1/volumes";
|
||||
|
||||
@@ -55,7 +57,7 @@ public class GoogleParser implements BookParser {
|
||||
|
||||
private List<BookMetadata> getMetadataListByIsbn(String isbn) {
|
||||
try {
|
||||
URI uri = UriComponentsBuilder.fromUriString(GOOGLE_BOOKS_API_URL)
|
||||
URI uri = UriComponentsBuilder.fromUriString(getApiUrl())
|
||||
.queryParam("q", "isbn:" + isbn.replace("-", ""))
|
||||
.build()
|
||||
.toUri();
|
||||
@@ -84,7 +86,7 @@ public class GoogleParser implements BookParser {
|
||||
|
||||
public List<BookMetadata> getMetadataListByTerm(String term) {
|
||||
try {
|
||||
URI uri = UriComponentsBuilder.fromUriString(GOOGLE_BOOKS_API_URL)
|
||||
URI uri = UriComponentsBuilder.fromUriString(getApiUrl())
|
||||
.queryParam("q", term)
|
||||
.build()
|
||||
.toUri();
|
||||
@@ -175,11 +177,11 @@ public class GoogleParser implements BookParser {
|
||||
|
||||
if (searchTerm != null) {
|
||||
searchTerm = SPECIAL_CHARACTERS_PATTERN.matcher(searchTerm).replaceAll("").trim();
|
||||
searchTerm = truncateToMaxLength(searchTerm, 60);
|
||||
searchTerm = "intitle:" + truncateToMaxLength(searchTerm, 60);
|
||||
}
|
||||
|
||||
if (searchTerm != null && request.getAuthor() != null && !request.getAuthor().isEmpty()) {
|
||||
searchTerm += " " + request.getAuthor();
|
||||
searchTerm += " inauthor:" + request.getAuthor();
|
||||
}
|
||||
|
||||
return searchTerm;
|
||||
@@ -209,4 +211,18 @@ public class GoogleParser implements BookParser {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String getApiUrl() {
|
||||
String language = appSettingService.getAppSettings().getMetadataProviderSettings().getGoogle().getLanguage();
|
||||
|
||||
if (language == null || language.isEmpty()) {
|
||||
return GOOGLE_BOOKS_API_URL;
|
||||
}
|
||||
|
||||
return UriComponentsBuilder.fromUriString(GOOGLE_BOOKS_API_URL)
|
||||
.queryParam("langRestrict", language)
|
||||
.build()
|
||||
.toUri()
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,7 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
}
|
||||
}
|
||||
} else if (isCb7) {
|
||||
try (SevenZFile sevenZ = new SevenZFile(file)) {
|
||||
try (SevenZFile sevenZ = SevenZFile.builder().setFile(file).get()) {
|
||||
SevenZArchiveEntry existing = null;
|
||||
for (SevenZArchiveEntry e : sevenZ.getEntries()) {
|
||||
if (e != null && !e.isDirectory() && isComicInfoName(e.getName())) {
|
||||
@@ -161,7 +161,7 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
if (isCb7) {
|
||||
// Convert to CBZ with updated ComicInfo.xml
|
||||
tempFile = Files.createTempFile("cbx_edit", ".cbz");
|
||||
try (SevenZFile sevenZ = new SevenZFile(file);
|
||||
try (SevenZFile sevenZ = SevenZFile.builder().setFile(file).get();
|
||||
ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(tempFile))) {
|
||||
for (SevenZArchiveEntry e : sevenZ.getEntries()) {
|
||||
if (e.isDirectory()) continue;
|
||||
@@ -397,7 +397,7 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
String n = name.replace('\\', '/');
|
||||
if (n.endsWith("/")) return false;
|
||||
String lower = n.toLowerCase(Locale.ROOT);
|
||||
return lower.equals("comicinfo.xml") || lower.endsWith("/comicinfo.xml");
|
||||
return "comicinfo.xml".equals(lower) || lower.endsWith("/comicinfo.xml");
|
||||
}
|
||||
|
||||
private static boolean isSafeEntryName(String name) {
|
||||
|
||||
@@ -28,7 +28,7 @@ import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
@@ -107,9 +107,7 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
hasChanges[0] = true;
|
||||
});
|
||||
|
||||
helper.copySeriesName(clear != null && clear.isSeriesName(), val -> {
|
||||
replaceMetaElement(metadataElement, opfDoc, "calibre:series", val, hasChanges);
|
||||
});
|
||||
helper.copySeriesName(clear != null && clear.isSeriesName(), val -> replaceMetaElement(metadataElement, opfDoc, "calibre:series", val, hasChanges));
|
||||
|
||||
helper.copySeriesNumber(clear != null && clear.isSeriesNumber(), val -> {
|
||||
String formatted = val != null ? String.format("%.1f", val) : null;
|
||||
@@ -136,24 +134,12 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
};
|
||||
|
||||
switch (scheme) {
|
||||
case "AMAZON" -> helper.copyAsin(clearFlag, idValue -> {
|
||||
updateIdentifier(metadataElement, opfDoc, scheme, idValue, hasChanges);
|
||||
});
|
||||
case "GOOGLE" -> helper.copyGoogleId(clearFlag, idValue -> {
|
||||
updateIdentifier(metadataElement, opfDoc, scheme, idValue, hasChanges);
|
||||
});
|
||||
case "GOODREADS" -> helper.copyGoodreadsId(clearFlag, idValue -> {
|
||||
updateIdentifier(metadataElement, opfDoc, scheme, idValue, hasChanges);
|
||||
});
|
||||
case "COMICVINE" -> helper.copyComicvineId(clearFlag, idValue -> {
|
||||
updateIdentifier(metadataElement, opfDoc, scheme, idValue, hasChanges);
|
||||
});
|
||||
case "HARDCOVER" -> helper.copyHardcoverId(clearFlag, idValue -> {
|
||||
updateIdentifier(metadataElement, opfDoc, scheme, idValue, hasChanges);
|
||||
});
|
||||
case "ISBN" -> helper.copyIsbn13(clearFlag, idValue -> {
|
||||
updateIdentifier(metadataElement, opfDoc, scheme, idValue, hasChanges);
|
||||
});
|
||||
case "AMAZON" -> helper.copyAsin(clearFlag, idValue -> updateIdentifier(metadataElement, opfDoc, scheme, idValue, hasChanges));
|
||||
case "GOOGLE" -> helper.copyGoogleId(clearFlag, idValue -> updateIdentifier(metadataElement, opfDoc, scheme, idValue, hasChanges));
|
||||
case "GOODREADS" -> helper.copyGoodreadsId(clearFlag, idValue -> updateIdentifier(metadataElement, opfDoc, scheme, idValue, hasChanges));
|
||||
case "COMICVINE" -> helper.copyComicvineId(clearFlag, idValue -> updateIdentifier(metadataElement, opfDoc, scheme, idValue, hasChanges));
|
||||
case "HARDCOVER" -> helper.copyHardcoverId(clearFlag, idValue -> updateIdentifier(metadataElement, opfDoc, scheme, idValue, hasChanges));
|
||||
case "ISBN" -> helper.copyIsbn13(clearFlag, idValue -> updateIdentifier(metadataElement, opfDoc, scheme, idValue, hasChanges));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,7 +462,7 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
}
|
||||
|
||||
private byte[] loadImage(String pathOrUrl) {
|
||||
try (InputStream stream = pathOrUrl.startsWith("http") ? new URL(pathOrUrl).openStream() : new FileInputStream(pathOrUrl)) {
|
||||
try (InputStream stream = pathOrUrl.startsWith("http") ? URI.create(pathOrUrl).toURL().openStream() : new FileInputStream(pathOrUrl)) {
|
||||
return stream.readAllBytes();
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to load image from {}: {}", pathOrUrl, e.getMessage());
|
||||
|
||||
@@ -123,13 +123,9 @@ public class PdfMetadataWriter implements MetadataWriter {
|
||||
dc.addDate(cal);
|
||||
});
|
||||
|
||||
helper.copyAuthors(clear != null && clear.isAuthors(), authors -> {
|
||||
(authors != null ? authors : List.of("")).forEach(dc::addCreator);
|
||||
});
|
||||
helper.copyAuthors(clear != null && clear.isAuthors(), authors -> (authors != null ? authors : List.of("")).forEach(dc::addCreator));
|
||||
|
||||
helper.copyCategories(clear != null && clear.isCategories(), cats -> {
|
||||
(cats != null ? cats : List.of("")).forEach(dc::addSubject);
|
||||
});
|
||||
helper.copyCategories(clear != null && clear.isCategories(), cats -> (cats != null ? cats : List.of("")).forEach(dc::addSubject));
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
new XmpSerializer().serialize(xmp, baos, true);
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.adityachandel.booklore.model.entity.BookMetadataEntity;
|
||||
import com.adityachandel.booklore.model.entity.CategoryEntity;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -143,6 +144,7 @@ public class BookVectorService {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Getter
|
||||
public static class ScoredBook {
|
||||
private final Long bookId;
|
||||
private final double score;
|
||||
@@ -152,13 +154,6 @@ public class BookVectorService {
|
||||
this.score = score;
|
||||
}
|
||||
|
||||
public Long getBookId() {
|
||||
return bookId;
|
||||
}
|
||||
|
||||
public double getScore() {
|
||||
return score;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -134,13 +134,16 @@ public class UserProvisioningService {
|
||||
return createUser(user);
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
/**
|
||||
* Create and persist a remote-provisioned user based on incoming headers.
|
||||
* This is the preferred (non-deprecated) entry point for remote provisioning.
|
||||
*/
|
||||
@Transactional
|
||||
public BookLoreUserEntity provisionRemoteUser(String name, String username, String email, String groups) {
|
||||
public BookLoreUserEntity provisionRemoteUserFromHeaders(String name, String username, String email, String groups) {
|
||||
boolean isAdmin = false;
|
||||
if (groups != null && appProperties.getRemoteAuth().getAdminGroup() != null) {
|
||||
String groupsContent = groups.trim();
|
||||
if (groupsContent.startsWith("[") && groupsContent.endsWith("]")) {
|
||||
if (groupsContent.length() >= 2 && groupsContent.charAt(0) == '[' && groupsContent.charAt(groupsContent.length() - 1) == ']') {
|
||||
groupsContent = groupsContent.substring(1, groupsContent.length() - 1);
|
||||
}
|
||||
List<String> groupsList = Arrays.asList(WHITESPACE_PATTERN.split(groupsContent));
|
||||
@@ -199,9 +202,9 @@ public class UserProvisioningService {
|
||||
}
|
||||
|
||||
protected BookLoreUserEntity createUser(BookLoreUserEntity user) {
|
||||
user = userRepository.save(user);
|
||||
userDefaultsService.addDefaultShelves(user);
|
||||
userDefaultsService.addDefaultSettings(user);
|
||||
return user;
|
||||
BookLoreUserEntity save = userRepository.save(user);
|
||||
userDefaultsService.addDefaultShelves(save);
|
||||
userDefaultsService.addDefaultSettings(save);
|
||||
return save;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,30 +9,32 @@ public class BookUtils {
|
||||
private static final Pattern PARENTHESES_WITH_OPTIONAL_SPACE_PATTERN = Pattern.compile("\\s?\\(.*?\\)");
|
||||
|
||||
public static String cleanFileName(String fileName) {
|
||||
if (fileName == null) {
|
||||
String name = fileName;
|
||||
if (name == null) {
|
||||
return null;
|
||||
}
|
||||
fileName = fileName.replace("(Z-Library)", "").trim();
|
||||
fileName = PARENTHESES_WITH_OPTIONAL_SPACE_PATTERN.matcher(fileName).replaceAll("").trim(); // Remove the author name inside parentheses (e.g. (Jon Yablonski))
|
||||
int dotIndex = fileName.lastIndexOf('.'); // Remove the file extension (e.g., .pdf, .docx)
|
||||
name = name.replace("(Z-Library)", "").trim();
|
||||
name = PARENTHESES_WITH_OPTIONAL_SPACE_PATTERN.matcher(name).replaceAll("").trim(); // Remove the author name inside parentheses (e.g. (Jon Yablonski))
|
||||
int dotIndex = name.lastIndexOf('.'); // Remove the file extension (e.g., .pdf, .docx)
|
||||
if (dotIndex > 0) {
|
||||
fileName = fileName.substring(0, dotIndex).trim();
|
||||
name = name.substring(0, dotIndex).trim();
|
||||
}
|
||||
return fileName;
|
||||
return name;
|
||||
}
|
||||
|
||||
public static String cleanAndTruncateSearchTerm(String term) {
|
||||
term = SPECIAL_CHARACTERS_PATTERN.matcher(term).replaceAll("").trim();
|
||||
if (term.length() > 60) {
|
||||
String[] words = WHITESPACE_PATTERN.split(term);
|
||||
String s = term;
|
||||
s = SPECIAL_CHARACTERS_PATTERN.matcher(s).replaceAll("").trim();
|
||||
if (s.length() > 60) {
|
||||
String[] words = WHITESPACE_PATTERN.split(s);
|
||||
StringBuilder truncated = new StringBuilder();
|
||||
for (String word : words) {
|
||||
if (truncated.length() + word.length() + 1 > 60) break;
|
||||
if (!truncated.isEmpty()) truncated.append(" ");
|
||||
truncated.append(word);
|
||||
}
|
||||
term = truncated.toString();
|
||||
s = truncated.toString();
|
||||
}
|
||||
return term;
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -154,7 +155,8 @@ public class FileService {
|
||||
|
||||
public BufferedImage downloadImageFromUrl(String imageUrl) throws IOException {
|
||||
try {
|
||||
URL url = new URL(imageUrl);
|
||||
URI uri = URI.create(imageUrl);
|
||||
URL url = uri.toURL();
|
||||
BufferedImage image = ImageIO.read(url);
|
||||
if (image == null) {
|
||||
throw new IOException("Unable to read image from URL: " + imageUrl);
|
||||
|
||||
@@ -157,10 +157,12 @@ public class MetadataChangeDetector {
|
||||
}
|
||||
|
||||
private static boolean isEffectivelyEmpty(Object value) {
|
||||
if (value == null) return true;
|
||||
if (value instanceof String s) return s.isBlank();
|
||||
if (value instanceof Collection<?> c) return c.isEmpty();
|
||||
return false;
|
||||
return switch (value) {
|
||||
case null -> true;
|
||||
case String s -> s.isBlank();
|
||||
case Collection<?> c -> c.isEmpty();
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean differsLock(Boolean dtoLock, Boolean entityLock) {
|
||||
|
||||
@@ -15,6 +15,7 @@ public class PathPatternResolver {
|
||||
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
|
||||
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile(".*\\.[a-zA-Z0-9]+$");
|
||||
private static final Pattern CONTROL_CHARACTER_PATTERN = Pattern.compile("[\\p{Cntrl}]");
|
||||
private static final Pattern INVALID_CHARS_PATTERN = Pattern.compile("[\\\\/:*?\"<>|]");
|
||||
|
||||
public static String resolvePattern(BookEntity book, String pattern) {
|
||||
String currentFilename = book.getFileName() != null ? book.getFileName().trim() : "";
|
||||
@@ -90,7 +91,7 @@ public class PathPatternResolver {
|
||||
|
||||
private static String resolvePatternWithValues(String pattern, Map<String, String> values, String currentFilename) {
|
||||
String extension = "";
|
||||
int lastDot = currentFilename.lastIndexOf(".");
|
||||
int lastDot = currentFilename.lastIndexOf('.');
|
||||
if (lastDot >= 0 && lastDot < currentFilename.length() - 1) {
|
||||
extension = sanitize(currentFilename.substring(lastDot + 1)); // e.g. "epub"
|
||||
}
|
||||
@@ -100,7 +101,7 @@ public class PathPatternResolver {
|
||||
// Handle optional blocks enclosed in <...>
|
||||
Pattern optionalBlockPattern = Pattern.compile("<([^<>]*)>");
|
||||
Matcher matcher = optionalBlockPattern.matcher(pattern);
|
||||
StringBuffer resolved = new StringBuffer();
|
||||
StringBuilder resolved = new StringBuilder();
|
||||
|
||||
while (matcher.find()) {
|
||||
String block = matcher.group(1);
|
||||
@@ -134,7 +135,7 @@ public class PathPatternResolver {
|
||||
// Replace known placeholders with values, preserve unknown ones
|
||||
Pattern placeholderPattern = Pattern.compile("\\{(.*?)}");
|
||||
Matcher placeholderMatcher = placeholderPattern.matcher(result);
|
||||
StringBuffer finalResult = new StringBuffer();
|
||||
StringBuilder finalResult = new StringBuilder();
|
||||
|
||||
while (placeholderMatcher.find()) {
|
||||
String key = placeholderMatcher.group(1);
|
||||
@@ -166,8 +167,7 @@ public class PathPatternResolver {
|
||||
|
||||
private static String sanitize(String input) {
|
||||
if (input == null) return "";
|
||||
return WHITESPACE_PATTERN.matcher(CONTROL_CHARACTER_PATTERN.matcher(input
|
||||
.replaceAll("[\\\\/:*?\"<>|]", "")).replaceAll("")).replaceAll(" ")
|
||||
return WHITESPACE_PATTERN.matcher(CONTROL_CHARACTER_PATTERN.matcher(INVALID_CHARS_PATTERN.matcher(input).replaceAll("")).replaceAll("")).replaceAll(" ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE magic_shelf ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Add Kobo progress tracking columns to user_book_progress table
|
||||
ALTER TABLE user_book_progress
|
||||
ADD COLUMN IF NOT EXISTS kobo_progress_percent FLOAT,
|
||||
ADD COLUMN IF NOT EXISTS kobo_location VARCHAR(1000),
|
||||
ADD COLUMN IF NOT EXISTS kobo_location_type VARCHAR(50),
|
||||
ADD COLUMN IF NOT EXISTS kobo_location_source VARCHAR(50),
|
||||
ADD COLUMN IF NOT EXISTS kobo_last_sync_time TIMESTAMP;
|
||||
|
||||
-- Add configurable progress thresholds to kobo_user_settings table
|
||||
ALTER TABLE kobo_user_settings
|
||||
ADD COLUMN IF NOT EXISTS progress_mark_as_reading_threshold FLOAT DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS progress_mark_as_finished_threshold FLOAT DEFAULT 99;
|
||||
@@ -55,14 +55,13 @@ class AdditionalFileServiceTest {
|
||||
private BookAdditionalFileEntity fileEntity;
|
||||
private AdditionalFile additionalFile;
|
||||
private BookEntity bookEntity;
|
||||
private LibraryPathEntity libraryPathEntity;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
Path testFile = tempDir.resolve("test-file.pdf");
|
||||
Files.createFile(testFile);
|
||||
|
||||
libraryPathEntity = new LibraryPathEntity();
|
||||
LibraryPathEntity libraryPathEntity = new LibraryPathEntity();
|
||||
libraryPathEntity.setId(1L);
|
||||
libraryPathEntity.setPath(tempDir.toString());
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ class BookNoteServiceTest {
|
||||
.build();
|
||||
|
||||
BookEntity book = BookEntity.builder().id(bookId).build();
|
||||
BookLoreUserEntity userEntity = BookLoreUserEntity.builder().id(userId).build();
|
||||
BookLoreUserEntity userEntity = BookLoreUserEntity.builder().id(userId).isDefaultPassword(false).build();
|
||||
BookNoteEntity savedEntity = BookNoteEntity.builder().id(noteId).build();
|
||||
BookNote dto = BookNote.builder().id(noteId).build();
|
||||
|
||||
@@ -108,7 +108,7 @@ class BookNoteServiceTest {
|
||||
BookNote dto = BookNote.builder().id(noteId).title("new title").content("new content").build();
|
||||
|
||||
when(bookRepository.findById(bookId)).thenReturn(Optional.of(BookEntity.builder().id(bookId).build()));
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(BookLoreUserEntity.builder().id(userId).build()));
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(BookLoreUserEntity.builder().id(userId).isDefaultPassword(false).build()));
|
||||
when(bookNoteRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.of(existing));
|
||||
when(bookNoteRepository.save(existing)).thenReturn(saved);
|
||||
when(mapper.toDto(saved)).thenReturn(dto);
|
||||
@@ -162,7 +162,7 @@ class BookNoteServiceTest {
|
||||
.build();
|
||||
|
||||
when(bookRepository.findById(bookId)).thenReturn(Optional.of(BookEntity.builder().id(bookId).build()));
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(BookLoreUserEntity.builder().id(userId).build()));
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(BookLoreUserEntity.builder().id(userId).isDefaultPassword(false).build()));
|
||||
when(bookNoteRepository.findByIdAndUserId(noteId, userId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(EntityNotFoundException.class, () -> service.createOrUpdateNote(req));
|
||||
|
||||
@@ -100,10 +100,10 @@ class BookReviewServiceTest {
|
||||
Set<MetadataPublicReviewsSettings.ReviewProviderConfig> configs = new HashSet<>();
|
||||
for (MetadataProvider provider : providers) {
|
||||
MetadataPublicReviewsSettings.ReviewProviderConfig config =
|
||||
MetadataPublicReviewsSettings.ReviewProviderConfig.builder()
|
||||
.provider(provider)
|
||||
.enabled(true)
|
||||
.build();
|
||||
MetadataPublicReviewsSettings.ReviewProviderConfig.builder()
|
||||
.provider(provider)
|
||||
.enabled(true)
|
||||
.maxReviews(10).build();
|
||||
configs.add(config);
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ class BookReviewServiceTest {
|
||||
when(bookRepository.findById(bookId)).thenReturn(Optional.of(bookEntity));
|
||||
when(mapper.toDto(savedEntity)).thenReturn(freshReview);
|
||||
|
||||
Map<MetadataProvider, BookMetadata> metadataMap = new HashMap<>();
|
||||
Map<MetadataProvider, BookMetadata> metadataMap = new EnumMap<>(MetadataProvider.class);
|
||||
metadataMap.put(MetadataProvider.Amazon, BookMetadata.builder()
|
||||
.bookReviews(Collections.singletonList(freshReview))
|
||||
.build());
|
||||
@@ -204,7 +204,7 @@ class BookReviewServiceTest {
|
||||
List<BookReview> result = service.getByBookId(bookId);
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals(freshReview, result.get(0));
|
||||
assertEquals(freshReview, result.getFirst());
|
||||
verify(bookReviewUpdateService).addReviewsToBook(
|
||||
Collections.singletonList(freshReview), bookEntity.getMetadata());
|
||||
verify(bookRepository).save(bookEntity);
|
||||
@@ -229,7 +229,7 @@ class BookReviewServiceTest {
|
||||
when(bookRepository.findById(bookId)).thenReturn(Optional.of(bookEntity));
|
||||
when(mapper.toDto(savedEntity)).thenReturn(freshReview);
|
||||
|
||||
Map<MetadataProvider, BookMetadata> metadataMap = new HashMap<>();
|
||||
Map<MetadataProvider, BookMetadata> metadataMap = new EnumMap<>(MetadataProvider.class);
|
||||
metadataMap.put(MetadataProvider.GoodReads, BookMetadata.builder()
|
||||
.bookReviews(Collections.singletonList(freshReview))
|
||||
.build());
|
||||
@@ -305,7 +305,7 @@ class BookReviewServiceTest {
|
||||
BookReview amazonReview = createBookReview(MetadataProvider.Amazon);
|
||||
BookReview goodreadsReview = createBookReview(MetadataProvider.GoodReads);
|
||||
|
||||
Map<MetadataProvider, BookMetadata> metadataMap = new HashMap<>();
|
||||
Map<MetadataProvider, BookMetadata> metadataMap = new EnumMap<>(MetadataProvider.class);
|
||||
metadataMap.put(MetadataProvider.Amazon, BookMetadata.builder()
|
||||
.bookReviews(Collections.singletonList(amazonReview))
|
||||
.build());
|
||||
@@ -330,7 +330,7 @@ class BookReviewServiceTest {
|
||||
AppSettings appSettings = new AppSettings();
|
||||
appSettings.setMetadataPublicReviewsSettings(createReviewSettings(true, MetadataProvider.Amazon));
|
||||
|
||||
Map<MetadataProvider, BookMetadata> metadataMap = new HashMap<>();
|
||||
Map<MetadataProvider, BookMetadata> metadataMap = new EnumMap<>(MetadataProvider.class);
|
||||
metadataMap.put(MetadataProvider.Amazon, BookMetadata.builder()
|
||||
.bookReviews(null)
|
||||
.build());
|
||||
|
||||
@@ -140,9 +140,7 @@ class BookdropMetadataServiceTest {
|
||||
|
||||
when(bookdropFileRepository.findById(sampleFile.getId())).thenReturn(Optional.of(sampleFile));
|
||||
|
||||
assertThatThrownBy(() -> {
|
||||
bookdropMetadataService.attachInitialMetadata(sampleFile.getId());
|
||||
}).isInstanceOf(APIException.class)
|
||||
assertThatThrownBy(() -> bookdropMetadataService.attachInitialMetadata(sampleFile.getId())).isInstanceOf(APIException.class)
|
||||
.hasMessageContaining("Invalid file format");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
package com.adityachandel.booklore.service;
|
||||
|
||||
import com.adityachandel.booklore.config.security.service.AuthenticationService;
|
||||
import com.adityachandel.booklore.mapper.KoboReadingStateMapper;
|
||||
import com.adityachandel.booklore.model.dto.BookLoreUser;
|
||||
import com.adityachandel.booklore.model.dto.KoboSyncSettings;
|
||||
import com.adityachandel.booklore.model.dto.kobo.KoboReadingState;
|
||||
import com.adityachandel.booklore.model.dto.kobo.KoboReadingStateWrapper;
|
||||
import com.adityachandel.booklore.model.dto.response.kobo.KoboReadingStateResponse;
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
|
||||
import com.adityachandel.booklore.model.entity.KoboReadingStateEntity;
|
||||
import com.adityachandel.booklore.model.entity.UserBookProgressEntity;
|
||||
import com.adityachandel.booklore.model.enums.ReadStatus;
|
||||
import com.adityachandel.booklore.repository.BookRepository;
|
||||
import com.adityachandel.booklore.repository.KoboReadingStateRepository;
|
||||
import com.adityachandel.booklore.repository.UserBookProgressRepository;
|
||||
import com.adityachandel.booklore.repository.UserRepository;
|
||||
import com.adityachandel.booklore.service.kobo.KoboReadingStateBuilder;
|
||||
import com.adityachandel.booklore.service.kobo.KoboReadingStateService;
|
||||
import com.adityachandel.booklore.service.kobo.KoboSettingsService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class KoboReadingStateServiceTest {
|
||||
|
||||
@Mock
|
||||
private KoboReadingStateRepository repository;
|
||||
|
||||
@Mock
|
||||
private KoboReadingStateMapper mapper;
|
||||
|
||||
@Mock
|
||||
private UserBookProgressRepository progressRepository;
|
||||
|
||||
@Mock
|
||||
private BookRepository bookRepository;
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Mock
|
||||
private AuthenticationService authenticationService;
|
||||
|
||||
@Mock
|
||||
private KoboSettingsService koboSettingsService;
|
||||
|
||||
@Mock
|
||||
private KoboReadingStateBuilder readingStateBuilder;
|
||||
|
||||
@InjectMocks
|
||||
private KoboReadingStateService service;
|
||||
|
||||
private BookLoreUser testUser;
|
||||
private BookEntity testBook;
|
||||
private BookLoreUserEntity testUserEntity;
|
||||
private KoboSyncSettings testSettings;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
testUser = BookLoreUser.builder()
|
||||
.id(1L)
|
||||
.username("testuser")
|
||||
.build();
|
||||
|
||||
testUserEntity = new BookLoreUserEntity();
|
||||
testUserEntity.setId(1L);
|
||||
testUserEntity.setUsername("testuser");
|
||||
|
||||
testBook = new BookEntity();
|
||||
testBook.setId(100L);
|
||||
|
||||
testSettings = new KoboSyncSettings();
|
||||
testSettings.setProgressMarkAsReadingThreshold(1f);
|
||||
testSettings.setProgressMarkAsFinishedThreshold(99f);
|
||||
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(testUser);
|
||||
when(koboSettingsService.getCurrentUserSettings()).thenReturn(testSettings);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should sync Kobo progress to UserBookProgress with valid data")
|
||||
void testSyncKoboProgressToUserBookProgress_Success() {
|
||||
String entitlementId = "100";
|
||||
KoboReadingState.CurrentBookmark.Location location = KoboReadingState.CurrentBookmark.Location.builder()
|
||||
.value("epubcfi(/6/4[chap01ref]!/4/2/1:3)")
|
||||
.type("EpubCfi")
|
||||
.source("Kobo")
|
||||
.build();
|
||||
|
||||
KoboReadingState.CurrentBookmark bookmark = KoboReadingState.CurrentBookmark.builder()
|
||||
.progressPercent(25)
|
||||
.location(location)
|
||||
.build();
|
||||
|
||||
KoboReadingState readingState = KoboReadingState.builder()
|
||||
.entitlementId(entitlementId)
|
||||
.currentBookmark(bookmark)
|
||||
.build();
|
||||
|
||||
KoboReadingStateEntity entity = new KoboReadingStateEntity();
|
||||
when(mapper.toEntity(any())).thenReturn(entity);
|
||||
when(mapper.toDto(any(KoboReadingStateEntity.class))).thenReturn(readingState);
|
||||
when(repository.findByEntitlementId(entitlementId)).thenReturn(Optional.empty());
|
||||
when(repository.save(any())).thenReturn(entity);
|
||||
when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook));
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(testUserEntity));
|
||||
when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.empty());
|
||||
|
||||
ArgumentCaptor<UserBookProgressEntity> progressCaptor = ArgumentCaptor.forClass(UserBookProgressEntity.class);
|
||||
when(progressRepository.save(progressCaptor.capture())).thenReturn(new UserBookProgressEntity());
|
||||
|
||||
KoboReadingStateResponse response = service.saveReadingState(List.of(readingState));
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals("Success", response.getRequestResult());
|
||||
assertEquals(1, response.getUpdateResults().size());
|
||||
|
||||
UserBookProgressEntity savedProgress = progressCaptor.getValue();
|
||||
assertNotNull(savedProgress);
|
||||
assertEquals(25.0f, savedProgress.getKoboProgressPercent());
|
||||
assertEquals("epubcfi(/6/4[chap01ref]!/4/2/1:3)", savedProgress.getKoboLocation());
|
||||
assertEquals("EpubCfi", savedProgress.getKoboLocationType());
|
||||
assertEquals("Kobo", savedProgress.getKoboLocationSource());
|
||||
assertNotNull(savedProgress.getKoboLastSyncTime());
|
||||
assertNotNull(savedProgress.getLastReadTime());
|
||||
assertEquals(ReadStatus.READING, savedProgress.getReadStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should update existing progress when syncing Kobo data")
|
||||
void testSyncKoboProgressToUserBookProgress_UpdateExisting() {
|
||||
String entitlementId = "100";
|
||||
UserBookProgressEntity existingProgress = new UserBookProgressEntity();
|
||||
existingProgress.setUser(testUserEntity);
|
||||
existingProgress.setBook(testBook);
|
||||
existingProgress.setKoboProgressPercent(10.0f);
|
||||
|
||||
KoboReadingState.CurrentBookmark bookmark = KoboReadingState.CurrentBookmark.builder()
|
||||
.progressPercent(50)
|
||||
.build();
|
||||
|
||||
KoboReadingState readingState = KoboReadingState.builder()
|
||||
.entitlementId(entitlementId)
|
||||
.currentBookmark(bookmark)
|
||||
.build();
|
||||
|
||||
KoboReadingStateEntity entity = new KoboReadingStateEntity();
|
||||
when(mapper.toEntity(any())).thenReturn(entity);
|
||||
when(mapper.toDto(any(KoboReadingStateEntity.class))).thenReturn(readingState);
|
||||
when(repository.findByEntitlementId(entitlementId)).thenReturn(Optional.empty());
|
||||
when(repository.save(any())).thenReturn(entity);
|
||||
when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook));
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(testUserEntity));
|
||||
when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(existingProgress));
|
||||
|
||||
ArgumentCaptor<UserBookProgressEntity> progressCaptor = ArgumentCaptor.forClass(UserBookProgressEntity.class);
|
||||
when(progressRepository.save(progressCaptor.capture())).thenReturn(existingProgress);
|
||||
|
||||
service.saveReadingState(List.of(readingState));
|
||||
|
||||
UserBookProgressEntity savedProgress = progressCaptor.getValue();
|
||||
assertEquals(50.0f, savedProgress.getKoboProgressPercent());
|
||||
assertEquals(ReadStatus.READING, savedProgress.getReadStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should mark book as READ when progress reaches finished threshold")
|
||||
void testSyncKoboProgressToUserBookProgress_MarkAsRead() {
|
||||
String entitlementId = "100";
|
||||
testSettings.setProgressMarkAsFinishedThreshold(99f);
|
||||
|
||||
KoboReadingState.CurrentBookmark bookmark = KoboReadingState.CurrentBookmark.builder()
|
||||
.progressPercent(100)
|
||||
.build();
|
||||
|
||||
KoboReadingState readingState = KoboReadingState.builder()
|
||||
.entitlementId(entitlementId)
|
||||
.currentBookmark(bookmark)
|
||||
.build();
|
||||
|
||||
KoboReadingStateEntity entity = new KoboReadingStateEntity();
|
||||
when(mapper.toEntity(any())).thenReturn(entity);
|
||||
when(mapper.toDto(any(KoboReadingStateEntity.class))).thenReturn(readingState);
|
||||
when(repository.findByEntitlementId(entitlementId)).thenReturn(Optional.empty());
|
||||
when(repository.save(any())).thenReturn(entity);
|
||||
when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook));
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(testUserEntity));
|
||||
when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.empty());
|
||||
|
||||
ArgumentCaptor<UserBookProgressEntity> progressCaptor = ArgumentCaptor.forClass(UserBookProgressEntity.class);
|
||||
when(progressRepository.save(progressCaptor.capture())).thenReturn(new UserBookProgressEntity());
|
||||
|
||||
service.saveReadingState(List.of(readingState));
|
||||
|
||||
UserBookProgressEntity savedProgress = progressCaptor.getValue();
|
||||
assertEquals(100.0f, savedProgress.getKoboProgressPercent());
|
||||
assertEquals(ReadStatus.READ, savedProgress.getReadStatus());
|
||||
assertNotNull(savedProgress.getDateFinished());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should mark book as READING when progress exceeds reading threshold")
|
||||
void testSyncKoboProgressToUserBookProgress_MarkAsReading() {
|
||||
String entitlementId = "100";
|
||||
testSettings.setProgressMarkAsReadingThreshold(1f);
|
||||
|
||||
KoboReadingState.CurrentBookmark bookmark = KoboReadingState.CurrentBookmark.builder()
|
||||
.progressPercent(1)
|
||||
.build();
|
||||
|
||||
KoboReadingState readingState = KoboReadingState.builder()
|
||||
.entitlementId(entitlementId)
|
||||
.currentBookmark(bookmark)
|
||||
.build();
|
||||
|
||||
KoboReadingStateEntity entity = new KoboReadingStateEntity();
|
||||
when(mapper.toEntity(any())).thenReturn(entity);
|
||||
when(mapper.toDto(any(KoboReadingStateEntity.class))).thenReturn(readingState);
|
||||
when(repository.findByEntitlementId(entitlementId)).thenReturn(Optional.empty());
|
||||
when(repository.save(any())).thenReturn(entity);
|
||||
when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook));
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(testUserEntity));
|
||||
when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.empty());
|
||||
|
||||
ArgumentCaptor<UserBookProgressEntity> progressCaptor = ArgumentCaptor.forClass(UserBookProgressEntity.class);
|
||||
when(progressRepository.save(progressCaptor.capture())).thenReturn(new UserBookProgressEntity());
|
||||
|
||||
service.saveReadingState(List.of(readingState));
|
||||
|
||||
UserBookProgressEntity savedProgress = progressCaptor.getValue();
|
||||
assertEquals(1.0f, savedProgress.getKoboProgressPercent());
|
||||
assertEquals(ReadStatus.READING, savedProgress.getReadStatus());
|
||||
assertNull(savedProgress.getDateFinished());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should use custom thresholds from settings")
|
||||
void testSyncKoboProgressToUserBookProgress_CustomThresholds() {
|
||||
String entitlementId = "100";
|
||||
testSettings.setProgressMarkAsReadingThreshold(5.0f);
|
||||
testSettings.setProgressMarkAsFinishedThreshold(95.0f);
|
||||
|
||||
KoboReadingState.CurrentBookmark bookmark = KoboReadingState.CurrentBookmark.builder()
|
||||
.progressPercent(96)
|
||||
.build();
|
||||
|
||||
KoboReadingState readingState = KoboReadingState.builder()
|
||||
.entitlementId(entitlementId)
|
||||
.currentBookmark(bookmark)
|
||||
.build();
|
||||
|
||||
KoboReadingStateEntity entity = new KoboReadingStateEntity();
|
||||
when(mapper.toEntity(any())).thenReturn(entity);
|
||||
when(mapper.toDto(any(KoboReadingStateEntity.class))).thenReturn(readingState);
|
||||
when(repository.findByEntitlementId(entitlementId)).thenReturn(Optional.empty());
|
||||
when(repository.save(any())).thenReturn(entity);
|
||||
when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook));
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(testUserEntity));
|
||||
when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.empty());
|
||||
|
||||
ArgumentCaptor<UserBookProgressEntity> progressCaptor = ArgumentCaptor.forClass(UserBookProgressEntity.class);
|
||||
when(progressRepository.save(progressCaptor.capture())).thenReturn(new UserBookProgressEntity());
|
||||
|
||||
service.saveReadingState(List.of(readingState));
|
||||
|
||||
UserBookProgressEntity savedProgress = progressCaptor.getValue();
|
||||
assertEquals(ReadStatus.READ, savedProgress.getReadStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle invalid entitlement ID gracefully")
|
||||
void testSyncKoboProgressToUserBookProgress_InvalidEntitlementId() {
|
||||
String entitlementId = "not-a-number";
|
||||
KoboReadingState readingState = KoboReadingState.builder()
|
||||
.entitlementId(entitlementId)
|
||||
.currentBookmark(KoboReadingState.CurrentBookmark.builder().build())
|
||||
.build();
|
||||
|
||||
KoboReadingStateEntity entity = new KoboReadingStateEntity();
|
||||
when(mapper.toEntity(any())).thenReturn(entity);
|
||||
when(mapper.toDto(any(KoboReadingStateEntity.class))).thenReturn(readingState);
|
||||
when(repository.findByEntitlementId(entitlementId)).thenReturn(Optional.empty());
|
||||
when(repository.save(any())).thenReturn(entity);
|
||||
|
||||
assertDoesNotThrow(() -> service.saveReadingState(List.of(readingState)));
|
||||
verify(progressRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle missing book gracefully")
|
||||
void testSyncKoboProgressToUserBookProgress_BookNotFound() {
|
||||
String entitlementId = "999";
|
||||
KoboReadingState readingState = KoboReadingState.builder()
|
||||
.entitlementId(entitlementId)
|
||||
.currentBookmark(KoboReadingState.CurrentBookmark.builder()
|
||||
.progressPercent(50)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
KoboReadingStateEntity entity = new KoboReadingStateEntity();
|
||||
when(mapper.toEntity(any())).thenReturn(entity);
|
||||
when(mapper.toDto(any(KoboReadingStateEntity.class))).thenReturn(readingState);
|
||||
when(repository.findByEntitlementId(entitlementId)).thenReturn(Optional.empty());
|
||||
when(repository.save(any())).thenReturn(entity);
|
||||
when(bookRepository.findById(999L)).thenReturn(Optional.empty());
|
||||
|
||||
assertDoesNotThrow(() -> service.saveReadingState(List.of(readingState)));
|
||||
verify(progressRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should construct reading state from UserBookProgress when no Kobo state exists")
|
||||
void testGetReadingState_ConstructFromProgress() {
|
||||
String entitlementId = "100";
|
||||
UserBookProgressEntity progress = new UserBookProgressEntity();
|
||||
progress.setKoboProgressPercent(75.5f);
|
||||
progress.setKoboLocation("epubcfi(/6/4[chap01ref]!/4/2/1:3)");
|
||||
progress.setKoboLocationType("EpubCfi");
|
||||
progress.setKoboLocationSource("Kobo");
|
||||
progress.setKoboLastSyncTime(Instant.now());
|
||||
|
||||
KoboReadingState expectedState = KoboReadingState.builder()
|
||||
.entitlementId(entitlementId)
|
||||
.currentBookmark(KoboReadingState.CurrentBookmark.builder()
|
||||
.progressPercent(75)
|
||||
.location(KoboReadingState.CurrentBookmark.Location.builder()
|
||||
.value("epubcfi(/6/4[chap01ref]!/4/2/1:3)")
|
||||
.type("EpubCfi")
|
||||
.source("Kobo")
|
||||
.build())
|
||||
.build())
|
||||
.build();
|
||||
|
||||
when(repository.findByEntitlementId(entitlementId)).thenReturn(Optional.empty());
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(testUser);
|
||||
when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(progress));
|
||||
when(readingStateBuilder.buildReadingStateFromProgress(entitlementId, progress)).thenReturn(expectedState);
|
||||
|
||||
KoboReadingStateWrapper result = service.getReadingState(entitlementId);
|
||||
|
||||
assertNotNull(result);
|
||||
assertNotNull(result.getReadingStates());
|
||||
assertEquals(1, result.getReadingStates().size());
|
||||
|
||||
KoboReadingState state = result.getReadingStates().get(0);
|
||||
assertEquals(entitlementId, state.getEntitlementId());
|
||||
assertNotNull(state.getCurrentBookmark());
|
||||
assertEquals(75, state.getCurrentBookmark().getProgressPercent());
|
||||
assertNotNull(state.getCurrentBookmark().getLocation());
|
||||
assertEquals("epubcfi(/6/4[chap01ref]!/4/2/1:3)", state.getCurrentBookmark().getLocation().getValue());
|
||||
assertEquals("EpubCfi", state.getCurrentBookmark().getLocation().getType());
|
||||
assertEquals("Kobo", state.getCurrentBookmark().getLocation().getSource());
|
||||
|
||||
verify(repository).findByEntitlementId(entitlementId);
|
||||
verify(progressRepository).findByUserIdAndBookId(1L, 100L);
|
||||
verify(readingStateBuilder).buildReadingStateFromProgress(entitlementId, progress);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should return null when no Kobo reading state exists and UserBookProgress has no Kobo data")
|
||||
void testGetReadingState_NoKoboDataInProgress() {
|
||||
String entitlementId = "100";
|
||||
UserBookProgressEntity progress = new UserBookProgressEntity();
|
||||
progress.setKoboProgressPercent(null);
|
||||
progress.setKoboLocation(null);
|
||||
|
||||
when(repository.findByEntitlementId(entitlementId)).thenReturn(Optional.empty());
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(testUser);
|
||||
when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(progress));
|
||||
|
||||
KoboReadingStateWrapper result = service.getReadingState(entitlementId);
|
||||
|
||||
assertNull(result);
|
||||
verify(repository).findByEntitlementId(entitlementId);
|
||||
verify(progressRepository).findByUserIdAndBookId(1L, 100L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should return null when no Kobo state and no UserBookProgress exists")
|
||||
void testGetReadingState_NoDataExists() {
|
||||
String entitlementId = "100";
|
||||
when(repository.findByEntitlementId(entitlementId)).thenReturn(Optional.empty());
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(testUser);
|
||||
when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.empty());
|
||||
|
||||
KoboReadingStateWrapper result = service.getReadingState(entitlementId);
|
||||
|
||||
assertNull(result);
|
||||
verify(progressRepository).findByUserIdAndBookId(1L, 100L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should return existing Kobo reading state when it exists")
|
||||
void testGetReadingState_ExistingState() {
|
||||
String entitlementId = "100";
|
||||
KoboReadingState existingState = KoboReadingState.builder()
|
||||
.entitlementId(entitlementId)
|
||||
.build();
|
||||
|
||||
KoboReadingStateEntity entity = new KoboReadingStateEntity();
|
||||
when(repository.findByEntitlementId(entitlementId)).thenReturn(Optional.of(entity));
|
||||
when(mapper.toDto(entity)).thenReturn(existingState);
|
||||
|
||||
KoboReadingStateWrapper result = service.getReadingState(entitlementId);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.getReadingStates().size());
|
||||
assertEquals(entitlementId, result.getReadingStates().get(0).getEntitlementId());
|
||||
verify(progressRepository, never()).findByUserIdAndBookId(anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle null bookmark gracefully")
|
||||
void testSyncKoboProgressToUserBookProgress_NullBookmark() {
|
||||
String entitlementId = "100";
|
||||
KoboReadingState readingState = KoboReadingState.builder()
|
||||
.entitlementId(entitlementId)
|
||||
.currentBookmark(null)
|
||||
.build();
|
||||
|
||||
KoboReadingStateEntity entity = new KoboReadingStateEntity();
|
||||
when(mapper.toEntity(any())).thenReturn(entity);
|
||||
when(mapper.toDto(any(KoboReadingStateEntity.class))).thenReturn(readingState);
|
||||
when(repository.findByEntitlementId(entitlementId)).thenReturn(Optional.empty());
|
||||
when(repository.save(any())).thenReturn(entity);
|
||||
when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook));
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(testUserEntity));
|
||||
when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.empty());
|
||||
|
||||
ArgumentCaptor<UserBookProgressEntity> progressCaptor = ArgumentCaptor.forClass(UserBookProgressEntity.class);
|
||||
when(progressRepository.save(progressCaptor.capture())).thenReturn(new UserBookProgressEntity());
|
||||
|
||||
assertDoesNotThrow(() -> service.saveReadingState(List.of(readingState)));
|
||||
|
||||
UserBookProgressEntity savedProgress = progressCaptor.getValue();
|
||||
assertNull(savedProgress.getKoboProgressPercent());
|
||||
assertNotNull(savedProgress.getKoboLastSyncTime());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle null progress percent in bookmark")
|
||||
void testSyncKoboProgressToUserBookProgress_NullProgressPercent() {
|
||||
String entitlementId = "100";
|
||||
KoboReadingState.CurrentBookmark bookmark = KoboReadingState.CurrentBookmark.builder()
|
||||
.progressPercent(null)
|
||||
.build();
|
||||
|
||||
KoboReadingState readingState = KoboReadingState.builder()
|
||||
.entitlementId(entitlementId)
|
||||
.currentBookmark(bookmark)
|
||||
.build();
|
||||
|
||||
KoboReadingStateEntity entity = new KoboReadingStateEntity();
|
||||
when(mapper.toEntity(any())).thenReturn(entity);
|
||||
when(mapper.toDto(any(KoboReadingStateEntity.class))).thenReturn(readingState);
|
||||
when(repository.findByEntitlementId(entitlementId)).thenReturn(Optional.empty());
|
||||
when(repository.save(any())).thenReturn(entity);
|
||||
when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook));
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(testUserEntity));
|
||||
when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.empty());
|
||||
|
||||
ArgumentCaptor<UserBookProgressEntity> progressCaptor = ArgumentCaptor.forClass(UserBookProgressEntity.class);
|
||||
when(progressRepository.save(progressCaptor.capture())).thenReturn(new UserBookProgressEntity());
|
||||
|
||||
assertDoesNotThrow(() -> service.saveReadingState(List.of(readingState)));
|
||||
|
||||
UserBookProgressEntity savedProgress = progressCaptor.getValue();
|
||||
assertNull(savedProgress.getKoboProgressPercent());
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,6 @@ class KoreaderServiceTest {
|
||||
KoreaderService service;
|
||||
|
||||
private KoreaderUserDetails details;
|
||||
private Authentication auth;
|
||||
private SecurityContext context;
|
||||
|
||||
@BeforeEach
|
||||
void setUpAuth() {
|
||||
@@ -55,8 +53,8 @@ class KoreaderServiceTest {
|
||||
when(details.getUsername()).thenReturn("u");
|
||||
when(details.getPassword()).thenReturn("md5pwd");
|
||||
when(details.getBookLoreUserId()).thenReturn(42L);
|
||||
auth = mock(Authentication.class);
|
||||
context = new SecurityContextImpl();
|
||||
Authentication auth = mock(Authentication.class);
|
||||
SecurityContext context = new SecurityContextImpl();
|
||||
when(auth.getPrincipal()).thenReturn(details);
|
||||
context.setAuthentication(auth);
|
||||
SecurityContextHolder.setContext(context);
|
||||
@@ -77,7 +75,7 @@ class KoreaderServiceTest {
|
||||
when(details.getPassword()).thenReturn("MD5PWD");
|
||||
|
||||
ResponseEntity<Map<String, String>> resp = service.authorizeUser();
|
||||
assertEquals(200, resp.getStatusCodeValue());
|
||||
assertEquals(200, resp.getStatusCode().value());
|
||||
assertEquals("u", resp.getBody().get("username"));
|
||||
}
|
||||
|
||||
@@ -138,6 +136,49 @@ class KoreaderServiceTest {
|
||||
assertThrows(APIException.class, () -> service.getProgress("h"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProgress_includesTimestamp() {
|
||||
when(details.isSyncEnabled()).thenReturn(true);
|
||||
var book = new BookEntity();
|
||||
book.setId(100L);
|
||||
when(bookRepo.findByCurrentHash("hash123")).thenReturn(Optional.of(book));
|
||||
|
||||
var prog = new UserBookProgressEntity();
|
||||
prog.setKoreaderProgress("progress/path");
|
||||
prog.setKoreaderProgressPercent(0.75F);
|
||||
Instant syncTime = Instant.ofEpochSecond(1762209924L);
|
||||
prog.setKoreaderLastSyncTime(syncTime);
|
||||
when(progressRepo.findByUserIdAndBookId(42L, 100L))
|
||||
.thenReturn(Optional.of(prog));
|
||||
|
||||
KoreaderProgress out = service.getProgress("hash123");
|
||||
assertEquals("hash123", out.getDocument());
|
||||
assertEquals("progress/path", out.getProgress());
|
||||
assertEquals(0.75F, out.getPercentage());
|
||||
assertEquals(1762209924L, out.getTimestamp());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProgress_nullTimestamp() {
|
||||
when(details.isSyncEnabled()).thenReturn(true);
|
||||
var book = new BookEntity();
|
||||
book.setId(101L);
|
||||
when(bookRepo.findByCurrentHash("hash456")).thenReturn(Optional.of(book));
|
||||
|
||||
var prog = new UserBookProgressEntity();
|
||||
prog.setKoreaderProgress("progress/path2");
|
||||
prog.setKoreaderProgressPercent(0.25F);
|
||||
prog.setKoreaderLastSyncTime(null);
|
||||
when(progressRepo.findByUserIdAndBookId(42L, 101L))
|
||||
.thenReturn(Optional.of(prog));
|
||||
|
||||
KoreaderProgress out = service.getProgress("hash456");
|
||||
assertEquals("hash456", out.getDocument());
|
||||
assertEquals("progress/path2", out.getProgress());
|
||||
assertEquals(0.25F, out.getPercentage());
|
||||
assertNull(out.getTimestamp());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveProgress_createsNew() {
|
||||
when(details.isSyncEnabled()).thenReturn(true);
|
||||
|
||||
@@ -34,14 +34,13 @@ class KoreaderUserServiceTest {
|
||||
@InjectMocks
|
||||
KoreaderUserService service;
|
||||
|
||||
private BookLoreUser ownerDto;
|
||||
private BookLoreUserEntity ownerEntity;
|
||||
private KoreaderUserEntity entity;
|
||||
private KoreaderUser dto;
|
||||
|
||||
@BeforeEach
|
||||
void init() {
|
||||
ownerDto = mock(BookLoreUser.class);
|
||||
BookLoreUser ownerDto = mock(BookLoreUser.class);
|
||||
when(ownerDto.getId()).thenReturn(123L);
|
||||
when(ownerDto.getUsername()).thenReturn("ownerName");
|
||||
when(authService.getAuthenticatedUser()).thenReturn(ownerDto);
|
||||
|
||||
@@ -27,6 +27,8 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
class MetadataTaskHistoryServiceTest {
|
||||
|
||||
private static final Instant FIXED_INSTANT = Instant.parse("2025-01-01T12:00:00Z");
|
||||
|
||||
@Mock
|
||||
private MetadataFetchJobRepository jobRepository;
|
||||
|
||||
@@ -68,8 +70,7 @@ class MetadataTaskHistoryServiceTest {
|
||||
when(jobEntity.getStatus()).thenReturn(MetadataFetchTaskStatus.IN_PROGRESS);
|
||||
when(jobEntity.getCompletedBooks()).thenReturn(2);
|
||||
when(jobEntity.getTotalBooksCount()).thenReturn(3);
|
||||
Instant now = Instant.now();
|
||||
when(jobEntity.getStartedAt()).thenReturn(now.minusSeconds(60));
|
||||
when(jobEntity.getStartedAt()).thenReturn(FIXED_INSTANT.minusSeconds(60));
|
||||
when(jobEntity.getCompletedAt()).thenReturn(null);
|
||||
when(jobEntity.getUserId()).thenReturn(99L);
|
||||
|
||||
@@ -92,7 +93,7 @@ class MetadataTaskHistoryServiceTest {
|
||||
assertThat(taskDto.getStatus()).isEqualTo(MetadataFetchTaskStatus.IN_PROGRESS);
|
||||
assertThat(taskDto.getCompleted()).isEqualTo(2);
|
||||
assertThat(taskDto.getTotalBooks()).isEqualTo(3);
|
||||
assertThat(taskDto.getStartedAt()).isEqualTo(now.minusSeconds(60));
|
||||
assertThat(taskDto.getStartedAt()).isEqualTo(FIXED_INSTANT.minusSeconds(60));
|
||||
assertThat(taskDto.getCompletedAt()).isNull();
|
||||
assertThat(taskDto.getInitiatedBy()).isEqualTo(99L);
|
||||
assertThat(taskDto.getProposals()).containsExactly(dto1);
|
||||
|
||||
@@ -77,7 +77,7 @@ class OpdsBookServiceTest {
|
||||
|
||||
List<Library> libraries = new ArrayList<>();
|
||||
for (Long id : libraryIds) {
|
||||
libraries.add(Library.builder().id(id).name("Lib" + id).build());
|
||||
libraries.add(Library.builder().id(id).name("Lib" + id).watch(false).build());
|
||||
}
|
||||
when(user.getAssignedLibraries()).thenReturn(libraries);
|
||||
|
||||
@@ -86,7 +86,7 @@ class OpdsBookServiceTest {
|
||||
|
||||
@Test
|
||||
void getAccessibleLibraries_returnsAllLibraries_whenNoUserDetails() {
|
||||
List<Library> libraries = List.of(Library.builder().id(1L).name("Lib1").build());
|
||||
List<Library> libraries = List.of(Library.builder().id(1L).name("Lib1").watch(false).build());
|
||||
when(libraryService.getAllLibraries()).thenReturn(libraries);
|
||||
|
||||
List<Library> result = opdsBookService.getAccessibleLibraries(null);
|
||||
@@ -104,7 +104,7 @@ class OpdsBookServiceTest {
|
||||
BookLoreUser.UserPermissions perms = mock(BookLoreUser.UserPermissions.class);
|
||||
when(user.getPermissions()).thenReturn(perms);
|
||||
when(perms.isAdmin()).thenReturn(false);
|
||||
List<Library> assigned = List.of(Library.builder().id(2L).build());
|
||||
List<Library> assigned = List.of(Library.builder().id(2L).watch(false).build());
|
||||
when(user.getAssignedLibraries()).thenReturn(assigned);
|
||||
|
||||
List<Library> result = opdsBookService.getAccessibleLibraries(details);
|
||||
@@ -115,7 +115,7 @@ class OpdsBookServiceTest {
|
||||
@Test
|
||||
void getAccessibleLibraries_returnsAllLibraries_forAdmin() {
|
||||
OpdsUserDetails details = v2UserDetails(1L, true, Set.of(1L));
|
||||
List<Library> allLibs = List.of(Library.builder().id(1L).build());
|
||||
List<Library> allLibs = List.of(Library.builder().id(1L).watch(false).build());
|
||||
when(libraryService.getAllLibraries()).thenReturn(allLibs);
|
||||
|
||||
List<Library> result = opdsBookService.getAccessibleLibraries(details);
|
||||
@@ -194,7 +194,7 @@ class OpdsBookServiceTest {
|
||||
BookLoreUser.UserPermissions perms = mock(BookLoreUser.UserPermissions.class);
|
||||
when(user.getPermissions()).thenReturn(perms);
|
||||
when(perms.isAdmin()).thenReturn(false);
|
||||
List<Library> libs = List.of(Library.builder().id(1L).build());
|
||||
List<Library> libs = List.of(Library.builder().id(1L).watch(false).build());
|
||||
when(user.getAssignedLibraries()).thenReturn(libs);
|
||||
|
||||
Book book = Book.builder().id(1L).shelves(Set.of(Shelf.builder().userId(2L).build())).build();
|
||||
@@ -208,12 +208,12 @@ class OpdsBookServiceTest {
|
||||
Page<Book> result = opdsBookService.getRecentBooksPage(details, 0, 10);
|
||||
|
||||
assertThat(result.getContent()).hasSize(1);
|
||||
assertThat(result.getContent().get(0).getShelves()).allMatch(shelf -> shelf.getUserId().equals(2L));
|
||||
assertThat(result.getContent().getFirst().getShelves()).allMatch(shelf -> shelf.getUserId().equals(2L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLibraryName_returnsName_whenFound() {
|
||||
List<Library> libs = List.of(Library.builder().id(1L).name("Lib1").build());
|
||||
List<Library> libs = List.of(Library.builder().id(1L).name("Lib1").watch(false).build());
|
||||
when(libraryService.getAllLibraries()).thenReturn(libs);
|
||||
|
||||
String name = opdsBookService.getLibraryName(1L);
|
||||
@@ -264,7 +264,7 @@ class OpdsBookServiceTest {
|
||||
void getRandomBooks_returnsBooks_whenLibrariesAccessible() {
|
||||
OpdsUserDetails details = v2UserDetails(1L, true, Set.of(1L));
|
||||
OpdsBookService spy = Mockito.spy(opdsBookService);
|
||||
List<Library> libs = List.of(Library.builder().id(1L).build());
|
||||
List<Library> libs = List.of(Library.builder().id(1L).watch(false).build());
|
||||
doReturn(libs).when(spy).getAccessibleLibraries(details);
|
||||
|
||||
when(bookOpdsRepository.findRandomBookIdsByLibraryIds(anyList())).thenReturn(List.of(1L, 2L));
|
||||
|
||||
@@ -139,8 +139,8 @@ class VersionServiceTest {
|
||||
|
||||
@Test
|
||||
void returnsNotesWhenAvailable() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
ReleaseNote note = new ReleaseNote("v1.1", "n", "b", "u", now);
|
||||
LocalDateTime fixedTime = LocalDateTime.of(2025, 1, 1, 12, 0, 0);
|
||||
ReleaseNote note = new ReleaseNote("v1.1", "n", "b", "u", fixedTime);
|
||||
|
||||
Mockito.doReturn(List.of(note))
|
||||
.when(spyService)
|
||||
|
||||
@@ -86,12 +86,11 @@ class BookDropServiceTest {
|
||||
|
||||
private BookdropFileEntity bookdropFileEntity;
|
||||
private LibraryEntity libraryEntity;
|
||||
private LibraryPathEntity libraryPathEntity;
|
||||
private BookdropFile bookdropFile;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
libraryPathEntity = new LibraryPathEntity();
|
||||
LibraryPathEntity libraryPathEntity = new LibraryPathEntity();
|
||||
libraryPathEntity.setId(1L);
|
||||
libraryPathEntity.setPath(tempDir.toString());
|
||||
|
||||
@@ -142,7 +141,7 @@ class BookDropServiceTest {
|
||||
Page<BookdropFile> result = bookDropService.getFilesByStatus("pending", pageable);
|
||||
|
||||
assertEquals(1, result.getContent().size());
|
||||
assertEquals(bookdropFile, result.getContent().get(0));
|
||||
assertEquals(bookdropFile, result.getContent().getFirst());
|
||||
verify(bookdropFileRepository).findAllByStatus(BookdropFileEntity.Status.PENDING_REVIEW, pageable);
|
||||
verify(mapper).toDto(bookdropFileEntity);
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ class AbstractFileProcessorTest {
|
||||
@Test
|
||||
void processFile_shouldReturnUpdated_whenDuplicateFoundWithDifferentLibraryPath() {
|
||||
// Given
|
||||
LibraryEntity library = LibraryEntity.builder().id(2L).build();
|
||||
LibraryEntity library = LibraryEntity.builder().id(2L).watch(false).build();
|
||||
LibraryPathEntity newLibraryPath = LibraryPathEntity.builder()
|
||||
.id(2L)
|
||||
.library(library)
|
||||
@@ -232,7 +232,7 @@ class AbstractFileProcessorTest {
|
||||
|
||||
Book duplicateBook = createMockBook(1L, "file.pdf");
|
||||
BookEntity existingEntity = createMockBookEntity(1L, "file.pdf", "hash1", "sub",
|
||||
LibraryPathEntity.builder().id(1L).library(LibraryEntity.builder().id(1L).build()).path("/old-path").build());
|
||||
LibraryPathEntity.builder().id(1L).library(LibraryEntity.builder().id(1L).watch(false).build()).path("/old-path").build());
|
||||
Book updatedBook = createMockBook(1L, "file.pdf");
|
||||
|
||||
try (MockedStatic<FileFingerprint> fingerprintMock = mockStatic(FileFingerprint.class)) {
|
||||
@@ -331,7 +331,7 @@ class AbstractFileProcessorTest {
|
||||
@Test
|
||||
void processFile_shouldHandleNullFileSubPath() {
|
||||
// Given
|
||||
LibraryEntity library = LibraryEntity.builder().id(1L).build();
|
||||
LibraryEntity library = LibraryEntity.builder().id(1L).watch(false).build();
|
||||
LibraryPathEntity libraryPath = LibraryPathEntity.builder()
|
||||
.id(1L)
|
||||
.library(library)
|
||||
@@ -373,7 +373,7 @@ class AbstractFileProcessorTest {
|
||||
@Test
|
||||
void processFile_shouldHandleMultipleMetadataChangesSimultaneously() {
|
||||
// Given
|
||||
LibraryEntity newLibrary = LibraryEntity.builder().id(2L).build();
|
||||
LibraryEntity newLibrary = LibraryEntity.builder().id(2L).watch(false).build();
|
||||
LibraryPathEntity newLibraryPath = LibraryPathEntity.builder()
|
||||
.id(2L)
|
||||
.library(newLibrary)
|
||||
@@ -390,7 +390,7 @@ class AbstractFileProcessorTest {
|
||||
|
||||
Book duplicateBook = createMockBook(1L, "old-file.pdf");
|
||||
BookEntity existingEntity = createMockBookEntity(1L, "old-file.pdf", "hash1", "old-sub",
|
||||
LibraryPathEntity.builder().id(1L).library(LibraryEntity.builder().id(1L).build()).build());
|
||||
LibraryPathEntity.builder().id(1L).library(LibraryEntity.builder().id(1L).watch(false).build()).build());
|
||||
Book updatedBook = createMockBook(1L, "new-file.pdf");
|
||||
|
||||
try (MockedStatic<FileFingerprint> fingerprintMock = mockStatic(FileFingerprint.class)) {
|
||||
@@ -442,7 +442,7 @@ class AbstractFileProcessorTest {
|
||||
|
||||
// Helper methods
|
||||
private LibraryFile createMockLibraryFile() {
|
||||
LibraryEntity library = LibraryEntity.builder().id(1L).build();
|
||||
LibraryEntity library = LibraryEntity.builder().id(1L).watch(false).build();
|
||||
LibraryPathEntity libraryPath = LibraryPathEntity.builder()
|
||||
.id(1L)
|
||||
.library(library)
|
||||
|
||||
@@ -422,7 +422,7 @@ class FolderAsBookFileProcessorTest {
|
||||
book.setFileName(fileName);
|
||||
book.setFileSubPath(subPath);
|
||||
book.setBookType(BookFileType.PDF);
|
||||
book.setAddedOn(Instant.now());
|
||||
book.setAddedOn(Instant.parse("2025-01-01T12:00:00Z"));
|
||||
|
||||
LibraryPathEntity libraryPath = new LibraryPathEntity();
|
||||
libraryPath.setId(1L);
|
||||
|
||||
@@ -62,7 +62,7 @@ class LibraryRescanHelperTest {
|
||||
|
||||
LibraryRescanOptions options = LibraryRescanOptions.builder()
|
||||
.metadataReplaceMode(MetadataReplaceMode.REPLACE_ALL)
|
||||
.build();
|
||||
.updateMetadataFromFiles(true).build();
|
||||
|
||||
rescanContext = RescanLibraryContext.builder()
|
||||
.libraryId(1L)
|
||||
@@ -226,7 +226,7 @@ class LibraryRescanHelperTest {
|
||||
|
||||
List<TaskProgressPayload> payloads = payloadCaptor.getAllValues();
|
||||
assertEquals(3, payloads.size());
|
||||
assertEquals(0, payloads.get(0).getProgress());
|
||||
assertEquals(0, payloads.getFirst().getProgress());
|
||||
assertEquals(TaskStatus.IN_PROGRESS, payloads.get(0).getTaskStatus());
|
||||
assertEquals(TaskType.REFRESH_LIBRARY_METADATA, payloads.get(0).getTaskType());
|
||||
assertEquals(TaskStatus.IN_PROGRESS, payloads.get(1).getTaskStatus());
|
||||
|
||||
@@ -16,6 +16,7 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
@@ -42,15 +43,14 @@ class CbxMetadataExtractorTest {
|
||||
if (tempDir != null) {
|
||||
// best-effort cleanup
|
||||
Files.walk(tempDir)
|
||||
.sorted((a, b) -> b.compareTo(a))
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.forEach(p -> { try { Files.deleteIfExists(p); } catch (Exception ignore) {} });
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractMetadata_fromCbz_withComicInfo_populatesFields() throws Exception {
|
||||
String xml = "" +
|
||||
"<ComicInfo>" +
|
||||
String xml = "<ComicInfo>" +
|
||||
" <Title>My Comic</Title>" +
|
||||
" <Summary>A short summary</Summary>" +
|
||||
" <Publisher>Indie</Publisher>" +
|
||||
@@ -88,8 +88,7 @@ class CbxMetadataExtractorTest {
|
||||
|
||||
@Test
|
||||
void extractCover_fromCbz_usesComicInfoImageFile() throws Exception {
|
||||
String xml = "" +
|
||||
"<ComicInfo>" +
|
||||
String xml = "<ComicInfo>" +
|
||||
" <Pages>" +
|
||||
" <Page Type=\"FrontCover\" ImageFile=\"images/002.jpg\"/>" +
|
||||
" </Pages>" +
|
||||
@@ -130,7 +129,7 @@ class CbxMetadataExtractorTest {
|
||||
@Test
|
||||
void extractMetadata_nonArchive_fallbackTitle() throws Exception {
|
||||
Path txt = tempDir.resolve("Some Book Title.txt");
|
||||
Files.write(txt, "hello".getBytes(StandardCharsets.UTF_8));
|
||||
Files.writeString(txt, "hello");
|
||||
BookMetadata md = extractor.extractMetadata(txt.toFile());
|
||||
assertEquals("Some Book Title", md.getTitle());
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
@@ -43,7 +44,7 @@ class CbxMetadataWriterTest {
|
||||
void cleanup() throws Exception {
|
||||
if (tempDir != null) {
|
||||
Files.walk(tempDir)
|
||||
.sorted((a, b) -> b.compareTo(a))
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.forEach(p -> { try { Files.deleteIfExists(p); } catch (Exception ignore) {} });
|
||||
}
|
||||
}
|
||||
@@ -142,10 +143,11 @@ class CbxMetadataWriterTest {
|
||||
void writeMetadataToFile_cbz_updatesExistingComicInfo() throws Exception {
|
||||
// Create a CBZ *with* an existing ComicInfo.xml
|
||||
Path out = tempDir.resolve("with_meta.cbz");
|
||||
String xml = "<ComicInfo>\n" +
|
||||
" <Title>Old Title</Title>\n" +
|
||||
" <Summary>Old Summary</Summary>\n" +
|
||||
"</ComicInfo>";
|
||||
String xml = """
|
||||
<ComicInfo>
|
||||
<Title>Old Title</Title>
|
||||
<Summary>Old Summary</Summary>
|
||||
</ComicInfo>""";
|
||||
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(out.toFile()))) {
|
||||
put(zos, "ComicInfo.xml", xml.getBytes(StandardCharsets.UTF_8));
|
||||
put(zos, "a.jpg", new byte[]{1});
|
||||
|
||||
@@ -25,6 +25,8 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
class OpdsFeedServiceTest {
|
||||
|
||||
private static final Instant FIXED_INSTANT = Instant.parse("2025-01-01T12:00:00Z");
|
||||
|
||||
private AuthenticationService authenticationService;
|
||||
private OpdsBookService opdsBookService;
|
||||
private OpdsFeedService opdsFeedService;
|
||||
@@ -55,7 +57,7 @@ class OpdsFeedServiceTest {
|
||||
OpdsUserDetails userDetails = mock(OpdsUserDetails.class);
|
||||
when(authenticationService.getOpdsUser()).thenReturn(userDetails);
|
||||
|
||||
Library lib = Library.builder().id(1L).name("Test Library").build();
|
||||
Library lib = Library.builder().id(1L).name("Test Library").watch(false).build();
|
||||
when(opdsBookService.getAccessibleLibraries(userDetails)).thenReturn(List.of(lib));
|
||||
|
||||
String xml = opdsFeedService.generateLibrariesNavigation(request);
|
||||
@@ -128,7 +130,7 @@ class OpdsFeedServiceTest {
|
||||
Book book = Book.builder()
|
||||
.id(10L)
|
||||
.bookType(BookFileType.EPUB)
|
||||
.addedOn(Instant.now())
|
||||
.addedOn(FIXED_INSTANT)
|
||||
.metadata(BookMetadata.builder()
|
||||
.title("Book Title")
|
||||
.authors(Set.of("Author A"))
|
||||
@@ -183,7 +185,7 @@ class OpdsFeedServiceTest {
|
||||
Book book = Book.builder()
|
||||
.id(11L)
|
||||
.bookType(BookFileType.PDF)
|
||||
.addedOn(Instant.now())
|
||||
.addedOn(FIXED_INSTANT)
|
||||
.metadata(BookMetadata.builder().title("Recent Book").build())
|
||||
.build();
|
||||
|
||||
@@ -220,7 +222,7 @@ class OpdsFeedServiceTest {
|
||||
Book book = Book.builder()
|
||||
.id(12L)
|
||||
.bookType(BookFileType.EPUB)
|
||||
.addedOn(Instant.now())
|
||||
.addedOn(FIXED_INSTANT)
|
||||
.metadata(BookMetadata.builder().title("Surprise Book").build())
|
||||
.build();
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ class TaskCronServiceTest {
|
||||
|
||||
private AutoCloseable mocks;
|
||||
|
||||
private static final LocalDateTime FIXED_TIME = LocalDateTime.of(2025, 1, 1, 12, 0, 0);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mocks = MockitoAnnotations.openMocks(this);
|
||||
@@ -55,8 +57,8 @@ class TaskCronServiceTest {
|
||||
.cronExpression(cron)
|
||||
.enabled(enabled)
|
||||
.createdBy(10L)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.createdAt(FIXED_TIME)
|
||||
.updatedAt(FIXED_TIME)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -69,7 +71,7 @@ class TaskCronServiceTest {
|
||||
|
||||
List<TaskCronConfigurationEntity> result = service.getAllEnabledCronConfigs();
|
||||
assertEquals(1, result.size());
|
||||
assertEquals(TaskType.CLEAR_CBX_CACHE, result.get(0).getTaskType());
|
||||
assertEquals(TaskType.CLEAR_CBX_CACHE, result.getFirst().getTaskType());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -115,7 +117,7 @@ class TaskCronServiceTest {
|
||||
@Test
|
||||
void testPatchCronConfig_updateExisting() {
|
||||
TaskType type = TaskType.CLEAR_CBX_CACHE;
|
||||
BookLoreUser user = BookLoreUser.builder().id(10L).build();
|
||||
BookLoreUser user = BookLoreUser.builder().id(10L).isDefaultPassword(false).build();
|
||||
TaskCronConfigurationEntity entity = buildEntity(type, "0 0 1 * * *", false);
|
||||
|
||||
when(authService.getAuthenticatedUser()).thenReturn(user);
|
||||
@@ -134,7 +136,7 @@ class TaskCronServiceTest {
|
||||
@Test
|
||||
void testPatchCronConfig_createNew() {
|
||||
TaskType type = TaskType.CLEAR_CBX_CACHE;
|
||||
BookLoreUser user = BookLoreUser.builder().id(10L).build();
|
||||
BookLoreUser user = BookLoreUser.builder().id(10L).isDefaultPassword(false).build();
|
||||
|
||||
when(authService.getAuthenticatedUser()).thenReturn(user);
|
||||
when(repository.findByTaskType(type)).thenReturn(Optional.empty());
|
||||
@@ -153,7 +155,7 @@ class TaskCronServiceTest {
|
||||
@Test
|
||||
void testPatchCronConfig_invalidCronExpression_throws() {
|
||||
TaskType type = TaskType.CLEAR_CBX_CACHE;
|
||||
BookLoreUser user = BookLoreUser.builder().id(10L).build();
|
||||
BookLoreUser user = BookLoreUser.builder().id(10L).isDefaultPassword(false).build();
|
||||
|
||||
when(authService.getAuthenticatedUser()).thenReturn(user);
|
||||
when(repository.findByTaskType(type)).thenReturn(Optional.empty());
|
||||
|
||||
@@ -18,6 +18,8 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
class TaskHistoryServiceTest {
|
||||
|
||||
private static final LocalDateTime FIXED_TIME = LocalDateTime.of(2025, 1, 1, 12, 0, 0);
|
||||
|
||||
@Mock
|
||||
private TaskHistoryRepository taskHistoryRepository;
|
||||
|
||||
@@ -69,7 +71,7 @@ class TaskHistoryServiceTest {
|
||||
.type(TaskType.SYNC_LIBRARY_FILES)
|
||||
.status(TaskStatus.ACCEPTED)
|
||||
.progressPercentage(0)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.createdAt(FIXED_TIME)
|
||||
.build();
|
||||
|
||||
when(taskHistoryRepository.findById(taskId)).thenReturn(Optional.of(entity));
|
||||
@@ -99,7 +101,7 @@ class TaskHistoryServiceTest {
|
||||
.type(TaskType.REFRESH_LIBRARY_METADATA)
|
||||
.status(TaskStatus.ACCEPTED)
|
||||
.progressPercentage(0)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.createdAt(FIXED_TIME)
|
||||
.build();
|
||||
|
||||
when(taskHistoryRepository.findById(taskId)).thenReturn(Optional.of(entity));
|
||||
@@ -128,7 +130,7 @@ class TaskHistoryServiceTest {
|
||||
.type(TaskType.REFRESH_LIBRARY_METADATA)
|
||||
.status(TaskStatus.COMPLETED)
|
||||
.progressPercentage(100)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.createdAt(FIXED_TIME)
|
||||
.build();
|
||||
|
||||
TaskHistoryEntity exportTask = TaskHistoryEntity.builder()
|
||||
@@ -136,7 +138,7 @@ class TaskHistoryServiceTest {
|
||||
.type(TaskType.SYNC_LIBRARY_FILES)
|
||||
.status(TaskStatus.ACCEPTED)
|
||||
.progressPercentage(50)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.createdAt(FIXED_TIME.plusMinutes(5))
|
||||
.build();
|
||||
|
||||
when(taskHistoryRepository.findLatestTaskForEachType())
|
||||
@@ -167,7 +169,7 @@ class TaskHistoryServiceTest {
|
||||
.type(null)
|
||||
.status(TaskStatus.FAILED)
|
||||
.progressPercentage(0)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.createdAt(FIXED_TIME)
|
||||
.build();
|
||||
|
||||
when(taskHistoryRepository.findLatestTaskForEachType()).thenReturn(Collections.singletonList(invalidTask));
|
||||
@@ -200,7 +202,7 @@ class TaskHistoryServiceTest {
|
||||
.type(TaskType.CLEANUP_TEMP_METADATA)
|
||||
.status(TaskStatus.ACCEPTED)
|
||||
.progressPercentage(0)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.createdAt(FIXED_TIME)
|
||||
.build();
|
||||
|
||||
when(taskHistoryRepository.findById(taskId)).thenReturn(Optional.of(entity));
|
||||
@@ -222,7 +224,7 @@ class TaskHistoryServiceTest {
|
||||
.type(TaskType.CLEANUP_TEMP_METADATA)
|
||||
.status(TaskStatus.ACCEPTED)
|
||||
.progressPercentage(0)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.createdAt(FIXED_TIME)
|
||||
.build();
|
||||
|
||||
when(taskHistoryRepository.findById(taskId)).thenReturn(Optional.of(entity));
|
||||
@@ -265,7 +267,7 @@ class TaskHistoryServiceTest {
|
||||
.type(TaskType.CLEAR_CBX_CACHE)
|
||||
.status(TaskStatus.FAILED)
|
||||
.progressPercentage(0)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.createdAt(FIXED_TIME)
|
||||
.build();
|
||||
when(taskHistoryRepository.findLatestTaskForEachType()).thenReturn(Collections.singletonList(dummyTask));
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ class TaskServiceTest {
|
||||
|
||||
@Test
|
||||
void testRunAsUserThrowsExceptionForNullTaskType() {
|
||||
TaskCreateRequest req = TaskCreateRequest.builder().build();
|
||||
TaskCreateRequest req = TaskCreateRequest.builder().triggeredByCron(false).build();
|
||||
APIException ex = assertThrows(APIException.class, () -> taskService.runAsUser(req));
|
||||
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatus());
|
||||
}
|
||||
@@ -94,14 +94,14 @@ class TaskServiceTest {
|
||||
user.setUsername("user1");
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
|
||||
when(mockTask.execute(any())).thenReturn(TaskCreateResponse.builder().taskType(TaskType.CLEANUP_TEMP_METADATA).build());
|
||||
TaskCreateRequest req = TaskCreateRequest.builder().taskType(TaskType.CLEANUP_TEMP_METADATA).build();
|
||||
TaskCreateRequest req = TaskCreateRequest.builder().taskType(TaskType.CLEANUP_TEMP_METADATA).triggeredByCron(false).build();
|
||||
TaskCreateResponse resp = taskService.runAsUser(req);
|
||||
assertEquals(TaskType.CLEANUP_TEMP_METADATA, resp.getTaskType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExecuteTaskThrowsForUnknownTaskType() {
|
||||
TaskCreateRequest req = TaskCreateRequest.builder().taskType(TaskType.CLEAR_CBX_CACHE).build();
|
||||
TaskCreateRequest req = TaskCreateRequest.builder().taskType(TaskType.CLEAR_CBX_CACHE).triggeredByCron(false).build();
|
||||
BookLoreUser user = new BookLoreUser();
|
||||
user.setId(1L);
|
||||
user.setUsername("user1");
|
||||
@@ -119,8 +119,8 @@ class TaskServiceTest {
|
||||
user.setUsername("parallelUser");
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
|
||||
|
||||
TaskCreateRequest req1 = TaskCreateRequest.builder().taskType(parallelType).build();
|
||||
TaskCreateRequest req2 = TaskCreateRequest.builder().taskType(parallelType).build();
|
||||
TaskCreateRequest req1 = TaskCreateRequest.builder().taskType(parallelType).triggeredByCron(false).build();
|
||||
TaskCreateRequest req2 = TaskCreateRequest.builder().taskType(parallelType).triggeredByCron(false).build();
|
||||
|
||||
TaskCreateResponse resp1 = taskService.runAsUser(req1);
|
||||
TaskCreateResponse resp2 = taskService.runAsUser(req2);
|
||||
@@ -152,7 +152,7 @@ class TaskServiceTest {
|
||||
user.setUsername("nonParallelUser");
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
|
||||
|
||||
TaskCreateRequest req = TaskCreateRequest.builder().taskType(nonParallelType).build();
|
||||
TaskCreateRequest req = TaskCreateRequest.builder().taskType(nonParallelType).triggeredByCron(false).build();
|
||||
taskService.runAsUser(req);
|
||||
|
||||
APIException ex = assertThrows(APIException.class, () -> taskService.runAsUser(req));
|
||||
@@ -193,7 +193,7 @@ class TaskServiceTest {
|
||||
user.setUsername("asyncUser");
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
|
||||
|
||||
TaskCreateRequest req = TaskCreateRequest.builder().taskType(asyncType).build();
|
||||
TaskCreateRequest req = TaskCreateRequest.builder().taskType(asyncType).triggeredByCron(false).build();
|
||||
TaskCreateResponse resp = taskService.runAsUser(req);
|
||||
|
||||
assertEquals(asyncType, resp.getTaskType());
|
||||
@@ -210,7 +210,7 @@ class TaskServiceTest {
|
||||
user.setUsername("nullOptionsUser");
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
|
||||
|
||||
TaskCreateRequest req = TaskCreateRequest.builder().taskType(type).options(null).build();
|
||||
TaskCreateRequest req = TaskCreateRequest.builder().taskType(type).options(null).triggeredByCron(false).build();
|
||||
TaskCreateResponse resp = taskService.runAsUser(req);
|
||||
assertEquals(type, resp.getTaskType());
|
||||
}
|
||||
@@ -225,20 +225,20 @@ class TaskServiceTest {
|
||||
user.setUsername("exceptionUser");
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
|
||||
|
||||
TaskCreateRequest req = TaskCreateRequest.builder().taskType(type).build();
|
||||
TaskCreateRequest req = TaskCreateRequest.builder().taskType(type).triggeredByCron(false).build();
|
||||
assertThrows(RuntimeException.class, () -> taskService.runAsUser(req));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRunAsUserThrowsExceptionForNullUser() {
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(null);
|
||||
TaskCreateRequest req = TaskCreateRequest.builder().taskType(TaskType.CLEANUP_TEMP_METADATA).build();
|
||||
TaskCreateRequest req = TaskCreateRequest.builder().taskType(TaskType.CLEANUP_TEMP_METADATA).triggeredByCron(false).build();
|
||||
assertThrows(NullPointerException.class, () -> taskService.runAsUser(req));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExecuteTaskThrowsForMissingTaskInRegistry() {
|
||||
TaskCreateRequest req = TaskCreateRequest.builder().taskType(TaskType.CLEANUP_DELETED_BOOKS).build();
|
||||
TaskCreateRequest req = TaskCreateRequest.builder().taskType(TaskType.CLEANUP_DELETED_BOOKS).triggeredByCron(false).build();
|
||||
BookLoreUser user = new BookLoreUser();
|
||||
user.setId(8L);
|
||||
user.setUsername("missingTaskUser");
|
||||
@@ -253,7 +253,7 @@ class TaskServiceTest {
|
||||
user.setUsername("invalidOptionsUser");
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
|
||||
when(objectMapper.convertValue(any(), eq(Map.class))).thenThrow(new IllegalArgumentException("Conversion failed"));
|
||||
TaskCreateRequest req = TaskCreateRequest.builder().taskType(TaskType.CLEANUP_TEMP_METADATA).options(new Object()).build();
|
||||
TaskCreateRequest req = TaskCreateRequest.builder().taskType(TaskType.CLEANUP_TEMP_METADATA).options(new Object()).triggeredByCron(false).build();
|
||||
when(mockTask.execute(any())).thenReturn(TaskCreateResponse.builder().taskType(TaskType.CLEANUP_TEMP_METADATA).build());
|
||||
TaskCreateResponse resp = taskService.runAsUser(req);
|
||||
assertEquals(TaskType.CLEANUP_TEMP_METADATA, resp.getTaskType());
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.adityachandel.booklore.util;
|
||||
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.model.entity.LibraryPathEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FileUtilsTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private BookEntity createBookEntity(Path libraryPath, String subPath, String fileName) {
|
||||
LibraryPathEntity libraryPathEntity = new LibraryPathEntity();
|
||||
libraryPathEntity.setPath(libraryPath.toString());
|
||||
|
||||
BookEntity bookEntity = new BookEntity();
|
||||
bookEntity.setLibraryPath(libraryPathEntity);
|
||||
bookEntity.setFileSubPath(subPath);
|
||||
bookEntity.setFileName(fileName);
|
||||
|
||||
return bookEntity;
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetBookFullPath() {
|
||||
Path libraryPath = tempDir;
|
||||
String subPath = "sub/folder";
|
||||
String fileName = "test.pdf";
|
||||
|
||||
BookEntity book = createBookEntity(libraryPath, subPath, fileName);
|
||||
|
||||
String fullPath = FileUtils.getBookFullPath(book);
|
||||
|
||||
String expected = libraryPath.resolve(subPath).resolve(fileName)
|
||||
.toString().replace("\\", "/");
|
||||
|
||||
assertEquals(expected, fullPath);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetRelativeSubPath() {
|
||||
Path base = tempDir;
|
||||
Path nested = base.resolve("a/b/c/file.txt");
|
||||
|
||||
String relative = FileUtils.getRelativeSubPath(base.toString(), nested);
|
||||
|
||||
assertEquals("a/b/c", relative);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetRelativeSubPath_noParent() {
|
||||
Path base = tempDir;
|
||||
Path file = base.resolve("file.txt");
|
||||
|
||||
String result = FileUtils.getRelativeSubPath(base.toString(), file);
|
||||
|
||||
assertEquals("", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetFileSizeInKb_path() throws IOException {
|
||||
Path file = tempDir.resolve("sample.txt");
|
||||
byte[] content = new byte[2048]; // 2 KB
|
||||
Files.write(file, content);
|
||||
|
||||
Long size = FileUtils.getFileSizeInKb(file);
|
||||
|
||||
assertEquals(2, size);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetFileSizeInKb_pathFileNotFound() {
|
||||
Path file = tempDir.resolve("missing.txt");
|
||||
|
||||
Long size = FileUtils.getFileSizeInKb(file);
|
||||
|
||||
assertNull(size);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetFileSizeInKb_bookEntity() throws IOException {
|
||||
Path library = tempDir.resolve("lib");
|
||||
Files.createDirectories(library);
|
||||
|
||||
String sub = "files";
|
||||
Path subFolder = library.resolve(sub);
|
||||
Files.createDirectories(subFolder);
|
||||
|
||||
Path file = subFolder.resolve("book.epub");
|
||||
Files.write(file, new byte[4096]); // 4 KB
|
||||
|
||||
BookEntity book = createBookEntity(library, sub, "book.epub");
|
||||
|
||||
Long size = FileUtils.getFileSizeInKb(book);
|
||||
|
||||
assertEquals(4, size);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteDirectoryRecursively() throws IOException {
|
||||
Path dir = tempDir.resolve("deleteMe");
|
||||
Files.createDirectories(dir);
|
||||
|
||||
Files.write(dir.resolve("file1.txt"), "data".getBytes());
|
||||
Files.createDirectories(dir.resolve("nested"));
|
||||
Files.write(dir.resolve("nested/file2.txt"), "more".getBytes());
|
||||
|
||||
assertTrue(Files.exists(dir));
|
||||
|
||||
FileUtils.deleteDirectoryRecursively(dir);
|
||||
|
||||
assertFalse(Files.exists(dir));
|
||||
}
|
||||
}
|
||||
@@ -183,7 +183,7 @@ public class LibraryTestBuilder {
|
||||
}
|
||||
|
||||
public LibraryTestBuilder addBook(String fileSubPath, String fileName) {
|
||||
fileSubPath = removeLeadingSlash(fileSubPath);
|
||||
String subPath = removeLeadingSlash(fileSubPath);
|
||||
|
||||
long id = bookRepository.size() + 1L;
|
||||
BookMetadataEntity metadata = BookMetadataEntity.builder()
|
||||
@@ -191,11 +191,11 @@ public class LibraryTestBuilder {
|
||||
.bookId(id)
|
||||
.build();
|
||||
|
||||
String hash = computeFileHash(Path.of(fileSubPath, fileName));
|
||||
String hash = computeFileHash(Path.of(subPath, fileName));
|
||||
BookEntity bookEntity = BookEntity.builder()
|
||||
.id(id)
|
||||
.fileName(fileName)
|
||||
.fileSubPath(fileSubPath)
|
||||
.fileSubPath(subPath)
|
||||
.bookType(getBookFileType(fileName))
|
||||
.fileSizeKb(1024L)
|
||||
.library(getLibraryEntity())
|
||||
|
||||
@@ -10,7 +10,6 @@ import {SetupComponent} from './shared/components/setup/setup.component';
|
||||
import {SetupGuard} from './shared/components/setup/setup.guard';
|
||||
import {SetupRedirectGuard} from './shared/components/setup/setup-redirect.guard';
|
||||
import {EmptyComponent} from './shared/components/empty/empty.component';
|
||||
import {LoginGuard} from './shared/components/setup/ login.guard';
|
||||
import {OidcCallbackComponent} from './core/security/oidc-callback/oidc-callback.component';
|
||||
import {CbxReaderComponent} from './features/readers/cbx-reader/cbx-reader.component';
|
||||
import {MainDashboardComponent} from './features/dashboard/components/main-dashboard/main-dashboard.component';
|
||||
@@ -21,6 +20,7 @@ import {EpubReaderComponent} from './features/readers/epub-reader/component/epub
|
||||
import {PdfReaderComponent} from './features/readers/pdf-reader/pdf-reader.component';
|
||||
import {BookdropFileReviewComponent} from './features/bookdrop/component/bookdrop-file-review/bookdrop-file-review.component';
|
||||
import {ManageLibraryGuard} from './core/security/guards/manage-library.guard';
|
||||
import {LoginGuard} from './shared/components/setup/login.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Component, inject, OnInit, ViewChild} from '@angular/core';
|
||||
import {ActivatedRoute, Router} from '@angular/router';
|
||||
import {ConfirmationService, MenuItem, MessageService, PrimeTemplate} from 'primeng/api';
|
||||
import {PageTitleService} from "../../../../shared/service/page-title.service";
|
||||
import {LibraryService} from '../../service/library.service';
|
||||
import {BookService} from '../../service/book.service';
|
||||
import {catchError, debounceTime, filter, map, switchMap, take} from 'rxjs/operators';
|
||||
@@ -44,10 +45,8 @@ import {BookMenuService} from '../../service/book-menu.service';
|
||||
import {MagicShelf, MagicShelfService} from '../../../magic-shelf/service/magic-shelf.service';
|
||||
import {BookRuleEvaluatorService} from '../../../magic-shelf/service/book-rule-evaluator.service';
|
||||
import {SidebarFilterTogglePrefService} from './filters/sidebar-filter-toggle-pref-service';
|
||||
import {MetadataRefreshRequest} from '../../../metadata/model/request/metadata-refresh-request.model';
|
||||
import {MetadataRefreshType} from '../../../metadata/model/request/metadata-refresh-type.enum';
|
||||
import {GroupRule} from '../../../magic-shelf/component/magic-shelf-component';
|
||||
import {TaskCreateRequest, TaskService, TaskType} from '../../../settings/task-management/task.service';
|
||||
import {TaskHelperService} from '../../../settings/task-management/task-helper.service';
|
||||
|
||||
export enum EntityType {
|
||||
@@ -116,6 +115,8 @@ export class BookBrowserComponent implements OnInit {
|
||||
protected confirmationService = inject(ConfirmationService);
|
||||
protected magicShelfService = inject(MagicShelfService);
|
||||
protected bookRuleEvaluatorService = inject(BookRuleEvaluatorService);
|
||||
private pageTitle = inject(PageTitleService);
|
||||
|
||||
protected taskHelperService = inject(TaskHelperService);
|
||||
|
||||
bookState$: Observable<BookState> | undefined;
|
||||
@@ -179,6 +180,7 @@ export class BookBrowserComponent implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.pageTitle.setPageTitle('')
|
||||
this.coverScalePreferenceService.scaleChange$.pipe(debounceTime(1000)).subscribe();
|
||||
|
||||
const currentPath = this.activatedRoute.snapshot.routeConfig?.path;
|
||||
@@ -187,6 +189,8 @@ export class BookBrowserComponent implements OnInit {
|
||||
this.entityType = entityType;
|
||||
this.entityType$ = of(entityType);
|
||||
this.entity$ = of(null);
|
||||
|
||||
this.pageTitle.setPageTitle(currentPath === 'all-books' ? 'All Books' : 'Unshelved Books');
|
||||
} else {
|
||||
const routeEntityInfo$ = this.getEntityInfoFromRoute();
|
||||
this.entityType$ = routeEntityInfo$.pipe(map(info => info.entityType));
|
||||
@@ -194,6 +198,9 @@ export class BookBrowserComponent implements OnInit {
|
||||
switchMap(({entityId, entityType}) => this.fetchEntity(entityId, entityType))
|
||||
);
|
||||
this.entity$.subscribe(entity => {
|
||||
if (entity) {
|
||||
this.pageTitle.setPageTitle(entity.name);
|
||||
}
|
||||
this.entity = entity ?? null;
|
||||
this.entityOptions = entity
|
||||
? this.isLibrary(entity)
|
||||
|
||||
@@ -213,7 +213,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
label: 'Download',
|
||||
icon: 'pi pi-download',
|
||||
command: () => {
|
||||
this.bookService.downloadFile(this.book.id);
|
||||
this.bookService.downloadFile(this.book);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -512,7 +512,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
label: `${this.book.fileName || 'Book File'}`,
|
||||
icon: 'pi pi-file',
|
||||
command: () => {
|
||||
this.bookService.downloadFile(this.book.id);
|
||||
this.bookService.downloadFile(this.book);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -528,7 +528,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
items.push({
|
||||
label: `${format.fileName} (${this.getFileSizeInMB(format)})`,
|
||||
icon: this.getFileIcon(extension),
|
||||
command: () => this.downloadAdditionalFile(this.book.id, format.id)
|
||||
command: () => this.downloadAdditionalFile(this.book, format.id)
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -546,7 +546,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
items.push({
|
||||
label: `${file.fileName} (${this.getFileSizeInMB(file)})`,
|
||||
icon: this.getFileIcon(extension),
|
||||
command: () => this.downloadAdditionalFile(this.book.id, file.id)
|
||||
command: () => this.downloadAdditionalFile(this.book, file.id)
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -619,8 +619,8 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
!!(this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0);
|
||||
}
|
||||
|
||||
private downloadAdditionalFile(bookId: number, fileId: number): void {
|
||||
this.bookService.downloadAdditionalFile(bookId, fileId);
|
||||
private downloadAdditionalFile(book: Book, fileId: number): void {
|
||||
this.bookService.downloadAdditionalFile(book, fileId);
|
||||
}
|
||||
|
||||
private deleteAdditionalFile(bookId: number, fileId: number, fileName: string): void {
|
||||
|
||||
@@ -48,8 +48,27 @@
|
||||
(click)="toggleMetadataLock(metadata)">
|
||||
</p-button>
|
||||
</td>
|
||||
<td (click)="openMetadataCenter(book.id)" class="cursor-pointer">
|
||||
<img [attr.src]="urlHelper.getThumbnailUrl(metadata.bookId, metadata.coverUpdatedOn)" alt="Book Cover" class="size-7"/>
|
||||
<td>
|
||||
<a [routerLink]="urlHelper.getBookUrl(book)">
|
||||
<img
|
||||
[attr.src]="urlHelper.getThumbnailUrl(metadata.bookId, metadata.coverUpdatedOn)"
|
||||
alt="Book Cover"
|
||||
class="size-7"
|
||||
tooltipPosition="left"
|
||||
[pTooltip]="tooltipContent"
|
||||
tooltipStyleClass="[&>.p-tooltip-text]:p-1"
|
||||
/>
|
||||
</a>
|
||||
<ng-template #tooltipContent>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<img
|
||||
[attr.src]="urlHelper.getThumbnailUrl(metadata.bookId, metadata.coverUpdatedOn)"
|
||||
alt="Book Cover"
|
||||
class="w-[40rem] h-auto"
|
||||
/>
|
||||
<em class="text-sm text-balance text-center">{{ metadata.title }}</em>
|
||||
</div>
|
||||
</ng-template>
|
||||
</td>
|
||||
|
||||
@for (col of visibleColumns; track col.field) {
|
||||
@@ -79,9 +98,17 @@
|
||||
</span>
|
||||
</td>
|
||||
} @else {
|
||||
<td [title]="getCellValue(metadata, book, col.field)"
|
||||
class="overflow-hidden truncate text-right min-w-[6rem] max-w-[12rem]">
|
||||
{{ getCellValue(metadata, book, col.field) }}
|
||||
<td
|
||||
[title]="getCellValue(metadata, book, col.field)"
|
||||
class="overflow-hidden truncate text-right min-w-[6rem] max-w-[12rem]">
|
||||
@if(['title', 'authors', 'publisher', 'seriesName', 'categories', 'language'].includes(col.field)) {
|
||||
@for (item of getCellClickableValue(metadata, book, col.field); track $index; let isLast = $last) {
|
||||
<a [routerLink]="item.url" class="hover:underline hover:text-blue-400">
|
||||
{{ item.anchor }}</a>@if (!isLast) {<span>, </span>}
|
||||
}
|
||||
} @else {
|
||||
{{ getCellValue(metadata, book, col.field) }}
|
||||
}
|
||||
</td>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {UrlHelperService} from '../../../../../shared/service/url-helper.service
|
||||
import {Button} from 'primeng/button';
|
||||
import {BookService} from '../../../service/book.service';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {Router} from '@angular/router';
|
||||
import {Router, RouterLink} from '@angular/router';
|
||||
import {filter, Subject} from 'rxjs';
|
||||
import {UserService} from '../../../../settings/user-management/user.service';
|
||||
import {BookMetadataCenterComponent} from '../../../../metadata/component/book-metadata-center/book-metadata-center.component';
|
||||
@@ -28,7 +28,8 @@ import {ReadStatusHelper} from '../../../helpers/read-status.helper';
|
||||
FormsModule,
|
||||
Button,
|
||||
TooltipModule,
|
||||
NgClass
|
||||
NgClass,
|
||||
RouterLink
|
||||
],
|
||||
styleUrls: ['./book-table.component.scss'],
|
||||
providers: [DatePipe]
|
||||
@@ -209,6 +210,55 @@ export class BookTableComponent implements OnInit, OnDestroy, OnChanges {
|
||||
return this.readStatusHelper.shouldShowStatusIcon(readStatus);
|
||||
}
|
||||
|
||||
getAuthors(metadata: BookMetadata): string[] {
|
||||
return metadata.authors ?? []
|
||||
}
|
||||
|
||||
getCellClickableValue(metadata: BookMetadata, book: Book, field: string){
|
||||
const filterKeys:Record<string, string> = {
|
||||
'authors': 'author',
|
||||
'publisher': 'publisher',
|
||||
'categories': 'category',
|
||||
'language': 'language',
|
||||
'title': 'title'
|
||||
} as const;
|
||||
|
||||
let data:string[] =[metadata[field]];
|
||||
|
||||
switch (field) {
|
||||
case 'title':
|
||||
return [
|
||||
{
|
||||
url: this.urlHelper.getBookUrl(book),
|
||||
anchor: metadata.title ?? book.fileName
|
||||
}
|
||||
];
|
||||
|
||||
case 'categories':
|
||||
data = metadata.categories ?? [];
|
||||
break;
|
||||
|
||||
case 'authors':
|
||||
data = metadata.authors ?? [];
|
||||
break;
|
||||
|
||||
case 'seriesName':
|
||||
return [
|
||||
{
|
||||
url: this.urlHelper.filterBooksBy('series', metadata.seriesName ?? '' ),
|
||||
anchor: metadata.seriesName
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return data.map(item => {
|
||||
return {
|
||||
url: this.urlHelper.filterBooksBy(filterKeys[field] ?? field, item),
|
||||
anchor: item
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getCellValue(metadata: BookMetadata, book: Book, field: string): string | number {
|
||||
switch (field) {
|
||||
case 'readStatus':
|
||||
|
||||
@@ -6,6 +6,7 @@ export class BookSorter {
|
||||
sortOptions: SortOption[] = [
|
||||
{ label: 'Title', field: 'title', direction: SortDirection.ASCENDING },
|
||||
{ label: 'Title + Series', field: 'titleSeries', direction: SortDirection.ASCENDING },
|
||||
{ label: 'File Name', field: 'fileName', direction: SortDirection.ASCENDING },
|
||||
{ label: 'Author', field: 'author', direction: SortDirection.ASCENDING },
|
||||
{ label: 'Last Read', field: 'lastReadTime', direction: SortDirection.ASCENDING },
|
||||
{ label: 'Added On', field: 'addedOn', direction: SortDirection.ASCENDING },
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface Book extends FileInfo {
|
||||
pdfProgress?: PdfProgress;
|
||||
cbxProgress?: CbxProgress;
|
||||
koreaderProgress?: KoReaderProgress;
|
||||
koboProgress?: KoboProgress;
|
||||
seriesCount?: number | null;
|
||||
metadataMatchScore?: number | null;
|
||||
readStatus?: ReadStatus;
|
||||
@@ -65,6 +66,10 @@ export interface KoReaderProgress {
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface KoboProgress {
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface BookMetadata {
|
||||
bookId: number;
|
||||
title?: string;
|
||||
|
||||
@@ -355,14 +355,15 @@ export class BookService {
|
||||
);
|
||||
}
|
||||
|
||||
downloadFile(bookId: number): void {
|
||||
const downloadUrl = `${this.url}/${bookId}/download`;
|
||||
this.fileDownloadService.downloadFile(downloadUrl, `book_${bookId}`);
|
||||
downloadFile(book: Book): void {
|
||||
const downloadUrl = `${this.url}/${book.id}/download`;
|
||||
this.fileDownloadService.downloadFile(downloadUrl, book.fileName!);
|
||||
}
|
||||
|
||||
downloadAdditionalFile(bookId: number, fileId: number): void {
|
||||
const downloadUrl = `${this.url}/${bookId}/files/${fileId}/download`;
|
||||
this.fileDownloadService.downloadFile(downloadUrl, `additional_file_${fileId}`);
|
||||
downloadAdditionalFile(book: Book, fileId: number): void {
|
||||
const additionalFile = book.alternativeFormats!.find((f: AdditionalFile) => f.id === fileId);
|
||||
const downloadUrl = `${this.url}/${additionalFile!.id}/files/${fileId}/download`;
|
||||
this.fileDownloadService.downloadFile(downloadUrl, additionalFile!.fileName!);
|
||||
}
|
||||
|
||||
savePdfProgress(bookId: number, page: number, percentage: number): Observable<void> {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {MagicShelfComponent} from '../../magic-shelf/component/magic-shelf-compo
|
||||
import {TaskCreateRequest, TaskType} from '../../settings/task-management/task.service';
|
||||
import {MetadataRefreshRequest} from '../../metadata/model/request/metadata-refresh-request.model';
|
||||
import {TaskHelperService} from '../../settings/task-management/task-helper.service';
|
||||
import {UserService} from "../../settings/user-management/user.service";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -29,6 +30,7 @@ export class LibraryShelfMenuService {
|
||||
private router = inject(Router);
|
||||
private dialogService = inject(DialogService);
|
||||
private magicShelfService = inject(MagicShelfService);
|
||||
private userService = inject(UserService);
|
||||
|
||||
initializeLibraryMenuItems(entity: Library | Shelf | MagicShelf | null): MenuItem[] {
|
||||
return [
|
||||
@@ -203,13 +205,18 @@ export class LibraryShelfMenuService {
|
||||
}
|
||||
|
||||
initializeMagicShelfMenuItems(entity: any): MenuItem[] {
|
||||
const isAdmin = this.userService.getCurrentUser()?.permissions.admin ?? false;
|
||||
const isPublicShelf = entity?.isPublic ?? false;
|
||||
const disableOptions = isPublicShelf && !isAdmin;
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Options',
|
||||
label: (isPublicShelf ? 'Public Shelf - ' : '') + (disableOptions ? 'Read only' : 'Options'),
|
||||
items: [
|
||||
{
|
||||
label: 'Edit Magic Shelf',
|
||||
icon: 'pi pi-pen-to-square',
|
||||
disabled: disableOptions,
|
||||
command: () => {
|
||||
this.dialogService.open(MagicShelfComponent, {
|
||||
header: 'Edit Magic Shelf',
|
||||
@@ -224,6 +231,7 @@ export class LibraryShelfMenuService {
|
||||
{
|
||||
label: 'Delete Magic Shelf',
|
||||
icon: 'pi pi-trash',
|
||||
disabled: disableOptions,
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to delete magic shelf: ${entity?.name}?`,
|
||||
|
||||
@@ -38,7 +38,8 @@ export class SortService {
|
||||
.every((key) => book.metadata?.[key] === true),
|
||||
lastReadTime: (book) => book.lastReadTime ? new Date(book.lastReadTime).getTime() : null,
|
||||
addedOn: (book) => book.addedOn ? new Date(book.addedOn).getTime() : null,
|
||||
fileSizeKb: (book) => book.fileSizeKb || null
|
||||
fileSizeKb: (book) => book.fileSizeKb || null,
|
||||
fileName:(book) => book.fileName,
|
||||
};
|
||||
|
||||
applySort(books: Book[], selectedSort: SortOption | null): Book[] {
|
||||
|
||||
@@ -74,7 +74,7 @@ export class BookdropFileMetadataPickerComponent {
|
||||
{label: 'HC ID', controlName: 'hardcoverId', lockedKey: 'hardcoverIdLocked', fetchedKey: 'hardcoverId'},
|
||||
{label: 'HC Reviews', controlName: 'hardcoverReviewCount', lockedKey: 'hardcoverReviewCountLocked', fetchedKey: 'hardcoverReviewCount'},
|
||||
{label: 'HC Rating', controlName: 'hardcoverRating', lockedKey: 'hardcoverRatingLocked', fetchedKey: 'hardcoverRating'},
|
||||
{label: 'Google ID', controlName: 'googleId', lockedKey: 'googleIdLocked', fetchedKey: 'googleIdRating'},
|
||||
{label: 'Google ID', controlName: 'googleId', lockedKey: 'googleIdLocked', fetchedKey: 'googleId'},
|
||||
{label: 'Comicvine ID', controlName: 'comicvineId', lockedKey: 'comicvineIdLocked', fetchedKey: 'comicvineId'},
|
||||
{label: 'Pages', controlName: 'pageCount', lockedKey: 'pageCountLocked', fetchedKey: 'pageCount'}
|
||||
];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Component, DestroyRef, inject, OnInit, QueryList, ViewChildren} from '@angular/core';
|
||||
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
|
||||
import {filter, startWith, take, tap} from 'rxjs/operators';
|
||||
import {PageTitleService} from "../../../../shared/service/page-title.service";
|
||||
|
||||
import {BookdropFile, BookdropFinalizePayload, BookdropFinalizeResult, BookdropService} from '../../service/bookdrop.service';
|
||||
import {LibraryService} from '../../../book/service/library.service';
|
||||
@@ -68,6 +69,7 @@ export class BookdropFileReviewComponent implements OnInit {
|
||||
private readonly messageService = inject(MessageService);
|
||||
private readonly urlHelper = inject(UrlHelperService);
|
||||
private readonly activatedRoute = inject(ActivatedRoute);
|
||||
private readonly pageTitle = inject(PageTitleService);
|
||||
|
||||
@ViewChildren('metadataPicker') metadataPickers!: QueryList<BookdropFileMetadataPickerComponent>;
|
||||
|
||||
@@ -93,6 +95,8 @@ export class BookdropFileReviewComponent implements OnInit {
|
||||
excludedFiles = new Set<number>();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.pageTitle.setPageTitle('Review Bookdrop Files');
|
||||
|
||||
this.activatedRoute.queryParams
|
||||
.pipe(startWith({}), tap(() => {
|
||||
this.loading = true;
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<label>Magic Shelf</label>
|
||||
<p-select
|
||||
fluid
|
||||
[options]="magicShelves$ | async"
|
||||
[options]="(magicShelves$ | async)!"
|
||||
[(ngModel)]="scroller.magicShelfId"
|
||||
placeholder="Select magic shelf"
|
||||
appendTo="body">
|
||||
|
||||
@@ -7,6 +7,7 @@ import {CheckboxModule} from 'primeng/checkbox';
|
||||
import {InputTextModule} from 'primeng/inputtext';
|
||||
import {SelectModule} from 'primeng/select';
|
||||
import {InputNumberModule} from 'primeng/inputnumber';
|
||||
import {SortDirection} from "../../../book/model/sort.model";
|
||||
import {DashboardConfig, ScrollerConfig, ScrollerType} from '../../models/dashboard-config.model';
|
||||
import {DashboardConfigService} from '../../services/dashboard-config.service';
|
||||
import {MagicShelfService} from '../../../magic-shelf/service/magic-shelf.service';
|
||||
@@ -56,6 +57,7 @@ export class DashboardSettingsComponent implements OnInit {
|
||||
sortFieldOptions = [
|
||||
{label: 'Title', value: 'title'},
|
||||
{label: 'Title + Series', value: 'titleSeries'},
|
||||
{label: 'File Name', field: 'fileName'},
|
||||
{label: 'Date Added', value: 'addedOn'},
|
||||
{label: 'Author', value: 'author'},
|
||||
{label: 'Personal Rating', value: 'personalRating'},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user