Kobo Phase 1: Enable Book Transfer/Sync

This commit is contained in:
aditya.chandel
2025-08-22 21:23:23 -06:00
committed by Aditya Chandel
parent a092d629c9
commit 6e6861b329
92 changed files with 3084 additions and 198 deletions

View File

@@ -27,13 +27,14 @@ RUN gradle clean build
# Stage 3: Final image
FROM eclipse-temurin:21-jre-alpine
RUN apk update && apk add nginx
RUN apk update && apk add nginx gettext
COPY ./nginx.conf /etc/nginx/nginx.conf
COPY --from=angular-build /angular-app/dist/booklore/browser /usr/share/nginx/html
COPY --from=springboot-build /springboot-app/build/libs/booklore-api-0.0.1-SNAPSHOT.jar /app/app.jar
COPY start.sh /start.sh
RUN chmod +x /start.sh
EXPOSE 8080 80
CMD /usr/sbin/nginx -g "daemon off;" & \
java -jar /app/app.jar
CMD ["/start.sh"]

View File

@@ -101,17 +101,19 @@ ### 2⃣ Create docker-compose.yml
- DATABASE_URL=jdbc:mariadb://mariadb:3306/booklore # Only modify this if you're familiar with JDBC and your database setup
- DATABASE_USERNAME=booklore # Must match MYSQL_USER defined in the mariadb container
- DATABASE_PASSWORD=your_secure_password # Use a strong password; must match MYSQL_PASSWORD defined in the mariadb container
- BOOKLORE_PORT=6060 # Port BookLore listens on inside the container; must match container port below
- SWAGGER_ENABLED=false # Enable or disable Swagger UI (API docs). Set to 'true' to allow access; 'false' to block access (recommended for production).
depends_on:
mariadb:
condition: service_healthy
ports:
- "6060:6060"
- "6060:6060" # HostPort:ContainerPort → Keep both numbers the same, and also ensure the container port matches BOOKLORE_PORT, no exceptions.
# All three (host port, container port, BOOKLORE_PORT) must be identical for BookLore to function properly.
# Example: To expose on host port 8080, set BOOKLORE_PORT=8080 and use "8080:8080".
volumes:
- /your/local/path/to/booklore/data:/app/data # Internal app data (settings, metadata, cache)
- /your/local/path/to/booklore/books1:/books1 # Book library folder — point to one of your collections
- /your/local/path/to/booklore/books2:/books2 # Another book library — you can mount multiple library folders this way
- /your/local/path/to/booklore/bookdrop:/bookdrop # Bookdrop folder — drop new files here for automatic import into libraries
- /your/local/path/to/booklore/data:/app/data # Application data (settings, metadata, cache, etc.). Persist this folder to retain your library state across container restarts.
- /your/local/path/to/booklore/books:/books # Primary book library folder. Mount your collection here so BookLore can access and organize your books.
- /your/local/path/to/booklore/bookdrop:/bookdrop # BookDrop folder. Files placed here are automatically detected and prepared for import.
restart: unless-stopped
mariadb:
@@ -137,7 +139,6 @@ ### 2⃣ Create docker-compose.yml
Note: You can find the latest BookLore image tag `BOOKLORE_IMAGE_TAG` (e.g. v.0.x.x) from the Releases section:
📦 [Latest Image Tag GitHub Releases](https://github.com/adityachandelgit/BookLore/releases)
### 3⃣ Start the Containers
Run the following command to start the services:

View File

@@ -0,0 +1,59 @@
package com.adityachandel.booklore.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.io.IOException;
@Slf4j
@Component
@Profile({"dev"})
public class LoggingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (request.getRequestURI().startsWith("/ws")) {
filterChain.doFilter(request, response);
return;
}
long start = System.currentTimeMillis();
log.info("Incoming request: {} {} from IP {}",
request.getMethod(),
request.getRequestURI(),
request.getRemoteAddr());
ServletUriComponentsBuilder servletUriComponentsBuilder = ServletUriComponentsBuilder
.fromCurrentContextPath();
log.info("servletUriComponentsBuilder.toUriString(): {}", servletUriComponentsBuilder.toUriString());
var headerNames = request.getHeaderNames();
if (headerNames != null) {
while (headerNames.hasMoreElements()) {
String headerName = headerNames.nextElement();
String headerValue = request.getHeader(headerName);
log.info("Header: {}={}", headerName, headerValue);
}
}
filterChain.doFilter(request, response);
long duration = System.currentTimeMillis() - start;
log.info("Completed {} {} with status {} in {} ms",
request.getMethod(),
request.getRequestURI(),
response.getStatus(),
duration);
}
}

View File

@@ -48,7 +48,8 @@ public class DualJwtAuthenticationFilter extends OncePerRequestFilter {
private static final List<String> WHITELISTED_PATHS = List.of(
"/api/v1/opds/",
"/api/v1/auth/refresh",
"/api/v1/setup/"
"/api/v1/setup/",
"/api/kobo/"
);

View File

@@ -0,0 +1,103 @@
package com.adityachandel.booklore.config.security;
import com.adityachandel.booklore.mapper.custom.BookLoreUserTransformer;
import com.adityachandel.booklore.model.dto.BookLoreUser;
import com.adityachandel.booklore.model.entity.UserPermissionsEntity;
import com.adityachandel.booklore.repository.KoboUserSettingsRepository;
import com.adityachandel.booklore.repository.UserRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@RequiredArgsConstructor
@Component
public class KoboAuthFilter extends OncePerRequestFilter {
private final KoboUserSettingsRepository koboUserSettingsRepository;
private final UserRepository userRepository;
private final BookLoreUserTransformer bookLoreUserTransformer;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String path = request.getRequestURI();
if (!path.startsWith("/api/kobo/")) {
filterChain.doFilter(request, response);
return;
}
String[] parts = path.split("/");
if (parts.length < 4) {
log.warn("KOBO token missing in path");
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "KOBO token missing");
return;
}
String token = parts[3];
var userTokenOpt = koboUserSettingsRepository.findByToken(token);
if (userTokenOpt.isEmpty()) {
log.warn("Invalid KOBO token: {}", token);
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid KOBO token");
return;
}
var userToken = userTokenOpt.get();
var userOpt = userRepository.findById(userToken.getUserId());
if (userOpt.isEmpty()) {
log.warn("User not found for token: {}", token);
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "User not found");
return;
}
var entity = userOpt.get();
if (entity.getPermissions() == null || !entity.getPermissions().isPermissionSyncKobo()) {
log.warn("User {} does not have syncKobo permission", entity.getId());
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Insufficient permissions");
return;
}
BookLoreUser user = bookLoreUserTransformer.toDTO(entity);
List<GrantedAuthority> authorities = getAuthorities(entity.getPermissions());
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user, null, authorities);
authentication.setDetails(new UserAuthenticationDetails(request, user.getId()));
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
private List<GrantedAuthority> getAuthorities(UserPermissionsEntity permissions) {
List<GrantedAuthority> authorities = new ArrayList<>();
if (permissions != null) {
addAuthorityIfPermissionGranted(authorities, "ROLE_UPLOAD", permissions.isPermissionUpload());
addAuthorityIfPermissionGranted(authorities, "ROLE_DOWNLOAD", permissions.isPermissionDownload());
addAuthorityIfPermissionGranted(authorities, "ROLE_EDIT_METADATA", permissions.isPermissionEditMetadata());
addAuthorityIfPermissionGranted(authorities, "ROLE_MANIPULATE_LIBRARY", permissions.isPermissionManipulateLibrary());
addAuthorityIfPermissionGranted(authorities, "ROLE_ADMIN", permissions.isPermissionAdmin());
addAuthorityIfPermissionGranted(authorities, "ROLE_SYNC_KOBO", permissions.isPermissionSyncKobo());
}
return authorities;
}
private void addAuthorityIfPermissionGranted(List<GrantedAuthority> authorities, String role, boolean permissionGranted) {
if (permissionGranted) {
authorities.add(new SimpleGrantedAuthority(role));
}
}
}

View File

