mirror of
https://github.com/booklore-app/booklore.git
synced 2025-12-23 22:28:11 -05:00
Kobo Phase 1: Enable Book Transfer/Sync
This commit is contained in:
committed by
Aditya Chandel
parent
a092d629c9
commit
6e6861b329
@@ -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"]
|
||||
13
README.md
13
README.md
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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/"
|
||||
);
|
||||
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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("^\"|\"$", "");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.adityachandel.booklore.model.dto.kobo;
|
||||
|
||||
public interface Entitlement {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -21,5 +21,6 @@ public class UserUpdateRequest {
|
||||
private boolean canEmailBook;
|
||||
private boolean canDeleteBook;
|
||||
private boolean canSyncKoReader;
|
||||
private boolean canSyncKobo;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -8,5 +8,6 @@ public enum PermissionType {
|
||||
EMAIL_BOOK,
|
||||
DELETE_BOOK,
|
||||
SYNC_KOREADER,
|
||||
SYNC_KOBO,
|
||||
ADMIN
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -10,6 +10,4 @@ import java.util.Set;
|
||||
|
||||
@Repository
|
||||
public interface BookShelfMappingRepository extends JpaRepository<BookShelfMapping, BookShelfKey> {
|
||||
|
||||
List<BookShelfMapping> findAllByBookIdIn(Set<Long> bookId);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
"""
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -51,6 +51,7 @@ export class CreateUserDialogComponent implements OnInit {
|
||||
permissionEmailBook: [false],
|
||||
permissionDeleteBook: [false],
|
||||
permissionSyncKoreader: [false],
|
||||
permissionSyncKobo: [false],
|
||||
permissionAdmin: [false],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -106,6 +106,7 @@ export interface User {
|
||||
canEditMetadata: boolean;
|
||||
canManipulateLibrary: boolean;
|
||||
canSyncKoReader: boolean;
|
||||
canSyncKobo: boolean;
|
||||
};
|
||||
userSettings: UserSettings;
|
||||
provisioningMethod?: 'LOCAL' | 'OIDC' | 'REMOTE';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
10
nginx.conf
10
nginx.conf
@@ -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
18
start.sh
Normal 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
|
||||
Reference in New Issue
Block a user