Merge pull request #1593 from booklore-app/develop

Merge develop into master for the release
This commit is contained in:
Aditya Chandel
2025-11-21 00:46:59 -07:00
committed by GitHub
134 changed files with 2815 additions and 637 deletions

View File

@@ -6,7 +6,6 @@ # BookLore
![Docker Pulls](https://img.shields.io/docker/pulls/booklore/booklore?color=2496ED)
[![Join us on Discord](https://img.shields.io/badge/Chat-Discord-5865F2?logo=discord&style=flat)](https://discord.gg/Ee5hd458Uz)
[![Open Collective backers and sponsors](https://img.shields.io/opencollective/all/booklore?label=Open%20Collective&logo=opencollective&color=7FADF2)](https://opencollective.com/booklore)
[![Venmo](https://img.shields.io/badge/Venmo-Donate-008CFF?logo=venmo)](https://venmo.com/AdityaChandel)
> 🚨 **Important Announcement:**
> Docker images have moved to new repositories:
> - Docker Hub: `https://hub.docker.com/r/booklore/booklore`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -72,6 +72,8 @@ public class MetadataController {
.updateThumbnail(true)
.mergeCategories(mergeCategories)
.replaceMode(MetadataReplaceMode.REPLACE_ALL)
.mergeMoods(true)
.mergeTags(true)
.build();
bookMetadataUpdater.setBookMetadata(context);

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,4 +9,6 @@ public class KoboSyncSettings {
private String userId;
private String token;
private boolean syncEnabled;
private Float progressMarkAsReadingThreshold;
private Float progressMarkAsFinishedThreshold;
}

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import lombok.ToString;
@Builder
@ToString
public class KoreaderProgress {
private Long timestamp;
private String document;
private Float percentage;
private String progress;

View File

@@ -21,6 +21,7 @@ public class MetadataProviderSettings {
@Data
public static class Google {
private boolean enabled;
private String language;
}
@Data

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
package com.adityachandel.booklore.model.enums;
public enum ResetProgressType {
BOOKLORE, KOREADER
BOOKLORE, KOREADER, KOBO
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
ALTER TABLE magic_shelf ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}?`,

View File

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

View File

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

View File

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

View File

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

View File

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