@@ -36,6 +36,7 @@ public class SecurityConfig {
private final CustomOpdsUserDetailsService customOpdsUserDetailsService;
private final DualJwtAuthenticationFilter dualJwtAuthenticationFilter;
private final KoboAuthFilter koboAuthFilter;
private final AppProperties appProperties;
private static final String[] SWAGGER_ENDPOINTS = {
@@ -46,6 +47,7 @@ public class SecurityConfig {
private static final String[] COMMON_PUBLIC_ENDPOINTS = {
"/ws/**",
"/kobo/**",
"/api/v1/auth/**",
"/api/v1/public-settings",
"/api/v1/setup/**",
@@ -104,6 +106,18 @@ public class SecurityConfig {
@Bean
@Order(3)
public SecurityFilterChain koboSecurityChain(HttpSecurity http, KoboAuthFilter koboAuthFilter) throws Exception {
http
.securityMatcher("/api/kobo/**")
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.addFilterBefore(koboAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
@Order(4)
public SecurityFilterChain jwtApiSecurityChain(HttpSecurity http) throws Exception {
List<String> publicEndpoints = new ArrayList<>(Arrays.asList(COMMON_PUBLIC_ENDPOINTS));
if (appProperties.getSwagger().isEnabled()) {

View File

@@ -50,6 +50,11 @@ public class SecurityUtil {
return user != null && user.getPermissions().isCanSyncKoReader();
}
public boolean canSyncKobo() {
var user = getCurrentUser();
return user != null && user.getPermissions().isCanSyncKobo();
}
public boolean canEditMetadata() {
var user = getCurrentUser();
return user != null && user.getPermissions().isCanEditMetadata();

View File

@@ -0,0 +1,162 @@
package com.adityachandel.booklore.controller;
import com.adityachandel.booklore.model.dto.BookLoreUser;
import com.adityachandel.booklore.model.dto.Shelf;
import com.adityachandel.booklore.model.dto.kobo.KoboAuthentication;
import com.adityachandel.booklore.model.dto.kobo.KoboReadingStateWrapper;
import com.adityachandel.booklore.model.dto.kobo.KoboResources;
import com.adityachandel.booklore.model.dto.kobo.KoboTestResponse;
import com.adityachandel.booklore.service.BookService;
import com.adityachandel.booklore.service.KoboEntitlementService;
import com.adityachandel.booklore.service.KoboReadingStateService;
import com.adityachandel.booklore.service.ShelfService;
import com.adityachandel.booklore.service.kobo.*;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Set;
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping(value = "/api/kobo/{token}")
public class KoboController {
private String token;
private final KoboServerProxy koboServerProxy;
private final KoboInitializationService koboInitializationService;
private final BookService bookService;
private final KoboReadingStateService koboReadingStateService;
private final KoboEntitlementService koboEntitlementService;
private final KoboDeviceAuthService koboDeviceAuthService;
private final KoboLibrarySyncService koboLibrarySyncService;
private final KoboThumbnailService koboThumbnailService;
private final ShelfService shelfService;
@ModelAttribute
public void captureToken(@PathVariable("token") String token) {
this.token = token;
}
@GetMapping("/v1/initialization")
public ResponseEntity<KoboResources> initialization() throws JsonProcessingException {
return koboInitializationService.initialize(token);
}
@GetMapping("/v1/library/sync")
public ResponseEntity<?> syncLibrary(@AuthenticationPrincipal BookLoreUser user) {
return koboLibrarySyncService.syncLibrary(user, token);
}
@GetMapping("/v1/books/{imageId}/thumbnail/{width}/{height}/false/image.jpg")
public ResponseEntity<Resource> getThumbnail(
@PathVariable String imageId,
@PathVariable int width,
@PathVariable int height) {
if (StringUtils.isNumeric(imageId)) {
return koboThumbnailService.getThumbnail(Long.valueOf(imageId));
} else {
String cdnUrl = String.format("https://cdn.kobo.com/book-images/%s/%d/%d/image.jpg", imageId, width, height);
return koboServerProxy.proxyExternalUrl(cdnUrl);
}
}
@GetMapping("/v1/books/{bookId}/thumbnail/{width}/{height}/{quality}/{isGreyscale}/image.jpg")
public ResponseEntity<Resource> getGreyThumbnail(
@PathVariable String bookId,
@PathVariable int width,
@PathVariable int height,
@PathVariable int quality,
@PathVariable boolean isGreyscale) {
if (StringUtils.isNumeric(bookId)) {
return koboThumbnailService.getThumbnail(Long.valueOf(bookId));
} else {
String cdnUrl = String.format("https://cdn.kobo.com/book-images/%s/%d/%d/%d/%b/image.jpg", bookId, width, height, quality, isGreyscale);
return koboServerProxy.proxyExternalUrl(cdnUrl);
}
}
@PostMapping("/v1/auth/device")
public ResponseEntity<KoboAuthentication> authenticateDevice(@RequestBody JsonNode body) {
return koboDeviceAuthService.authenticateDevice(body);
}
@GetMapping("/v1/library/{bookId}/metadata")
public ResponseEntity<?> getBookMetadata(@PathVariable String bookId) {
if (StringUtils.isNumeric(bookId)) {
return ResponseEntity.ok(List.of(koboEntitlementService.getMetadataForBook(Long.parseLong(bookId), token)));
} else {
return koboServerProxy.proxyCurrentRequest(null, false);
}
}
@GetMapping("/v1/library/{bookId}/state")
public ResponseEntity<?> getState(@PathVariable String bookId) {
if (StringUtils.isNumeric(bookId)) {
return ResponseEntity.ok(koboReadingStateService.getReadingState(bookId));
} else {
return koboServerProxy.proxyCurrentRequest(null, false);
}
}
@PutMapping("/v1/library/{bookId}/state")
public ResponseEntity<?> updateState(@PathVariable String bookId, @RequestBody KoboReadingStateWrapper body) {
if (StringUtils.isNumeric(bookId)) {
return ResponseEntity.ok(koboReadingStateService.saveReadingState(body.getReadingStates()));
} else {
return koboServerProxy.proxyCurrentRequest(body, false);
}
}
@PostMapping("/v1/analytics/gettests")
public ResponseEntity<?> getTests(@RequestBody Object body) {
return ResponseEntity.ok(KoboTestResponse.builder()
.result("Success")
.testKey(RandomStringUtils.secure().nextAlphanumeric(24))
.build());
}
@GetMapping("/v1/books/{bookId}/download")
public ResponseEntity<?> downloadBook(@PathVariable String bookId) {
if (StringUtils.isNumeric(bookId)) {
return bookService.downloadBook(Long.parseLong(bookId));
} else {
return koboServerProxy.proxyCurrentRequest(null, false);
}
}
@DeleteMapping("/v1/library/{bookId}")
public ResponseEntity<?> deleteBookFromLibrary(@PathVariable String bookId) {
if (StringUtils.isNumeric(bookId)) {
Shelf userKoboShelf = shelfService.getUserKoboShelf();
bookService.assignShelvesToBooks(Set.of(Long.valueOf(bookId)), Set.of(), Set.of(userKoboShelf.getId()));
return ResponseEntity.ok().build();
} else {
return koboServerProxy.proxyCurrentRequest(null, false);
}
}
@RequestMapping(value = "/**", method = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.PATCH})
public ResponseEntity<JsonNode> catchAll(HttpServletRequest request, @RequestBody(required = false) Object body) {
String path = request.getRequestURI();
if (path.contains("/v1/analytics/event")) {
return ResponseEntity.ok().build();
}
if (path.matches(".*/v1/products/\\d+/nextread.*")) {
return ResponseEntity.ok().build();
}
return koboServerProxy.proxyCurrentRequest(body, false);
}
}

View File

@@ -0,0 +1,39 @@
package com.adityachandel.booklore.controller;
import com.adityachandel.booklore.model.dto.KoboSyncSettings;
import com.adityachandel.booklore.service.KoboSettingsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@RequestMapping("/api/v1/kobo-settings")
@RequiredArgsConstructor
public class KoboSettingsController {
private final KoboSettingsService koboService;
@GetMapping
@PreAuthorize("@securityUtil.canSyncKobo() or @securityUtil.isAdmin()")
public ResponseEntity<KoboSyncSettings> getSettings() {
KoboSyncSettings settings = koboService.getCurrentUserSettings();
return ResponseEntity.ok(settings);
}
@PutMapping
@PreAuthorize("@securityUtil.canSyncKobo() or @securityUtil.isAdmin()")
public ResponseEntity<KoboSyncSettings> createOrUpdateToken() {
KoboSyncSettings updated = koboService.createOrUpdateToken();
return ResponseEntity.ok(updated);
}
@PatchMapping("/sync")
@PreAuthorize("@securityUtil.canSyncKobo() or @securityUtil.isAdmin()")
public ResponseEntity<Void> toggleSync(@RequestParam boolean enabled) {
koboService.setSyncEnabled(enabled);
return ResponseEntity.noContent().build();
}
}

View File

@@ -50,7 +50,8 @@ public enum ApiError {
FILE_DELETION_DISABLED(HttpStatus.BAD_REQUEST, "File deletion is disabled"),
UNSUPPORTED_FILE_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "%s"),
CONFLICT(HttpStatus.CONFLICT, "%s"),
FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "File not found: %s");
FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "File not found: %s"),
SHELF_CANNOT_BE_DELETED(HttpStatus.FORBIDDEN, "'%s' shelf can't be deleted" ),;
private final HttpStatus status;
private final String message;

View File

@@ -0,0 +1,15 @@
package com.adityachandel.booklore.mapper;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.KoboSnapshotBookEntity;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
public interface BookEntityToKoboSnapshotBookMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "bookId", expression = "java(book.getId())")
@Mapping(target = "synced", constant = "false")
KoboSnapshotBookEntity toKoboSnapshotBook(BookEntity book);
}

View File

@@ -0,0 +1,53 @@
package com.adityachandel.booklore.mapper;
import com.adityachandel.booklore.model.dto.kobo.KoboReadingState;
import com.adityachandel.booklore.model.entity.KoboReadingStateEntity;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
public interface KoboReadingStateMapper {
ObjectMapper objectMapper = new ObjectMapper();
@Mapping(target = "currentBookmarkJson", expression = "java(toJson(dto.getCurrentBookmark()))")
@Mapping(target = "statisticsJson", expression = "java(toJson(dto.getStatistics()))")
@Mapping(target = "statusInfoJson", expression = "java(toJson(dto.getStatusInfo()))")
@Mapping(target = "entitlementId", expression = "java(cleanString(dto.getEntitlementId()))")
@Mapping(target = "created", expression = "java(dto.getCreated())")
@Mapping(target = "lastModified", expression = "java(dto.getLastModified())")
@Mapping(target = "priorityTimestamp", expression = "java(dto.getPriorityTimestamp())")
KoboReadingStateEntity toEntity(KoboReadingState dto);
@Mapping(target = "currentBookmark", expression = "java(fromJson(entity.getCurrentBookmarkJson(), KoboReadingState.CurrentBookmark.class))")
@Mapping(target = "statistics", expression = "java(fromJson(entity.getStatisticsJson(), KoboReadingState.Statistics.class))")
@Mapping(target = "statusInfo", expression = "java(fromJson(entity.getStatusInfoJson(), KoboReadingState.StatusInfo.class))")
@Mapping(target = "entitlementId", expression = "java(cleanString(entity.getEntitlementId()))")
@Mapping(target = "created", expression = "java(entity.getCreated())")
@Mapping(target = "lastModified", expression = "java(entity.getLastModified())")
@Mapping(target = "priorityTimestamp", expression = "java(entity.getPriorityTimestamp())")
KoboReadingState toDto(KoboReadingStateEntity entity);
default String toJson(Object value) {
try {
return value == null ? null : objectMapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to serialize JSON", e);
}
}
default <T> T fromJson(String json, Class<T> clazz) {
try {
return json == null ? null : objectMapper.readValue(json, clazz);
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to deserialize JSON", e);
}
}
default String cleanString(String value) {
if (value == null) return null;
return value.replaceAll("^\"|\"$", "");
}
}

View File

@@ -33,6 +33,7 @@ public class BookLoreUserTransformer {
permissions.setCanDeleteBook(userEntity.getPermissions().isPermissionDeleteBook());
permissions.setCanManipulateLibrary(userEntity.getPermissions().isPermissionManipulateLibrary());
permissions.setCanSyncKoReader(userEntity.getPermissions().isPermissionSyncKoreader());
permissions.setCanSyncKobo(userEntity.getPermissions().isPermissionSyncKobo());
BookLoreUser bookLoreUser = new BookLoreUser();
bookLoreUser.setId(userEntity.getId());

View File

@@ -29,6 +29,7 @@ public class BookLoreUser {
private boolean canEditMetadata;
private boolean canManipulateLibrary;
private boolean canSyncKoReader;
private boolean canSyncKobo;
private boolean canEmailBook;
private boolean canDeleteBook;
}

View File

@@ -0,0 +1,16 @@
package com.adityachandel.booklore.model.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
public class BookloreSyncToken {
private String ongoingSyncPointId;
private String lastSuccessfulSyncPointId;
private String rawKoboSyncToken;
}

View File

@@ -0,0 +1,12 @@
package com.adityachandel.booklore.model.dto;
import lombok.Data;
@Data
public class KoboSyncSettings {
private Long id;
private String userId;
private String token;
private boolean syncEnabled = true;
}

View File

@@ -18,6 +18,7 @@ public class UserCreateRequest {
private boolean permissionEmailBook;
private boolean permissionDeleteBook;
private boolean permissionSyncKoreader;
private boolean permissionSyncKobo;
private boolean permissionAdmin;
private Set<Long> selectedLibraries;

View File

@@ -0,0 +1,56 @@
package com.adityachandel.booklore.model.dto.kobo;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class BookEntitlement {
private ActivePeriod activePeriod;
@JsonProperty("IsRemoved")
private boolean isRemoved;
private String status;
@Builder.Default
private String accessibility = "Full";
private String crossRevisionId;
private String revisionId;
@JsonProperty("IsHiddenFromArchive")
@Builder.Default
private boolean isHiddenFromArchive = false;
private String id;
private String created;
private String lastModified;
@JsonProperty("IsLocked")
@Builder.Default
private boolean isLocked = false;
@Builder.Default
private String originCategory = "Imported";
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class ActivePeriod {
private String from;
}
}

View File

@@ -0,0 +1,21 @@
package com.adityachandel.booklore.model.dto.kobo;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class BookEntitlementContainer {
private BookEntitlement bookEntitlement;
private KoboBookMetadata bookMetadata;
private KoboReadingState readingState;
}

View File

@@ -0,0 +1,18 @@
package com.adityachandel.booklore.model.dto.kobo;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
public class ChangedEntitlement implements Entitlement {
private BookEntitlementContainer changedEntitlement;
}

View File

@@ -0,0 +1,4 @@
package com.adityachandel.booklore.model.dto.kobo;
public interface Entitlement {
}

View File

@@ -0,0 +1,21 @@
package com.adityachandel.booklore.model.dto.kobo;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
public class KoboAuthentication {
private String accessToken;
private String refreshToken;
private String tokenType = "Bearer";
private String trackingId;
private String userKey;
}

View File

@@ -0,0 +1,159 @@
package com.adityachandel.booklore.model.dto.kobo;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KoboBookMetadata {
private String crossRevisionId;
private String revisionId;
private Publisher publisher;
private String publicationDate;
@Builder.Default
private String language = "en";
private String isbn;
private String genre;
private String slug;
private String coverImageId;
@JsonProperty("IsSocialEnabled")
@Builder.Default
private boolean isSocialEnabled = false;
private String workId;
@Builder.Default
private List<Object> externalIds = new ArrayList<>();
@JsonProperty("IsPreOrder")
@Builder.Default
private boolean isPreOrder = false;
@Builder.Default
private List<ContributorRole> contributorRoles = new ArrayList<>();
@JsonProperty("IsInternetArchive")
@Builder.Default
private boolean isInternetArchive = false;
private String entitlementId;
private String title;
private String description;
@Builder.Default
private List<String> categories = List.of("00000000-0000-0000-0000-000000000001");
@Builder.Default
private List<DownloadUrl> downloadUrls = new ArrayList<>();
@Builder.Default
private List<String> contributors = new ArrayList<>();
private Series series;
@Builder.Default
private CurrentDisplayPrice currentDisplayPrice = CurrentDisplayPrice.builder()
.totalAmount(0)
.currencyCode("USD")
.build();
@Builder.Default
private CurrentLoveDisplayPrice currentLoveDisplayPrice = CurrentLoveDisplayPrice.builder()
.totalAmount(0)
.build();
@JsonProperty("IsEligibleForKoboLove")
@Builder.Default
private boolean isEligibleForKoboLove = false;
@Builder.Default
private Map<String, String> phoneticPronunciations = Map.of();
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Publisher {
private String name;
private String imprint;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class ContributorRole {
private String name;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class DownloadUrl {
@Builder.Default
private String drmType = "None";
private String format;
private String url;
private long size;
@Builder.Default
private String platform = "Generic";
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Series {
private String id;
private String name;
private String number;
private double numberFloat;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class CurrentDisplayPrice {
private double totalAmount;
private String currencyCode;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class CurrentLoveDisplayPrice {
private double totalAmount;
}
}

View File

@@ -0,0 +1,11 @@
package com.adityachandel.booklore.model.dto.kobo;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class KoboHeaders {
public static final String X_KOBO_SYNCTOKEN = "x-kobo-synctoken";
public static final String X_KOBO_SYNC = "X-Kobo-sync";
}

View File

@@ -0,0 +1,77 @@
package com.adityachandel.booklore.model.dto.kobo;
import com.adityachandel.booklore.model.enums.KoboReadStatus;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KoboReadingState {
private String entitlementId;
private String created;
private String lastModified;
private StatusInfo statusInfo;
private Statistics statistics;
private CurrentBookmark currentBookmark;
private String priorityTimestamp;
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class StatusInfo {
private String lastModified;
private KoboReadStatus status;
private int timesStartedReading;
private String lastTimeStartedReading;
private String lastTimeFinished;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Statistics {
private String lastModified;
private Integer spentReadingMinutes;
private Integer remainingTimeMinutes;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class CurrentBookmark {
private String lastModified;
private Integer progressPercent;
private Integer contentSourceProgressPercent;
private Location location;
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Location {
private String value;
private String type;
private String source;
}
}
}

View File

@@ -0,0 +1,16 @@
package com.adityachandel.booklore.model.dto.kobo;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
public class KoboReadingStateWrapper {
private List<KoboReadingState> readingStates;
}

View File

@@ -0,0 +1,18 @@
package com.adityachandel.booklore.model.dto.kobo;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KoboResources {
private JsonNode resources;
}

View File

@@ -0,0 +1,26 @@
package com.adityachandel.booklore.model.dto.kobo;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KoboTestResponse {
@JsonProperty("Result")
private String result;
@JsonProperty("TestKey")
private String testKey;
@JsonProperty("Tests")
private Map<String, Object> tests;
}

View File

@@ -0,0 +1,19 @@
package com.adityachandel.booklore.model.dto.kobo;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class NewEntitlement implements Entitlement {
private BookEntitlementContainer newEntitlement;
}

View File

@@ -2,8 +2,10 @@ package com.adityachandel.booklore.model.dto.request;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Null;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class ShelfCreateRequest {

View File

@@ -21,5 +21,6 @@ public class UserUpdateRequest {
private boolean canEmailBook;
private boolean canDeleteBook;
private boolean canSyncKoReader;
private boolean canSyncKobo;
}
}

View File

@@ -0,0 +1,32 @@
package com.adityachandel.booklore.model.dto.response.kobo;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class KoboReadingStateResponse {
private String requestResult;
private List<UpdateResult> updateResults;
@Data
@Builder
public static class UpdateResult {
private String entitlementId;
private Result currentBookmarkResult;
private Result statisticsResult;
private Result statusInfoResult;
}
@Data
@Builder
public static class Result {
private String result;
public static Result success() {
return Result.builder().result("Success").build();
}
}
}

View File

@@ -0,0 +1,26 @@
package com.adityachandel.booklore.model.entity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "kobo_removed_books_tracking")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class KoboDeletedBookProgressEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "snapshot_id", nullable = false)
private String snapshotId;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "book_id_synced", nullable = false)
private Long bookIdSynced;
}

View File

@@ -0,0 +1,29 @@
package com.adityachandel.booklore.model.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
import java.util.List;
@Entity
@Table(name = "kobo_library_snapshot")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class KoboLibrarySnapshotEntity {
@Id
private String id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "created_date", nullable = false)
private LocalDateTime createdDate;
@OneToMany(mappedBy = "snapshot", cascade = CascadeType.ALL, orphanRemoval = true)
private List<KoboSnapshotBookEntity> books;
}

View File

@@ -0,0 +1,44 @@
package com.adityachandel.booklore.model.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.UpdateTimestamp;
@Entity
@Table(name = "kobo_reading_state")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class KoboReadingStateEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "entitlement_id", nullable = false, unique = true)
private String entitlementId;
@Column(name = "created")
private String created;
@UpdateTimestamp
@Column(name = "last_modified")
private String lastModified;
@Column(name = "priority_timestamp")
private String priorityTimestamp;
@Column(name = "current_bookmark_json", columnDefinition = "json")
private String currentBookmarkJson;
@Column(name = "statistics_json", columnDefinition = "json")
private String statisticsJson;
@Column(name = "status_info_json", columnDefinition = "json")
private String statusInfoJson;
@Column(name = "last_modified_string")
private String lastModifiedString;
}

View File

@@ -0,0 +1,29 @@
package com.adityachandel.booklore.model.entity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "kobo_library_snapshot_book")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class KoboSnapshotBookEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "snapshot_id", nullable = false)
private KoboLibrarySnapshotEntity snapshot;
@Column(name = "book_id", nullable = false)
private Long bookId;
@Column(nullable = false)
private boolean synced = false;
}

View File

@@ -0,0 +1,27 @@
package com.adityachandel.booklore.model.entity;
import jakarta.persistence.*;
import lombok.*;
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
@Setter
@Entity
@Table(name = "kobo_user_settings")
public class KoboUserSettingsEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false, unique = true)
private Long userId;
@Column(name = "token", nullable = false, length = 2048)
private String token;
@Column(name = "sync_enabled")
private boolean syncEnabled = true;
}

View File

@@ -41,6 +41,9 @@ public class UserPermissionsEntity {
@Column(name = "permission_sync_koreader", nullable = false)
private boolean permissionSyncKoreader = false;
@Column(name = "permission_sync_kobo", nullable = false)
private boolean permissionSyncKobo = false;
@Column(name = "permission_admin", nullable = false)
private boolean permissionAdmin;
}

View File

@@ -0,0 +1,17 @@
package com.adityachandel.booklore.model.enums;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
public enum KoboReadStatus {
@JsonProperty("ReadyToRead")
READY_TO_READ,
@JsonProperty("Finished")
FINISHED,
@JsonProperty("Reading")
READING,
}

View File

@@ -8,5 +8,6 @@ public enum PermissionType {
EMAIL_BOOK,
DELETE_BOOK,
SYNC_KOREADER,
SYNC_KOBO,
ADMIN
}

View File

@@ -0,0 +1,16 @@
package com.adityachandel.booklore.model.enums;
import lombok.Getter;
@Getter
public enum ShelfType {
KOBO("Kobo", "pi pi-tablet");
private final String name;
private final String icon;
ShelfType(String name, String icon) {
this.name = name;
this.icon = icon;
}
}

View File

@@ -2,6 +2,7 @@ package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.BookEntity;
import jakarta.transaction.Transactional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

View File

@@ -10,6 +10,4 @@ import java.util.Set;
@Repository
public interface BookShelfMappingRepository extends JpaRepository<BookShelfMapping, BookShelfKey> {
List<BookShelfMapping> findAllByBookIdIn(Set<Long> bookId);
}

View File

@@ -0,0 +1,18 @@
package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.KoboDeletedBookProgressEntity;
import jakarta.transaction.Transactional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@Repository
public interface KoboDeletedBookProgressRepository extends JpaRepository<KoboDeletedBookProgressEntity, Long> {
@Modifying
@Transactional
@Query("DELETE FROM KoboDeletedBookProgressEntity p WHERE p.snapshotId = :snapshotId AND p.userId = :userId")
void deleteBySnapshotIdAndUserId(@Param("snapshotId") String snapshotId, @Param("userId") Long userId);
}

View File

@@ -0,0 +1,16 @@
package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.KoboLibrarySnapshotEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface KoboLibrarySnapshotRepository extends JpaRepository<KoboLibrarySnapshotEntity, String> {
Optional<KoboLibrarySnapshotEntity> findByIdAndUserId(String id, Long userId);
Optional<KoboLibrarySnapshotEntity> findTopByUserIdOrderByCreatedDateDesc(Long userId);
}

View File

@@ -0,0 +1,12 @@
package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.KoboReadingStateEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface KoboReadingStateRepository extends JpaRepository<KoboReadingStateEntity, Long> {
Optional<KoboReadingStateEntity> findByEntitlementId(String entitlementId);
}

View File

@@ -0,0 +1,77 @@
package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.KoboSnapshotBookEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface KoboSnapshotBookRepository extends JpaRepository<KoboSnapshotBookEntity, Long> {
Page<KoboSnapshotBookEntity> findBySnapshot_IdAndSyncedFalse(String snapshotId, Pageable pageable);
@Modifying
@Query("UPDATE KoboSnapshotBookEntity b SET b.synced = true WHERE b.snapshot.id = :snapshotId AND b.bookId IN :bookIds")
void markBooksSynced(@Param("snapshotId") String snapshotId, @Param("bookIds") List<Long> bookIds);
@Query("""
SELECT curr
FROM KoboSnapshotBookEntity curr
WHERE curr.snapshot.id = :currSnapshotId
AND curr.bookId IN (
SELECT prev.bookId
FROM KoboSnapshotBookEntity prev
WHERE prev.snapshot.id = :prevSnapshotId
)
""")
List<KoboSnapshotBookEntity> findExistingBooksBetweenSnapshots(
@Param("prevSnapshotId") String prevSnapshotId,
@Param("currSnapshotId") String currSnapshotId
);
@Query("""
SELECT curr
FROM KoboSnapshotBookEntity curr
WHERE curr.snapshot.id = :currSnapshotId
AND (:unsyncedOnly = false OR curr.synced = false)
AND curr.bookId NOT IN (
SELECT prev.bookId
FROM KoboSnapshotBookEntity prev
WHERE prev.snapshot.id = :prevSnapshotId
)
""")
Page<KoboSnapshotBookEntity> findNewlyAddedBooks(
@Param("prevSnapshotId") String prevSnapshotId,
@Param("currSnapshotId") String currSnapshotId,
@Param("unsyncedOnly") boolean unsyncedOnly,
Pageable pageable
);
@Query("""
SELECT prev
FROM KoboSnapshotBookEntity prev
WHERE prev.snapshot.id = :prevSnapshotId
AND prev.bookId NOT IN (
SELECT curr.bookId
FROM KoboSnapshotBookEntity curr
WHERE curr.snapshot.id = :currSnapshotId
)
AND prev.bookId NOT IN (
SELECT p.bookIdSynced
FROM KoboDeletedBookProgressEntity p
WHERE p.snapshotId = :currSnapshotId
)
""")
Page<KoboSnapshotBookEntity> findRemovedBooks(
@Param("prevSnapshotId") String prevSnapshotId,
@Param("currSnapshotId") String currSnapshotId,
Pageable pageable
);
}

View File

@@ -0,0 +1,15 @@
package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.KoboUserSettingsEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface KoboUserSettingsRepository extends JpaRepository<KoboUserSettingsEntity, Long> {
Optional<KoboUserSettingsEntity> findByUserId(Long userId);
Optional<KoboUserSettingsEntity> findByToken(String token);
}

View File

@@ -7,6 +7,7 @@ import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@Repository
@@ -16,5 +17,5 @@ public interface ShelfRepository extends JpaRepository<ShelfEntity, Long> {
List<ShelfEntity> findByUserId(Long id);
List<ShelfEntity> findAllByIdIn(Set<Long> ids);
Optional<ShelfEntity> findByUserIdAndName(Long id, String name);
}

View File

@@ -0,0 +1,187 @@
package com.adityachandel.booklore.service;
import com.adityachandel.booklore.model.dto.kobo.*;
import com.adityachandel.booklore.model.entity.AuthorEntity;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
import com.adityachandel.booklore.model.entity.CategoryEntity;
import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.model.enums.KoboReadStatus;
import com.adityachandel.booklore.util.kobo.KoboUrlBuilder;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@AllArgsConstructor
@Service
public class KoboEntitlementService {
private final KoboUrlBuilder koboUrlBuilder;
private final BookQueryService bookQueryService;
public List<NewEntitlement> generateNewEntitlements(Set<Long> bookIds, String token, boolean removed) {
List<BookEntity> books = bookQueryService.findAllWithMetadataByIds(bookIds);
return books.stream()
.filter(bookEntity -> bookEntity.getBookType() == BookFileType.EPUB)
.map(book -> NewEntitlement.builder()
.newEntitlement(BookEntitlementContainer.builder()
.bookEntitlement(buildBookEntitlement(book, removed))
.bookMetadata(mapToKoboMetadata(book, token))
.readingState(createInitialReadingState(book))
.build())
.build())
.collect(Collectors.toList());
}
public List<ChangedEntitlement> generateChangedEntitlements(Set<Long> bookIds, String token, boolean removed) {
List<BookEntity> books = bookQueryService.findAllWithMetadataByIds(bookIds);
return books.stream()
.filter(bookEntity -> bookEntity.getBookType() == BookFileType.EPUB)
.map(book -> {
KoboBookMetadata metadata;
if (removed) {
metadata = KoboBookMetadata.builder()
.coverImageId(String.valueOf(book.getId()))
.crossRevisionId(String.valueOf(book.getId()))
.entitlementId(String.valueOf(book.getId()))
.revisionId(String.valueOf(book.getId()))
.workId(String.valueOf(book.getId()))
.title(String.valueOf(book.getId()))
.build();
} else {
metadata = mapToKoboMetadata(book, token);
}
return ChangedEntitlement.builder()
.changedEntitlement(BookEntitlementContainer.builder()
.bookEntitlement(buildBookEntitlement(book, true))
.bookMetadata(metadata)
.build())
.build();
})
.collect(Collectors.toList());
}
private KoboReadingState createInitialReadingState(BookEntity book) {
OffsetDateTime now = getCurrentUtc();
OffsetDateTime createdOn = getCreatedOn(book);
return KoboReadingState.builder()
.entitlementId(String.valueOf(book.getId()))
.created(createdOn.toString())
.lastModified(now.toString())
.statusInfo(KoboReadingState.StatusInfo.builder()
.lastModified(now.toString())
.status(KoboReadStatus.READY_TO_READ)
.timesStartedReading(0)
.build())
.currentBookmark(KoboReadingState.CurrentBookmark.builder()
.lastModified(now.toString())
.build())
.statistics(KoboReadingState.Statistics.builder()
.lastModified(now.toString())
.build())
.priorityTimestamp(now.toString())
.build();
}
private BookEntitlement buildBookEntitlement(BookEntity book, boolean removed) {
OffsetDateTime now = getCurrentUtc();
OffsetDateTime createdOn = getCreatedOn(book);
return BookEntitlement.builder()
.activePeriod(BookEntitlement.ActivePeriod.builder()
.from(now.toString())
.build())
.isRemoved(removed)
.status("Active")
.crossRevisionId(String.valueOf(book.getId()))
.revisionId(String.valueOf(book.getId()))
.id(String.valueOf(book.getId()))
.created(createdOn.toString())
.lastModified(now.toString())
.build();
}
public KoboBookMetadata getMetadataForBook(long bookId, String token) {
List<BookEntity> books = bookQueryService.findAllWithMetadataByIds(Set.of(bookId))
.stream()
.filter(bookEntity -> bookEntity.getBookType() == BookFileType.EPUB)
.toList();
return mapToKoboMetadata(books.getFirst(), token);
}
private KoboBookMetadata mapToKoboMetadata(BookEntity book, String token) {
BookMetadataEntity metadata = book.getMetadata();
KoboBookMetadata.Publisher publisher = KoboBookMetadata.Publisher.builder()
.name(metadata.getPublisher())
.imprint(metadata.getPublisher())
.build();
List<String> authors = Optional.ofNullable(metadata.getAuthors())
.map(list -> list.stream().map(AuthorEntity::getName).toList())
.orElse(Collections.emptyList());
List<String> categories = Optional.ofNullable(metadata.getCategories())
.map(list -> list.stream().map(CategoryEntity::getName).toList())
.orElse(Collections.emptyList());
KoboBookMetadata.Series series = null;
if (metadata.getSeriesName() != null) {
series = KoboBookMetadata.Series.builder()
.id("series_" + metadata.getSeriesName().hashCode())
.name(metadata.getSeriesName())
.number(metadata.getSeriesNumber() != null ? metadata.getSeriesNumber().toString() : "1")
.numberFloat(metadata.getSeriesNumber() != null ? metadata.getSeriesNumber().doubleValue() : 1.0)
.build();
}
String downloadUrl = koboUrlBuilder.downloadUrl(token, book.getId());
return KoboBookMetadata.builder()
.crossRevisionId(String.valueOf(book.getId()))
.revisionId(String.valueOf(book.getId()))
.publisher(publisher)
.publicationDate(metadata.getPublishedDate() != null
? metadata.getPublishedDate().atStartOfDay().atOffset(ZoneOffset.UTC).toString()
: null)
.isbn(metadata.getIsbn13() != null ? metadata.getIsbn13() : metadata.getIsbn10())
.genre(categories.isEmpty() ? null : categories.getFirst())
.slug(metadata.getTitle() != null
? metadata.getTitle().toLowerCase().replaceAll("[^a-z0-9]", "-")
: null)
.coverImageId(String.valueOf(metadata.getBookId()))
.workId(String.valueOf(book.getId()))
.isPreOrder(false)
.contributorRoles(Collections.emptyList())
.entitlementId(String.valueOf(book.getId()))
.title(metadata.getTitle())
.description(metadata.getDescription())
.contributors(authors)
.series(series)
.downloadUrls(List.of(
KoboBookMetadata.DownloadUrl.builder()
.url(downloadUrl)
.format("EPUB3")
.size(book.getFileSizeKb() * 1024)
.build()
))
.build();
}
private OffsetDateTime getCurrentUtc() {
return OffsetDateTime.now(ZoneOffset.UTC);
}
private OffsetDateTime getCreatedOn(BookEntity book) {
return book.getAddedOn() != null ? book.getAddedOn().atOffset(ZoneOffset.UTC) : getCurrentUtc();
}
}

View File

@@ -0,0 +1,133 @@
package com.adityachandel.booklore.service;
import com.adityachandel.booklore.mapper.BookEntityToKoboSnapshotBookMapper;
import com.adityachandel.booklore.model.entity.KoboDeletedBookProgressEntity;
import com.adityachandel.booklore.model.entity.KoboSnapshotBookEntity;
import com.adityachandel.booklore.model.entity.ShelfEntity;
import com.adityachandel.booklore.model.entity.KoboLibrarySnapshotEntity;
import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.model.enums.ShelfType;
import com.adityachandel.booklore.repository.KoboDeletedBookProgressRepository;
import com.adityachandel.booklore.repository.ShelfRepository;
import com.adityachandel.booklore.repository.KoboSnapshotBookRepository;
import com.adityachandel.booklore.repository.KoboLibrarySnapshotRepository;
import lombok.AllArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
@AllArgsConstructor
@Service
public class KoboLibrarySnapshotService {
private final KoboLibrarySnapshotRepository koboLibrarySnapshotRepository;
private final KoboSnapshotBookRepository koboSnapshotBookRepository;
private final ShelfRepository shelfRepository;
private final BookEntityToKoboSnapshotBookMapper mapper;
private final KoboDeletedBookProgressRepository koboDeletedBookProgressRepository;
@Transactional(readOnly = true)
public Optional<KoboLibrarySnapshotEntity> findByIdAndUserId(String id, Long userId) {
return koboLibrarySnapshotRepository.findByIdAndUserId(id, userId);
}
@Transactional
public KoboLibrarySnapshotEntity create(Long userId) {
KoboLibrarySnapshotEntity snapshot = KoboLibrarySnapshotEntity.builder()
.id(UUID.randomUUID().toString())
.userId(userId)
.build();
List<KoboSnapshotBookEntity> books = mapBooksToKoboSnapshotBook(getKoboShelf(userId), snapshot);
snapshot.setBooks(books);
return koboLibrarySnapshotRepository.save(snapshot);
}
@Transactional
public Page<KoboSnapshotBookEntity> getUnsyncedBooks(String snapshotId, Pageable pageable) {
Page<KoboSnapshotBookEntity> page = koboSnapshotBookRepository.findBySnapshot_IdAndSyncedFalse(snapshotId, pageable);
List<Long> bookIds = page.getContent().stream()
.map(KoboSnapshotBookEntity::getBookId)
.toList();
if (!bookIds.isEmpty()) {
koboSnapshotBookRepository.markBooksSynced(snapshotId, bookIds);
}
return page;
}
@Transactional
public void updateSyncedStatusForExistingBooks(String previousSnapshotId, String currentSnapshotId) {
List<KoboSnapshotBookEntity> list = koboSnapshotBookRepository.findExistingBooksBetweenSnapshots(previousSnapshotId, currentSnapshotId);
List<Long> existingBooks = list.stream()
.map(KoboSnapshotBookEntity::getBookId)
.toList();
if (!existingBooks.isEmpty()) {
koboSnapshotBookRepository.markBooksSynced(currentSnapshotId, existingBooks);
}
}
@Transactional
public Page<KoboSnapshotBookEntity> getNewlyAddedBooks(String previousSnapshotId, String currentSnapshotId, Pageable pageable, Long userId) {
Page<KoboSnapshotBookEntity> page = koboSnapshotBookRepository.findNewlyAddedBooks(previousSnapshotId, currentSnapshotId, true, pageable);
List<Long> newlyAddedBookIds = page.getContent().stream()
.map(KoboSnapshotBookEntity::getBookId)
.toList();
if (!newlyAddedBookIds.isEmpty()) {
koboSnapshotBookRepository.markBooksSynced(currentSnapshotId, newlyAddedBookIds);
}
return page;
}
@Transactional
public Page<KoboSnapshotBookEntity> getRemovedBooks(String previousSnapshotId, String currentSnapshotId, Long userId, Pageable pageable) {
Page<KoboSnapshotBookEntity> page = koboSnapshotBookRepository.findRemovedBooks(previousSnapshotId, currentSnapshotId, pageable);
List<Long> bookIds = page.getContent().stream()
.map(KoboSnapshotBookEntity::getBookId)
.toList();
if (!bookIds.isEmpty()) {
List<KoboDeletedBookProgressEntity> progressEntities = bookIds.stream()
.map(bookId -> KoboDeletedBookProgressEntity.builder()
.bookIdSynced(bookId)
.snapshotId(currentSnapshotId)
.userId(userId)
.build())
.toList();
koboDeletedBookProgressRepository.saveAll(progressEntities);
}
return page;
}
private ShelfEntity getKoboShelf(Long userId) {
return shelfRepository
.findByUserIdAndName(userId, ShelfType.KOBO.getName())
.orElseThrow(() -> new NoSuchElementException(
String.format("Shelf '%s' not found for user %d", ShelfType.KOBO.getName(), userId)
));
}
private List<KoboSnapshotBookEntity> mapBooksToKoboSnapshotBook(ShelfEntity shelf, KoboLibrarySnapshotEntity snapshot) {
return shelf.getBookEntities().stream()
.filter(bookEntity -> bookEntity.getBookType() == BookFileType.EPUB)
.map(book -> {
KoboSnapshotBookEntity snapshotBook = mapper.toKoboSnapshotBook(book);
snapshotBook.setSnapshot(snapshot);
return snapshotBook;
})
.collect(Collectors.toList());
}
public void deleteById(String id) {
koboLibrarySnapshotRepository.deleteById(id);
}
}

View File

@@ -0,0 +1,70 @@
package com.adityachandel.booklore.service;
import com.adityachandel.booklore.mapper.KoboReadingStateMapper;
import com.adityachandel.booklore.model.dto.kobo.KoboReadingState;
import com.adityachandel.booklore.model.dto.kobo.KoboReadingStateWrapper;
import com.adityachandel.booklore.model.dto.response.kobo.KoboReadingStateResponse;
import com.adityachandel.booklore.model.entity.KoboReadingStateEntity;
import com.adityachandel.booklore.repository.KoboReadingStateRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class KoboReadingStateService {
private final KoboReadingStateRepository repository;
private final KoboReadingStateMapper mapper;
public KoboReadingStateResponse saveReadingState(List<KoboReadingState> readingStates) {
List<KoboReadingState> koboReadingStates = saveAll(readingStates);
List<KoboReadingStateResponse.UpdateResult> updateResults = koboReadingStates.stream()
.map(state -> KoboReadingStateResponse.UpdateResult.builder()
.entitlementId(state.getEntitlementId())
.currentBookmarkResult(KoboReadingStateResponse.Result.success())
.statisticsResult(KoboReadingStateResponse.Result.success())
.statusInfoResult(KoboReadingStateResponse.Result.success())
.build())
.collect(Collectors.toList());
return KoboReadingStateResponse.builder()
.requestResult("Success")
.updateResults(updateResults)
.build();
}
private List<KoboReadingState> saveAll(List<KoboReadingState> dtos) {
return dtos.stream()
.map(dto -> {
KoboReadingStateEntity entity = repository.findByEntitlementId(dto.getEntitlementId())
.map(existing -> {
existing.setCurrentBookmarkJson(mapper.toJson(dto.getCurrentBookmark()));
existing.setStatisticsJson(mapper.toJson(dto.getStatistics()));
existing.setStatusInfoJson(mapper.toJson(dto.getStatusInfo()));
existing.setLastModifiedString(mapper.cleanString(String.valueOf(dto.getLastModified())));
return existing;
})
.orElseGet(() -> {
KoboReadingStateEntity newEntity = mapper.toEntity(dto);
newEntity.setCreated(mapper.cleanString(String.valueOf(dto.getCreated())));
return newEntity;
});
return repository.save(entity);
})
.map(mapper::toDto)
.collect(Collectors.toList());
}
public KoboReadingStateWrapper getReadingState(String entitlementId) {
Optional<KoboReadingState> readingState = repository.findByEntitlementId(entitlementId).map(mapper::toDto);
return readingState.map(state -> KoboReadingStateWrapper.builder()
.readingStates(List.of(state))
.build()).orElse(null);
}
}

View File

@@ -0,0 +1,97 @@
package com.adityachandel.booklore.service;
import com.adityachandel.booklore.config.security.AuthenticationService;
import com.adityachandel.booklore.model.dto.BookLoreUser;
import com.adityachandel.booklore.model.dto.KoboSyncSettings;
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.ShelfType;
import com.adityachandel.booklore.repository.KoboUserSettingsRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class KoboSettingsService {
private final KoboUserSettingsRepository repository;
private final AuthenticationService authenticationService;
private final ShelfService shelfService;
@Transactional(readOnly = true)
public KoboSyncSettings getCurrentUserSettings() {
BookLoreUser user = authenticationService.getAuthenticatedUser();
KoboUserSettingsEntity entity = repository.findByUserId(user.getId())
.orElseGet(() -> initDefaultSettings(user.getId()));
return mapToDto(entity);
}
@Transactional
public KoboSyncSettings createOrUpdateToken() {
BookLoreUser user = authenticationService.getAuthenticatedUser();
String newToken = generateToken();
KoboUserSettingsEntity entity = repository.findByUserId(user.getId())
.map(existing -> {
existing.setToken(newToken);
return existing;
})
.orElseGet(() -> KoboUserSettingsEntity.builder()
.userId(user.getId())
.token(newToken)
.build());
ensureKoboShelfExists(user.getId());
repository.save(entity);
return mapToDto(entity);
}
@Transactional
public void setSyncEnabled(boolean enabled) {
BookLoreUser user = authenticationService.getAuthenticatedUser();
KoboUserSettingsEntity entity = repository.findByUserId(user.getId())
.orElseThrow(() -> new IllegalStateException("Kobo settings not found for user"));
entity.setSyncEnabled(enabled);
repository.save(entity);
}
private KoboUserSettingsEntity initDefaultSettings(Long userId) {
ensureKoboShelfExists(userId);
KoboUserSettingsEntity entity = KoboUserSettingsEntity.builder()
.userId(userId)
.token(generateToken())
.build();
return repository.save(entity);
}
private void ensureKoboShelfExists(Long userId) {
Optional<ShelfEntity> shelf = shelfService.getShelf(userId, ShelfType.KOBO.getName());
if (shelf.isEmpty()) {
shelfService.createShelf(
ShelfCreateRequest.builder()
.name(ShelfType.KOBO.getName())
.icon(ShelfType.KOBO.getIcon())
.build()
);
}
}
private String generateToken() {
return UUID.randomUUID().toString();
}
private KoboSyncSettings mapToDto(KoboUserSettingsEntity entity) {
KoboSyncSettings dto = new KoboSyncSettings();
dto.setId(entity.getId());
dto.setUserId(entity.getUserId().toString());
dto.setToken(entity.getToken());
return dto;
}
}

View File

@@ -10,6 +10,7 @@ import com.adityachandel.booklore.model.dto.Shelf;
import com.adityachandel.booklore.model.dto.request.ShelfCreateRequest;
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
import com.adityachandel.booklore.model.entity.ShelfEntity;
import com.adityachandel.booklore.model.enums.ShelfType;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.repository.ShelfRepository;
import com.adityachandel.booklore.repository.UserRepository;
@@ -18,6 +19,7 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@AllArgsConstructor
@Service
@@ -62,10 +64,20 @@ public class ShelfService {
}
public void deleteShelf(Long shelfId) {
findShelfByIdOrThrow(shelfId);
ShelfEntity shelfEntity = findShelfByIdOrThrow(shelfId);
if (shelfEntity.getName().equalsIgnoreCase(ShelfType.KOBO.getName())) {
throw ApiError.SHELF_CANNOT_BE_DELETED.createException(ShelfType.KOBO.getName());
}
shelfRepository.deleteById(shelfId);
}
public Shelf getUserKoboShelf() {
Long userId = getAuthenticatedUserId();
ShelfEntity koboShelf = shelfRepository.findByUserIdAndName(userId, ShelfType.KOBO.getName())
.orElseThrow(() -> ApiError.SHELF_NOT_FOUND.createException(ShelfType.KOBO.getName()));
return shelfMapper.toShelf(koboShelf);
}
public List<Book> getShelfBooks(Long shelfId) {
findShelfByIdOrThrow(shelfId);
return bookRepository.findAllWithMetadataByShelfId(shelfId).stream()
@@ -87,4 +99,8 @@ public class ShelfService {
return shelfRepository.findById(shelfId)
.orElseThrow(() -> ApiError.SHELF_NOT_FOUND.createException(shelfId));
}
public Optional<ShelfEntity> getShelf(Long id, String name) {
return shelfRepository.findByUserIdAndName(id, name);
}
}

View File

@@ -0,0 +1,50 @@
package com.adityachandel.booklore.service.kobo;
import com.adityachandel.booklore.model.dto.kobo.KoboAuthentication;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomStringUtils;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
public class KoboDeviceAuthService {
private final ObjectMapper objectMapper;
public ResponseEntity<KoboAuthentication> authenticateDevice(JsonNode requestBody) {
if (requestBody == null || requestBody.get("UserKey") == null) {
throw new IllegalArgumentException("UserKey is required");
}
log.info("Kobo device authentication request received: {}", requestBody);
KoboAuthentication auth = new KoboAuthentication();
auth.setAccessToken(RandomStringUtils.randomAlphanumeric(24));
auth.setRefreshToken(RandomStringUtils.randomAlphanumeric(24));
auth.setTrackingId(UUID.randomUUID().toString());
auth.setUserKey(requestBody.get("UserKey").asText());
return ResponseEntity.ok()
.header("Content-Type", "application/json; charset=utf-8")
.header("Content-Length", String.valueOf(toJsonBytes(auth).length))
.body(auth);
}
public byte[] toJsonBytes(KoboAuthentication KoboAuthentication) {
try {
return objectMapper.writeValueAsString(KoboAuthentication).getBytes(StandardCharsets.UTF_8);
} catch (JsonProcessingException e) {
log.error("Failed to serialize AuthDto to JSON", e);
throw new RuntimeException("Failed to serialize AuthDto", e);
}
}
}

View File

@@ -0,0 +1,50 @@
package com.adityachandel.booklore.service.kobo;
import com.adityachandel.booklore.model.dto.kobo.KoboResources;
import com.adityachandel.booklore.util.kobo.KoboUrlBuilder;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.util.UriComponentsBuilder;
@Slf4j
@Service
@RequiredArgsConstructor
public class KoboInitializationService {
private final KoboServerProxy koboServerProxy;
private final KoboResourcesComponent koboResourcesComponent;
private final KoboUrlBuilder koboUrlBuilder;
public ResponseEntity<KoboResources> initialize(String token) throws JsonProcessingException {
JsonNode resources;
JsonNode body = null;
try {
var response = koboServerProxy.proxyCurrentRequest(null, false);
body = response != null ? response.getBody() : null;
} catch (Exception e) {
log.warn("Failed to get response from Kobo /v1/initialization, fallback to noproxy", e);
}
resources = (body != null && body.has("Resources"))
? body.get("Resources")
: koboResourcesComponent.getResources();
if (resources instanceof ObjectNode objectNode) {
UriComponentsBuilder baseBuilder = koboUrlBuilder.baseBuilder();
objectNode.put("image_host", baseBuilder.build().toUriString());
objectNode.put("image_url_template", koboUrlBuilder.imageUrlTemplate(token));
objectNode.put("image_url_quality_template", koboUrlBuilder.imageUrlQualityTemplate(token));
}
return ResponseEntity.ok()
.header("x-kobo-apitoken", "e30=")
.body(new KoboResources(resources));
}
}

View File

@@ -0,0 +1,137 @@
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.*;
import com.adityachandel.booklore.model.entity.KoboSnapshotBookEntity;
import com.adityachandel.booklore.model.entity.KoboLibrarySnapshotEntity;
import com.adityachandel.booklore.repository.KoboDeletedBookProgressRepository;
import com.adityachandel.booklore.service.KoboEntitlementService;
import com.adityachandel.booklore.service.KoboLibrarySnapshotService;
import com.adityachandel.booklore.util.RequestUtils;
import com.adityachandel.booklore.util.kobo.BookloreSyncTokenGenerator;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import jakarta.servlet.http.HttpServletRequest;
import java.util.*;
import java.util.stream.Collectors;
@AllArgsConstructor
@Service
@Slf4j
public class KoboLibrarySyncService {
private final BookloreSyncTokenGenerator tokenGenerator;
private final KoboLibrarySnapshotService koboLibrarySnapshotService;
private final KoboEntitlementService entitlementService;
private final KoboDeletedBookProgressRepository koboDeletedBookProgressRepository;
private final KoboServerProxy koboServerProxy;
private final ObjectMapper objectMapper;
public ResponseEntity<?> syncLibrary(BookLoreUser user, String token) {
HttpServletRequest request = RequestUtils.getCurrentRequest();
BookloreSyncToken syncToken = Optional.ofNullable(tokenGenerator.fromRequestHeaders(request)).orElse(new BookloreSyncToken());
KoboLibrarySnapshotEntity currSnapshot = koboLibrarySnapshotService.findByIdAndUserId(syncToken.getOngoingSyncPointId(), user.getId()).orElseGet(() -> koboLibrarySnapshotService.create(user.getId()));
Optional<KoboLibrarySnapshotEntity> prevSnapshot = koboLibrarySnapshotService.findByIdAndUserId(syncToken.getLastSuccessfulSyncPointId(), user.getId());
List<Entitlement> entitlements = new ArrayList<>();
boolean shouldContinueSync = false;
if (prevSnapshot.isPresent()) {
int maxRemaining = 5;
List<KoboSnapshotBookEntity> addedAll = new ArrayList<>();
List<KoboSnapshotBookEntity> removedAll = new ArrayList<>();
koboLibrarySnapshotService.updateSyncedStatusForExistingBooks(prevSnapshot.get().getId(), currSnapshot.getId());
Page<KoboSnapshotBookEntity> addedPage = koboLibrarySnapshotService.getNewlyAddedBooks(prevSnapshot.get().getId(), currSnapshot.getId(), PageRequest.of(0, maxRemaining), user.getId());
addedAll.addAll(addedPage.getContent());
maxRemaining -= addedPage.getNumberOfElements();
shouldContinueSync = addedPage.hasNext();
Page<KoboSnapshotBookEntity> removedPage = Page.empty();
if (addedPage.isLast() && maxRemaining > 0) {
removedPage = koboLibrarySnapshotService.getRemovedBooks(prevSnapshot.get().getId(), currSnapshot.getId(), user.getId(), PageRequest.of(0, maxRemaining));
removedAll.addAll(removedPage.getContent());
shouldContinueSync = shouldContinueSync || removedPage.hasNext();
}
Set<Long> addedIds = addedAll.stream().map(KoboSnapshotBookEntity::getBookId).collect(Collectors.toSet());
Set<Long> removedIds = removedAll.stream().map(KoboSnapshotBookEntity::getBookId).collect(Collectors.toSet());
entitlements.addAll(entitlementService.generateNewEntitlements(addedIds, token, false));
entitlements.addAll(entitlementService.generateChangedEntitlements(removedIds, token, true));
} else {
int maxRemaining = 5;
List<KoboSnapshotBookEntity> all = new ArrayList<>();
while (maxRemaining > 0) {
var page = koboLibrarySnapshotService.getUnsyncedBooks(currSnapshot.getId(), PageRequest.of(0, maxRemaining));
all.addAll(page.getContent());
maxRemaining -= page.getNumberOfElements();
shouldContinueSync = page.hasNext();
if (!shouldContinueSync || page.getNumberOfElements() == 0) break;
}
Set<Long> ids = all.stream().map(KoboSnapshotBookEntity::getBookId).collect(Collectors.toSet());
entitlements.addAll(entitlementService.generateNewEntitlements(ids, token, false));
}
if (!shouldContinueSync) {
ResponseEntity<JsonNode> koboStoreResponse = koboServerProxy.proxyCurrentRequest(null, true);
Collection<Entitlement> syncResultsKobo = Optional.ofNullable(koboStoreResponse.getBody())
.map(body -> {
try {
List<Entitlement> results = new ArrayList<>();
if (body.isArray()) {
for (JsonNode node : body) {
if (node.has("NewEntitlement")) {
results.add(objectMapper.treeToValue(node, NewEntitlement.class));
} else if (node.has("ChangedEntitlement")) {
results.add(objectMapper.treeToValue(node, ChangedEntitlement.class));
} else {
log.warn("Unknown entitlement type in Kobo response: {}", node);
}
}
}
return results;
} catch (Exception e) {
log.error("Failed to map Kobo response to Entitlement objects", e);
return Collections.<Entitlement>emptyList();
}
})
.orElse(Collections.emptyList());
entitlements.addAll(syncResultsKobo);
shouldContinueSync = "continue".equalsIgnoreCase(
Optional.ofNullable(koboStoreResponse.getHeaders().getFirst(KoboHeaders.X_KOBO_SYNC)).orElse("")
);
String koboSyncTokenHeader = koboStoreResponse.getHeaders().getFirst(KoboHeaders.X_KOBO_SYNCTOKEN);
syncToken = koboSyncTokenHeader != null ? tokenGenerator.fromBase64(koboSyncTokenHeader) : syncToken;
}
if (shouldContinueSync) {
syncToken.setOngoingSyncPointId(currSnapshot.getId());
} else {
prevSnapshot.ifPresent(sp -> koboLibrarySnapshotService.deleteById(sp.getId()));
koboDeletedBookProgressRepository.deleteBySnapshotIdAndUserId(syncToken.getOngoingSyncPointId(), user.getId());
syncToken.setOngoingSyncPointId(null);
syncToken.setLastSuccessfulSyncPointId(currSnapshot.getId());
}
return ResponseEntity.ok()
.header(KoboHeaders.X_KOBO_SYNC, shouldContinueSync ? "continue" : "")
.header(KoboHeaders.X_KOBO_SYNCTOKEN, tokenGenerator.toBase64(syncToken))
.body(entitlements);
}
}

View File

@@ -0,0 +1,184 @@
package com.adityachandel.booklore.service.kobo;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Component;
@AllArgsConstructor
@Component
public class KoboResourcesComponent {
private final ObjectMapper objectMapper;
public JsonNode getResources() throws JsonProcessingException {
return objectMapper.readTree(
"""
{
"account_page": "https://www.kobo.com/account/settings",
"account_page_rakuten": "https://my.rakuten.co.jp/",
"add_device": "https://storeapi.kobo.com/v1/user/add-device",
"add_entitlement": "https://storeapi.kobo.com/v1/library/{RevisionIds}",
"affiliaterequest": "https://storeapi.kobo.com/v1/affiliate",
"assets": "https://storeapi.kobo.com/v1/assets",
"audiobook": "https://storeapi.kobo.com/v1/products/audiobooks/{ProductId}",
"audiobook_detail_page": "https://www.kobo.com/{region}/{language}/audiobook/{slug}",
"audiobook_get_credits": "https://www.kobo.com/{region}/{language}/audiobooks/plans",
"audiobook_landing_page": "https://www.kobo.com/{region}/{language}/audiobooks",
"audiobook_preview": "https://storeapi.kobo.com/v1/products/audiobooks/{Id}/preview",
"audiobook_purchase_withcredit": "https://storeapi.kobo.com/v1/store/audiobook/{Id}",
"audiobook_subscription_management": "https://www.kobo.com/{region}/{language}/account/subscriptions",
"audiobook_subscription_orange_deal_inclusion_url": "https://authorize.kobo.com/inclusion",
"audiobook_subscription_purchase": "https://www.kobo.com/{region}/{language}/checkoutoption/21C6D938-934B-4A91-B979-E14D70B2F280",
"audiobook_subscription_tiers": "https://www.kobo.com/{region}/{language}/checkoutoption/21C6D938-934B-4A91-B979-E14D70B2F280",
"authorproduct_recommendations": "https://storeapi.kobo.com/v1/products/books/authors/recommendations",
"autocomplete": "https://storeapi.kobo.com/v1/products/autocomplete",
"blackstone_header": {
"key": "x-amz-request-payer",
"value": "requester"
},
"book": "https://storeapi.kobo.com/v1/products/books/{ProductId}",
"book_detail_page": "https://www.kobo.com/{region}/{language}/ebook/{slug}",
"book_detail_page_rakuten": "http://books.rakuten.co.jp/rk/{crossrevisionid}",
"book_landing_page": "https://www.kobo.com/ebooks",
"book_subscription": "https://storeapi.kobo.com/v1/products/books/subscriptions",
"browse_history": "https://storeapi.kobo.com/v1/user/browsehistory",
"categories": "https://storeapi.kobo.com/v1/categories",
"categories_page": "https://www.kobo.com/ebooks/categories",
"category": "https://storeapi.kobo.com/v1/categories/{CategoryId}",
"category_featured_lists": "https://storeapi.kobo.com/v1/categories/{CategoryId}/featured",
"category_products": "https://storeapi.kobo.com/v1/categories/{CategoryId}/products",
"checkout_borrowed_book": "https://storeapi.kobo.com/v1/library/borrow",
"client_authd_referral": "https://authorize.kobo.com/api/AuthenticatedReferral/client/v1/getLink",
"configuration_data": "https://storeapi.kobo.com/v1/configuration",
"content_access_book": "https://storeapi.kobo.com/v1/products/books/{ProductId}/access",
"customer_care_live_chat": "https://v2.zopim.com/widget/livechat.html?key=Y6gwUmnu4OATxN3Tli4Av9bYN319BTdO",
"daily_deal": "https://storeapi.kobo.com/v1/products/dailydeal",
"deals": "https://storeapi.kobo.com/v1/deals",
"delete_entitlement": "https://storeapi.kobo.com/v1/library/{Ids}",
"delete_tag": "https://storeapi.kobo.com/v1/library/tags/{TagId}",
"delete_tag_items": "https://storeapi.kobo.com/v1/library/tags/{TagId}/items/delete",
"device_auth": "https://storeapi.kobo.com/v1/auth/device",
"device_refresh": "https://storeapi.kobo.com/v1/auth/refresh",
"dictionary_host": "https://ereaderfiles.kobo.com",
"discovery_host": "https://discovery.kobobooks.com",
"dropbox_link_account_poll": "https://authorize.kobo.com/{region}/{language}/LinkDropbox",
"dropbox_link_account_start": "https://authorize.kobo.com/LinkDropbox/start",
"eula_page": "https://www.kobo.com/termsofuse?style=onestore",
"exchange_auth": "https://storeapi.kobo.com/v1/auth/exchange",
"external_book": "https://storeapi.kobo.com/v1/products/books/external/{Ids}",
"facebook_sso_page": "https://authorize.kobo.com/signin/provider/Facebook/login?returnUrl=http://kobo.com/",
"featured_list": "https://storeapi.kobo.com/v1/products/featured/{FeaturedListId}",
"featured_lists": "https://storeapi.kobo.com/v1/products/featured",
"free_books_page": {
"EN": "https://www.kobo.com/{region}/{language}/p/free-ebooks",
"FR": "https://www.kobo.com/{region}/{language}/p/livres-gratuits",
"IT": "https://www.kobo.com/{region}/{language}/p/libri-gratuiti",
"NL": "https://www.kobo.com/{region}/{language}/List/bekijk-het-overzicht-van-gratis-ebooks/QpkkVWnUw8sxmgjSlCbJRg",
"PT": "https://www.kobo.com/{region}/{language}/p/livros-gratis"
},
"fte_feedback": "https://storeapi.kobo.com/v1/products/ftefeedback",
"funnel_metrics": "https://storeapi.kobo.com/v1/funnelmetrics",
"get_download_keys": "https://storeapi.kobo.com/v1/library/downloadkeys",
"get_download_link": "https://storeapi.kobo.com/v1/library/downloadlink",
"get_tests_request": "https://storeapi.kobo.com/v1/analytics/gettests",
"giftcard_epd_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem-ereader",
"giftcard_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem",
"googledrive_link_account_start": "https://authorize.kobo.com/{region}/{language}/linkcloudstorage/provider/google_drive",
"gpb_flow_enabled": "False",
"help_page": "http://www.kobo.com/help",
"image_host": "//cdn.kobo.com/book-images/",
"image_url_quality_template": "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/{Quality}/{IsGreyscale}/image.jpg",
"image_url_template": "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/false/image.jpg",
"kobo_audiobooks_credit_redemption": "True",
"kobo_audiobooks_enabled": "True",
"kobo_audiobooks_orange_deal_enabled": "True",
"kobo_audiobooks_subscriptions_enabled": "True",
"kobo_display_price": "True",
"kobo_dropbox_link_account_enabled": "True",
"kobo_google_tax": "False",
"kobo_googledrive_link_account_enabled": "True",
"kobo_nativeborrow_enabled": "False",
"kobo_onedrive_link_account_enabled": "False",
"kobo_onestorelibrary_enabled": "False",
"kobo_privacyCentre_url": "https://www.kobo.com/privacy",
"kobo_redeem_enabled": "True",
"kobo_shelfie_enabled": "False",
"kobo_subscriptions_enabled": "True",
"kobo_superpoints_enabled": "False",
"kobo_wishlist_enabled": "True",
"library_book": "https://storeapi.kobo.com/v1/user/library/books/{LibraryItemId}",
"library_items": "https://storeapi.kobo.com/v1/user/library",
"library_metadata": "https://storeapi.kobo.com/v1/library/{Ids}/metadata",
"library_prices": "https://storeapi.kobo.com/v1/user/library/previews/prices",
"library_search": "https://storeapi.kobo.com/v1/library/search",
"library_sync": "https://storeapi.kobo.com/v1/library/sync",
"love_dashboard_page": "https://www.kobo.com/{region}/{language}/kobosuperpoints",
"love_points_redemption_page": "https://www.kobo.com/{region}/{language}/KoboSuperPointsRedemption?productId={ProductId}",
"magazine_landing_page": "https://www.kobo.com/emagazines",
"more_sign_in_options": "https://authorize.kobo.com/signin?returnUrl=http://kobo.com/#allProviders",
"notebooks": "https://storeapi.kobo.com/api/internal/notebooks",
"notifications_registration_issue": "https://storeapi.kobo.com/v1/notifications/registration",
"oauth_host": "https://oauth.kobo.com",
"password_retrieval_page": "https://www.kobo.com/passwordretrieval.html",
"personalizedrecommendations": "https://storeapi.kobo.com/v2/users/personalizedrecommendations",
"pocket_link_account_start": "https://authorize.kobo.com/{region}/{language}/linkpocket",
"post_analytics_event": "https://storeapi.kobo.com/v1/analytics/event",
"ppx_purchasing_url": "https://purchasing.kobo.com",
"privacy_page": "https://www.kobo.com/privacypolicy?style=onestore",
"product_nextread": "https://storeapi.kobo.com/v1/products/{ProductIds}/nextread",
"product_prices": "https://storeapi.kobo.com/v1/products/{ProductIds}/prices",
"product_recommendations": "https://storeapi.kobo.com/v1/products/{ProductId}/recommendations",
"product_reviews": "https://storeapi.kobo.com/v1/products/{ProductIds}/reviews",
"products": "https://storeapi.kobo.com/v1/products",
"productsv2": "https://storeapi.kobo.com/v2/products",
"provider_external_sign_in_page": "https://authorize.kobo.com/ExternalSignIn/{providerName}?returnUrl=http://kobo.com/",
"purchase_buy": "https://www.kobo.com/checkoutoption/",
"purchase_buy_templated": "https://www.kobo.com/{region}/{language}/checkoutoption/{ProductId}",
"quickbuy_checkout": "https://storeapi.kobo.com/v1/store/quickbuy/{PurchaseId}/checkout",
"quickbuy_create": "https://storeapi.kobo.com/v1/store/quickbuy/purchase",
"rakuten_token_exchange": "https://storeapi.kobo.com/v1/auth/rakuten_token_exchange",
"rating": "https://storeapi.kobo.com/v1/products/{ProductId}/rating/{Rating}",
"reading_services_host": "https://readingservices.kobo.com",
"reading_state": "https://storeapi.kobo.com/v1/library/{Ids}/state",
"redeem_interstitial_page": "https://www.kobo.com",
"registration_page": "https://authorize.kobo.com/signup?returnUrl=http://kobo.com/",
"related_items": "https://storeapi.kobo.com/v1/products/{Id}/related",
"remaining_book_series": "https://storeapi.kobo.com/v1/products/books/series/{SeriesId}",
"rename_tag": "https://storeapi.kobo.com/v1/library/tags/{TagId}",
"review": "https://storeapi.kobo.com/v1/products/reviews/{ReviewId}",
"review_sentiment": "https://storeapi.kobo.com/v1/products/reviews/{ReviewId}/sentiment/{Sentiment}",
"shelfie_recommendations": "https://storeapi.kobo.com/v1/user/recommendations/shelfie",
"sign_in_page": "https://auth.kobobooks.com/ActivateOnWeb",
"social_authorization_host": "https://social.kobobooks.com:8443",
"social_host": "https://social.kobobooks.com",
"store_home": "www.kobo.com/{region}/{language}",
"store_host": "www.kobo.com",
"store_newreleases": "https://www.kobo.com/{region}/{language}/List/new-releases/961XUjtsU0qxkFItWOutGA",
"store_search": "https://www.kobo.com/{region}/{language}/Search?Query={query}",
"store_top50": "https://www.kobo.com/{region}/{language}/ebooks/Top",
"subs_landing_page": "https://www.kobo.com/{region}/{language}/plus",
"subs_management_page": "https://www.kobo.com/{region}/{language}/account/subscriptions",
"subs_purchase_buy_templated": "https://www.kobo.com/{region}/{language}/Checkoutoption/{ProductId}/{TierId}",
"subscription_publisher_price_page": "https://www.kobo.com/{region}/{language}/subscriptionpublisherprice",
"tag_items": "https://storeapi.kobo.com/v1/library/tags/{TagId}/Items",
"tags": "https://storeapi.kobo.com/v1/library/tags",
"taste_profile": "https://storeapi.kobo.com/v1/products/tasteprofile",
"terms_of_sale_page": "https://authorize.kobo.com/{region}/{language}/terms/termsofsale",
"update_accessibility_to_preview": "https://storeapi.kobo.com/v1/library/{EntitlementIds}/preview",
"use_one_store": "True",
"user_loyalty_benefits": "https://storeapi.kobo.com/v1/user/loyalty/benefits",
"user_platform": "https://storeapi.kobo.com/v1/user/platform",
"user_profile": "https://storeapi.kobo.com/v1/user/profile",
"user_ratings": "https://storeapi.kobo.com/v1/user/ratings",
"user_recommendations": "https://storeapi.kobo.com/v1/user/recommendations",
"user_reviews": "https://storeapi.kobo.com/v1/user/reviews",
"user_wishlist": "https://storeapi.kobo.com/v1/user/wishlist",
"userguide_host": "https://ereaderfiles.kobo.com",
"wishlist_page": "https://www.kobo.com/{region}/{language}/account/wishlist"
}
"""
);
}
}

View File

@@ -0,0 +1,160 @@
package com.adityachandel.booklore.service.kobo;
import com.adityachandel.booklore.model.dto.BookloreSyncToken;
import com.adityachandel.booklore.model.dto.kobo.KoboHeaders;
import com.adityachandel.booklore.util.RequestUtils;
import com.adityachandel.booklore.util.kobo.BookloreSyncTokenGenerator;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
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;
import java.net.URI;
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;
@Slf4j
@Component
@RequiredArgsConstructor
public class KoboServerProxy {
private final HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofMinutes(1)).build();
private final ObjectMapper objectMapper;
private final BookloreSyncTokenGenerator bookloreSyncTokenGenerator;
private static final Set<String> HEADERS_OUT_INCLUDE = Set.of(
HttpHeaders.AUTHORIZATION.toLowerCase(),
HttpHeaders.USER_AGENT,
HttpHeaders.ACCEPT,
HttpHeaders.ACCEPT_LANGUAGE
);
private static final Set<String> HEADERS_OUT_EXCLUDE = Set.of(
KoboHeaders.X_KOBO_SYNCTOKEN
);
private boolean isKoboHeader(String headerName) {
return headerName.toLowerCase().startsWith("x-kobo-");
}
public ResponseEntity<JsonNode> proxyCurrentRequest(Object body, boolean includeSyncToken) {
HttpServletRequest request = RequestUtils.getCurrentRequest();
String path = request.getRequestURI().replaceFirst("^/api/kobo/[^/]+", "");
BookloreSyncToken syncToken = null;
if (includeSyncToken) {
syncToken = bookloreSyncTokenGenerator.fromRequestHeaders(request);
if (syncToken == null || syncToken.getRawKoboSyncToken() == null || syncToken.getRawKoboSyncToken().isBlank()) {
//throw new IllegalStateException("Request must include sync token, but none found");
}
}
return executeProxyRequest(request, body, path, includeSyncToken, syncToken);
}
public ResponseEntity<Resource> proxyExternalUrl(String url) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.IMAGE_JPEG);
return new ResponseEntity<>(new ByteArrayResource(response.body()), headers, HttpStatus.valueOf(response.statusCode()));
} catch (Exception e) {
log.error("Failed to proxy external Kobo CDN URL", e);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to fetch image", e);
}
}
private ResponseEntity<JsonNode> executeProxyRequest(HttpServletRequest request, Object body, String path, boolean includeSyncToken, BookloreSyncToken syncToken) {
try {
String koboBaseUrl = "https://storeapi.kobo.com";
String queryString = request.getQueryString();
UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(koboBaseUrl)
.path(path);
if (queryString != null && !queryString.isBlank()) {
uriBuilder.query(queryString);
}
URI uri = uriBuilder.build(true).toUri();
log.info("Kobo proxy URL: {}", uri);
String bodyString = body != null ? objectMapper.writeValueAsString(body) : "{}";
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(uri)
.timeout(Duration.ofMinutes(1))
.method(request.getMethod(), HttpRequest.BodyPublishers.ofString(bodyString))
.header(HttpHeaders.CONTENT_TYPE, "application/json");
Collections.list(request.getHeaderNames()).forEach(headerName -> {
if (!HEADERS_OUT_EXCLUDE.contains(headerName.toLowerCase()) &&
(HEADERS_OUT_INCLUDE.contains(headerName) || isKoboHeader(headerName))) {
Collections.list(request.getHeaders(headerName))
.forEach(value -> builder.header(headerName, value));
}
});
if (includeSyncToken && syncToken != null && syncToken.getRawKoboSyncToken() != null && !syncToken.getRawKoboSyncToken().isBlank()) {
builder.header(KoboHeaders.X_KOBO_SYNCTOKEN, syncToken.getRawKoboSyncToken());
}
HttpRequest httpRequest = builder.build();
HttpResponse<String> response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
JsonNode responseBody = response.body() != null && !response.body().isBlank()
? objectMapper.readTree(response.body())
: null;
HttpHeaders responseHeaders = new HttpHeaders();
response.headers().map().forEach((key, values) -> {
if (isKoboHeader(key)) {
responseHeaders.put(key, values);
}
});
if (responseHeaders.containsKey(KoboHeaders.X_KOBO_SYNCTOKEN) && includeSyncToken && syncToken != null) {
String koboToken = responseHeaders.getFirst(KoboHeaders.X_KOBO_SYNCTOKEN);
if (koboToken != null) {
BookloreSyncToken updated = BookloreSyncToken.builder()
.ongoingSyncPointId(syncToken.getOngoingSyncPointId())
.lastSuccessfulSyncPointId(syncToken.getLastSuccessfulSyncPointId())
.rawKoboSyncToken(koboToken)
.build();
responseHeaders.set(KoboHeaders.X_KOBO_SYNCTOKEN, bookloreSyncTokenGenerator.toBase64(updated));
}
}
log.info("Kobo proxy response status: {}", response.statusCode());
return new ResponseEntity<>(responseBody, responseHeaders, HttpStatus.valueOf(response.statusCode()));
} catch (Exception e) {
log.error("Failed to proxy request to Kobo", e);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to proxy request to Kobo", e);
}
}
}

