mirror of
https://github.com/adityachandelgit/BookLore.git
synced 2026-01-09 06:21:08 +08:00
Merge pull request #2063 from booklore-app/develop
Merge develop into master for release
This commit is contained in:
commit
43a095eb62
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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" \
|
||||
|
||||
18
README.md
18
README.md
@ -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>
|
||||
|
||||
[](https://github.com/adityachandelgit/BookLore/releases)
|
||||
[](https://github.com/booklore-app/booklore/releases)
|
||||
[](LICENSE)
|
||||
[](https://github.com/adityachandelgit/BookLore/stargazers)
|
||||
[](https://github.com/booklore-app/booklore/stargazers)
|
||||
[](https://hub.docker.com/r/booklore/booklore)
|
||||
|
||||
[](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!
|
||||
|
||||
[](https://github.com/adityachandelgit/BookLore)
|
||||
[](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
|
||||
|
||||
[](https://booklore-app.github.io/booklore-docs/docs/getting-started/)
|
||||
[](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! 🙏
|
||||
|
||||
[](https://github.com/adityachandelgit/BookLore/graphs/contributors)
|
||||
[](https://github.com/booklore-app/booklore/graphs/contributors)
|
||||
|
||||
**Want to see your face here?** [Start contributing today!](CONTRIBUTING.md)
|
||||
|
||||
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,8 +4,6 @@ import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
|
||||
@ -3,8 +3,6 @@ package com.adityachandel.booklore.model.entity;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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> {
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
});
|
||||
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.*;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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.*;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ public class AppMigrationStartup {
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void runMigrationsOnce() {
|
||||
appMigrationService.generateInstallationId();
|
||||
appMigrationService.migrateInstallationIdToJson();
|
||||
appMigrationService.populateMissingFileSizesOnce();
|
||||
appMigrationService.populateMetadataScoresOnce();
|
||||
appMigrationService.populateFileHashesOnce();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.*;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user