Merge pull request #2063 from booklore-app/develop

Merge develop into master for release
This commit is contained in:
ACX 2025-12-30 17:23:10 -07:00 committed by GitHub
commit 43a095eb62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
111 changed files with 1086 additions and 400 deletions

View File

@ -393,7 +393,7 @@ Instances of unacceptable behavior may result in temporary or permanent ban from
**Need help or want to discuss ideas?**
- 💬 **Discord**: [Join our server](https://discord.gg/Ee5hd458Uz)
- 🐛 **Issues**: [GitHub Issues](https://github.com/adityachandelgit/BookLore/issues)
- 🐛 **Issues**: [GitHub Issues](https://github.com/booklore-app/booklore/issues)
---
@ -409,9 +409,9 @@ By contributing, you agree that your contributions will be licensed under the sa
Not sure where to start? Check out:
- Issues labeled [`good first issue`](https://github.com/adityachandelgit/BookLore/labels/good%20first%20issue)
- Issues labeled [`help wanted`](https://github.com/adityachandelgit/BookLore/labels/help%20wanted)
- Our [project roadmap](https://github.com/adityachandelgit/BookLore/projects)
- Issues labeled [`good first issue`](https://github.com/booklore-app/booklore/labels/good%20first%20issue)
- Issues labeled [`help wanted`](https://github.com/booklore-app/booklore/labels/help%20wanted)
- Our [project roadmap](https://github.com/booklore-app/booklore/projects)
---

View File

@ -45,7 +45,7 @@ LABEL org.opencontainers.image.title="BookLore" \
org.opencontainers.image.description="BookLore: A self-hosted, multi-user digital library with smart shelves, auto metadata, Kobo & KOReader sync, BookDrop imports, OPDS support, and a built-in reader for EPUB, PDF, and comics." \
org.opencontainers.image.source="https://github.com/booklore-app/booklore" \
org.opencontainers.image.url="https://github.com/booklore-app/booklore" \
org.opencontainers.image.documentation="https://booklore-app.github.io/booklore-docs/docs/getting-started" \
org.opencontainers.image.documentation="https://booklore.org/docs/getting-started" \
org.opencontainers.image.version=$APP_VERSION \
org.opencontainers.image.revision=$APP_REVISION \
org.opencontainers.image.licenses="GPL-3.0" \

View File

@ -8,9 +8,9 @@
<img src="assets/demo.gif" alt="BookLore Demo" width="800px" style="border-radius: 10px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);" />
</p>
[![Release](https://img.shields.io/github/v/release/adityachandelgit/BookLore?color=4c6ef5&style=for-the-badge&logo=github)](https://github.com/adityachandelgit/BookLore/releases)
[![Release](https://img.shields.io/github/v/release/adityachandelgit/BookLore?color=4c6ef5&style=for-the-badge&logo=github)](https://github.com/booklore-app/booklore/releases)
[![License](https://img.shields.io/github/license/adityachandelgit/BookLore?color=fab005&style=for-the-badge)](LICENSE)
[![Stars](https://img.shields.io/github/stars/adityachandelgit/BookLore?style=for-the-badge&color=ffd43b)](https://github.com/adityachandelgit/BookLore/stargazers)
[![Stars](https://img.shields.io/github/stars/adityachandelgit/BookLore?style=for-the-badge&color=ffd43b)](https://github.com/booklore-app/booklore/stargazers)
[![Docker Pulls](https://img.shields.io/docker/pulls/booklore/booklore?color=2496ED&style=for-the-badge&logo=docker&logoColor=white)](https://hub.docker.com/r/booklore/booklore)
[![Discord](https://img.shields.io/badge/Join_Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/Ee5hd458Uz)
@ -20,7 +20,7 @@
**BookLore** is a powerful, self-hosted web application designed to organize and manage your personal book collection with elegance and ease. Build your dream library with an intuitive interface, robust metadata management, and seamless multi-user support.
[🚀 Get Started](#-getting-started-with-booklore) • [📖 Documentation](https://booklore-app.github.io/booklore-docs/) • [🎮 Try Demo](#-live-demo-explore-booklore-in-action) • [💬 Community](https://discord.gg/Ee5hd458Uz)
[🚀 Get Started](#-getting-started-with-booklore) • [📖 Documentation](https://booklore.org/docs/getting-started) • [🎮 Try Demo](#-live-demo-explore-booklore-in-action) • [💬 Community](https://discord.gg/Ee5hd458Uz)
</div>
@ -99,7 +99,7 @@ Your support helps BookLore grow and improve! 🌱
Give us a star to show your support and help others discover BookLore!
[![Star this repo](https://img.shields.io/github/stars/adityachandelgit/BookLore?style=social)](https://github.com/adityachandelgit/BookLore)
[![Star this repo](https://img.shields.io/github/stars/adityachandelgit/BookLore?style=social)](https://github.com/booklore-app/booklore)
</td>
<td align="center" width="33%">
@ -163,7 +163,7 @@ Experience BookLore's features in a live environment before deploying your own i
Guides for installation, setup, features, and more
[![Read the Docs](https://img.shields.io/badge/📖_Read_the_Docs-4c6ef5?style=for-the-badge)](https://booklore-app.github.io/booklore-docs/docs/getting-started/)
[![Read the Docs](https://img.shields.io/badge/📖_Read_the_Docs-4c6ef5?style=for-the-badge)](https://booklore.org/docs/getting-started)
*Contribute to the docs at: [booklore-docs](https://github.com/booklore-app/booklore-docs)*
@ -257,6 +257,12 @@ services:
- ./data:/app/data
- ./books:/books
- ./bookdrop:/bookdrop
healthcheck:
test: wget -q -O - http://localhost:${BOOKLORE_PORT}/api/v1/healthcheck
interval: 60s
retries: 5
start_period: 60s
timeout: 10s
restart: unless-stopped
mariadb:
@ -412,7 +418,7 @@ Join community!
### Thanks to all our amazing contributors! 🙏
[![Contributors](https://contrib.rocks/image?repo=adityachandelgit/BookLore)](https://github.com/adityachandelgit/BookLore/graphs/contributors)
[![Contributors](https://contrib.rocks/image?repo=adityachandelgit/BookLore)](https://github.com/booklore-app/booklore/graphs/contributors)
**Want to see your face here?** [Start contributing today!](CONTRIBUTING.md)

View File

@ -16,6 +16,7 @@ public class AppProperties {
private RemoteAuth remoteAuth;
private Swagger swagger = new Swagger();
private Boolean forceDisableOidc = false;
private Telemetry telemetry = new Telemetry();
@Getter
@Setter
@ -35,4 +36,10 @@ public class AppProperties {
public static class Swagger {
private boolean enabled = true;
}
@Getter
@Setter
public static class Telemetry {
private String baseUrl = "https://telemetry.booklore.org";
}
}

View File

@ -50,7 +50,8 @@ public class SecurityConfig {
"/kobo/**", // Kobo API requests (auth handled in KoboAuthFilter)
"/api/v1/auth/**", // Login and token refresh endpoints (must remain public)
"/api/v1/public-settings", // Public endpoint for checking OIDC or other app settings
"/api/v1/setup/**" // Setup wizard endpoints (must remain accessible before initial setup)
"/api/v1/setup/**", // Setup wizard endpoints (must remain accessible before initial setup)
"/api/v1/healthcheck/**" // Healthcheck endpoints (must remain accessible for Docker healthchecks)
};
private static final String[] COMMON_UNAUTHENTICATED_ENDPOINTS = {

View File

@ -2,7 +2,6 @@ package com.adityachandel.booklore.config.security.interceptor;
import com.adityachandel.booklore.config.security.JwtUtils;
import com.adityachandel.booklore.config.security.service.DynamicOidcJwtProcessor;
import com.adityachandel.booklore.mapper.custom.BookLoreUserTransformer;
import com.adityachandel.booklore.model.dto.settings.OidcProviderDetails;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import com.nimbusds.jwt.JWTClaimsSet;
@ -18,10 +17,8 @@ import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
@Slf4j

View File

@ -12,7 +12,6 @@ import com.adityachandel.booklore.model.dto.response.BookdropPatternExtractResul
import com.adityachandel.booklore.service.bookdrop.BookDropService;
import com.adityachandel.booklore.service.bookdrop.BookdropBulkEditService;
import com.adityachandel.booklore.service.bookdrop.BookdropMonitoringService;
import com.adityachandel.booklore.service.monitoring.MonitoringService;
import com.adityachandel.booklore.service.bookdrop.FilenamePatternExtractor;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;

View File

@ -0,0 +1,22 @@
package com.adityachandel.booklore.controller;
import com.adityachandel.booklore.model.dto.response.SuccessResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/healthcheck")
@Tag(name = "Healthcheck", description = "Endpoints for checking the healch of the application")
public class HealthcheckController {
@Operation(summary = "Get a ping response", description = "Check if the application is responding to requests")
@ApiResponse(responseCode = "200", description = "Health status returned successfully")
@GetMapping
public ResponseEntity<?> getPing() {
return ResponseEntity.ok(new SuccessResponse<>(200, "Pong"));
}
}

View File

@ -27,7 +27,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;

View File

@ -3,7 +3,6 @@ package com.adityachandel.booklore.controller;
import com.adityachandel.booklore.config.security.service.AuthenticationService;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.exception.APIException;
import com.adityachandel.booklore.mapper.custom.BookLoreUserTransformer;
import com.adityachandel.booklore.model.dto.settings.OidcAutoProvisionDetails;
import com.adityachandel.booklore.model.dto.settings.OidcProviderDetails;
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;

View File

@ -5,9 +5,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;

View File

@ -10,8 +10,6 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/v1/public-settings")
@RequiredArgsConstructor

View File

@ -9,7 +9,6 @@ import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.Set;
@Converter

View File

@ -1,6 +1,8 @@
package com.adityachandel.booklore.crons;
import com.adityachandel.booklore.config.AppProperties;
import com.adityachandel.booklore.model.dto.BookloreTelemetry;
import com.adityachandel.booklore.model.dto.InstallationPing;
import com.adityachandel.booklore.service.TelemetryService;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import lombok.AllArgsConstructor;
@ -16,6 +18,7 @@ import java.util.concurrent.TimeUnit;
@Slf4j
public class CronService {
private final AppProperties appProperties;
private final TelemetryService telemetryService;
private final RestClient restClient;
private final AppSettingService appSettingService;
@ -24,15 +27,31 @@ public class CronService {
public void sendTelemetryData() {
if (appSettingService.getAppSettings().isTelemetryEnabled()) {
try {
String url = appProperties.getTelemetry().getBaseUrl() + "/api/v1/ingest";
BookloreTelemetry telemetry = telemetryService.collectTelemetry();
restClient.post()
.uri("https://telemetry.booklore.dev/api/v1/ingest")
.body(telemetry)
.retrieve()
.body(String.class);
} catch (Exception ignored) {
postData(url, telemetry);
} catch (Exception e) {
log.warn("Failed to send telemetry data: {}", e.getMessage());
}
}
}
@Scheduled(fixedDelay = 24, timeUnit = TimeUnit.HOURS, initialDelay = 10)
public void sendPing() {
try {
String url = appProperties.getTelemetry().getBaseUrl() + "/api/v1/heartbeat";
InstallationPing ping = telemetryService.getInstallationPing();
postData(url, ping);
} catch (Exception e) {
log.warn("Failed to send installation ping: {}", e.getMessage());
}
}
private void postData(String url, Object body) {
restClient.post()
.uri(url)
.body(body)
.retrieve()
.body(String.class);
}
}

View File

@ -2,7 +2,6 @@ package com.adityachandel.booklore.mapper;
import com.adityachandel.booklore.model.dto.AdditionalFile;
import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity;
import com.adityachandel.booklore.util.FileUtils;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Named;

View File

@ -4,7 +4,6 @@ import com.adityachandel.booklore.model.dto.BookReview;
import com.adityachandel.booklore.model.entity.BookReviewEntity;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
@Mapper(componentModel = "spring")
public interface BookReviewMapper {

View File

@ -11,8 +11,9 @@ import java.util.Map;
@Setter
@Getter
public class BookloreTelemetry {
private int telemetryVersion = 1;
private int telemetryVersion;
private String installationId;
private String installationDate;
private String appVersion;
private int totalLibraries;
@ -48,8 +49,8 @@ public class BookloreTelemetry {
@Builder
@Getter
public static class MetadataStatistics {
private int[] enabledMetadataProviders;
private int[] enabledReviewMetadataProviders;
private String[] enabledMetadataProviders;
private String[] enabledReviewMetadataProviders;
private boolean saveMetadataToFile;
private boolean moveFileViaPattern;
private boolean autoBookSearchEnabled;
@ -84,17 +85,16 @@ public class BookloreTelemetry {
@Getter
public static class BookStatistics {
private long totalBooks;
private Map<Integer, Long> bookCountByType;
private Map<String, Long> bookCountByType;
}
@Builder
@Getter
public static class LibraryStatistics {
private String libraryName;
private long bookCount;
private int totalLibraryPaths;
private boolean watchEnabled;
private int iconType;
private int scanMode;
private String iconType;
private String scanMode;
}
}

View File

@ -5,12 +5,6 @@ import lombok.*;
import java.time.LocalDate;
import java.util.Set;
import lombok.*;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Set;
@Getter
@Setter
@NoArgsConstructor

View File

@ -0,0 +1,16 @@
package com.adityachandel.booklore.model.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Installation {
private String id;
private Instant date;
}

View File

@ -0,0 +1,17 @@
package com.adityachandel.booklore.model.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Builder;
import lombok.Getter;
import java.time.Instant;
@Builder
@Getter
public class InstallationPing {
private int pingVersion;
private String appVersion;
private String installationId;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", timezone = "UTC")
private Instant installationDate;
}

View File

@ -19,9 +19,11 @@ public class MetadataRefreshOptions {
private boolean mergeCategories;
private Boolean reviewBeforeApply;
@NotNull(message = "Field options cannot be null")
private FieldOptions fieldOptions;
@Builder.Default
private FieldOptions fieldOptions = new FieldOptions();
@NotNull(message = "Enabled fields cannot be null")
private EnabledFields enabledFields;
@Builder.Default
private EnabledFields enabledFields = new EnabledFields();
@Getter
@Setter

View File

@ -7,7 +7,7 @@ import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.List;
@Data
@ -27,8 +27,8 @@ public class TasksHistoryResponse {
private TaskStatus status;
private Integer progressPercentage;
private String message;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime completedAt;
private Instant createdAt;
private Instant updatedAt;
private Instant completedAt;
}
}

View File

@ -4,8 +4,6 @@ import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
@Entity
@Getter

View File

@ -3,8 +3,6 @@ package com.adityachandel.booklore.model.entity;
import jakarta.persistence.*;
import lombok.*;
import java.util.List;
@Entity
@Getter
@Setter

View File

@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@Repository
public interface AuthorRepository extends JpaRepository<AuthorEntity, Long> {

View File

@ -4,7 +4,6 @@ import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.LibraryPathEntity;
import com.adityachandel.booklore.model.enums.BookFileType;
import jakarta.transaction.Transactional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;

View File

@ -5,9 +5,6 @@ import com.adityachandel.booklore.model.entity.BookShelfMapping;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Set;
@Repository
public interface BookShelfMappingRepository extends JpaRepository<BookShelfMapping, BookShelfKey> {
}

View File

@ -1,7 +1,6 @@
package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.CbxViewerPreferencesEntity;
import com.adityachandel.booklore.model.entity.PdfViewerPreferencesEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

View File

@ -3,8 +3,6 @@ package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.LibraryEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;

View File

@ -1,6 +1,5 @@
package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.CbxViewerPreferencesEntity;
import com.adityachandel.booklore.model.entity.NewPdfViewerPreferencesEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

View File

@ -25,7 +25,7 @@ public interface ReadingSessionRepository extends JpaRepository<ReadingSessionEn
List<ReadingSessionCountDto> findSessionCountsByUserAndYear(@Param("userId") Long userId, @Param("year") int year);
@Query("""
SELECT
SELECT
b.id as bookId,
b.metadata.title as bookTitle,
rs.bookType as bookFileType,
@ -49,7 +49,7 @@ public interface ReadingSessionRepository extends JpaRepository<ReadingSessionEn
@Param("week") int week);
@Query("""
SELECT
SELECT
CAST(rs.createdAt AS LocalDate) as date,
AVG(rs.progressDelta / (rs.durationSeconds / 60.0)) as avgProgressPerMinute,
COUNT(rs) as totalSessions
@ -64,7 +64,7 @@ public interface ReadingSessionRepository extends JpaRepository<ReadingSessionEn
List<ReadingSpeedDto> findReadingSpeedByUserAndYear(@Param("userId") Long userId, @Param("year") int year);
@Query("""
SELECT
SELECT
HOUR(rs.startTime) as hourOfDay,
COUNT(rs) as sessionCount,
SUM(rs.durationSeconds) as totalDurationSeconds
@ -81,7 +81,7 @@ public interface ReadingSessionRepository extends JpaRepository<ReadingSessionEn
@Param("month") Integer month);
@Query("""
SELECT
SELECT
DAYOFWEEK(rs.startTime) as dayOfWeek,
COUNT(rs) as sessionCount,
SUM(rs.durationSeconds) as totalDurationSeconds
@ -98,7 +98,7 @@ public interface ReadingSessionRepository extends JpaRepository<ReadingSessionEn
@Param("month") Integer month);
@Query("""
SELECT
SELECT
c.name as genre,
COUNT(DISTINCT b.id) as bookCount,
COUNT(rs) as totalSessions,

View File

@ -1,14 +1,11 @@
package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.ShelfEntity;
import jakarta.validation.constraints.NotBlank;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@Repository
public interface ShelfRepository extends JpaRepository<ShelfEntity, Long> {

View File

@ -44,7 +44,7 @@ public interface UserBookProgressRepository extends JpaRepository<UserBookProgre
);
@Query("""
SELECT
SELECT
YEAR(COALESCE(ubp.dateFinished, ubp.readStatusModifiedTime, ubp.lastReadTime)) as year,
MONTH(COALESCE(ubp.dateFinished, ubp.readStatusModifiedTime, ubp.lastReadTime)) as month,
ubp.readStatus as readStatus,

View File

@ -0,0 +1,95 @@
package com.adityachandel.booklore.service;
import com.adityachandel.booklore.model.dto.Installation;
import com.adityachandel.booklore.model.entity.AppSettingEntity;
import com.adityachandel.booklore.repository.AppSettingsRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.UUID;
@Service
@Slf4j
public class InstallationService {
private static final String INSTALLATION_ID_KEY = "installation_id";
private final AppSettingsRepository appSettingsRepository;
private final ObjectMapper objectMapper;
public InstallationService(AppSettingsRepository appSettingsRepository, ObjectMapper objectMapper) {
this.appSettingsRepository = appSettingsRepository;
this.objectMapper = objectMapper.copy();
this.objectMapper.registerModule(new JavaTimeModule());
this.objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}
public Installation getOrCreateInstallation() {
AppSettingEntity setting = appSettingsRepository.findByName(INSTALLATION_ID_KEY);
if (setting == null) {
return createNewInstallation();
}
try {
return objectMapper.readValue(setting.getVal(), Installation.class);
} catch (Exception e) {
log.warn("Failed to parse installation ID, creating new one", e);
return createNewInstallation();
}
}
private Installation createNewInstallation() {
Instant now = Instant.now();
String uuid = UUID.randomUUID().toString();
String combined = now.toString() + "_" + uuid;
String installationId = hashToSha256(combined).substring(0, 24);
Installation installation = new Installation(installationId, now);
saveInstallation(installation);
log.info("Generated new installation ID");
return installation;
}
private void saveInstallation(Installation installation) {
try {
String json = objectMapper.writeValueAsString(installation);
AppSettingEntity setting = appSettingsRepository.findByName(INSTALLATION_ID_KEY);
if (setting == null) {
setting = new AppSettingEntity();
setting.setName(INSTALLATION_ID_KEY);
}
setting.setVal(json);
appSettingsRepository.save(setting);
} catch (Exception e) {
throw new RuntimeException("Failed to save installation ID", e);
}
}
private String hashToSha256(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return hexString.toString();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 algorithm not found", e);
}
}
}

View File

@ -29,6 +29,7 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -180,11 +181,11 @@ public class ReadingSessionService {
public List<CompletionTimelineResponse> getCompletionTimeline(int year) {
BookLoreUser authenticatedUser = authenticationService.getAuthenticatedUser();
Long userId = authenticatedUser.getId();
Map<String, Map<ReadStatus, Long>> timelineMap = new HashMap<>();
Map<String, EnumMap<ReadStatus, Long>> timelineMap = new HashMap<>();
userBookProgressRepository.findCompletionTimelineByUser(userId, year).forEach(dto -> {
String key = dto.getYear() + "-" + dto.getMonth();
timelineMap.computeIfAbsent(key, k -> new HashMap<>())
timelineMap.computeIfAbsent(key, k -> new EnumMap<>(ReadStatus.class))
.put(dto.getReadStatus(), dto.getBookCount());
});

View File

@ -1,63 +1,30 @@
package com.adityachandel.booklore.service;
import com.adityachandel.booklore.model.dto.BookloreTelemetry;
import com.adityachandel.booklore.model.dto.Installation;
import com.adityachandel.booklore.model.dto.InstallationPing;
import com.adityachandel.booklore.model.dto.settings.AppSettings;
import com.adityachandel.booklore.model.dto.settings.MetadataProviderSettings;
import com.adityachandel.booklore.model.dto.settings.MetadataPublicReviewsSettings;
import com.adityachandel.booklore.model.entity.AppSettingEntity;
import com.adityachandel.booklore.model.entity.LibraryEntity;
import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.model.enums.ProvisioningMethod;
import com.adityachandel.booklore.model.enums.IconType;
import com.adityachandel.booklore.model.enums.LibraryScanMode;
import com.adityachandel.booklore.model.enums.MetadataProvider;
import com.adityachandel.booklore.model.enums.ProvisioningMethod;
import com.adityachandel.booklore.repository.*;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@AllArgsConstructor
public class TelemetryService {
private static class EnumMappings {
static final Map<BookFileType, Integer> BOOK_FILE_TYPE = Map.of(
BookFileType.PDF, 1,
BookFileType.EPUB, 2,
BookFileType.CBX, 3,
BookFileType.FB2, 4
);
static final Map<IconType, Integer> ICON_TYPE = Map.of(
IconType.PRIME_NG, 1,
IconType.CUSTOM_SVG, 2
);
static final Map<LibraryScanMode, Integer> LIBRARY_SCAN_MODE = Map.of(
LibraryScanMode.FILE_AS_BOOK, 1,
LibraryScanMode.FOLDER_AS_BOOK, 2
);
static final Map<MetadataProvider, Integer> METADATA_PROVIDER = Map.of(
MetadataProvider.Amazon, 1,
MetadataProvider.Google, 2,
MetadataProvider.GoodReads, 3,
MetadataProvider.Hardcover, 4,
MetadataProvider.Comicvine, 5,
MetadataProvider.Douban, 6
);
static final Map<MetadataProvider, Integer> REVIEW_PROVIDER = Map.of(
MetadataProvider.Amazon, 1,
MetadataProvider.Google, 2,
MetadataProvider.GoodReads, 3,
MetadataProvider.Hardcover, 4,
MetadataProvider.Comicvine, 5,
MetadataProvider.Douban, 6
);
}
private static final String INSTALLATION_ID_KEY = "installation_id";
private final AppSettingsRepository appSettingsRepository;
private final VersionService versionService;
private final LibraryRepository libraryRepository;
private final BookRepository bookRepository;
@ -77,6 +44,18 @@ public class TelemetryService {
private final KoboUserSettingsRepository koboUserSettingsRepository;
private final KoreaderUserRepository koreaderUserRepository;
private final OpdsUserV2Repository opdsUserV2Repository;
private final InstallationService installationService;
public InstallationPing getInstallationPing() {
Installation installation = installationService.getOrCreateInstallation();
return InstallationPing.builder()
.pingVersion(1)
.appVersion(versionService.appVersion)
.installationId(installation.getId())
.installationDate(installation.getDate())
.build();
}
public BookloreTelemetry collectTelemetry() {
long totalUsers = userRepository.count();
@ -94,11 +73,15 @@ public class TelemetryService {
.map(this::mapLibraryStatistics)
.collect(Collectors.toList());
int[] enabledMetadataProviders = getEnabledMetadataProvidersAsInt(settings.getMetadataProviderSettings());
int[] enabledReviewMetadataProviders = getEnabledReviewMetadataProvidersAsInt(settings.getMetadataPublicReviewsSettings());
String[] enabledMetadataProviders = getEnabledMetadataProviders(settings.getMetadataProviderSettings());
String[] enabledReviewMetadataProviders = getEnabledReviewMetadataProviders(settings.getMetadataPublicReviewsSettings());
Installation installation = installationService.getOrCreateInstallation();
return BookloreTelemetry.builder()
.installationId(getInstallationId())
.telemetryVersion(2)
.installationId(installation.getId())
.installationDate(installation.getDate() != null ? installation.getDate().toString() : null)
.appVersion(versionService.appVersion)
.totalLibraries((int) libraryRepository.count())
.totalBooks(bookRepository.count())
@ -146,64 +129,50 @@ public class TelemetryService {
.build();
}
private Map<Integer, Long> getBookFileTypeCounts() {
Map<Integer, Long> countByType = new HashMap<>();
private Map<String, Long> getBookFileTypeCounts() {
Map<String, Long> countByType = new HashMap<>();
for (BookFileType type : BookFileType.values()) {
Integer mapped = EnumMappings.BOOK_FILE_TYPE.get(type);
if (mapped != null) {
countByType.put(mapped, bookRepository.countByBookType(type));
}
countByType.put(type.name(), bookRepository.countByBookType(type));
}
return countByType;
}
private BookloreTelemetry.LibraryStatistics mapLibraryStatistics(LibraryEntity lib) {
return BookloreTelemetry.LibraryStatistics.builder()
.libraryName(lib.getName())
.totalLibraryPaths(lib.getLibraryPaths() != null ? lib.getLibraryPaths().size() : 0)
.bookCount(bookRepository.countByLibraryId(lib.getId()))
.watchEnabled(lib.isWatch())
.iconType(lib.getIconType() != null ? EnumMappings.ICON_TYPE.getOrDefault(lib.getIconType(), -1) : -1)
.scanMode(lib.getScanMode() != null ? EnumMappings.LIBRARY_SCAN_MODE.getOrDefault(lib.getScanMode(), -1) : -1)
.iconType(lib.getIconType() != null ? lib.getIconType().name() : null)
.scanMode(lib.getScanMode() != null ? lib.getScanMode().name() : null)
.build();
}
private int[] getEnabledMetadataProvidersAsInt(MetadataProviderSettings providers) {
List<Integer> enabled = new ArrayList<>();
private String[] getEnabledMetadataProviders(MetadataProviderSettings providers) {
List<String> enabled = new ArrayList<>();
if (providers.getAmazon() != null && providers.getAmazon().isEnabled())
enabled.add(EnumMappings.METADATA_PROVIDER.get(MetadataProvider.Amazon));
enabled.add(MetadataProvider.Amazon.name());
if (providers.getGoogle() != null && providers.getGoogle().isEnabled())
enabled.add(EnumMappings.METADATA_PROVIDER.get(MetadataProvider.Google));
enabled.add(MetadataProvider.Google.name());
if (providers.getGoodReads() != null && providers.getGoodReads().isEnabled())
enabled.add(EnumMappings.METADATA_PROVIDER.get(MetadataProvider.GoodReads));
enabled.add(MetadataProvider.GoodReads.name());
if (providers.getHardcover() != null && providers.getHardcover().isEnabled())
enabled.add(EnumMappings.METADATA_PROVIDER.get(MetadataProvider.Hardcover));
enabled.add(MetadataProvider.Hardcover.name());
if (providers.getComicvine() != null && providers.getComicvine().isEnabled())
enabled.add(EnumMappings.METADATA_PROVIDER.get(MetadataProvider.Comicvine));
enabled.add(MetadataProvider.Comicvine.name());
if (providers.getDouban() != null && providers.getDouban().isEnabled())
enabled.add(EnumMappings.METADATA_PROVIDER.get(MetadataProvider.Douban));
return enabled.stream().mapToInt(i -> i).toArray();
enabled.add(MetadataProvider.Douban.name());
if (providers.getLubimyczytac() != null && providers.getLubimyczytac().isEnabled())
enabled.add(MetadataProvider.Lubimyczytac.name());
return enabled.toArray(new String[0]);
}
private int[] getEnabledReviewMetadataProvidersAsInt(MetadataPublicReviewsSettings reviewSettings) {
List<Integer> enabled = new ArrayList<>();
private String[] getEnabledReviewMetadataProviders(MetadataPublicReviewsSettings reviewSettings) {
List<String> enabled = new ArrayList<>();
if (reviewSettings.getProviders() != null) {
reviewSettings.getProviders().stream()
.filter(MetadataPublicReviewsSettings.ReviewProviderConfig::isEnabled)
.forEach(cfg -> {
try {
MetadataProvider provider = MetadataProvider.valueOf(cfg.getProvider().name());
Integer mapped = EnumMappings.REVIEW_PROVIDER.get(provider);
if (mapped != null) enabled.add(mapped);
} catch (IllegalArgumentException ignored) {
}
});
.forEach(cfg -> enabled.add(cfg.getProvider().name()));
}
return enabled.stream().mapToInt(i -> i).toArray();
}
private String getInstallationId() {
AppSettingEntity setting = appSettingsRepository.findByName(INSTALLATION_ID_KEY);
return setting != null ? setting.getVal() : "unknown";
return enabled.toArray(new String[0]);
}
}

View File

@ -8,7 +8,6 @@ import com.adityachandel.booklore.service.file.FileFingerprint;
import com.adityachandel.booklore.util.FileUtils;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import java.time.Instant;

View File

@ -21,7 +21,6 @@ import org.springframework.stereotype.Service;
import org.springframework.util.FileSystemUtils;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;

View File

@ -405,7 +405,13 @@ public class BookDropService {
private BookdropFileResult performFileMove(BookdropFileEntity bookdropFile, Path source, Path target, LibraryEntity library, LibraryPathEntity path, BookMetadata metadata) {
Path tempPath = null;
try {
tempPath = Files.createTempFile("bookdrop-finalize-", bookdropFile.getFileName());
String suffix = "";
String fileName = bookdropFile.getFileName();
int lastDotIndex = fileName.lastIndexOf('.');
if (lastDotIndex >= 0) {
suffix = fileName.substring(lastDotIndex);
}
tempPath = Files.createTempFile("bookdrop-finalize-", suffix);
Files.copy(source, tempPath, StandardCopyOption.REPLACE_EXISTING);
Files.createDirectories(target.getParent());

View File

@ -2,12 +2,10 @@ package com.adityachandel.booklore.service.bookdrop;
import com.adityachandel.booklore.config.AppProperties;
import com.adityachandel.booklore.model.enums.BookFileExtension;
import com.adityachandel.booklore.util.FileService;
import com.adityachandel.booklore.util.FileUtils;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;

View File

@ -10,7 +10,6 @@ import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.Set;
@Service

View File

@ -13,6 +13,8 @@ import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.PreDestroy;
import java.time.LocalDate;
import java.time.Year;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.*;
@ -26,6 +28,7 @@ import java.util.regex.PatternSyntaxException;
@RequiredArgsConstructor
public class FilenamePatternExtractor {
private static final Pattern PATTERN = Pattern.compile("[,;&]");
private final BookdropFileRepository bookdropFileRepository;
private final BookdropMetadataHelper metadataHelper;
private final ExecutorService regexExecutor = Executors.newCachedThreadPool(runnable -> {
@ -62,6 +65,8 @@ public class FilenamePatternExtractor {
private static final Pattern FOUR_DIGIT_YEAR_PATTERN = Pattern.compile("\\d{4}");
private static final Pattern TWO_DIGIT_YEAR_PATTERN = Pattern.compile("\\d{2}");
private static final Pattern COMPACT_DATE_PATTERN = Pattern.compile("\\d{8}");
private static final Pattern YEAR_MONTH_PATTERN = Pattern.compile("(\\d{4})([^\\d])(\\d{1,2})");
private static final Pattern MONTH_YEAR_PATTERN = Pattern.compile("(\\d{1,2})([^\\d])(\\d{4})");
private static final Pattern FLEXIBLE_DATE_PATTERN = Pattern.compile("(\\d{1,4})([^\\d])(\\d{1,2})\\2(\\d{1,4})");
@Transactional
@ -426,7 +431,7 @@ public class FilenamePatternExtractor {
}
private Set<String> parseAuthors(String value) {
String[] parts = value.split("[,;&]");
String[] parts = PATTERN.split(value);
Set<String> authors = new LinkedHashSet<>();
for (String part : parts) {
String trimmed = part.trim();
@ -455,18 +460,32 @@ public class FilenamePatternExtractor {
}
try {
if ("yyyy".equals(detectedFormat) || "yy".equals(detectedFormat)) {
if ("yyyy".equals(detectedFormat)) {
Year year = Year.parse(value, DateTimeFormatter.ofPattern("yyyy"));
metadata.setPublishedDate(year.atMonthDay(java.time.MonthDay.of(1, 1)));
return;
}
if ("yy".equals(detectedFormat)) {
int year = Integer.parseInt(value);
if ("yy".equals(detectedFormat) && year < 100) {
if (year < 100) {
year += (year < TWO_DIGIT_YEAR_CUTOFF) ? 2000 : TWO_DIGIT_YEAR_CENTURY_BASE;
}
metadata.setPublishedDate(LocalDate.of(year, 1, 1));
return;
}
if (isYearMonthFormat(detectedFormat)) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(detectedFormat);
YearMonth yearMonth = YearMonth.parse(value, formatter);
metadata.setPublishedDate(yearMonth.atDay(1));
return;
}
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(detectedFormat);
LocalDate date = LocalDate.parse(value, formatter);
metadata.setPublishedDate(date);
return;
} catch (NumberFormatException e) {
log.warn("Failed to parse year value '{}': {}", value, e.getMessage());
} catch (DateTimeParseException e) {
@ -496,6 +515,22 @@ public class FilenamePatternExtractor {
return "yyyyMMdd";
}
Matcher yearMonthMatcher = YEAR_MONTH_PATTERN.matcher(trimmed);
if (yearMonthMatcher.matches()) {
String separator = yearMonthMatcher.group(2);
String monthPart = yearMonthMatcher.group(3);
String monthFormat = monthPart.length() == 1 ? "M" : "MM";
return "yyyy" + separator + monthFormat;
}
Matcher monthYearMatcher = MONTH_YEAR_PATTERN.matcher(trimmed);
if (monthYearMatcher.matches()) {
String monthPart = monthYearMatcher.group(1);
String separator = monthYearMatcher.group(2);
String monthFormat = monthPart.length() == 1 ? "M" : "MM";
return monthFormat + separator + "yyyy";
}
Matcher flexibleMatcher = FLEXIBLE_DATE_PATTERN.matcher(trimmed);
if (flexibleMatcher.matches()) {
String separator = flexibleMatcher.group(2);
@ -618,6 +653,13 @@ public class FilenamePatternExtractor {
bookdropFileRepository.saveAll(filesToSave);
}
}
private boolean isYearMonthFormat(String format) {
return format != null &&
(format.contains("y") || format.contains("Y")) &&
(format.contains("M")) &&
!(format.contains("d") || format.contains("D"));
}
private record PlaceholderConfig(String regex, String metadataField) {}

View File

@ -29,8 +29,6 @@ import org.springframework.stereotype.Service;
import java.io.File;
import java.util.Properties;
import static com.adityachandel.booklore.model.websocket.LogNotification.createLogNotification;
@Slf4j
@Service
@AllArgsConstructor

View File

@ -9,9 +9,6 @@ import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
import static com.adityachandel.booklore.model.websocket.LogNotification.createLogNotification;
@AllArgsConstructor
@Service

View File

@ -9,8 +9,6 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import static com.adityachandel.booklore.model.websocket.LogNotification.createLogNotification;
@Slf4j
@AllArgsConstructor
@Service

View File

@ -21,7 +21,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Collectors;
@AllArgsConstructor
@Service

View File

@ -26,7 +26,6 @@ import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.*;
import java.time.Instant;
import java.util.*;
import java.util.regex.Pattern;
@ -41,7 +40,6 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce
private static final Pattern IMAGE_EXTENSION_PATTERN = Pattern.compile(".*\\.(jpg|jpeg|png|webp)");
private static final Pattern IMAGE_EXTENSION_CASE_INSENSITIVE_PATTERN = Pattern.compile("(?i).*\\.(jpg|jpeg|png|webp)");
private static final Pattern CBX_FILE_EXTENSION_PATTERN = Pattern.compile("(?i)\\.cb[rz7]$");
private final BookMetadataRepository bookMetadataRepository;
private final CbxMetadataExtractor cbxMetadataExtractor;
public CbxProcessor(BookRepository bookRepository,
@ -53,8 +51,7 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce
MetadataMatchService metadataMatchService,
CbxMetadataExtractor cbxMetadataExtractor) {
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService);
this.bookMetadataRepository = bookMetadataRepository;
this.cbxMetadataExtractor = cbxMetadataExtractor;
this.cbxMetadataExtractor = cbxMetadataExtractor;
}
@Override

View File

@ -21,7 +21,6 @@ import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

View File

@ -21,7 +21,6 @@ import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@ -33,7 +32,6 @@ import static com.adityachandel.booklore.util.FileService.truncate;
public class Fb2Processor extends AbstractFileProcessor implements BookFileProcessor {
private final Fb2MetadataExtractor fb2MetadataExtractor;
private final BookMetadataRepository bookMetadataRepository;
public Fb2Processor(BookRepository bookRepository,
BookAdditionalFileRepository bookAdditionalFileRepository,
@ -45,7 +43,6 @@ public class Fb2Processor extends AbstractFileProcessor implements BookFileProce
Fb2MetadataExtractor fb2MetadataExtractor) {
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService);
this.fb2MetadataExtractor = fb2MetadataExtractor;
this.bookMetadataRepository = bookMetadataRepository;
}
@Override

View File

@ -24,7 +24,6 @@ import org.springframework.stereotype.Service;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import static com.adityachandel.booklore.util.FileService.truncate;
@ -34,7 +33,6 @@ import static com.adityachandel.booklore.util.FileService.truncate;
public class PdfProcessor extends AbstractFileProcessor implements BookFileProcessor {
private final PdfMetadataExtractor pdfMetadataExtractor;
private final BookMetadataRepository bookMetadataRepository;
public PdfProcessor(BookRepository bookRepository,
BookAdditionalFileRepository bookAdditionalFileRepository,
@ -46,7 +44,6 @@ public class PdfProcessor extends AbstractFileProcessor implements BookFileProce
PdfMetadataExtractor pdfMetadataExtractor) {
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService);
this.pdfMetadataExtractor = pdfMetadataExtractor;
this.bookMetadataRepository = bookMetadataRepository;
}
@Override

View File

@ -1,6 +1,5 @@
package com.adityachandel.booklore.service.kobo;
import com.adityachandel.booklore.model.dto.kobo.KoboHeaders;
import com.adityachandel.booklore.model.dto.BookLoreUser;
import com.adityachandel.booklore.model.dto.BookloreSyncToken;
import com.adityachandel.booklore.model.dto.kobo.*;

View File

@ -16,8 +16,6 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.util.UriComponentsBuilder;
@ -26,7 +24,6 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collections;
import java.util.Set;
import java.util.regex.Pattern;

View File

@ -30,7 +30,6 @@ import org.springframework.util.StringUtils;
import java.io.File;
import java.net.InetAddress;
import java.net.URI;
import java.time.Instant;
import java.util.*;
import java.util.function.Consumer;
import java.util.function.Supplier;

View File

@ -2,7 +2,6 @@ package com.adityachandel.booklore.service.metadata;
import com.adityachandel.booklore.model.dto.BookMetadata;
import java.util.List;
import java.util.Set;
@FunctionalInterface

View File

@ -394,8 +394,16 @@ public class MetadataRefreshService {
public BookMetadata buildFetchMetadata(Long bookId, MetadataRefreshOptions refreshOptions, Map<MetadataProvider, BookMetadata> metadataMap) {
BookMetadata metadata = BookMetadata.builder().bookId(bookId).build();
MetadataRefreshOptions.FieldOptions fieldOptions = refreshOptions.getFieldOptions();
if (fieldOptions == null) {
fieldOptions = new MetadataRefreshOptions.FieldOptions();
}
MetadataRefreshOptions.EnabledFields enabledFields = refreshOptions.getEnabledFields();
if (enabledFields == null) {
enabledFields = new MetadataRefreshOptions.EnabledFields();
}
if (enabledFields.isTitle()) {
metadata.setTitle(resolveFieldAsString(metadataMap, fieldOptions.getTitle(), BookMetadata::getTitle));

View File

@ -67,18 +67,17 @@ public class MetadataTaskService {
public boolean updateProposalStatus(String taskId, Long proposalId, String statusStr) {
Long userId = authenticationService.getAuthenticatedUser().getId();
Optional<FetchedMetadataProposalStatus> statusOpt = parseStatus(statusStr);
if (statusOpt.isEmpty()) return false;
return proposalRepository.findById(proposalId)
return statusOpt.map(fetchedMetadataProposalStatus -> proposalRepository.findById(proposalId)
.filter(p -> p.getJob() != null && taskId.equals(p.getJob().getTaskId()))
.map(proposal -> {
proposal.setStatus(statusOpt.get());
proposal.setStatus(fetchedMetadataProposalStatus);
proposal.setReviewedAt(Instant.now());
proposal.setReviewerUserId(userId);
proposalRepository.save(proposal);
return true;
})
.orElse(false);
.orElse(false)).orElse(false);
}
private Optional<FetchedMetadataProposalStatus> parseStatus(String statusStr) {

View File

@ -21,8 +21,6 @@ import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.List;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Collections;
import java.util.stream.Collectors;
import org.apache.commons.compress.archivers.sevenz.SevenZFile;
import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry;

View File

@ -2,6 +2,9 @@ package com.adityachandel.booklore.service.metadata.extractor;
import com.adityachandel.booklore.model.dto.BookMetadata;
import io.documentnode.epub4j.domain.Book;
import io.documentnode.epub4j.domain.MediaType;
import io.documentnode.epub4j.domain.MediaTypes;
import io.documentnode.epub4j.domain.Resource;
import io.documentnode.epub4j.epub.EpubReader;
import lombok.extern.slf4j.Slf4j;
import net.lingala.zip4j.ZipFile;
@ -20,13 +23,16 @@ import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
@ -38,39 +44,54 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
private static final Pattern YEAR_ONLY_PATTERN = Pattern.compile("^\\d{4}$");
private static final String OPF_NS = "http://www.idpf.org/2007/opf";
// List of all media types that epub4j has so we can lazy load them.
// Note that we have to add in null to handle files without extentions like mimetype.
private static List<MediaType> MEDIA_TYPES = new ArrayList<>();
static {
for (int i = 0; i < MediaTypes.mediaTypes.length; i++) {
MEDIA_TYPES.add(MediaTypes.mediaTypes[i]);
}
MEDIA_TYPES.add(null);
}
@Override
public byte[] extractCover(File epubFile) {
try (FileInputStream fis = new FileInputStream(epubFile)) {
Book epub = new EpubReader().readEpub(fis);
io.documentnode.epub4j.domain.Resource coverImage = epub.getCoverImage();
try (ZipFile zip = new ZipFile(epubFile)) {
Book epub = new EpubReader().readEpubLazy(zip, "UTF-8", MEDIA_TYPES);
if (coverImage == null) {
String coverHref = findCoverImageHrefInOpf(epubFile);
if (coverHref != null) {
byte[] data = extractFileFromZip(epubFile, coverHref);
if (data != null) return data;
// First we read the cover image from the epub4j reader.
// We filter to only images since it will default to the first page.
byte[] image = getImageFromEpubResource(epub.getCoverImage());
if (image != null) {
return image;
}
// We fall back to reading the image based on the cover-image property.
String coverHref = findCoverImageHrefInOpf(epubFile);
if (coverHref != null) {
image = extractFileFromZip(epubFile, coverHref);
if (image != null) {
return image;
}
}
if (coverImage == null) {
for (io.documentnode.epub4j.domain.Resource res : epub.getResources().getAll()) {
String id = res.getId();
String href = res.getHref();
if ((id != null && id.toLowerCase().contains("cover")) ||
(href != null && href.toLowerCase().contains("cover"))) {
if (res.getMediaType() != null && res.getMediaType().getName().startsWith("image")) {
coverImage = res;
break;
}
// As a last resort we look at all of the files in the epub for something cover related.
for (Resource res : epub.getResources().getAll()) {
String id = res.getId();
String href = res.getHref();
if ((id != null && id.toLowerCase().contains("cover")) ||
(href != null && href.toLowerCase().contains("cover"))) {
image = getImageFromEpubResource(res);
if (image != null) {
return image;
}
}
}
return (coverImage != null) ? coverImage.getData() : null;
} catch (Exception e) {
log.warn("Failed to extract cover from EPUB: {}", epubFile.getName(), e);
return null;
}
return null;
}
@Override
@ -332,6 +353,24 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
return null;
}
private byte[] getImageFromEpubResource(Resource res) {
if (res == null) {
return null;
}
MediaType mt = res.getMediaType();
if (mt == null || !mt.getName().startsWith("image")) {
return null;
}
try {
return res.getData();
} catch (IOException e) {
log.warn("Failed to read data for resource", e);
return null;
}
}
private String findCoverImageHrefInOpf(File epubFile) {
try (ZipFile zip = new ZipFile(epubFile)) {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();

View File

@ -1,7 +1,6 @@
package com.adityachandel.booklore.service.metadata.extractor;
import com.adityachandel.booklore.model.dto.BookMetadata;
import com.adityachandel.booklore.util.FileUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
@ -14,7 +13,6 @@ import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.common.PDMetadata;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.springframework.messaging.rsocket.MetadataExtractor;
import org.springframework.stereotype.Component;
import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
@ -30,7 +28,6 @@ import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.*;

View File

@ -20,7 +20,6 @@ import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Slf4j
@Service

View File

@ -41,6 +41,7 @@ public class LubimyCzytacParser implements BookParser {
private static final Pattern SERIES_NUMBER_PATTERN = Pattern.compile("\\(tom\\s+(\\d+)\\)");
private static final Pattern BOOK_ID_PATTERN = Pattern.compile("/ksiazka/(\\d+)");
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final Pattern WHITESPACE_HYPHEN_PATTERN = Pattern.compile("[\\s-]");
private final AppSettingService appSettingService;
@ -297,11 +298,11 @@ public class LubimyCzytacParser implements BookParser {
private boolean isbnMatches(BookMetadata metadata, String searchIsbn) {
// Normalize ISBN by removing hyphens and spaces for comparison
String normalizedSearch = searchIsbn.replaceAll("[\\s-]", "");
String normalizedSearch = WHITESPACE_HYPHEN_PATTERN.matcher(searchIsbn).replaceAll("");
// Check ISBN-13
if (metadata.getIsbn13() != null) {
String normalized13 = metadata.getIsbn13().replaceAll("[\\s-]", "");
String normalized13 = WHITESPACE_HYPHEN_PATTERN.matcher(metadata.getIsbn13()).replaceAll("");
if (normalized13.equals(normalizedSearch)) {
return true;
}
@ -309,7 +310,7 @@ public class LubimyCzytacParser implements BookParser {
// Check ISBN-10
if (metadata.getIsbn10() != null) {
String normalized10 = metadata.getIsbn10().replaceAll("[\\s-]", "");
String normalized10 = WHITESPACE_HYPHEN_PATTERN.matcher(metadata.getIsbn10()).replaceAll("");
if (normalized10.equals(normalizedSearch)) {
return true;
}

View File

@ -1,7 +1,6 @@
package com.adityachandel.booklore.service.metadata.writer;
import com.adityachandel.booklore.model.enums.BookFileType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;

View File

@ -11,9 +11,11 @@ import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.service.book.BookQueryService;
import com.adityachandel.booklore.service.file.FileFingerprint;
import com.adityachandel.booklore.service.metadata.MetadataMatchService;
import com.adityachandel.booklore.service.InstallationService;
import com.adityachandel.booklore.util.BookUtils;
import com.adityachandel.booklore.util.FileService;
import com.adityachandel.booklore.util.FileUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.transaction.Transactional;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -33,6 +35,7 @@ import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.List;
@ -52,47 +55,18 @@ public class AppMigrationService {
private MetadataMatchService metadataMatchService;
private AppProperties appProperties;
private FileService fileService;
private ObjectMapper objectMapper;
private InstallationService installationService;
@Transactional
public void generateInstallationId() {
if (migrationRepository.existsById("generateInstallationId")) return;
AppSettingEntity setting = appSettingsRepository.findByName(INSTALLATION_ID_KEY);
if (setting == null) {
LocalDateTime now = LocalDateTime.now();
String uuid = UUID.randomUUID().toString();
String combined = now + "_" + uuid;
String installationId = hashToSha256(combined).substring(0, 24);
setting = new AppSettingEntity();
setting.setName(INSTALLATION_ID_KEY);
setting.setVal(installationId);
appSettingsRepository.save(setting);
log.info("Generated new installation ID");
}
installationService.getOrCreateInstallation();
migrationRepository.save(new AppMigrationEntity("generateInstallationId", LocalDateTime.now(), "Generate unique installation ID using timestamp and UUID"));
}
private String hashToSha256(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return hexString.toString();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 algorithm not found", e);
}
}
@Transactional
public void populateSearchTextOnce() {
if (migrationRepository.existsById("populateSearchText")) return;
@ -338,4 +312,31 @@ public class AppMigrationService {
}
}
@Transactional
public void migrateInstallationIdToJson() {
if (migrationRepository.existsById("migrateInstallationIdToJson")) return;
AppSettingEntity setting = appSettingsRepository.findByName(INSTALLATION_ID_KEY);
if (setting != null) {
String value = setting.getVal();
try {
objectMapper.readTree(value);
log.info("Installation ID is already in JSON format, skipping migration");
} catch (Exception e) {
Instant now = Instant.now();
String json = String.format("{\"id\":\"%s\",\"date\":\"%s\"}", value, now);
setting.setVal(json);
appSettingsRepository.save(setting);
log.info("Migrated installation ID to JSON format with current date");
}
}
migrationRepository.save(new AppMigrationEntity(
"migrateInstallationIdToJson",
LocalDateTime.now(),
"Migrate existing installation_id from plain string to JSON format with date"
));
}
}

View File

@ -14,6 +14,7 @@ public class AppMigrationStartup {
@EventListener(ApplicationReadyEvent.class)
public void runMigrationsOnce() {
appMigrationService.generateInstallationId();
appMigrationService.migrateInstallationIdToJson();
appMigrationService.populateMissingFileSizesOnce();
appMigrationService.populateMetadataScoresOnce();
appMigrationService.populateFileHashesOnce();

View File

@ -4,7 +4,6 @@ import com.adityachandel.booklore.config.security.service.AuthenticationService;
import com.adityachandel.booklore.config.security.userdetails.OpdsUserDetails;
import com.adityachandel.booklore.model.dto.Book;
import com.adityachandel.booklore.model.dto.Library;
import com.adityachandel.booklore.model.dto.MagicShelf;
import com.adityachandel.booklore.model.enums.OpdsSortOrder;
import com.adityachandel.booklore.service.MagicShelfService;
import jakarta.servlet.http.HttpServletRequest;

View File

@ -22,7 +22,6 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
@ -31,8 +30,6 @@ import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
@Slf4j
@Service
@ -233,6 +230,9 @@ public class CbxReaderService {
}
private boolean isImageFile(String name) {
if (!isContentEntry(name)) {
return false;
}
String lower = name.toLowerCase().replace("\\", "/");
for (String extension : SUPPORTED_IMAGE_EXTENSIONS) {
if (lower.endsWith(extension)) {
@ -242,6 +242,27 @@ public class CbxReaderService {
return false;
}
private boolean isContentEntry(String name) {
if (name == null) return false;
String norm = name.replace('\\', '/');
if (norm.startsWith("__MACOSX/") || norm.contains("/__MACOSX/")) return false;
String[] parts = norm.split("/");
for (String part : parts) {
if ("__MACOSX".equalsIgnoreCase(part)) return false;
}
String base = baseName(norm);
if (base.startsWith("._")) return false;
if (base.startsWith(".")) return false;
if (".ds_store".equalsIgnoreCase(base)) return false;
return true;
}
private String baseName(String path) {
if (path == null) return null;
int slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
return slash >= 0 ? path.substring(slash + 1) : path;
}
private boolean needsCacheRefresh(Path cbzPath, Path cacheInfoPath) throws IOException {
if (!Files.exists(cacheInfoPath)) return true;

View File

@ -10,7 +10,9 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.*;
import java.util.stream.Collectors;
@ -112,12 +114,16 @@ public class TaskHistoryService {
.status(task.getStatus())
.progressPercentage(task.getProgressPercentage())
.message(task.getMessage())
.createdAt(task.getCreatedAt())
.updatedAt(task.getUpdatedAt())
.completedAt(task.getCompletedAt())
.createdAt(toUtcInstant(task.getCreatedAt()))
.updatedAt(toUtcInstant(task.getUpdatedAt()))
.completedAt(toUtcInstant(task.getCompletedAt()))
.build();
}
private Instant toUtcInstant(LocalDateTime localDateTime) {
return localDateTime != null ? localDateTime.atZone(ZoneId.systemDefault()).toInstant() : null;
}
private TasksHistoryResponse.TaskHistory createMetadataOnlyTaskInfo(TaskType taskType) {
return TasksHistoryResponse.TaskHistory.builder()
.id(null)

View File

@ -66,7 +66,7 @@ public class FileUploadService {
file.transferTo(tempPath);
final BookFileExtension fileExtension = getFileExtension(originalFileName);
final BookMetadata metadata = extractMetadata(fileExtension, tempPath.toFile());
final BookMetadata metadata = extractMetadata(fileExtension, tempPath.toFile(), originalFileName);
final String uploadPattern = fileMovingHelper.getFileNamingPattern(libraryEntity);
final String relativePath = PathPatternResolver.resolvePattern(metadata, uploadPattern, originalFileName);
@ -89,28 +89,29 @@ public class FileUploadService {
public AdditionalFile uploadAdditionalFile(Long bookId, MultipartFile file, AdditionalFileType additionalFileType, String description) {
final BookEntity book = findBookById(bookId);
final String originalFileName = getValidatedFileName(file);
final String sanitizedFileName = PathPatternResolver.truncateFilenameWithExtension(originalFileName);
Path tempPath = null;
try {
tempPath = createTempFile(UPLOAD_TEMP_PREFIX, originalFileName);
tempPath = createTempFile(UPLOAD_TEMP_PREFIX, sanitizedFileName);
file.transferTo(tempPath);
final String fileHash = FileFingerprint.generateHash(tempPath);
validateAlternativeFormatDuplicate(additionalFileType, fileHash);
final Path finalPath = buildAdditionalFilePath(book, originalFileName);
final Path finalPath = buildAdditionalFilePath(book, sanitizedFileName);
validateFinalPath(finalPath);
moveFileToFinalLocation(tempPath, finalPath);
log.info("Additional file uploaded to final location: {}", finalPath);
final BookAdditionalFileEntity entity = createAdditionalFileEntity(book, originalFileName, additionalFileType, file.getSize(), fileHash, description);
final BookAdditionalFileEntity entity = createAdditionalFileEntity(book, sanitizedFileName, additionalFileType, file.getSize(), fileHash, description);
final BookAdditionalFileEntity savedEntity = additionalFileRepository.save(entity);
return additionalFileMapper.toAdditionalFile(savedEntity);
} catch (IOException e) {
log.error("Failed to upload additional file for book {}: {}", bookId, originalFileName, e);
log.error("Failed to upload additional file for book {}: {}", bookId, sanitizedFileName, e);
throw ApiError.FILE_READ_ERROR.createException(e.getMessage());
} finally {
cleanupTempFile(tempPath);
@ -124,13 +125,14 @@ public class FileUploadService {
Files.createDirectories(dropFolder);
final String originalFilename = getValidatedFileName(file);
final String sanitizedFilename = PathPatternResolver.truncateFilenameWithExtension(originalFilename);
Path tempPath = null;
try {
tempPath = createTempFile(BOOKDROP_TEMP_PREFIX, originalFilename);
tempPath = createTempFile(BOOKDROP_TEMP_PREFIX, sanitizedFilename);
file.transferTo(tempPath);
final Path finalPath = dropFolder.resolve(originalFilename);
final Path finalPath = dropFolder.resolve(sanitizedFilename);
validateFinalPath(finalPath);
Files.move(tempPath, finalPath);
@ -174,7 +176,12 @@ public class FileUploadService {
}
private Path createTempFile(String prefix, String fileName) throws IOException {
return Files.createTempFile(prefix, fileName);
String suffix = "";
int lastDotIndex = fileName.lastIndexOf('.');
if (lastDotIndex >= 0) {
suffix = fileName.substring(lastDotIndex);
}
return Files.createTempFile(prefix, suffix);
}
private void validateFinalPath(Path finalPath) {
@ -225,8 +232,28 @@ public class FileUploadService {
}
}
private BookMetadata extractMetadata(BookFileExtension fileExt, File file) {
return metadataExtractorFactory.extractMetadata(fileExt, file);
private BookMetadata extractMetadata(BookFileExtension fileExt, File file, String originalFileName) {
BookMetadata metadata = metadataExtractorFactory.extractMetadata(fileExt, file);
// If the metadata title is the same as the temporary file's base name (which happens
// when CBX files have no embedded metadata), use the original filename as the title instead
String tempFileBaseName = java.nio.file.Paths.get(file.getName()).getFileName().toString();
int lastDotIndex = tempFileBaseName.lastIndexOf('.');
if (lastDotIndex > 0) {
tempFileBaseName = tempFileBaseName.substring(0, lastDotIndex);
}
String originalFileBaseName = originalFileName;
lastDotIndex = originalFileName.lastIndexOf('.');
if (lastDotIndex > 0) {
originalFileBaseName = originalFileName.substring(0, lastDotIndex);
}
if (metadata.getTitle() != null && (metadata.getTitle().equals(tempFileBaseName) || metadata.getTitle().startsWith(UPLOAD_TEMP_PREFIX))) {
metadata.setTitle(originalFileBaseName);
}
return metadata;
}
private void validateFile(MultipartFile file) {

View File

@ -5,7 +5,6 @@ import com.adityachandel.booklore.mapper.BookMapper;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.LibraryEntity;
import com.adityachandel.booklore.model.entity.LibraryPathEntity;
import com.adityachandel.booklore.model.enums.PermissionType;
import com.adityachandel.booklore.model.websocket.Topic;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.service.NotificationService;

View File

@ -2,7 +2,6 @@ package com.adityachandel.booklore.service.watcher;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.model.dto.settings.LibraryFile;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.LibraryEntity;
import com.adityachandel.booklore.model.entity.LibraryPathEntity;
import com.adityachandel.booklore.model.enums.BookFileExtension;
@ -19,7 +18,6 @@ import org.springframework.stereotype.Service;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static com.adityachandel.booklore.model.enums.PermissionType.ADMIN;

View File

@ -7,7 +7,6 @@ import com.adityachandel.booklore.model.enums.BookFileExtension;
import com.adityachandel.booklore.model.enums.PermissionType;
import com.adityachandel.booklore.model.websocket.Topic;
import com.adityachandel.booklore.repository.LibraryRepository;
import com.adityachandel.booklore.service.file.FileFingerprint;
import com.adityachandel.booklore.service.NotificationService;
import com.adityachandel.booklore.util.FileUtils;
import jakarta.annotation.PostConstruct;

View File

@ -238,7 +238,7 @@ public class PathPatternResolver {
return result.toString();
}
private String truncatePathComponent(String component, int maxBytes) {
public String truncatePathComponent(String component, int maxBytes) {
if (component == null || component.isEmpty()) {
return component;
}
@ -288,7 +288,7 @@ public class PathPatternResolver {
return result.toString();
}
private String truncateFilenameWithExtension(String filename) {
public String truncateFilenameWithExtension(String filename) {
int lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex == -1 || lastDotIndex == 0) {
// No extension or dot is at start (hidden file), treat as normal component

View File

@ -14,6 +14,8 @@ app:
admin-group: ${REMOTE_AUTH_ADMIN_GROUP}
groups-delimiter: ${REMOTE_AUTH_GROUPS_DELIMITER:\\s+}
force-disable-oidc: ${FORCE_DISABLE_OIDC:false}
telemetry:
base-url: ${TELEMETRY_BASE_URL:https://telemetry.booklore.org}
server:
forward-headers-strategy: native

View File

@ -16,11 +16,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)

View File

@ -1,6 +1,5 @@
package com.adityachandel.booklore.convertor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

View File

@ -31,7 +31,6 @@ import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)

View File

@ -25,7 +25,6 @@ import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)

View File

@ -28,7 +28,6 @@ import java.time.Instant;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)

View File

@ -2,7 +2,6 @@ package com.adityachandel.booklore.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.isNull;
import com.adityachandel.booklore.config.security.userdetails.KoreaderUserDetails;
import com.adityachandel.booklore.exception.APIException;

View File

@ -1,7 +1,6 @@
package com.adityachandel.booklore.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import com.adityachandel.booklore.config.security.service.AuthenticationService;

View File

@ -2,7 +2,6 @@ package com.adityachandel.booklore.service.bookdrop;
import com.adityachandel.booklore.config.AppProperties;
import com.adityachandel.booklore.model.dto.request.BookdropFinalizeRequest;
import com.adityachandel.booklore.model.dto.response.BookdropFinalizeResult;
import com.adityachandel.booklore.repository.BookdropFileRepository;
import com.adityachandel.booklore.repository.LibraryRepository;
import com.adityachandel.booklore.service.NotificationService;

View File

@ -50,7 +50,6 @@ import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)

View File

@ -5,7 +5,6 @@ import com.adityachandel.booklore.model.dto.request.BookdropBulkEditRequest;
import com.adityachandel.booklore.model.dto.response.BookdropBulkEditResult;
import com.adityachandel.booklore.model.entity.BookdropFileEntity;
import com.adityachandel.booklore.repository.BookdropFileRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ -18,7 +17,6 @@ import org.mockito.junit.jupiter.MockitoExtension;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)

View File

@ -10,8 +10,6 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardWatchEventKinds;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
class BookdropMonitoringServiceTest {

View File

@ -15,8 +15,6 @@ import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@ -509,6 +507,48 @@ class FilenamePatternExtractorTest {
assertEquals(15, result.getPublishedDate().getDayOfMonth());
}
@Test
void extractFromFilename_WithPublishedYearMonth_ShouldExtractAndDefaultToFirstDay() {
String filename = "The Lost City (2012-05).epub";
String pattern = "{Title} ({Published:yyyy-MM})";
BookMetadata result = extractor.extractFromFilename(filename, pattern);
assertNotNull(result);
assertEquals("The Lost City", result.getTitle());
assertEquals(2012, result.getPublishedDate().getYear());
assertEquals(5, result.getPublishedDate().getMonthValue());
assertEquals(1, result.getPublishedDate().getDayOfMonth());
}
@Test
void extractFromFilename_WithPublishedYearMonthDots_ShouldExtractAndDefaultToFirstDay() {
String filename = "Chronicles of Tomorrow (2025.12).epub";
String pattern = "{Title} ({Published:yyyy.MM})";
BookMetadata result = extractor.extractFromFilename(filename, pattern);
assertNotNull(result);
assertEquals("Chronicles of Tomorrow", result.getTitle());
assertEquals(2025, result.getPublishedDate().getYear());
assertEquals(12, result.getPublishedDate().getMonthValue());
assertEquals(1, result.getPublishedDate().getDayOfMonth());
}
@Test
void extractFromFilename_WithPublishedMonthYear_ShouldExtractAndDefaultToFirstDay() {
String filename = "The Lost City (05-2012).epub";
String pattern = "{Title} ({Published:MM-yyyy})";
BookMetadata result = extractor.extractFromFilename(filename, pattern);
assertNotNull(result);
assertEquals("The Lost City", result.getTitle());
assertEquals(2012, result.getPublishedDate().getYear());
assertEquals(5, result.getPublishedDate().getMonthValue());
assertEquals(1, result.getPublishedDate().getDayOfMonth());
}
@Test
void extractFromFilename_PublishedWithoutFormat_AutoDetectsISODate() {
String filename = "The Lost City (2023-05-15).epub";
@ -563,6 +603,34 @@ class FilenamePatternExtractorTest {
assertEquals(1999, result.getPublishedDate().getYear());
}
@Test
void extractFromFilename_PublishedWithoutFormat_AutoDetectsYearMonth() {
String filename = "The Lost City (2012-05).epub";
String pattern = "{Title} ({Published})";
BookMetadata result = extractor.extractFromFilename(filename, pattern);
assertNotNull(result);
assertEquals("The Lost City", result.getTitle());
assertEquals(2012, result.getPublishedDate().getYear());
assertEquals(5, result.getPublishedDate().getMonthValue());
assertEquals(1, result.getPublishedDate().getDayOfMonth());
}
@Test
void extractFromFilename_PublishedWithoutFormat_AutoDetectsMonthYear() {
String filename = "Chronicles of Earth (05-2012).epub";
String pattern = "{Title} ({Published})";
BookMetadata result = extractor.extractFromFilename(filename, pattern);
assertNotNull(result);
assertEquals("Chronicles of Earth", result.getTitle());
assertEquals(2012, result.getPublishedDate().getYear());
assertEquals(5, result.getPublishedDate().getMonthValue());
assertEquals(1, result.getPublishedDate().getDayOfMonth());
}
@Test
void extractFromFilename_PublishedWithoutFormat_AutoDetectsFlexibleFormat() {
String filename = "Tomorrow (15|05|2023).epub";

View File

@ -22,7 +22,6 @@ import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)

View File

@ -17,7 +17,6 @@ import org.mockito.junit.jupiter.MockitoExtension;
import java.util.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)

View File

@ -7,7 +7,6 @@ import com.adityachandel.booklore.model.dto.Shelf;
import com.adityachandel.booklore.model.dto.request.ShelfCreateRequest;
import com.adityachandel.booklore.model.entity.KoboUserSettingsEntity;
import com.adityachandel.booklore.model.entity.ShelfEntity;
import com.adityachandel.booklore.model.enums.IconType;
import com.adityachandel.booklore.model.enums.ShelfType;
import com.adityachandel.booklore.repository.KoboUserSettingsRepository;
import com.adityachandel.booklore.service.ShelfService;
@ -18,7 +17,6 @@ import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

View File

@ -19,7 +19,6 @@ import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class FileAsBookProcessorTest {

View File

@ -3,7 +3,6 @@ package com.adityachandel.booklore.service.library;
import com.adityachandel.booklore.model.enums.LibraryScanMode;
import com.adityachandel.booklore.repository.BookAdditionalFileRepository;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.service.NotificationService;
import com.adityachandel.booklore.service.event.AdminEventBroadcaster;
import com.adityachandel.booklore.service.event.BookEventBroadcaster;
import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry;

View File

@ -31,7 +31,6 @@ import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)

View File

@ -34,7 +34,6 @@ import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)

View File

@ -27,11 +27,7 @@ import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class BookMetadataUpdaterCategoryTest {

View File

@ -0,0 +1,55 @@
package com.adityachandel.booklore.service.metadata;
import com.adityachandel.booklore.model.dto.BookMetadata;
import com.adityachandel.booklore.model.dto.request.MetadataRefreshOptions;
import com.adityachandel.booklore.model.enums.MetadataProvider;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.InjectMocks;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Collections;
import java.util.Map;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
@ExtendWith(MockitoExtension.class)
class MetadataRefreshServiceTest {
@InjectMocks
private MetadataRefreshService metadataRefreshService;
@ParameterizedTest
@MethodSource("provideNullCombinations")
void buildFetchMetadata_shouldHandleNullOptions(
MetadataRefreshOptions.FieldOptions fieldOptions,
MetadataRefreshOptions.EnabledFields enabledFields
) {
Long bookId = 1L;
MetadataRefreshOptions refreshOptions = new MetadataRefreshOptions();
refreshOptions.setFieldOptions(fieldOptions);
refreshOptions.setEnabledFields(enabledFields);
Map<MetadataProvider, BookMetadata> metadataMap = Collections.emptyMap();
BookMetadata result = assertDoesNotThrow(() ->
metadataRefreshService.buildFetchMetadata(bookId, refreshOptions, metadataMap)
);
assertThat(result).isNotNull();
assertThat(result.getBookId()).isEqualTo(bookId);
}
private static Stream<Arguments> provideNullCombinations() {
return Stream.of(
Arguments.of(null, null),
Arguments.of(new MetadataRefreshOptions.FieldOptions(), null),
Arguments.of(null, new MetadataRefreshOptions.EnabledFields()),
Arguments.of(new MetadataRefreshOptions.FieldOptions(), new MetadataRefreshOptions.EnabledFields())
);
}
}

View File

@ -12,7 +12,6 @@ import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.awt.image.BufferedImage;
import javax.imageio.ImageIO;

View File

@ -15,8 +15,6 @@ import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)

View File

@ -12,10 +12,8 @@ import org.mockito.Mockito;
import java.lang.reflect.Field;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class MonitoringServiceTest {

View File

@ -0,0 +1,211 @@
package com.adityachandel.booklore.service.reader;
import com.adityachandel.booklore.model.dto.settings.AppSettings;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.LibraryPathEntity;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import com.adityachandel.booklore.util.FileService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import javax.imageio.ImageIO;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CbxReaderServiceTest {
@TempDir
Path tempDir;
@Mock
private BookRepository bookRepository;
@Mock
private AppSettingService appSettingService;
@Mock
private FileService fileService;
private CbxReaderService service;
private Path cbzFile;
private Path cacheDir;
private BookEntity testBook;
private Long bookId = 113L;
@BeforeEach
void setUp() throws IOException {
service = new CbxReaderService(bookRepository, appSettingService, fileService);
cacheDir = tempDir.resolve("cbx_cache").resolve(String.valueOf(bookId));
Files.createDirectories(cacheDir);
cbzFile = tempDir.resolve("doctorwho_fourdoctors.cbz");
createTestCbzWithMacOsFiles(cbzFile.toFile());
LibraryPathEntity libraryPath = new LibraryPathEntity();
libraryPath.setPath(cbzFile.getParent().toString());
testBook = new BookEntity();
testBook.setId(bookId);
testBook.setLibraryPath(libraryPath);
testBook.setFileSubPath("");
testBook.setFileName(cbzFile.getFileName().toString());
when(bookRepository.findById(bookId)).thenReturn(Optional.of(testBook));
when(fileService.getCbxCachePath()).thenReturn(cacheDir.getParent().toString());
AppSettings appSettings = new AppSettings();
appSettings.setCbxCacheSizeInMb(1000);
when(appSettingService.getAppSettings()).thenReturn(appSettings);
}
@AfterEach
void tearDown() throws IOException {
if (Files.exists(cacheDir)) {
Files.walk(cacheDir)
.sorted((a, b) -> -a.compareTo(b))
.forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
}
});
}
}
@Test
void getAvailablePages_filtersOutMacOsFiles_shouldReturnCorrectPageCount() throws IOException {
List<Integer> pages = service.getAvailablePages(bookId);
assertEquals(130, pages.size(),
"Page count should be 130 (actual comic pages), not 260 (including __MACOSX files)");
assertEquals(1, pages.get(0));
assertEquals(130, pages.get(pages.size() - 1));
List<Path> cachedFiles = Files.list(cacheDir)
.filter(Files::isRegularFile)
.filter(p -> !p.getFileName().toString().equals(".cache-info"))
.toList();
assertEquals(130, cachedFiles.size(),
"Cache should contain exactly 130 image files, not 260. Actual files: " +
cachedFiles.stream().map(p -> p.getFileName().toString()).sorted().toList());
boolean hasMacOsFiles = cachedFiles.stream()
.anyMatch(p -> p.getFileName().toString().startsWith("._") ||
p.getFileName().toString().contains("__MACOSX"));
assertFalse(hasMacOsFiles, "Cache should not contain any __MACOSX or ._ files. Found: " +
cachedFiles.stream()
.map(p -> p.getFileName().toString())
.filter(name -> name.startsWith("._") || name.contains("__MACOSX"))
.toList());
boolean allAreComicPages = cachedFiles.stream()
.allMatch(p -> p.getFileName().toString().matches("DW_4D_\\d{3}\\.jpg"));
assertTrue(allAreComicPages, "All cached files should be actual comic pages (DW_4D_*.jpg)");
}
@Test
void streamPageImage_returnsActualComicPages_notMacOsFiles() throws IOException {
service.getAvailablePages(bookId);
ByteArrayOutputStream page1Output = new ByteArrayOutputStream();
service.streamPageImage(bookId, 1, page1Output);
byte[] page1Data = page1Output.toByteArray();
assertTrue(page1Data.length > 0, "Page 1 should have content");
assertEquals(0xFF, page1Data[0] & 0xFF);
assertEquals(0xD8, page1Data[1] & 0xFF);
ByteArrayOutputStream page130Output = new ByteArrayOutputStream();
service.streamPageImage(bookId, 130, page130Output);
byte[] page130Data = page130Output.toByteArray();
assertTrue(page130Data.length > 0, "Page 130 should have content");
assertEquals(0xFF, page130Data[0] & 0xFF);
assertEquals(0xD8, page130Data[1] & 0xFF);
List<Path> cachedFiles = Files.list(cacheDir)
.filter(Files::isRegularFile)
.filter(p -> !p.getFileName().toString().equals(".cache-info"))
.sorted()
.toList();
assertEquals("DW_4D_001.jpg", cachedFiles.get(0).getFileName().toString());
assertEquals("DW_4D_130.jpg", cachedFiles.get(129).getFileName().toString());
}
@Test
void getAvailablePages_withMacOsFiles_shouldNotDoubleCountPages() throws IOException {
List<Integer> pages = service.getAvailablePages(bookId);
assertNotEquals(260, pages.size(),
"Page count should NOT be 260 (this was the bug - double counting __MACOSX files)");
assertEquals(130, pages.size(),
"Page count should be exactly 130 (actual comic pages only)");
}
private void createTestCbzWithMacOsFiles(File cbzFile) throws IOException {
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(cbzFile))) {
for (int i = 1; i <= 130; i++) {
String pageNumber = String.format("%03d", i);
String comicPageName = "DW_4D_" + pageNumber + ".jpg";
ZipEntry comicEntry = new ZipEntry(comicPageName);
comicEntry.setTime(0L);
zos.putNextEntry(comicEntry);
byte[] comicImage = createTestImage(Color.RED, "Page " + i);
zos.write(comicImage);
zos.closeEntry();
String macOsFileName = "__MACOSX/._DW_4D_" + pageNumber + ".jpg";
ZipEntry macOsEntry = new ZipEntry(macOsFileName);
macOsEntry.setTime(0L);
zos.putNextEntry(macOsEntry);
byte[] macOsData = "MacOS metadata".getBytes();
zos.write(macOsData);
zos.closeEntry();
}
}
}
private byte[] createTestImage(Color color, String label) throws IOException {
BufferedImage image = new BufferedImage(400, 600, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = image.createGraphics();
g2d.setColor(color);
g2d.fillRect(0, 0, 400, 600);
g2d.setColor(Color.BLACK);
g2d.setFont(g2d.getFont().deriveFont(24f));
g2d.drawString(label, 50, 300);
g2d.dispose();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image, "jpg", baos);
return baos.toByteArray();
}
}

View File

@ -43,7 +43,6 @@ import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class FileUploadServiceTest {
@ -209,6 +208,9 @@ class FileUploadServiceTest {
when(libraryRepository.findById(7L)).thenReturn(Optional.of(lib));
when(fileMovingHelper.getFileNamingPattern(lib)).thenReturn("{currentFilename}");
BookMetadata metadata = BookMetadata.builder().title("book").build();
when(metadataExtractorFactory.extractMetadata(any(BookFileExtension.class), any(File.class))).thenReturn(metadata);
service.uploadFile(file, 7L, 2L);
Path moved = tempDir.resolve("book.cbz");
@ -304,4 +306,56 @@ class FileUploadServiceTest {
assertDoesNotThrow(() -> service.uploadFile(file, 10L, 3L));
}
@Test
@DisplayName("Should truncate long filenames in uploadFileBookDrop")
void uploadFileBookDrop_truncatesLongFilename() throws IOException {
byte[] content = "data".getBytes();
String longName = "A".repeat(300) + ".pdf";
MockMultipartFile file = new MockMultipartFile("file", longName, "application/pdf", content);
service.uploadFileBookDrop(file);
File[] files = tempDir.toFile().listFiles();
assertThat(files).isNotNull();
assertThat(files).hasSize(1);
String savedName = files[0].getName();
assertThat(savedName.length()).isLessThan(longName.length());
assertThat(savedName).endsWith(".pdf");
}
@Test
@DisplayName("Should truncate long filenames in uploadAdditionalFile")
void uploadAdditionalFile_truncatesLongFilename() {
long bookId = 11L;
String longName = "B".repeat(300) + ".pdf";
MockMultipartFile file = new MockMultipartFile("file", longName, "application/pdf", "payload".getBytes());
LibraryPathEntity libPath = new LibraryPathEntity();
libPath.setId(1L);
libPath.setPath(tempDir.toString());
BookEntity book = new BookEntity();
book.setId(bookId);
book.setLibraryPath(libPath);
book.setFileSubPath(".");
when(bookRepository.findById(bookId)).thenReturn(Optional.of(book));
when(bookAdditionalFileRepository.save(any(BookAdditionalFileEntity.class))).thenAnswer(inv -> inv.getArgument(0));
when(additionalFileMapper.toAdditionalFile(any(BookAdditionalFileEntity.class))).thenReturn(mock(AdditionalFile.class));
try (MockedStatic<FileFingerprint> fp = mockStatic(FileFingerprint.class)) {
fp.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash");
service.uploadAdditionalFile(bookId, file, AdditionalFileType.ALTERNATIVE_FORMAT, "desc");
File[] files = tempDir.toFile().listFiles();
assertThat(files).isNotNull();
assertThat(files).hasSize(1);
String savedName = files[0].getName();
assertThat(savedName.length()).isLessThan(longName.length());
assertThat(savedName).endsWith(".pdf");
}
}
}

View File

@ -821,4 +821,66 @@ class PathPatternResolverTest {
MAX_AUTHORS_BYTES, authorsBytes));
assertTrue(authorsPart.contains("et al."), "Should add 'et al.' when truncating");
}
@Test
@DisplayName("Should truncate long filename while preserving extension")
void testTruncateFilenameWithExtension_truncatesLongFilename() {
String longName = "A".repeat(300);
String extension = ".pdf";
String filename = longName + extension;
String result = PathPatternResolver.truncateFilenameWithExtension(filename);
assertTrue(result.length() < filename.length(), "Filename should be truncated");
assertTrue(result.endsWith(extension), "Extension should be preserved");
assertTrue(result.getBytes(StandardCharsets.UTF_8).length <= MAX_FILENAME_BYTES,
"Result bytes should be <= " + MAX_FILENAME_BYTES);
}
@Test
@DisplayName("Should not truncate short filename")
void testTruncateFilenameWithExtension_shortFilename() {
String filename = "short_filename.pdf";
String result = PathPatternResolver.truncateFilenameWithExtension(filename);
assertEquals(filename, result, "Short filename should not be modified");
}
@Test
@DisplayName("Should truncate filename without extension if too long")
void testTruncateFilenameWithExtension_noExtension() {
String longName = "A".repeat(300);
String result = PathPatternResolver.truncateFilenameWithExtension(longName);
assertTrue(result.length() < longName.length(), "Filename should be truncated");
assertTrue(result.getBytes(StandardCharsets.UTF_8).length <= MAX_FILENAME_BYTES,
"Result bytes should be <= " + MAX_FILENAME_BYTES);
}
@Test
@DisplayName("Should handle long unicode filename with extension")
void testTruncateFilenameWithExtension_unicode() {
String longName = "测试".repeat(100); // 600 bytes
String extension = ".txt";
String filename = longName + extension;
String result = PathPatternResolver.truncateFilenameWithExtension(filename);
assertTrue(result.length() < filename.length(), "Filename should be truncated");
assertTrue(result.endsWith(extension), "Extension should be preserved");
assertTrue(result.getBytes(StandardCharsets.UTF_8).length <= MAX_FILENAME_BYTES,
"Result bytes should be <= " + MAX_FILENAME_BYTES);
}
@Test
@DisplayName("Should handle hidden files (starting with dot)")
void testTruncateFilenameWithExtension_hiddenFile() {
String longName = ".config" + "A".repeat(300);
String result = PathPatternResolver.truncateFilenameWithExtension(longName);
assertTrue(result.startsWith(".config"), "Should still start with .config (or be treated as filename)");
assertTrue(result.getBytes(StandardCharsets.UTF_8).length <= MAX_FILENAME_BYTES,
"Result bytes should be <= " + MAX_FILENAME_BYTES);
}
}

Some files were not shown because too many files have changed in this diff Show More