View File

@@ -0,0 +1,38 @@
package com.adityachandel.booklore.service.kobo;
import com.adityachandel.booklore.service.BookService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class KoboThumbnailService {
private final BookService bookService;
public ResponseEntity<Resource> getThumbnail(Long bookId) {
return getThumbnailInternal(bookId);
}
private ResponseEntity<Resource> getThumbnailInternal(Long bookId) {
Resource image = bookService.getBookCover(bookId);
if (!isValidImage(image)) {
log.warn("Thumbnail not found for bookId={}", bookId);
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_TYPE, "image/jpeg")
.body(image);
}
private boolean isValidImage(Resource image) {
return image != null && image.exists();
}
}

View File

@@ -54,6 +54,7 @@ public class UserProvisioningService {
perms.setPermissionEmailBook(true);
perms.setPermissionDeleteBook(true);
perms.setPermissionSyncKoreader(true);
perms.setPermissionSyncKobo(true);
user.setPermissions(perms);
createUser(user);
@@ -83,6 +84,7 @@ public class UserProvisioningService {
permissions.setPermissionEmailBook(request.isPermissionEmailBook());
permissions.setPermissionDeleteBook(request.isPermissionDeleteBook());
permissions.setPermissionSyncKoreader(request.isPermissionSyncKoreader());
permissions.setPermissionSyncKobo(request.isPermissionSyncKobo());
permissions.setPermissionAdmin(request.isPermissionAdmin());
user.setPermissions(permissions);
@@ -114,6 +116,7 @@ public class UserProvisioningService {
perms.setPermissionEmailBook(defaultPermissions.contains("permissionEmailBook"));
perms.setPermissionDeleteBook(defaultPermissions.contains("permissionDeleteBook"));
perms.setPermissionSyncKoreader(defaultPermissions.contains("permissionSyncKoreader"));
perms.setPermissionSyncKobo(defaultPermissions.contains("permissionSyncKobo"));
}
user.setPermissions(perms);
@@ -161,15 +164,17 @@ public class UserProvisioningService {
permissions.setPermissionManipulateLibrary(defaultPermissions.contains("permissionManipulateLibrary"));
permissions.setPermissionEmailBook(defaultPermissions.contains("permissionEmailBook"));
permissions.setPermissionDeleteBook(defaultPermissions.contains("permissionDeleteBook"));
permissions.setPermissionDeleteBook(defaultPermissions.contains("permissionSyncKoreader"));
permissions.setPermissionSyncKoreader(defaultPermissions.contains("permissionSyncKoreader"));
permissions.setPermissionSyncKobo(defaultPermissions.contains("permissionSyncKobo"));
} else {
permissions.setPermissionUpload(true);
permissions.setPermissionDownload(true);
permissions.setPermissionEditMetadata(true);
permissions.setPermissionUpload(false);
permissions.setPermissionDownload(false);
permissions.setPermissionEditMetadata(false);
permissions.setPermissionManipulateLibrary(false);
permissions.setPermissionEmailBook(true);
permissions.setPermissionDeleteBook(true);
permissions.setPermissionSyncKoreader(true);
permissions.setPermissionEmailBook(false);
permissions.setPermissionDeleteBook(false);
permissions.setPermissionSyncKoreader(false);
permissions.setPermissionSyncKobo(false);
}
permissions.setPermissionAdmin(isAdmin);

View File

@@ -54,6 +54,7 @@ public class UserService {
user.getPermissions().setPermissionEmailBook(updateRequest.getPermissions().isCanEmailBook());
user.getPermissions().setPermissionDeleteBook(updateRequest.getPermissions().isCanDeleteBook());
user.getPermissions().setPermissionSyncKoreader(updateRequest.getPermissions().isCanSyncKoReader());
user.getPermissions().setPermissionSyncKobo(updateRequest.getPermissions().isCanSyncKobo());
}
if (updateRequest.getAssignedLibraries() != null && getMyself().getPermissions().isAdmin()) {

View File

@@ -0,0 +1,20 @@
package com.adityachandel.booklore.util;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
public final class RequestUtils {
private RequestUtils() {
}
public static HttpServletRequest getCurrentRequest() {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs == null) {
throw new IllegalStateException("No current HTTP request found");
}
return attrs.getRequest();
}
}

View File

@@ -14,6 +14,7 @@ public class UserPermissionUtils {
case EMAIL_BOOK -> perms.isPermissionEmailBook();
case DELETE_BOOK -> perms.isPermissionDeleteBook();
case SYNC_KOREADER -> perms.isPermissionSyncKoreader();
case SYNC_KOBO -> perms.isPermissionSyncKobo();
case ADMIN -> perms.isPermissionAdmin();
};
}

View File

@@ -0,0 +1,56 @@
package com.adityachandel.booklore.util.kobo;
import com.adityachandel.booklore.model.dto.kobo.KoboHeaders;
import com.adityachandel.booklore.model.dto.BookloreSyncToken;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Base64;
@Slf4j
@RequiredArgsConstructor
@Component
public class BookloreSyncTokenGenerator {
private static final String BOOKLORE_TOKEN_PREFIX = "BOOKLORE.";
private final ObjectMapper objectMapper;
private final Base64.Encoder base64Encoder = Base64.getEncoder().withoutPadding();
private final Base64.Decoder base64Decoder = Base64.getDecoder();
public BookloreSyncToken fromBase64(String base64Token) {
try {
if (base64Token.startsWith(BOOKLORE_TOKEN_PREFIX)) {
byte[] decoded = base64Decoder.decode(base64Token.substring(BOOKLORE_TOKEN_PREFIX.length()));
return objectMapper.readValue(decoded, BookloreSyncToken.class);
}
if (base64Token.contains(".")) {
return BookloreSyncToken.builder()
.rawKoboSyncToken(base64Token)
.build();
}
} catch (Exception ignored) {
}
return new BookloreSyncToken();
}
public String toBase64(BookloreSyncToken token) {
try {
String json = objectMapper.writeValueAsString(token);
return BOOKLORE_TOKEN_PREFIX + base64Encoder.encodeToString(json.getBytes());
} catch (Exception e) {
log.error("Failed to serialize Booklore sync token", e);
return BOOKLORE_TOKEN_PREFIX;
}
}
public BookloreSyncToken fromRequestHeaders(HttpServletRequest request) {
String tokenB64 = request.getHeader(KoboHeaders.X_KOBO_SYNCTOKEN);
return tokenB64 != null ? fromBase64(tokenB64) : null;
}
}

View File

@@ -0,0 +1,64 @@
package com.adityachandel.booklore.util.kobo;
import com.adityachandel.booklore.util.RequestUtils;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import org.springframework.web.util.UriComponentsBuilder;
@Component
@Slf4j
public class KoboUrlBuilder {
public UriComponentsBuilder baseBuilder() {
HttpServletRequest request = RequestUtils.getCurrentRequest();
UriComponentsBuilder builder = ServletUriComponentsBuilder
.fromCurrentContextPath()
.replacePath("")
.replaceQuery(null)
.port(-1); // drop default port
String host = builder.build().getHost();
String scheme = builder.build().getScheme();
if (host == null) host = "";
String xfPort = request.getHeader("X-Forwarded-Port");
try {
int port = Integer.parseInt(xfPort);
if (host.matches("\\d+\\.\\d+\\.\\d+\\.\\d+") || "localhost".equals(host)) {
builder.port(port);
}
log.info("Applied X-Forwarded-Port: {}", port);
} catch (NumberFormatException e) {
log.warn("Invalid X-Forwarded-Port header: {}", xfPort);
}
log.info("Final base URL: {}", builder.build().toUriString());
return builder;
}
public String downloadUrl(String token, Long bookId) {
return baseBuilder()
.pathSegment("api", "kobo", token, "v1", "books", "{bookId}", "download")
.buildAndExpand(bookId)
.toUriString();
}
public String imageUrlTemplate(String token) {
return baseBuilder()
.pathSegment("api", "kobo", token, "v1", "books", "{ImageId}", "thumbnail", "{Width}", "{Height}", "false", "image.jpg")
.build()
.toUriString();
}
public String imageUrlQualityTemplate(String token) {
return baseBuilder()
.pathSegment("api", "kobo", token, "v1", "books", "{ImageId}", "thumbnail", "{Width}", "{Height}", "{Quality}", "{IsGreyscale}", "image.jpg")
.build()
.toUriString();
}
}

View File

@@ -13,6 +13,9 @@ app:
header-groups: ${REMOTE_AUTH_HEADER_GROUPS:Remote-Groups}
admin-group: ${REMOTE_AUTH_ADMIN_GROUP}
server:
forward-headers-strategy: native
spring:
servlet:
multipart:

View File

@@ -0,0 +1,56 @@
CREATE TABLE IF NOT EXISTS kobo_user_settings
(
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL UNIQUE,
token VARCHAR(2048) NOT NULL,
sync_enabled BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS kobo_library_snapshot
(
id VARCHAR(36) PRIMARY KEY,
user_id BIGINT NOT NULL,
created_date TIMESTAMP NOT NULL,
CONSTRAINT fk_snapshot_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS kobo_library_snapshot_book
(
id BIGINT AUTO_INCREMENT PRIMARY KEY,
snapshot_id VARCHAR(36) NOT NULL,
book_id BIGINT NOT NULL,
synced BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT fk_snapshot_book FOREIGN KEY (snapshot_id) REFERENCES kobo_library_snapshot (id) ON DELETE CASCADE,
CONSTRAINT uq_snapshot_book UNIQUE (snapshot_id, book_id)
);
CREATE TABLE IF NOT EXISTS kobo_reading_state
(
id BIGINT AUTO_INCREMENT PRIMARY KEY,
entitlement_id VARCHAR(255) NOT NULL UNIQUE,
created VARCHAR(255) NULL,
last_modified VARCHAR(255) NULL,
priority_timestamp VARCHAR(255) NULL,
current_bookmark_json JSON,
statistics_json JSON,
status_info_json JSON,
last_modified_string VARCHAR(255)
);
CREATE TABLE IF NOT EXISTS kobo_removed_books_tracking
(
id BIGINT AUTO_INCREMENT PRIMARY KEY,
snapshot_id VARCHAR(36) NOT NULL,
user_id BIGINT NOT NULL,
book_id_synced BIGINT NOT NULL,
CONSTRAINT uq_snapshot_user_book UNIQUE (snapshot_id, user_id, book_id_synced),
CONSTRAINT fk_removed_snapshot FOREIGN KEY (snapshot_id) REFERENCES kobo_library_snapshot (id) ON DELETE CASCADE
);
ALTER TABLE user_permissions
ADD COLUMN IF NOT EXISTS permission_sync_kobo BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE user_permissions
SET permission_sync_kobo = TRUE
WHERE permission_admin = TRUE;

View File

@@ -39,7 +39,8 @@ export class AuthenticationSettingsComponent implements OnInit {
{label: 'Manage Library', value: 'permissionManipulateLibrary', selected: false},
{label: 'Email Book', value: 'permissionEmailBook', selected: false},
{label: 'Delete Book', value: 'permissionDeleteBook', selected: false},
{label: 'KOReader Sync', value: 'permissionSyncKoreader', selected: false}
{label: 'KOReader Sync', value: 'permissionSyncKoreader', selected: false},
{label: 'Kobo Sync', value: 'permissionSyncKobo', selected: false}
];
internalAuthEnabled = true;

View File

@@ -147,35 +147,11 @@
}
@media (max-width: 767px) {
.dashboard-scroller-container {
padding: 1.5rem;
margin-bottom: 2rem;
border-radius: 16px;
}
.dashboard-scroller-title {
font-size: 1.75rem;
margin-bottom: 1rem;
&::after {
width: 50px;
height: 3px;
}
}
.dashboard-scroller-card {
height: 230px;
width: 135px;
flex-basis: 135px;
}
.dashboard-scroller-infinite {
gap: 1.5rem;
padding: 0.75rem 0.25rem 1.5rem 0.25rem;
&::-webkit-scrollbar {
height: 8px;
}
height: 184px; /* 80% of 230px */
width: 108px; /* 80% of 135px */
flex-basis: 108px;
}
.dashboard-scroller-no-books {

View File

@@ -138,6 +138,12 @@ export class AppMenuComponent implements OnInit {
const shelves = state.shelves ?? [];
const sortedShelves = this.sortArray(shelves, this.shelfSortField, this.shelfSortOrder);
const koboShelfIndex = sortedShelves.findIndex(shelf => shelf.name === 'Kobo');
let koboShelf = null;
if (koboShelfIndex !== -1) {
koboShelf = sortedShelves.splice(koboShelfIndex, 1)[0];
}
const shelfItems = sortedShelves.map((shelf) => ({
menu: this.libraryShelfMenuService.initializeShelfMenuItems(shelf),
label: shelf.name,
@@ -155,13 +161,25 @@ export class AppMenuComponent implements OnInit {
bookCount$: this.shelfService.getUnshelvedBookCount?.() ?? of(0),
};
const items = [unshelvedItem];
if (koboShelf) {
items.push({
label: koboShelf.name,
type: 'Shelf',
icon: 'pi pi-' + koboShelf.icon,
routerLink: [`/shelf/${koboShelf.id}/books`],
bookCount$: this.shelfService.getBookCount(koboShelf.id ?? 0),
});
}
items.push(...shelfItems);
return [
{
type: 'shelf',
label: 'Shelves',
hasDropDown: true,
hasCreate: false,
items: [unshelvedItem, ...shelfItems],
items,
},
];
})

View File

@@ -51,7 +51,7 @@
}
<span>
@if (item.type === 'Library' || item.type === 'Shelf' || item.type === 'magicShelfItem') {
@if ((item.type !== 'Library' || (admin || canManipulateLibrary)) && item.label !== 'Unshelved') {
@if ((item.type !== 'Library' || (admin || canManipulateLibrary)) && item.label !== 'Unshelved' && item.label !== 'Kobo') {
<p-button
(click)="entitymenu.toggle($event)"
(mouseover)="hovered = true"
@@ -68,7 +68,7 @@
<p-menu #entitymenu [model]="item.menu" [popup]="true" appendTo="body"></p-menu>
}
@if (item.type === 'Library' && (!admin && !canManipulateLibrary) || item.type === 'All Books' || item.label === 'Unshelved') {
@if (item.type === 'Library' && (!admin && !canManipulateLibrary) || item.type === 'All Books' || item.label === 'Unshelved' || item.label === 'Kobo') {
<p class="text-[var(--primary-color)] pr-3">
{{ (item.bookCount$ | async)?.toString() || '0' }}
</p>

View File

@@ -226,7 +226,7 @@
}
<div class="px-1 md:px-0">
<div class="grid md:grid-cols-4 gap-y-2.5 text-sm pt-2 md:pt-4 pb-2 text-gray-300 max-w-7xl">
<div class="grid md:grid-cols-4 gap-y-2.5 text-sm pt-2 md:pt-4 pb-2 text-gray-300 min-w-[60rem] max-w-[100rem]">
<p class="whitespace-nowrap max-w-[250px] overflow-hidden text-ellipsis">
<span class="font-bold">Publisher: </span>
@if (book?.metadata?.publisher) {

View File

@@ -2,4 +2,9 @@
<div class="pt-2">
<app-koreader-settings-component></app-koreader-settings-component>
</div>
<p-divider></p-divider>
<div class="py-2">
<app-kobo-sync-setting-component></app-kobo-sync-setting-component>
</div>
<p-divider></p-divider>
</div>

View File

@@ -1,10 +1,14 @@
import {Component} from '@angular/core';
import {KoreaderSettingsComponent} from '../koreader-settings-component/koreader-settings-component';
import {KoreaderSettingsComponent} from './koreader-settings-component/koreader-settings-component';
import {KoboSyncSettingsComponent} from './kobo-sync-settings-component/kobo-sync-settings-component';
import {Divider} from 'primeng/divider';
@Component({
selector: 'app-device-settings-component',
imports: [
KoreaderSettingsComponent
KoreaderSettingsComponent,
KoboSyncSettingsComponent,
Divider
],
templateUrl: './device-settings-component.html',
styleUrl: './device-settings-component.scss'

View File

@@ -0,0 +1,62 @@
<p-confirmDialog></p-confirmDialog>
<p class="text-lg flex items-center gap-2 px-4 pt-4 pb-2">
Kobo Sync Settings:
<i class="pi pi-info-circle text-sky-600"
pTooltip="Click to view Kobo setup documentation"
tooltipPosition="right"
(click)="openKoboDocumentation()"
style="cursor: pointer;">
</i>
</p>
@if (hasPermission) {
<form #koboForm="ngForm" class="px-4 py-2">
<div class="flex flex-col px-4 py-4 space-y-4">
<div class="flex flex-col gap-1 max-w-[33rem]">
<label for="koboToken">Kobo Sync Token</label>
<div class="flex items-center gap-2">
<input
pInputText
id="koboToken"
[(ngModel)]="koboToken"
[type]="showToken ? 'text' : 'password'"
readonly
fluid
name="koboToken"
/>
<p-button
icon="pi pi-copy"
outlined="true"
severity="info"
(onClick)="copyText(koboToken)">
</p-button>
<p-button
[icon]="showToken ? 'pi pi-eye-slash' : 'pi pi-eye'"
outlined="true"
severity="info"
(onClick)="toggleShowToken()">
</p-button>
</div>
</div>
<p-button
icon="pi pi-refresh"
outlined="true"
severity="warn"
label="Regenerate Token"
(onClick)="confirmRegenerateToken()"
[disabled]="!credentialsSaved">
</p-button>
</div>
</form>
} @else {
<div class="px-4 py-4 m-4 rounded-lg bg-red-700/30 border border-red-600 text-red-200 flex items-center gap-2 max-w-lg">
<i class="pi pi-lock text-red-400 text-xl"></i>
<span>
Access to Kobo sync is restricted.
<br>
Please contact your administrator to request permission.
</span>
</div>
}

View File

@@ -0,0 +1,95 @@
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
import {ConfirmationService, MessageService} from 'primeng/api';
import {Clipboard} from '@angular/cdk/clipboard';
import {KoboService, KoboSyncSettings} from '../kobo.service';
import {FormsModule} from '@angular/forms';
import {Button} from 'primeng/button';
import {InputText} from 'primeng/inputtext';
import {ConfirmDialog} from 'primeng/confirmdialog';
import {UserService} from '../../user-management/user.service';
import {Subject} from 'rxjs';
import {filter, takeUntil} from 'rxjs/operators';
import {Tooltip} from 'primeng/tooltip';
@Component({
selector: 'app-kobo-sync-setting-component',
standalone: true,
templateUrl: './kobo-sync-settings-component.html',
styleUrl: './kobo-sync-settings-component.scss',
imports: [FormsModule, Button, InputText, ConfirmDialog, Tooltip],
providers: [MessageService, ConfirmationService]
})
export class KoboSyncSettingsComponent implements OnInit, OnDestroy {
private koboService = inject(KoboService);
private messageService = inject(MessageService);
private confirmationService = inject(ConfirmationService);
private clipboard = inject(Clipboard);
protected userService = inject(UserService);
private readonly destroy$ = new Subject<void>();
hasPermission = false;
koboToken = '';
credentialsSaved = false;
showToken = false;
ngOnInit() {
this.userService.userState$.pipe(
filter(userState => !!userState?.user && userState.loaded),
takeUntil(this.destroy$)
).subscribe(userState => {
this.hasPermission = (userState.user?.permissions.canSyncKobo || userState.user?.permissions.admin) ?? false;
if (this.hasPermission) {
this.koboService.getUser().subscribe({
next: (settings: KoboSyncSettings) => {
this.koboToken = settings.token;
this.credentialsSaved = !!settings.token;
},
error: () => {
this.messageService.add({severity: 'error', summary: 'Error', detail: 'Failed to load Kobo settings'});
}
});
}
});
}
copyText(text: string) {
this.clipboard.copy(text);
this.messageService.add({severity: 'success', summary: 'Copied', detail: 'Token copied to clipboard'});
}
toggleShowToken() {
this.showToken = !this.showToken;
}
confirmRegenerateToken() {
this.confirmationService.confirm({
message: 'This will generate a new token and invalidate the previous one. Continue?',
header: 'Confirm Regeneration',
icon: 'pi pi-exclamation-triangle',
accept: () => this.regenerateToken()
});
}
private regenerateToken() {
this.koboService.createOrUpdateToken().subscribe({
next: (settings) => {
this.koboToken = settings.token;
this.credentialsSaved = true;
this.messageService.add({severity: 'success', summary: 'Token regenerated', detail: 'New token generated successfully'});
},
error: () => {
this.messageService.add({severity: 'error', summary: 'Error', detail: 'Failed to regenerate token'});
}
});
}
openKoboDocumentation(): void {
window.open('https://booklore-app.github.io/booklore-docs/docs/devices/kobo', '_blank');
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -0,0 +1,24 @@
import {inject, Injectable} from '@angular/core';
import {HttpClient, HttpParams} from '@angular/common/http';
import {Observable} from 'rxjs';
import {API_CONFIG} from '../../config/api-config';
export interface KoboSyncSettings {
token: string;
}
@Injectable({
providedIn: 'root'
})
export class KoboService {
private readonly baseUrl = `${API_CONFIG.BASE_URL}/api/v1/kobo-settings`;
private readonly http = inject(HttpClient);
createOrUpdateToken(): Observable<KoboSyncSettings> {
return this.http.put<KoboSyncSettings>(`${this.baseUrl}`, null);
}
getUser(): Observable<KoboSyncSettings> {
return this.http.get<KoboSyncSettings>(`${this.baseUrl}`);
}
}

View File

@@ -0,0 +1,128 @@
<p-toast position="top-right"></p-toast>
<p class="text-lg flex items-center gap-2 px-4 pt-4 pb-2">
KOReader Sync Settings:
<i class="pi pi-info-circle text-sky-600"
pTooltip="Click to view KOReader setup documentation"
tooltipPosition="right"
(click)="openKoReaderDocumentation()"
style="cursor: pointer;">
</i>
</p>
@if (hasPermission) {
<form #koreaderForm="ngForm" class="px-4 py-4">
<div class="p-field flex items-center px-4 py-2">
<p-toggle-switch
name="syncEnabled"
[(ngModel)]="koReaderSyncEnabled"
(ngModelChange)="onToggleEnabled($event)"
inputId="syncEnabled"
[disabled]="!credentialsSaved">
</p-toggle-switch>
<label for="syncEnabled" class="ml-2">
Enable KOReader Sync
</label>
</div>
<div class="flex flex-col px-4 py-4 space-y-4">
<!-- API Path -->
<div class="flex flex-col gap-1 max-w-[30rem]">
<label for="koreaderEndpoint">KOReader API Path</label>
<div class="flex items-center gap-2">
<input
fluid
pInputText
id="koreaderEndpoint"
[value]="koreaderEndpoint"
readonly/>
<p-button
icon="pi pi-copy"
outlined="true"
severity="info"
(onClick)="copyText(koreaderEndpoint)">
</p-button>
</div>
</div>
<!-- Username -->
<div class="flex flex-col gap-1 max-w-[30rem]">
<label for="username">KOReader Username</label>
<div class="flex items-center gap-2">
<input
fluid
pInputText
id="username"
name="username"
required
[(ngModel)]="koReaderUsername"
#usernameModel="ngModel"
[class.p-invalid]="usernameModel.invalid && usernameModel.touched"
[disabled]="editMode"/>
<p-button
icon="pi pi-copy"
outlined="true"
severity="info"
(onClick)="copyText(koReaderUsername)">
</p-button>
</div>
<small *ngIf="editMode && usernameModel.invalid && usernameModel.touched" class="p-error">
Username is required.
</small>
</div>
<!-- Password -->
<div class="flex flex-col gap-1 max-w-[33rem]">
<label for="password">KOReader Password</label>
<div class="flex items-center gap-2">
<input
fluid
pInputText
id="password"
name="password"
required
minlength="6"
[type]="showPassword ? 'text' : 'password'"
[(ngModel)]="koReaderPassword"
#passwordModel="ngModel"
[class.p-invalid]="passwordModel.invalid && passwordModel.touched"
[disabled]="editMode"/>
<p-button
icon="pi pi-copy"
outlined="true"
severity="info"
(onClick)="copyText(koReaderPassword)">
</p-button>
<p-button
[icon]="showPassword ? 'pi pi-eye-slash' : 'pi pi-eye'"
outlined="true"
severity="info"
(onClick)="toggleShowPassword()">
</p-button>
</div>
<small *ngIf="editMode && passwordModel.invalid && passwordModel.touched" class="p-error">
Password must be at least 6 characters.
</small>
</div>
<p-button
[icon]="!editMode ? 'pi pi-save' : 'pi pi-pencil'"
outlined="true"
[severity]="!editMode ? 'success' : 'warn'"
[label]="!editMode ? 'Save' : 'Edit'"
(onClick)="onEditSave()"
[disabled]="!editMode && !canSave">
</p-button>
</div>
</form>
} @else {
<div class="px-4 py-4 m-4 rounded-lg bg-red-700/30 border border-red-600 text-red-200 flex items-center gap-2 max-w-lg">
<i class="pi pi-lock text-red-400 text-xl"></i>
<span>
Access to KOReader sync is restricted.
<br>
Please contact your administrator to request permission.
</span>
</div>
}

View File

@@ -1,4 +1,4 @@
import {Component, inject, OnInit} from '@angular/core';
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {InputText} from 'primeng/inputtext';
@@ -6,7 +6,11 @@ import {ToggleSwitch} from 'primeng/toggleswitch';
import {Button} from 'primeng/button';
import {ToastModule} from 'primeng/toast';
import {MessageService} from 'primeng/api';
import {KoreaderService} from '../../koreader-service';
import {KoreaderService} from '../koreader.service';
import {UserService} from '../../user-management/user.service';
import {filter, takeUntil} from 'rxjs/operators';
import {Subject} from 'rxjs';
import {Tooltip} from 'primeng/tooltip';
@Component({
standalone: true,
@@ -17,13 +21,14 @@ import {KoreaderService} from '../../koreader-service';
InputText,
ToggleSwitch,
Button,
ToastModule
ToastModule,
Tooltip
],
providers: [MessageService],
templateUrl: './koreader-settings-component.html',
styleUrls: ['./koreader-settings-component.scss']
})
export class KoreaderSettingsComponent implements OnInit {
export class KoreaderSettingsComponent implements OnInit, OnDestroy {
editMode = true;
showPassword = false;
koReaderSyncEnabled = false;
@@ -34,8 +39,24 @@ export class KoreaderSettingsComponent implements OnInit {
private readonly messageService = inject(MessageService);
private readonly koreaderService = inject(KoreaderService);
private readonly userService = inject(UserService);
private readonly destroy$ = new Subject<void>();
hasPermission = false;
ngOnInit() {
this.userService.userState$.pipe(
filter(userState => !!userState?.user && userState.loaded),
takeUntil(this.destroy$)
).subscribe(userState => {
this.hasPermission = (userState.user?.permissions.canSyncKoReader || userState.user?.permissions.admin) ?? false;
if (this.hasPermission) {
this.loadKoreaderSettings();
}
});
}
private loadKoreaderSettings() {
this.koreaderService.getUser().subscribe({
next: koreaderUser => {
this.koReaderUsername = koreaderUser.username;
@@ -44,10 +65,12 @@ export class KoreaderSettingsComponent implements OnInit {
this.credentialsSaved = true;
},
error: err => {
if (err.status === 404) {
this.messageService.add({severity: 'warn', summary: 'User Not Found', detail: 'No KOReader account found. Please create one to enable sync.', life: 5000});
} else {
this.messageService.add({severity: 'error', summary: 'Load Error', detail: 'Unable to retrieve KOReader account. Please try again.'});
if (err.status !== 404) {
this.messageService.add({
severity: 'error',
summary: 'Load Error',
detail: 'Unable to retrieve KOReader account. Please try again.'
});
}
}
});
@@ -102,4 +125,13 @@ export class KoreaderSettingsComponent implements OnInit {
console.error('Copy failed', err);
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
openKoReaderDocumentation() {
window.open('https://booklore-app.github.io/booklore-docs/docs/devices/koreader', '_blank');
}
}

View File

@@ -1,6 +1,6 @@
import {inject, Injectable} from '@angular/core';
import {Observable} from 'rxjs';
import {API_CONFIG} from './config/api-config';
import {API_CONFIG} from '../../config/api-config';
import {HttpClient} from '@angular/common/http';

View File

@@ -1,113 +0,0 @@
<p-toast position="top-right"></p-toast>
<p class="text-lg flex items-center gap-2 px-4 pt-4 pb-2">
KOReader Settings:
</p>
<form #koreaderForm="ngForm" class="px-4 py-4">
<div class="p-field flex items-center px-4 py-2">
<p-toggle-switch
name="syncEnabled"
[(ngModel)]="koReaderSyncEnabled"
(ngModelChange)="onToggleEnabled($event)"
inputId="syncEnabled"
[disabled]="!credentialsSaved">
</p-toggle-switch>
<label for="syncEnabled" class="ml-2">
Enable KOReader Sync
</label>
</div>
<div class="flex flex-col px-4 py-4 space-y-4">
<div class="flex flex-col gap-1 max-w-[30rem]">
<label for="koreaderEndpoint">KOReader API Path</label>
<div class="flex items-center gap-2">
<input
fluid
pInputText
id="koreaderEndpoint"
[value]="koreaderEndpoint"
readonly/>
<p-button
icon="pi pi-copy"
outlined="true"
severity="info"
(onClick)="copyText(koreaderEndpoint)">
</p-button>
</div>
</div>
<div class="flex flex-col gap-1 max-w-[30rem]">
<label for="username">KOReader Username</label>
<div class="flex items-center gap-2">
<input
fluid
pInputText
id="username"
name="username"
required
[(ngModel)]="koReaderUsername"
#usernameModel="ngModel"
[class.p-invalid]="usernameModel.invalid && usernameModel.touched"
[disabled]="editMode"/>
<p-button
icon="pi pi-copy"
outlined="true"
severity="info"
(onClick)="copyText(koReaderUsername)">
</p-button>
</div>
@if (editMode && usernameModel.invalid && usernameModel.touched) {
<small class="p-error">
Username is required.
</small>
}
</div>
<div class="flex flex-col gap-1 max-w-[33rem]">
<label for="password">KOReader Password</label>
<div class="flex items-center gap-2">
<input
fluid
pInputText
id="password"
name="password"
required
minlength="6"
[type]="showPassword ? 'text' : 'password'"
[(ngModel)]="koReaderPassword"
#passwordModel="ngModel"
[class.p-invalid]="passwordModel.invalid && passwordModel.touched"
[disabled]="editMode"/>
<p-button
icon="pi pi-copy"
outlined="true"
severity="info"
(onClick)="copyText(koReaderPassword)">
</p-button>
<p-button
[icon]="showPassword ? 'pi pi-eye-slash' : 'pi pi-eye'"
outlined="true"
severity="info"
(onClick)="toggleShowPassword()">
</p-button>
</div>
@if (editMode && passwordModel.invalid && passwordModel.touched) {
<small class="p-error">
Password must be at least 6 characters.
</small>
}
</div>
<p-button
[icon]="!editMode ? 'pi pi-save' : 'pi pi-pencil'"
outlined="true"
[severity]="!editMode ? 'success' : 'warn'"
[label]="!editMode ? 'Save' : 'Edit'"
(onClick)="onEditSave()"
[disabled]="!editMode && !canSave">
</p-button>
</div>
</form>

View File

@@ -32,11 +32,9 @@
<i class="pi pi-globe"></i> OPDS
</p-tab>
}
@if (userState.user.permissions.admin || userState.user.permissions.canSyncKoReader) {
<p-tab [value]="SettingsTab.DeviceSettings">
<i class="pi pi-mobile"></i> Devices
</p-tab>
}
<p-tab [value]="SettingsTab.DeviceSettings">
<i class="pi pi-mobile"></i> Devices
</p-tab>
</p-tablist>
<p-tabpanels>
<p-tabpanel [value]="SettingsTab.ReaderSettings">
@@ -68,11 +66,9 @@
<app-opds-settings></app-opds-settings>
</p-tabpanel>
}
@if (userState.user.permissions.admin || userState.user.permissions.canSyncKoReader) {
<p-tabpanel [value]="SettingsTab.DeviceSettings">
<app-device-settings-component></app-device-settings-component>
</p-tabpanel>
}
<p-tabpanel [value]="SettingsTab.DeviceSettings">
<app-device-settings-component></app-device-settings-component>
</p-tabpanel>
</p-tabpanels>
</p-tabs>
</div>

View File

@@ -96,6 +96,11 @@
<label>KOReader Sync</label>
</div>
<div class="flex items-center gap-2">
<p-checkbox formControlName="permissionSyncKobo" [binary]="true"></p-checkbox>
<label>Kobo Sync</label>
</div>
<div class="flex items-center gap-2">
<p-checkbox formControlName="permissionAdmin" [binary]="true"></p-checkbox>
<label class="text-orange-500 font-bold">Admin</label>

View File

@@ -51,6 +51,7 @@ export class CreateUserDialogComponent implements OnInit {
permissionEmailBook: [false],
permissionDeleteBook: [false],
permissionSyncKoreader: [false],
permissionSyncKobo: [false],
permissionAdmin: [false],
});
}

View File

@@ -23,6 +23,7 @@
<th style="width: 80px;">Email Books</th>
<th style="width: 80px;">Delete Books</th>
<th style="width: 80px;">KOReader Sync</th>
<th style="width: 80px;">Kobo Sync</th>
<th style="width: 120px;">Edit</th>
<th style="width: 80px;">Change Password</th>
<th style="width: 80px;">Delete</th>
@@ -31,11 +32,13 @@
<ng-template pTemplate="body" let-user>
<tr>
<td>
<td class="min-w-16">
{{ (user.provisioningMethod || 'LOCAL') | lowercase | titlecase }}
</td>
<td>{{ user.username }}</td>
<td>
<td class="min-w-16 max-w-32">
{{ user.username }}
</td>
<td class="min-w-36 max-w-72">
@if (user.isEditing) {
<input type="text" [(ngModel)]="user.name" class="p-inputtext w-full"/>
}
@@ -133,6 +136,14 @@
}
</td>
<td class="text-center">
@if (user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canSyncKobo"></p-checkbox>
}
@if (!user.isEditing) {
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canSyncKobo" disabled></p-checkbox>
}
</td>
<td class="flex text-center gap-2">
<ng-container>
@if (!user.isEditing) {
<p-button icon="pi pi-pencil" outlined="true" severity="info" (onClick)="toggleEdit(user)"></p-button>

View File

@@ -106,6 +106,7 @@ export interface User {
canEditMetadata: boolean;
canManipulateLibrary: boolean;
canSyncKoReader: boolean;
canSyncKobo: boolean;
};
userSettings: UserSettings;
provisioningMethod?: 'LOCAL' | 'OIDC' | 'REMOTE';

View File

@@ -76,7 +76,7 @@
outline: 0 none;
color: var(--text-color);
cursor: pointer;
padding: 0.6rem 0.4rem 0.6rem 0.75rem;
padding: 0.45rem 0.3rem 0.45rem 0.6rem;
border-radius: variables.$borderRadius;
transition: background-color variables.$transitionDuration, box-shadow variables.$transitionDuration;

View File

@@ -9,7 +9,7 @@ http {
client_max_body_size 1000M;
server {
listen 6060; # Listen on port 6060 for both API and UI
listen ${BOOKLORE_PORT};
# Set the root directory for the server (Angular app)
root /usr/share/nginx/html;
@@ -33,11 +33,9 @@ http {
# Proxy API requests that start with /api/ to the backend
location /api/ {
proxy_pass http://localhost:8080; # Backend API running on port 8080
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://localhost:8080;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Host $host;
}
# Proxy WebSocket requests (ws://) to the backend

18
start.sh Normal file
View File

@@ -0,0 +1,18 @@
#!/bin/sh
# Set default and export so envsubst sees it
: "${BOOKLORE_PORT:=6060}"
export BOOKLORE_PORT
# Use envsubst safely
TMP_CONF="/tmp/nginx.conf.tmp"
envsubst '${BOOKLORE_PORT}' < /etc/nginx/nginx.conf > "$TMP_CONF"
# Move to final location
mv "$TMP_CONF" /etc/nginx/nginx.conf
# Start nginx in background
nginx -g 'daemon off;' &
# Start Spring Boot in foreground
exec java -jar /app/app.jar