mirror of
https://github.com/booklore-app/booklore.git
synced 2025-12-23 22:28:11 -05:00
Add koreader sync support for book progress tracking
This commit is contained in:
6
.github/workflows/docker-build-publish.yml
vendored
6
.github/workflows/docker-build-publish.yml
vendored
@@ -160,7 +160,7 @@ jobs:
|
||||
gh release edit ${{ env.new_tag }} --draft=false
|
||||
|
||||
- name: Notify Discord of New Release
|
||||
if: github.ref == 'refs/heads/master' && secrets.DISCORD_WEBHOOK_URL != ''
|
||||
if: github.ref == 'refs/heads/master'
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -168,6 +168,10 @@ jobs:
|
||||
NEW_TAG: ${{ env.new_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${DISCORD_WEBHOOK_URL:-}" ]; then
|
||||
echo "DISCORD_WEBHOOK_URL not set, skipping Discord notification."
|
||||
exit 0
|
||||
fi
|
||||
echo "Preparing Discord notification for release $NEW_TAG"
|
||||
|
||||
# fetch release info
|
||||
|
||||
@@ -57,7 +57,7 @@ public class AuthenticationService {
|
||||
|
||||
public ResponseEntity<Map<String, String>> loginRemote(String name, String username, String email, String groups) {
|
||||
if (username == null || username.isEmpty()) {
|
||||
throw ApiError.BAD_REQUEST.createException("Remote-User header is missing");
|
||||
throw ApiError.GENERIC_BAD_REQUEST.createException("Remote-User header is missing");
|
||||
}
|
||||
|
||||
Optional<BookLoreUserEntity> user = userRepository.findByUsername(username);
|
||||
|
||||
@@ -97,7 +97,7 @@ public class DualJwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
Date expirationTime = claimsSet.getExpirationTime();
|
||||
if (expirationTime == null || expirationTime.before(new Date())) {
|
||||
log.warn("OIDC token is expired or missing exp claim");
|
||||
throw ApiError.UNAUTHORIZED.createException("Token has expired or is invalid.");
|
||||
throw ApiError.GENERIC_UNAUTHORIZED.createException("Token has expired or is invalid.");
|
||||
}
|
||||
|
||||
OidcProviderDetails.ClaimMapping claimMapping = providerDetails.getClaimMapping();
|
||||
@@ -112,7 +112,7 @@ public class DualJwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
.orElseGet(() -> {
|
||||
if (!autoProvision) {
|
||||
log.warn("User '{}' not found and auto-provisioning is disabled.", username);
|
||||
throw ApiError.UNAUTHORIZED.createException("User not found and auto-provisioning is disabled.");
|
||||
throw ApiError.GENERIC_UNAUTHORIZED.createException("User not found and auto-provisioning is disabled.");
|
||||
}
|
||||
Object lock = userLocks.computeIfAbsent(username, k -> new Object());
|
||||
try {
|
||||
@@ -137,7 +137,7 @@ public class DualJwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("OIDC authentication failed", e);
|
||||
throw ApiError.UNAUTHORIZED.createException("OIDC JWT validation failed");
|
||||
throw ApiError.GENERIC_UNAUTHORIZED.createException("OIDC JWT validation failed");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.adityachandel.booklore.config.security;
|
||||
|
||||
import com.adityachandel.booklore.repository.KoreaderUserRepository;
|
||||
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.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Component
|
||||
@Slf4j
|
||||
public class KoreaderAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
private final KoreaderUserRepository koreaderUserRepository;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
|
||||
|
||||
String path = request.getRequestURI();
|
||||
if (!path.startsWith("/api/koreader/")) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
String username = request.getHeader("x-auth-user");
|
||||
String key = request.getHeader("x-auth-key");
|
||||
|
||||
if (username != null && key != null) {
|
||||
koreaderUserRepository.findByUsername(username).ifPresentOrElse(user -> {
|
||||
if (user.getPasswordMD5().equalsIgnoreCase(key)) {
|
||||
Long bookLoreUserId = null;
|
||||
if (user.getBookLoreUser() != null) {
|
||||
bookLoreUserId = user.getBookLoreUser().getId();
|
||||
}
|
||||
|
||||
UserDetails userDetails = new KoreaderUserDetails(
|
||||
user.getUsername(),
|
||||
user.getPasswordMD5(),
|
||||
user.isSyncEnabled(),
|
||||
bookLoreUserId,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_USER"))
|
||||
);
|
||||
|
||||
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
|
||||
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
} else {
|
||||
log.warn("KOReader auth failed: password mismatch for user '{}'", username);
|
||||
}
|
||||
}, () -> log.warn("KOReader user '{}' not found", username));
|
||||
} else {
|
||||
log.warn("Missing KOReader headers");
|
||||
}
|
||||
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.adityachandel.booklore.config.security;
|
||||
|
||||
import lombok.Getter;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
public class KoreaderUserDetails implements UserDetails {
|
||||
|
||||
private final String username;
|
||||
private final String password;
|
||||
@Getter
|
||||
private final boolean syncEnabled;
|
||||
@Getter
|
||||
private final Long bookLoreUserId;
|
||||
private final Collection<? extends GrantedAuthority> authorities;
|
||||
|
||||
public KoreaderUserDetails(String username, String password, boolean syncEnabled, Long bookLoreUserId, Collection<? extends GrantedAuthority> authorities) {
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.syncEnabled = syncEnabled;
|
||||
this.bookLoreUserId = bookLoreUserId;
|
||||
this.authorities = authorities;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return authorities;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ public class SecurityConfig {
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(unauthenticatedEndpoints.toArray(new String[0])).permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
)
|
||||
.httpBasic(basic -> basic
|
||||
.realmName("Booklore OPDS")
|
||||
.authenticationEntryPoint((request, response, authException) -> {
|
||||
@@ -92,6 +92,18 @@ public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
@Order(2)
|
||||
public SecurityFilterChain koreaderSecurityChain(HttpSecurity http, KoreaderAuthFilter koreaderAuthFilter) throws Exception {
|
||||
http
|
||||
.securityMatcher("/api/koreader/**")
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
|
||||
.addFilterBefore(koreaderAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(3)
|
||||
public SecurityFilterChain jwtApiSecurityChain(HttpSecurity http) throws Exception {
|
||||
List<String> publicEndpoints = new ArrayList<>(Arrays.asList(COMMON_PUBLIC_ENDPOINTS));
|
||||
if (appProperties.getSwagger().isEnabled()) {
|
||||
|
||||
@@ -45,6 +45,11 @@ public class SecurityUtil {
|
||||
return user != null && user.getPermissions().isCanManipulateLibrary();
|
||||
}
|
||||
|
||||
public boolean canSyncKoReader() {
|
||||
var user = getCurrentUser();
|
||||
return user != null && user.getPermissions().isCanSyncKoReader();
|
||||
}
|
||||
|
||||
public boolean canEditMetadata() {
|
||||
var user = getCurrentUser();
|
||||
return user != null && user.getPermissions().isCanEditMetadata();
|
||||
@@ -54,12 +59,12 @@ public class SecurityUtil {
|
||||
var user = getCurrentUser();
|
||||
return user != null && user.getPermissions().isCanEmailBook();
|
||||
}
|
||||
|
||||
public boolean canDeleteBook() {
|
||||
var user = getCurrentUser();
|
||||
return user != null && user.getPermissions().isCanDeleteBook();
|
||||
}
|
||||
|
||||
|
||||
public boolean canViewUserProfile(Long userId) {
|
||||
var user = getCurrentUser();
|
||||
return user != null && (user.getPermissions().isAdmin() || user.getId().equals(userId));
|
||||
|
||||
@@ -36,7 +36,7 @@ public class BookAccessAspect {
|
||||
|
||||
Long bookId = extractBookId(joinPoint.getArgs(), methodSignature.getParameterNames(), annotation.bookIdParam());
|
||||
if (bookId == null) {
|
||||
throw ApiError.BAD_REQUEST.createException("Missing or invalid book ID in method parameters.");
|
||||
throw ApiError.GENERIC_BAD_REQUEST.createException("Missing or invalid book ID in method parameters.");
|
||||
}
|
||||
|
||||
BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
|
||||
@@ -32,7 +32,7 @@ public class LibraryAccessAspect {
|
||||
Long libraryId = extractLibraryId(methodSignature.getParameterNames(), joinPoint.getArgs(), annotation.libraryIdParam());
|
||||
|
||||
if (libraryId == null) {
|
||||
throw ApiError.BAD_REQUEST.createException("Library ID not found in method parameters.");
|
||||
throw ApiError.GENERIC_BAD_REQUEST.createException("Library ID not found in method parameters.");
|
||||
}
|
||||
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.adityachandel.booklore.model.dto.request.ReadProgressRequest;
|
||||
import com.adityachandel.booklore.model.dto.request.ReadStatusUpdateRequest;
|
||||
import com.adityachandel.booklore.model.dto.request.ShelvesAssignmentRequest;
|
||||
import com.adityachandel.booklore.model.dto.response.BookDeletionResponse;
|
||||
import com.adityachandel.booklore.model.enums.ResetProgressType;
|
||||
import com.adityachandel.booklore.service.BookService;
|
||||
import com.adityachandel.booklore.service.metadata.BookMetadataService;
|
||||
import com.adityachandel.booklore.service.recommender.BookRecommendationService;
|
||||
@@ -126,11 +127,11 @@ public class BookController {
|
||||
}
|
||||
|
||||
@PostMapping("/reset-progress")
|
||||
public ResponseEntity<List<Book>> resetProgress(@RequestBody List<Long> bookIds) {
|
||||
public ResponseEntity<List<Book>> resetProgress(@RequestBody List<Long> bookIds, @RequestParam ResetProgressType type) {
|
||||
if (bookIds == null || bookIds.isEmpty()) {
|
||||
throw ApiError.BAD_REQUEST.createException("No book IDs provided");
|
||||
throw ApiError.GENERIC_BAD_REQUEST.createException("No book IDs provided");
|
||||
}
|
||||
List<Book> updatedBooks = bookService.resetProgress(bookIds);
|
||||
List<Book> updatedBooks = bookService.resetProgress(bookIds, type);
|
||||
return ResponseEntity.ok(updatedBooks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.adityachandel.booklore.controller;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.progress.KoreaderProgress;
|
||||
import com.adityachandel.booklore.service.KoreaderService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
@RequestMapping("/api/koreader")
|
||||
public class KoreaderController {
|
||||
|
||||
private final KoreaderService koreaderService;
|
||||
|
||||
@GetMapping("/users/auth")
|
||||
public ResponseEntity<Map<String, String>> authorizeUser() {
|
||||
return koreaderService.authorizeUser();
|
||||
}
|
||||
|
||||
@PostMapping("/users/create")
|
||||
public ResponseEntity<?> createUser(@RequestBody Map<String, Object> userData) {
|
||||
log.warn("Attempt to register user via Koreader blocked: {}", userData);
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of("error", "User registration via Koreader is disabled"));
|
||||
}
|
||||
|
||||
@GetMapping("/syncs/progress/{bookHash}")
|
||||
public ResponseEntity<KoreaderProgress> getProgress(@PathVariable String bookHash) {
|
||||
KoreaderProgress progress = koreaderService.getProgress(bookHash);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(progress);
|
||||
}
|
||||
|
||||
@PutMapping("/syncs/progress")
|
||||
public ResponseEntity<?> updateProgress(@Valid @RequestBody KoreaderProgress koreaderProgress) {
|
||||
koreaderService.saveProgress(koreaderProgress.getDocument(), koreaderProgress);
|
||||
return ResponseEntity.ok(Map.of("status", "progress updated"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.adityachandel.booklore.controller;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.KoreaderUser;
|
||||
import com.adityachandel.booklore.service.KoreaderUserService;
|
||||
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.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/koreader-users")
|
||||
@RequiredArgsConstructor
|
||||
public class KoreaderUserController {
|
||||
|
||||
private final KoreaderUserService koreaderUserService;
|
||||
|
||||
@GetMapping("/me")
|
||||
@PreAuthorize("@securityUtil.canSyncKoReader() or @securityUtil.isAdmin()")
|
||||
public ResponseEntity<KoreaderUser> getCurrentUser() {
|
||||
return ResponseEntity.ok(koreaderUserService.getUser());
|
||||
}
|
||||
|
||||
@PutMapping("/me")
|
||||
@PreAuthorize("@securityUtil.canSyncKoReader() or @securityUtil.isAdmin()")
|
||||
public ResponseEntity<KoreaderUser> upsertCurrentUser(@RequestBody Map<String, String> userData) {
|
||||
KoreaderUser user = koreaderUserService.upsertUser(userData.get("username"), userData.get("password"));
|
||||
return ResponseEntity.ok(user);
|
||||
}
|
||||
|
||||
@PatchMapping("/me/sync")
|
||||
@PreAuthorize("@securityUtil.canSyncKoReader() or @securityUtil.isAdmin()")
|
||||
public ResponseEntity<Void> updateSyncEnabled(@RequestParam boolean enabled) {
|
||||
koreaderUserService.toggleSync(enabled);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import org.springframework.http.HttpStatus;
|
||||
|
||||
@Getter
|
||||
public enum ApiError {
|
||||
GENERIC_NOT_FOUND(HttpStatus.NOT_FOUND, "%s"),
|
||||
GENERIC_BAD_REQUEST(HttpStatus.BAD_REQUEST, "%s"),
|
||||
GENERIC_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "%s"),
|
||||
|
||||
BOOK_NOT_FOUND(HttpStatus.NOT_FOUND, "Book not found with ID: %d"),
|
||||
EMAIL_PROVIDER_NOT_FOUND(HttpStatus.NOT_FOUND, "Email provider with ID %s not found"),
|
||||
DEFAULT_EMAIL_PROVIDER_NOT_FOUND(HttpStatus.NOT_FOUND, "Default email provider not found"),
|
||||
@@ -34,9 +38,7 @@ public enum ApiError {
|
||||
USERNAME_ALREADY_TAKEN(HttpStatus.BAD_REQUEST, "Username already taken: %s"),
|
||||
USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "User not found: %s"),
|
||||
CANNOT_DELETE_ADMIN(HttpStatus.FORBIDDEN, "Admin user cannot be deleted"),
|
||||
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "%s"),
|
||||
FORBIDDEN(HttpStatus.FORBIDDEN, "%s"),
|
||||
BAD_REQUEST(HttpStatus.BAD_REQUEST, "%s"),
|
||||
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "%s"),
|
||||
PASSWORD_INCORRECT(HttpStatus.BAD_REQUEST, "Incorrect current password"),
|
||||
PASSWORD_TOO_SHORT(HttpStatus.BAD_REQUEST, "Password must be at least 6 characters long"),
|
||||
@@ -47,6 +49,7 @@ public enum ApiError {
|
||||
INVALID_INPUT(HttpStatus.BAD_REQUEST, "%s"),
|
||||
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");
|
||||
|
||||
private final HttpStatus status;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.adityachandel.booklore.mapper;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.KoreaderUser;
|
||||
import com.adityachandel.booklore.model.entity.KoreaderUserEntity;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.factory.Mappers;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper(componentModel = "spring")
|
||||
public interface KoreaderUserMapper {
|
||||
|
||||
KoreaderUserMapper INSTANCE = Mappers.getMapper(KoreaderUserMapper.class);
|
||||
|
||||
KoreaderUser toDto(KoreaderUserEntity entity);
|
||||
|
||||
KoreaderUserEntity toEntity(KoreaderUser dto);
|
||||
|
||||
List<KoreaderUser> toDtoList(List<KoreaderUserEntity> entities);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ public class BookLoreUserTransformer {
|
||||
permissions.setCanEmailBook(userEntity.getPermissions().isPermissionEmailBook());
|
||||
permissions.setCanDeleteBook(userEntity.getPermissions().isPermissionDeleteBook());
|
||||
permissions.setCanManipulateLibrary(userEntity.getPermissions().isPermissionManipulateLibrary());
|
||||
permissions.setCanSyncKoReader(userEntity.getPermissions().isPermissionSyncKoreader());
|
||||
|
||||
BookLoreUser bookLoreUser = new BookLoreUser();
|
||||
bookLoreUser.setId(userEntity.getId());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.adityachandel.booklore.model.dto;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.progress.*;
|
||||
import com.adityachandel.booklore.model.enums.BookFileType;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Builder;
|
||||
@@ -27,6 +28,7 @@ public class Book {
|
||||
private PdfProgress pdfProgress;
|
||||
private EpubProgress epubProgress;
|
||||
private CbxProgress cbxProgress;
|
||||
private KoProgress koreaderProgress;
|
||||
private Set<Shelf> shelves;
|
||||
private String readStatus;
|
||||
private Instant dateFinished;
|
||||
|
||||
@@ -28,6 +28,7 @@ public class BookLoreUser {
|
||||
private boolean canDownload;
|
||||
private boolean canEditMetadata;
|
||||
private boolean canManipulateLibrary;
|
||||
private boolean canSyncKoReader;
|
||||
private boolean canEmailBook;
|
||||
private boolean canDeleteBook;
|
||||
}
|
||||
@@ -45,6 +46,7 @@ public class BookLoreUser {
|
||||
public List<TableColumnPreference> tableColumnPreference;
|
||||
public String filterSortingMode;
|
||||
public String metadataCenterViewMode;
|
||||
public boolean koReaderEnabled;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.adityachandel.booklore.model.dto;
|
||||
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public class KoreaderUser {
|
||||
private Long id;
|
||||
private String username;
|
||||
private String password;
|
||||
private String passwordMD5;
|
||||
private boolean syncEnabled;
|
||||
}
|
||||
@@ -14,8 +14,10 @@ public class UserCreateRequest {
|
||||
private boolean permissionUpload;
|
||||
private boolean permissionDownload;
|
||||
private boolean permissionEditMetadata;
|
||||
private boolean permissionManipulateLibrary;
|
||||
private boolean permissionEmailBook;
|
||||
private boolean permissionDeleteBook;
|
||||
private boolean permissionSyncKoreader;
|
||||
private boolean permissionAdmin;
|
||||
|
||||
private Set<Long> selectedLibraries;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.adityachandel.booklore.model.dto;
|
||||
package com.adityachandel.booklore.model.dto.progress;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Builder;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.adityachandel.booklore.model.dto;
|
||||
package com.adityachandel.booklore.model.dto.progress;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Builder;
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.adityachandel.booklore.model.dto.progress;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Builder
|
||||
@Data
|
||||
public class KoProgress {
|
||||
private Float percentage;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.adityachandel.booklore.model.dto.progress;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.ToString;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@ToString
|
||||
public class KoreaderProgress {
|
||||
private String document;
|
||||
private Float percentage;
|
||||
private String progress;
|
||||
private String device;
|
||||
private String device_id;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.adityachandel.booklore.model.dto;
|
||||
package com.adityachandel.booklore.model.dto.progress;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Builder;
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.adityachandel.booklore.model.dto.request;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.CbxProgress;
|
||||
import com.adityachandel.booklore.model.dto.EpubProgress;
|
||||
import com.adityachandel.booklore.model.dto.PdfProgress;
|
||||
import com.adityachandel.booklore.model.dto.progress.CbxProgress;
|
||||
import com.adityachandel.booklore.model.dto.progress.EpubProgress;
|
||||
import com.adityachandel.booklore.model.dto.progress.PdfProgress;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -20,5 +20,6 @@ public class UserUpdateRequest {
|
||||
private boolean canManipulateLibrary;
|
||||
private boolean canEmailBook;
|
||||
private boolean canDeleteBook;
|
||||
private boolean canSyncKoReader;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ public enum UserSettingKey {
|
||||
SIDEBAR_SHELF_SORTING("sidebarShelfSorting", true),
|
||||
ENTITY_VIEW_PREFERENCES("entityViewPreferences", true),
|
||||
TABLE_COLUMN_PREFERENCE("tableColumnPreference", true),
|
||||
|
||||
FILTER_SORTING_MODE("filterSortingMode", false),
|
||||
METADATA_CENTER_VIEW_MODE("metadataCenterViewMode", false);
|
||||
|
||||
|
||||
private final String dbKey;
|
||||
private final boolean isJson;
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.adityachandel.booklore.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "koreader_user")
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class KoreaderUserEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 100)
|
||||
private String username;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String password;
|
||||
|
||||
@Column(name = "password_md5", nullable = false)
|
||||
private String passwordMD5;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private Instant updatedAt;
|
||||
|
||||
@Column(name = "sync_enabled", nullable = false)
|
||||
private boolean syncEnabled = false;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "booklore_user_id")
|
||||
private BookLoreUserEntity bookLoreUser;
|
||||
|
||||
@PreUpdate
|
||||
public void preUpdate() {
|
||||
updatedAt = Instant.now();
|
||||
}
|
||||
}
|
||||
@@ -48,10 +48,25 @@ public class UserBookProgressEntity {
|
||||
@Column(name = "cbx_progress_percent")
|
||||
private Float cbxProgressPercent;
|
||||
|
||||
@Column(name = "koreader_progress", length = 1000)
|
||||
private String koreaderProgress;
|
||||
|
||||
@Column(name = "koreader_progress_percent")
|
||||
private Float koreaderProgressPercent;
|
||||
|
||||
@Column(name = "koreader_device", length = 100)
|
||||
private String koreaderDevice;
|
||||
|
||||
@Column(name = "koreader_device_id", length = 100)
|
||||
private String koreaderDeviceId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "read_status")
|
||||
private ReadStatus readStatus;
|
||||
|
||||
@Column(name = "date_finished")
|
||||
private Instant dateFinished;
|
||||
}
|
||||
|
||||
@Column(name = "koreader_last_sync_time")
|
||||
private Instant koreaderLastSyncTime;
|
||||
}
|
||||
@@ -38,6 +38,9 @@ public class UserPermissionsEntity {
|
||||
@Column(name = "permission_delete_book", nullable = false)
|
||||
private boolean permissionDeleteBook = false;
|
||||
|
||||
@Column(name = "permission_sync_koreader", nullable = false)
|
||||
private boolean permissionSyncKoreader = false;
|
||||
|
||||
@Column(name = "permission_admin", nullable = false)
|
||||
private boolean permissionAdmin;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.adityachandel.booklore.model.enums;
|
||||
|
||||
public enum ResetProgressType {
|
||||
BOOKLORE, KOREADER
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.adityachandel.booklore.repository;
|
||||
|
||||
|
||||
import com.adityachandel.booklore.model.entity.KoreaderUserEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface KoreaderUserRepository extends JpaRepository<KoreaderUserEntity, Long> {
|
||||
Optional<KoreaderUserEntity> findByUsername(String username);
|
||||
|
||||
Optional<KoreaderUserEntity> findByBookLoreUserId(Long bookLoreUserId);
|
||||
}
|
||||
@@ -4,11 +4,16 @@ import com.adityachandel.booklore.config.security.AuthenticationService;
|
||||
import com.adityachandel.booklore.exception.ApiError;
|
||||
import com.adityachandel.booklore.mapper.BookMapper;
|
||||
import com.adityachandel.booklore.model.dto.*;
|
||||
import com.adityachandel.booklore.model.dto.progress.CbxProgress;
|
||||
import com.adityachandel.booklore.model.dto.progress.EpubProgress;
|
||||
import com.adityachandel.booklore.model.dto.progress.KoProgress;
|
||||
import com.adityachandel.booklore.model.dto.progress.PdfProgress;
|
||||
import com.adityachandel.booklore.model.dto.request.ReadProgressRequest;
|
||||
import com.adityachandel.booklore.model.dto.response.BookDeletionResponse;
|
||||
import com.adityachandel.booklore.model.entity.*;
|
||||
import com.adityachandel.booklore.model.enums.BookFileType;
|
||||
import com.adityachandel.booklore.model.enums.ReadStatus;
|
||||
import com.adityachandel.booklore.model.enums.ResetProgressType;
|
||||
import com.adityachandel.booklore.repository.*;
|
||||
import com.adityachandel.booklore.util.FileService;
|
||||
import com.adityachandel.booklore.util.FileUtils;
|
||||
@@ -58,10 +63,15 @@ public class BookService {
|
||||
|
||||
private void setBookProgress(Book book, UserBookProgressEntity progress) {
|
||||
switch (book.getBookType()) {
|
||||
case EPUB -> book.setEpubProgress(EpubProgress.builder()
|
||||
.cfi(progress.getEpubProgress())
|
||||
.percentage(progress.getEpubProgressPercent())
|
||||
.build());
|
||||
case EPUB -> {
|
||||
book.setEpubProgress(EpubProgress.builder()
|
||||
.cfi(progress.getEpubProgress())
|
||||
.percentage(progress.getEpubProgressPercent())
|
||||
.build());
|
||||
book.setKoreaderProgress(KoProgress.builder()
|
||||
.percentage(progress.getKoreaderProgressPercent() != null ? progress.getKoreaderProgressPercent() * 100 : null)
|
||||
.build());
|
||||
}
|
||||
case PDF -> book.setPdfProgress(PdfProgress.builder()
|
||||
.page(progress.getPdfProgress())
|
||||
.percentage(progress.getPdfProgressPercent())
|
||||
@@ -143,6 +153,12 @@ public class BookService {
|
||||
.cfi(userProgress.getEpubProgress())
|
||||
.percentage(userProgress.getEpubProgressPercent())
|
||||
.build());
|
||||
if (userProgress.getKoreaderProgressPercent() != null) {
|
||||
if (book.getKoreaderProgress() == null) {
|
||||
book.setKoreaderProgress(KoProgress.builder().build());
|
||||
}
|
||||
book.getKoreaderProgress().setPercentage(userProgress.getKoreaderProgressPercent() * 100);
|
||||
}
|
||||
}
|
||||
if (bookEntity.getBookType() == BookFileType.CBX) {
|
||||
book.setCbxProgress(CbxProgress.builder()
|
||||
@@ -343,7 +359,7 @@ public class BookService {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public List<Book> resetProgress(List<Long> bookIds) {
|
||||
public List<Book> resetProgress(List<Long> bookIds, ResetProgressType type) {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
List<Book> updatedBooks = new ArrayList<>();
|
||||
Optional<BookLoreUserEntity> userEntity = userRepository.findById(user.getId());
|
||||
@@ -359,14 +375,21 @@ public class BookService {
|
||||
progress.setUser(userEntity.orElseThrow());
|
||||
progress.setReadStatus(null);
|
||||
progress.setLastReadTime(null);
|
||||
progress.setPdfProgress(null);
|
||||
progress.setPdfProgressPercent(null);
|
||||
progress.setEpubProgress(null);
|
||||
progress.setEpubProgressPercent(null);
|
||||
progress.setCbxProgress(null);
|
||||
progress.setCbxProgressPercent(null);
|
||||
progress.setDateFinished(null);
|
||||
|
||||
if (type == ResetProgressType.BOOKLORE) {
|
||||
progress.setPdfProgress(null);
|
||||
progress.setPdfProgressPercent(null);
|
||||
progress.setEpubProgress(null);
|
||||
progress.setEpubProgressPercent(null);
|
||||
progress.setCbxProgress(null);
|
||||
progress.setCbxProgressPercent(null);
|
||||
} else if (type == ResetProgressType.KOREADER) {
|
||||
progress.setKoreaderProgress(null);
|
||||
progress.setKoreaderProgressPercent(null);
|
||||
progress.setKoreaderDeviceId(null);
|
||||
progress.setKoreaderDevice(null);
|
||||
progress.setKoreaderLastSyncTime(null);
|
||||
}
|
||||
userBookProgressRepository.save(progress);
|
||||
updatedBooks.add(bookMapper.toBook(bookEntity));
|
||||
}
|
||||
@@ -382,10 +405,10 @@ public class BookService {
|
||||
Set<Long> userShelfIds = userEntity.getShelves().stream().map(ShelfEntity::getId).collect(Collectors.toSet());
|
||||
|
||||
if (!userShelfIds.containsAll(shelfIdsToAssign)) {
|
||||
throw ApiError.UNAUTHORIZED.createException("Cannot assign shelves that do not belong to the user.");
|
||||
throw ApiError.GENERIC_UNAUTHORIZED.createException("Cannot assign shelves that do not belong to the user.");
|
||||
}
|
||||
if (!userShelfIds.containsAll(shelfIdsToUnassign)) {
|
||||
throw ApiError.UNAUTHORIZED.createException("Cannot unassign shelves that do not belong to the user.");
|
||||
throw ApiError.GENERIC_UNAUTHORIZED.createException("Cannot unassign shelves that do not belong to the user.");
|
||||
}
|
||||
|
||||
List<BookEntity> bookEntities = bookQueryService.findAllWithMetadataByIds(bookIds);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.adityachandel.booklore.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
public class FileFingerprint {
|
||||
|
||||
public static String generateHash(Path filePath) {
|
||||
final long base = 1024L;
|
||||
final int blockSize = 1024;
|
||||
|
||||
try (RandomAccessFile raf = new RandomAccessFile(filePath.toFile(), "r")) {
|
||||
MessageDigest md5 = MessageDigest.getInstance("MD5");
|
||||
byte[] buffer = new byte[blockSize];
|
||||
|
||||
for (int i = -1; i <= 10; i++) {
|
||||
long position = base << (2 * i);
|
||||
if (position >= raf.length()) break;
|
||||
|
||||
raf.seek(position);
|
||||
int read = raf.read(buffer);
|
||||
if (read > 0) {
|
||||
md5.update(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
|
||||
byte[] hash = md5.digest();
|
||||
StringBuilder result = new StringBuilder(hash.length * 2);
|
||||
for (byte b : hash) {
|
||||
result.append(String.format("%02x", b));
|
||||
}
|
||||
return result.toString();
|
||||
|
||||
} catch (IOException | NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException("Failed to compute partial MD5 hash for: " + filePath, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.adityachandel.booklore.service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import com.adityachandel.booklore.config.security.KoreaderUserDetails;
|
||||
import com.adityachandel.booklore.exception.ApiError;
|
||||
import com.adityachandel.booklore.model.dto.progress.KoreaderProgress;
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
|
||||
import com.adityachandel.booklore.model.entity.KoreaderUserEntity;
|
||||
import com.adityachandel.booklore.model.entity.UserBookProgressEntity;
|
||||
import com.adityachandel.booklore.model.enums.ReadStatus;
|
||||
import com.adityachandel.booklore.repository.BookRepository;
|
||||
import com.adityachandel.booklore.repository.KoreaderUserRepository;
|
||||
import com.adityachandel.booklore.repository.UserBookProgressRepository;
|
||||
import com.adityachandel.booklore.repository.UserRepository;
|
||||
|
||||
@Slf4j
|
||||
@AllArgsConstructor
|
||||
@Service
|
||||
public class KoreaderService {
|
||||
|
||||
private final UserBookProgressRepository progressRepository;
|
||||
private final BookRepository bookRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final KoreaderUserRepository koreaderUserRepository;
|
||||
|
||||
private KoreaderUserDetails getAuthDetails() {
|
||||
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||
if (!(principal instanceof KoreaderUserDetails details)) {
|
||||
log.warn("Authentication failed: invalid principal type");
|
||||
throw ApiError.GENERIC_UNAUTHORIZED.createException("User not authenticated");
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
public ResponseEntity<Map<String, String>> authorizeUser() {
|
||||
KoreaderUserDetails details = getAuthDetails();
|
||||
Optional<KoreaderUserEntity> userOpt = koreaderUserRepository.findByUsername(details.getUsername());
|
||||
if (userOpt.isEmpty()) {
|
||||
log.warn("KOReader user '{}' not found", details.getUsername());
|
||||
throw ApiError.GENERIC_NOT_FOUND.createException("KOReader user not found");
|
||||
}
|
||||
KoreaderUserEntity user = userOpt.get();
|
||||
if (user.getPasswordMD5() == null || !user.getPasswordMD5().equalsIgnoreCase(details.getPassword())) {
|
||||
log.warn("Password mismatch for user '{}'", details.getUsername());
|
||||
throw ApiError.GENERIC_UNAUTHORIZED.createException("Invalid credentials");
|
||||
}
|
||||
log.info("User '{}' authorized", details.getUsername());
|
||||
return ResponseEntity.ok(Map.of("username", details.getUsername()));
|
||||
}
|
||||
|
||||
public KoreaderProgress getProgress(String bookHash) {
|
||||
KoreaderUserDetails details = getAuthDetails();
|
||||
ensureSyncEnabled(details);
|
||||
long userId = details.getBookLoreUserId();
|
||||
BookEntity book = bookRepository.findByCurrentHash(bookHash)
|
||||
.orElseThrow(() -> ApiError.GENERIC_NOT_FOUND.createException("Book not found for hash " + bookHash));
|
||||
UserBookProgressEntity progress = progressRepository.findByUserIdAndBookId(userId, book.getId())
|
||||
.orElseThrow(() -> ApiError.GENERIC_NOT_FOUND.createException("No progress found for user and book"));
|
||||
log.info("getProgress: fetched progress='{}' percentage={} for userId={} bookHash={}", progress.getKoreaderProgress(), progress.getKoreaderProgressPercent(), userId, bookHash);
|
||||
return KoreaderProgress.builder()
|
||||
.document(bookHash)
|
||||
.progress(progress.getKoreaderProgress())
|
||||
.percentage(progress.getKoreaderProgressPercent())
|
||||
.device("BookLore")
|
||||
.device_id("BookLore")
|
||||
.build();
|
||||
}
|
||||
|
||||
public void saveProgress(String bookHash, KoreaderProgress progressDto) {
|
||||
KoreaderUserDetails details = getAuthDetails();
|
||||
ensureSyncEnabled(details);
|
||||
long userId = details.getBookLoreUserId();
|
||||
BookEntity book = bookRepository.findByCurrentHash(bookHash)
|
||||
.orElseThrow(() -> ApiError.GENERIC_NOT_FOUND.createException("Book not found for hash " + bookHash));
|
||||
BookLoreUserEntity user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> ApiError.GENERIC_NOT_FOUND.createException("User not found with id " + userId));
|
||||
UserBookProgressEntity progress = progressRepository.findByUserIdAndBookId(userId, book.getId())
|
||||
.orElseGet(() -> {
|
||||
UserBookProgressEntity p = new UserBookProgressEntity();
|
||||
p.setUser(user);
|
||||
p.setBook(book);
|
||||
return p;
|
||||
});
|
||||
progress.setKoreaderProgress(progressDto.getProgress());
|
||||
progress.setKoreaderProgressPercent(progressDto.getPercentage());
|
||||
progress.setKoreaderDevice(progressDto.getDevice());
|
||||
progress.setKoreaderDeviceId(progressDto.getDevice_id());
|
||||
progress.setKoreaderLastSyncTime(Instant.now());
|
||||
if (progressDto.getPercentage() >= 0.5) progress.setReadStatus(ReadStatus.READING);
|
||||
progress.setLastReadTime(Instant.now());
|
||||
progressRepository.save(progress);
|
||||
log.info("saveProgress: saved progress='{}' percentage={} for userId={} bookHash={}", progressDto.getProgress(), progressDto.getPercentage(), userId, bookHash);
|
||||
}
|
||||
|
||||
private void ensureSyncEnabled(KoreaderUserDetails details) {
|
||||
if (!details.isSyncEnabled()) {
|
||||
log.warn("Sync is disabled for user '{}'", details.getUsername());
|
||||
throw ApiError.GENERIC_UNAUTHORIZED.createException("Sync is disabled for this user");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.adityachandel.booklore.service;
|
||||
|
||||
import com.adityachandel.booklore.config.security.AuthenticationService;
|
||||
import com.adityachandel.booklore.exception.ApiError;
|
||||
import com.adityachandel.booklore.mapper.KoreaderUserMapper;
|
||||
import com.adityachandel.booklore.model.dto.KoreaderUser;
|
||||
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
|
||||
import com.adityachandel.booklore.model.entity.KoreaderUserEntity;
|
||||
import com.adityachandel.booklore.repository.KoreaderUserRepository;
|
||||
import com.adityachandel.booklore.repository.UserRepository;
|
||||
import com.adityachandel.booklore.util.Md5Util;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class KoreaderUserService {
|
||||
|
||||
private final AuthenticationService authService;
|
||||
private final UserRepository userRepository;
|
||||
private final KoreaderUserRepository koreaderUserRepository;
|
||||
private final KoreaderUserMapper koreaderUserMapper;
|
||||
|
||||
@Transactional
|
||||
public KoreaderUser upsertUser(String username, String rawPassword) {
|
||||
Long ownerId = authService.getAuthenticatedUser().getId();
|
||||
BookLoreUserEntity owner = userRepository.findById(ownerId)
|
||||
.orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(ownerId));
|
||||
|
||||
String md5Password = Md5Util.md5Hex(rawPassword);
|
||||
Optional<KoreaderUserEntity> existing = koreaderUserRepository.findByBookLoreUserId(ownerId);
|
||||
boolean isUpdate = existing.isPresent();
|
||||
KoreaderUserEntity user = existing.orElseGet(() -> {
|
||||
KoreaderUserEntity u = new KoreaderUserEntity();
|
||||
u.setBookLoreUser(owner);
|
||||
return u;
|
||||
});
|
||||
|
||||
user.setUsername(username);
|
||||
user.setPassword(rawPassword);
|
||||
user.setPasswordMD5(md5Password);
|
||||
KoreaderUserEntity saved = koreaderUserRepository.save(user);
|
||||
|
||||
log.info("upsertUser: {} KoreaderUser [id={}, username='{}'] for BookLoreUser='{}'",
|
||||
isUpdate ? "Updated" : "Created",
|
||||
saved.getId(), saved.getUsername(),
|
||||
authService.getAuthenticatedUser().getUsername());
|
||||
|
||||
return koreaderUserMapper.toDto(saved);
|
||||
}
|
||||
|
||||
public KoreaderUser getUser() {
|
||||
Long id = authService.getAuthenticatedUser().getId();
|
||||
KoreaderUserEntity user = koreaderUserRepository.findByBookLoreUserId(id)
|
||||
.orElseThrow(() -> ApiError.GENERIC_NOT_FOUND.createException("Koreader user not found for BookLore user ID: " + id));
|
||||
return koreaderUserMapper.toDto(user);
|
||||
}
|
||||
|
||||
public void toggleSync(boolean enabled) {
|
||||
Long id = authService.getAuthenticatedUser().getId();
|
||||
KoreaderUserEntity user = koreaderUserRepository.findByBookLoreUserId(id)
|
||||
.orElseThrow(() -> ApiError.GENERIC_NOT_FOUND.createException("Koreader user not found for BookLore user ID: " + id));
|
||||
user.setSyncEnabled(enabled);
|
||||
koreaderUserRepository.save(user);
|
||||
}
|
||||
}
|
||||
@@ -205,7 +205,7 @@ public class BookDropService {
|
||||
} else {
|
||||
if (defaultLibraryId == null || defaultPathId == null) {
|
||||
log.warn("Missing default metadata for fileId={}", fileEntity.getId());
|
||||
throw ApiError.BAD_REQUEST.createException("Missing metadata and defaults for fileId=" + fileEntity.getId());
|
||||
throw ApiError.GENERIC_BAD_REQUEST.createException("Missing metadata and defaults for fileId=" + fileEntity.getId());
|
||||
}
|
||||
|
||||
metadata = fileEntity.getFetchedMetadata() != null
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.repository.BookMetadataRepository;
|
||||
import com.adityachandel.booklore.repository.BookRepository;
|
||||
import com.adityachandel.booklore.service.BookCreatorService;
|
||||
import com.adityachandel.booklore.service.FileFingerprint;
|
||||
import com.adityachandel.booklore.service.metadata.MetadataMatchService;
|
||||
import com.adityachandel.booklore.util.FileUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -40,7 +41,7 @@ public abstract class AbstractFileProcessor implements BookFileProcessor {
|
||||
public Book processFile(LibraryFile libraryFile) {
|
||||
Path filePath = libraryFile.getFullPath();
|
||||
String fileName = filePath.getFileName().toString();
|
||||
String hash = FileUtils.computeFileHash(filePath);
|
||||
String hash = FileFingerprint.generateHash(filePath);
|
||||
|
||||
Optional<Book> existing = fileProcessingUtils.checkForDuplicateAndUpdateMetadataIfNeeded(libraryFile, hash, bookRepository, bookMapper);
|
||||
if (existing.isPresent()) {
|
||||
@@ -63,5 +64,4 @@ public abstract class AbstractFileProcessor implements BookFileProcessor {
|
||||
}
|
||||
|
||||
protected abstract BookEntity processNewFile(LibraryFile libraryFile);
|
||||
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.adityachandel.booklore.model.entity.CategoryEntity;
|
||||
import com.adityachandel.booklore.model.enums.BookFileType;
|
||||
import com.adityachandel.booklore.repository.AuthorRepository;
|
||||
import com.adityachandel.booklore.repository.CategoryRepository;
|
||||
import com.adityachandel.booklore.service.FileFingerprint;
|
||||
import com.adityachandel.booklore.service.appsettings.AppSettingService;
|
||||
import com.adityachandel.booklore.service.metadata.backuprestore.MetadataBackupRestore;
|
||||
import com.adityachandel.booklore.service.metadata.backuprestore.MetadataBackupRestoreFactory;
|
||||
@@ -114,7 +115,7 @@ public class BookMetadataUpdater {
|
||||
|
||||
File file = new File(bookEntity.getFullFilePath().toUri());
|
||||
writer.writeMetadataToFile(file, metadata, thumbnailUrl, false, clearFlags);
|
||||
String newHash = FileUtils.computeFileHash(bookEntity);
|
||||
String newHash = FileFingerprint.generateHash(bookEntity.getFullFilePath());
|
||||
bookEntity.setCurrentHash(newHash);
|
||||
log.info("Metadata written for book ID {}", bookId);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.repository.AppMigrationRepository;
|
||||
import com.adityachandel.booklore.repository.BookRepository;
|
||||
import com.adityachandel.booklore.service.BookQueryService;
|
||||
import com.adityachandel.booklore.service.FileFingerprint;
|
||||
import com.adityachandel.booklore.service.metadata.MetadataMatchService;
|
||||
import com.adityachandel.booklore.util.FileUtils;
|
||||
import jakarta.transaction.Transactional;
|
||||
@@ -12,6 +13,8 @@ import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@@ -66,29 +69,35 @@ public class AppMigrationService {
|
||||
|
||||
@Transactional
|
||||
public void populateFileHashesOnce() {
|
||||
if (migrationRepository.existsById("populateFileHashes")) return;
|
||||
if (migrationRepository.existsById("populateFileHashesV2")) return;
|
||||
|
||||
List<BookEntity> books = bookRepository.findAll();
|
||||
int updated = 0;
|
||||
|
||||
for (BookEntity book : books) {
|
||||
if (book.getCurrentHash() == null || book.getInitialHash() == null) {
|
||||
String hash = FileUtils.computeFileHash(book);
|
||||
if (hash != null) {
|
||||
if (book.getInitialHash() == null) {
|
||||
book.setInitialHash(hash);
|
||||
}
|
||||
book.setCurrentHash(hash);
|
||||
updated++;
|
||||
Path path = book.getFullFilePath();
|
||||
if (path == null || !Files.exists(path)) {
|
||||
log.warn("Skipping hashing for book ID {} — file not found at path: {}", book.getId(), path);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
String hash = FileFingerprint.generateHash(path);
|
||||
if (book.getInitialHash() == null) {
|
||||
book.setInitialHash(hash);
|
||||
}
|
||||
book.setCurrentHash(hash);
|
||||
updated++;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to compute hash for file: {}", path, e);
|
||||
}
|
||||
}
|
||||
|
||||
bookRepository.saveAll(books);
|
||||
|
||||
log.info("Migration 'populateFileHashes' applied to {} books.", updated);
|
||||
log.info("Migration 'populateFileHashesV2' applied to {} books.", updated);
|
||||
migrationRepository.save(new AppMigrationEntity(
|
||||
"populateFileHashes",
|
||||
"populateFileHashesV2",
|
||||
LocalDateTime.now(),
|
||||
"Calculate and store initialHash and currentHash for all books"
|
||||
));
|
||||
|
||||
@@ -53,6 +53,7 @@ public class UserProvisioningService {
|
||||
perms.setPermissionManipulateLibrary(true);
|
||||
perms.setPermissionEmailBook(true);
|
||||
perms.setPermissionDeleteBook(true);
|
||||
perms.setPermissionSyncKoreader(true);
|
||||
|
||||
user.setPermissions(perms);
|
||||
createUser(user);
|
||||
@@ -78,8 +79,10 @@ public class UserProvisioningService {
|
||||
permissions.setPermissionUpload(request.isPermissionUpload());
|
||||
permissions.setPermissionDownload(request.isPermissionDownload());
|
||||
permissions.setPermissionEditMetadata(request.isPermissionEditMetadata());
|
||||
permissions.setPermissionManipulateLibrary(request.isPermissionManipulateLibrary());
|
||||
permissions.setPermissionEmailBook(request.isPermissionEmailBook());
|
||||
permissions.setPermissionDeleteBook(request.isPermissionDeleteBook());
|
||||
permissions.setPermissionSyncKoreader(request.isPermissionSyncKoreader());
|
||||
permissions.setPermissionAdmin(request.isPermissionAdmin());
|
||||
user.setPermissions(permissions);
|
||||
|
||||
@@ -110,6 +113,7 @@ public class UserProvisioningService {
|
||||
perms.setPermissionManipulateLibrary(defaultPermissions.contains("permissionManipulateLibrary"));
|
||||
perms.setPermissionEmailBook(defaultPermissions.contains("permissionEmailBook"));
|
||||
perms.setPermissionDeleteBook(defaultPermissions.contains("permissionDeleteBook"));
|
||||
perms.setPermissionSyncKoreader(defaultPermissions.contains("permissionSyncKoreader"));
|
||||
}
|
||||
user.setPermissions(perms);
|
||||
|
||||
@@ -157,6 +161,7 @@ public class UserProvisioningService {
|
||||
permissions.setPermissionManipulateLibrary(defaultPermissions.contains("permissionManipulateLibrary"));
|
||||
permissions.setPermissionEmailBook(defaultPermissions.contains("permissionEmailBook"));
|
||||
permissions.setPermissionDeleteBook(defaultPermissions.contains("permissionDeleteBook"));
|
||||
permissions.setPermissionDeleteBook(defaultPermissions.contains("permissionSyncKoreader"));
|
||||
} else {
|
||||
permissions.setPermissionUpload(true);
|
||||
permissions.setPermissionDownload(true);
|
||||
@@ -164,6 +169,7 @@ public class UserProvisioningService {
|
||||
permissions.setPermissionManipulateLibrary(false);
|
||||
permissions.setPermissionEmailBook(true);
|
||||
permissions.setPermissionDeleteBook(true);
|
||||
permissions.setPermissionSyncKoreader(true);
|
||||
}
|
||||
|
||||
permissions.setPermissionAdmin(isAdmin);
|
||||
|
||||
@@ -53,6 +53,7 @@ public class UserService {
|
||||
user.getPermissions().setPermissionManipulateLibrary(updateRequest.getPermissions().isCanManipulateLibrary());
|
||||
user.getPermissions().setPermissionEmailBook(updateRequest.getPermissions().isCanEmailBook());
|
||||
user.getPermissions().setPermissionDeleteBook(updateRequest.getPermissions().isCanDeleteBook());
|
||||
user.getPermissions().setPermissionSyncKoreader(updateRequest.getPermissions().isCanSyncKoReader());
|
||||
}
|
||||
|
||||
if (updateRequest.getAssignedLibraries() != null && getMyself().getPermissions().isAdmin()) {
|
||||
@@ -70,7 +71,7 @@ public class UserService {
|
||||
BookLoreUser currentUser = authenticationService.getAuthenticatedUser();
|
||||
boolean isAdmin = currentUser.getPermissions().isAdmin();
|
||||
if (!isAdmin) {
|
||||
throw ApiError.UNAUTHORIZED.createException("You do not have permission to delete this User");
|
||||
throw ApiError.GENERIC_UNAUTHORIZED.createException("You do not have permission to delete this User");
|
||||
}
|
||||
if (currentUser.getId().equals(userToDelete.getId())) {
|
||||
throw ApiError.SELF_DELETION_NOT_ALLOWED.createException();
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.adityachandel.booklore.model.entity.LibraryPathEntity;
|
||||
import com.adityachandel.booklore.model.enums.BookFileExtension;
|
||||
import com.adityachandel.booklore.model.websocket.Topic;
|
||||
import com.adityachandel.booklore.repository.LibraryRepository;
|
||||
import com.adityachandel.booklore.service.FileFingerprint;
|
||||
import com.adityachandel.booklore.service.NotificationService;
|
||||
import com.adityachandel.booklore.util.FileUtils;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
@@ -89,11 +90,7 @@ public class LibraryFileEventProcessor {
|
||||
|
||||
private void handleFileCreate(LibraryEntity library, Path path) {
|
||||
log.info("[FILE_CREATE] '{}'", path);
|
||||
String hash = FileUtils.computeFileHash(path);
|
||||
if (hash == null) {
|
||||
log.warn("[SKIP] Unable to compute hash for '{}'", path);
|
||||
return;
|
||||
}
|
||||
String hash = FileFingerprint.generateHash(path);
|
||||
bookFileTransactionalHandler.handleNewBookFile(library.getId(), path, hash);
|
||||
}
|
||||
|
||||
@@ -127,10 +124,8 @@ public class LibraryFileEventProcessor {
|
||||
.filter(p -> isBookFile(p.getFileName().toString()))
|
||||
.forEach(p -> {
|
||||
try {
|
||||
String hash = FileUtils.computeFileHash(p);
|
||||
if (hash != null) {
|
||||
bookFileTransactionalHandler.handleNewBookFile(library.getId(), p, hash);
|
||||
}
|
||||
String hash = FileFingerprint.generateHash(p);
|
||||
bookFileTransactionalHandler.handleNewBookFile(library.getId(), p, hash);
|
||||
} catch (Exception e) {
|
||||
log.warn("[ERROR] Processing file '{}': {}", p, e.getMessage());
|
||||
}
|
||||
|
||||
@@ -61,46 +61,4 @@ public class FileUtils {
|
||||
.forEach(File::delete);
|
||||
}
|
||||
}
|
||||
|
||||
public static String computeFileHash(Path path) {
|
||||
try {
|
||||
return computeSHA256HeadAndTail(path, 64 * 1024);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to compute hash for file '{}': {}", path, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static String computeFileHash(BookEntity book) {
|
||||
try {
|
||||
Path filePath = book.getFullFilePath();
|
||||
return computeSHA256HeadAndTail(filePath, 64 * 1024);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to compute hash for book '{}': {}", book.getFileName(), e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static String computeSHA256HeadAndTail(Path path, int sampleSize) throws IOException, NoSuchAlgorithmException {
|
||||
long fileSize = Files.size(path);
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
try (RandomAccessFile raf = new RandomAccessFile(path.toFile(), "r")) {
|
||||
|
||||
byte[] startBytes = new byte[(int) Math.min(sampleSize, fileSize)];
|
||||
raf.seek(0);
|
||||
raf.readFully(startBytes);
|
||||
digest.update(startBytes);
|
||||
|
||||
if (fileSize > sampleSize) {
|
||||
byte[] endBytes = new byte[(int) Math.min(sampleSize, fileSize)];
|
||||
raf.seek(fileSize - sampleSize);
|
||||
raf.readFully(endBytes);
|
||||
digest.update(endBytes);
|
||||
}
|
||||
}
|
||||
|
||||
byte[] hashBytes = digest.digest();
|
||||
return HexFormat.of().formatHex(hashBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.adityachandel.booklore.util;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
public class Md5Util {
|
||||
|
||||
public static String md5Hex(String input) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
byte[] digest = md.digest(input.getBytes());
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : digest) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
CREATE TABLE IF NOT EXISTS koreader_user
|
||||
(
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL UNIQUE,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
password_md5 VARCHAR(255) NOT NULL,
|
||||
sync_enabled BOOLEAN,
|
||||
booklore_user_id BIGINT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_booklore_user FOREIGN KEY (booklore_user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
ALTER TABLE user_book_progress
|
||||
ADD COLUMN IF NOT EXISTS koreader_progress VARCHAR(1000),
|
||||
ADD COLUMN IF NOT EXISTS koreader_progress_percent FLOAT,
|
||||
ADD COLUMN IF NOT EXISTS koreader_device VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS koreader_device_id VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS koreader_last_sync_time TIMESTAMP;
|
||||
|
||||
ALTER TABLE user_permissions
|
||||
ADD COLUMN IF NOT EXISTS permission_sync_koreader BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
UPDATE user_permissions
|
||||
SET permission_sync_koreader = TRUE
|
||||
WHERE permission_admin = TRUE;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.adityachandel.booklore.controller;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.progress.KoreaderProgress;
|
||||
import com.adityachandel.booklore.service.KoreaderService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.*;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class KoreaderControllerTest {
|
||||
|
||||
@Mock
|
||||
private KoreaderService koreaderService;
|
||||
|
||||
@InjectMocks
|
||||
private KoreaderController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
try (AutoCloseable mocks = MockitoAnnotations.openMocks(this)) {
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorizeUser_returnsServiceResponse() {
|
||||
Map<String, String> expected = Map.of("username", "test");
|
||||
when(koreaderService.authorizeUser()).thenReturn(ResponseEntity.ok(expected));
|
||||
ResponseEntity<Map<String, String>> resp = controller.authorizeUser();
|
||||
assertEquals(HttpStatus.OK, resp.getStatusCode());
|
||||
assertEquals(expected, resp.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUser_returnsForbiddenAndLogs() {
|
||||
Map<String, Object> userData = Map.of("username", "test");
|
||||
ResponseEntity<?> resp = controller.createUser(userData);
|
||||
assertEquals(HttpStatus.FORBIDDEN, resp.getStatusCode());
|
||||
assertTrue(((Map<?, ?>) resp.getBody()).get("error").toString().contains("disabled"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProgress_returnsProgress() {
|
||||
KoreaderProgress progress = KoreaderProgress.builder()
|
||||
.document("doc")
|
||||
.progress("progress")
|
||||
.percentage(0.5F)
|
||||
.device("dev")
|
||||
.device_id("id")
|
||||
.build();
|
||||
when(koreaderService.getProgress("hash")).thenReturn(progress);
|
||||
ResponseEntity<KoreaderProgress> resp = controller.getProgress("hash");
|
||||
assertEquals(HttpStatus.OK, resp.getStatusCode());
|
||||
assertEquals(MediaType.APPLICATION_JSON, resp.getHeaders().getContentType());
|
||||
assertEquals(progress, resp.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateProgress_returnsOk() {
|
||||
KoreaderProgress progress = KoreaderProgress.builder()
|
||||
.document("doc")
|
||||
.progress("progress")
|
||||
.percentage(0.5F)
|
||||
.device("dev")
|
||||
.device_id("id")
|
||||
.build();
|
||||
doNothing().when(koreaderService).saveProgress("doc", progress);
|
||||
ResponseEntity<?> resp = controller.updateProgress(progress);
|
||||
assertEquals(HttpStatus.OK, resp.getStatusCode());
|
||||
assertEquals("progress updated", ((Map<?, ?>) resp.getBody()).get("status"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.adityachandel.booklore.controller;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.KoreaderUser;
|
||||
import com.adityachandel.booklore.service.KoreaderUserService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.*;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class KoreaderUserControllerTest {
|
||||
|
||||
@Mock
|
||||
private KoreaderUserService koreaderUserService;
|
||||
|
||||
@InjectMocks
|
||||
private KoreaderUserController controller;
|
||||
|
||||
private KoreaderUser user;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
try (AutoCloseable mocks = MockitoAnnotations.openMocks(this)) {
|
||||
user = new KoreaderUser(1L, "testuser", "pass", "md5", true);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCurrentUser_returnsUser() {
|
||||
when(koreaderUserService.getUser()).thenReturn(user);
|
||||
ResponseEntity<KoreaderUser> resp = controller.getCurrentUser();
|
||||
assertEquals(HttpStatus.OK, resp.getStatusCode());
|
||||
assertEquals(user, resp.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertCurrentUser_returnsUser() {
|
||||
Map<String, String> userData = Map.of("username", "testuser", "password", "pass");
|
||||
when(koreaderUserService.upsertUser("testuser", "pass")).thenReturn(user);
|
||||
ResponseEntity<KoreaderUser> resp = controller.upsertCurrentUser(userData);
|
||||
assertEquals(HttpStatus.OK, resp.getStatusCode());
|
||||
assertEquals(user, resp.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateSyncEnabled_returnsNoContent() {
|
||||
doNothing().when(koreaderUserService).toggleSync(true);
|
||||
ResponseEntity<Void> resp = controller.updateSyncEnabled(true);
|
||||
assertEquals(HttpStatus.NO_CONTENT, resp.getStatusCode());
|
||||
assertNull(resp.getBody());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.adityachandel.booklore.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
|
||||
import com.adityachandel.booklore.config.security.KoreaderUserDetails;
|
||||
import com.adityachandel.booklore.exception.APIException;
|
||||
import com.adityachandel.booklore.model.dto.progress.KoreaderProgress;
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
|
||||
import com.adityachandel.booklore.model.entity.UserBookProgressEntity;
|
||||
import com.adityachandel.booklore.repository.BookRepository;
|
||||
import com.adityachandel.booklore.repository.UserBookProgressRepository;
|
||||
import com.adityachandel.booklore.repository.UserRepository;
|
||||
import com.adityachandel.booklore.repository.KoreaderUserRepository;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.*;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class KoreaderServiceTest {
|
||||
|
||||
@Mock
|
||||
UserBookProgressRepository progressRepo;
|
||||
@Mock
|
||||
BookRepository bookRepo;
|
||||
@Mock
|
||||
UserRepository userRepo;
|
||||
@Mock
|
||||
KoreaderUserRepository koreaderUserRepo;
|
||||
|
||||
@InjectMocks
|
||||
KoreaderService service;
|
||||
|
||||
private KoreaderUserDetails details;
|
||||
private Authentication auth;
|
||||
private SecurityContext context;
|
||||
|
||||
@BeforeEach
|
||||
void setUpAuth() {
|
||||
details = mock(KoreaderUserDetails.class);
|
||||
when(details.getUsername()).thenReturn("u");
|
||||
when(details.getPassword()).thenReturn("md5pwd");
|
||||
when(details.getBookLoreUserId()).thenReturn(42L);
|
||||
auth = mock(Authentication.class);
|
||||
context = new SecurityContextImpl();
|
||||
when(auth.getPrincipal()).thenReturn(details);
|
||||
context.setAuthentication(auth);
|
||||
SecurityContextHolder.setContext(context);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void authorizeUser_success() {
|
||||
var userEntity = new com.adityachandel.booklore.model.entity.KoreaderUserEntity();
|
||||
userEntity.setPasswordMD5("MD5PWD");
|
||||
when(koreaderUserRepo.findByUsername("u"))
|
||||
.thenReturn(Optional.of(userEntity));
|
||||
when(details.getPassword()).thenReturn("MD5PWD");
|
||||
|
||||
ResponseEntity<Map<String, String>> resp = service.authorizeUser();
|
||||
assertEquals(200, resp.getStatusCodeValue());
|
||||
assertEquals("u", resp.getBody().get("username"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorizeUser_notFound() {
|
||||
when(koreaderUserRepo.findByUsername("u")).thenReturn(Optional.empty());
|
||||
APIException ex = assertThrows(APIException.class, () -> service.authorizeUser());
|
||||
assertTrue(ex.getStatus().is4xxClientError());
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorizeUser_badPassword() {
|
||||
var userEntity = new com.adityachandel.booklore.model.entity.KoreaderUserEntity();
|
||||
userEntity.setPasswordMD5("OTHER");
|
||||
when(koreaderUserRepo.findByUsername("u"))
|
||||
.thenReturn(Optional.of(userEntity));
|
||||
assertThrows(APIException.class, () -> service.authorizeUser());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProgress_success() {
|
||||
when(details.isSyncEnabled()).thenReturn(true);
|
||||
var book = new BookEntity();
|
||||
book.setId(99L);
|
||||
when(bookRepo.findByCurrentHash("h")).thenReturn(Optional.of(book));
|
||||
var prog = new UserBookProgressEntity();
|
||||
prog.setKoreaderProgress("p");
|
||||
prog.setKoreaderProgressPercent(0.5F);
|
||||
when(progressRepo.findByUserIdAndBookId(42L, 99L))
|
||||
.thenReturn(Optional.of(prog));
|
||||
|
||||
KoreaderProgress out = service.getProgress("h");
|
||||
assertEquals("h", out.getDocument());
|
||||
assertEquals("p", out.getProgress());
|
||||
assertEquals(0.5F, out.getPercentage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProgress_bookNotFound() {
|
||||
when(details.isSyncEnabled()).thenReturn(true);
|
||||
when(bookRepo.findByCurrentHash("h")).thenReturn(Optional.empty());
|
||||
assertThrows(APIException.class, () -> service.getProgress("h"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProgress_noProgress() {
|
||||
when(details.isSyncEnabled()).thenReturn(true);
|
||||
when(bookRepo.findByCurrentHash("h"))
|
||||
.thenReturn(Optional.of(new BookEntity()));
|
||||
when(progressRepo.findByUserIdAndBookId(anyLong(), isNull()))
|
||||
.thenReturn(Optional.empty());
|
||||
assertThrows(APIException.class, () -> service.getProgress("h"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProgress_syncDisabled() {
|
||||
when(details.isSyncEnabled()).thenReturn(false);
|
||||
assertThrows(APIException.class, () -> service.getProgress("h"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveProgress_createsNew() {
|
||||
when(details.isSyncEnabled()).thenReturn(true);
|
||||
var book = new BookEntity();
|
||||
book.setId(7L);
|
||||
when(bookRepo.findByCurrentHash("h")).thenReturn(Optional.of(book));
|
||||
var user = new BookLoreUserEntity();
|
||||
user.setId(42L);
|
||||
when(userRepo.findById(42L)).thenReturn(Optional.of(user));
|
||||
when(progressRepo.findByUserIdAndBookId(42L, 7L))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
var dto = KoreaderProgress.builder()
|
||||
.document("h").progress("x").percentage(0.6F).device("d").device_id("id").build();
|
||||
service.saveProgress("h", dto);
|
||||
|
||||
ArgumentCaptor<UserBookProgressEntity> cap = ArgumentCaptor.forClass(UserBookProgressEntity.class);
|
||||
verify(progressRepo).save(cap.capture());
|
||||
var saved = cap.getValue();
|
||||
assertEquals("x", saved.getKoreaderProgress());
|
||||
assertEquals(0.6F, saved.getKoreaderProgressPercent());
|
||||
assertEquals("d", saved.getKoreaderDevice());
|
||||
assertEquals("id", saved.getKoreaderDeviceId());
|
||||
assertEquals(Instant.class, saved.getKoreaderLastSyncTime().getClass());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveProgress_updatesExisting() {
|
||||
when(details.isSyncEnabled()).thenReturn(true);
|
||||
var book = new BookEntity();
|
||||
book.setId(8L);
|
||||
when(bookRepo.findByCurrentHash("h")).thenReturn(Optional.of(book));
|
||||
var user = new BookLoreUserEntity();
|
||||
user.setId(42L);
|
||||
when(userRepo.findById(42L)).thenReturn(Optional.of(user));
|
||||
var existing = new UserBookProgressEntity();
|
||||
when(progressRepo.findByUserIdAndBookId(42L, 8L))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
var dto = KoreaderProgress.builder()
|
||||
.document("h").progress("y").percentage(0.4F).device("d").device_id("id").build();
|
||||
service.saveProgress("h", dto);
|
||||
|
||||
verify(progressRepo).save(existing);
|
||||
assertEquals("y", existing.getKoreaderProgress());
|
||||
assertEquals(0.4F, existing.getKoreaderProgressPercent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveProgress_syncDisabled() {
|
||||
when(details.isSyncEnabled()).thenReturn(false);
|
||||
var dto = KoreaderProgress.builder().document("h").build();
|
||||
assertThrows(APIException.class, () -> service.saveProgress("h", dto));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.adityachandel.booklore.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import com.adityachandel.booklore.config.security.AuthenticationService;
|
||||
import com.adityachandel.booklore.model.dto.BookLoreUser;
|
||||
import com.adityachandel.booklore.model.dto.KoreaderUser;
|
||||
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
|
||||
import com.adityachandel.booklore.model.entity.KoreaderUserEntity;
|
||||
import com.adityachandel.booklore.repository.KoreaderUserRepository;
|
||||
import com.adityachandel.booklore.repository.UserRepository;
|
||||
import com.adityachandel.booklore.util.Md5Util;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.*;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class KoreaderUserServiceTest {
|
||||
|
||||
@Mock AuthenticationService authService;
|
||||
@Mock UserRepository userRepository;
|
||||
@Mock KoreaderUserRepository koreaderUserRepository;
|
||||
@Mock com.adityachandel.booklore.mapper.KoreaderUserMapper koreaderUserMapper;
|
||||
@InjectMocks KoreaderUserService service;
|
||||
|
||||
private BookLoreUser ownerDto;
|
||||
private BookLoreUserEntity ownerEntity;
|
||||
private KoreaderUserEntity entity;
|
||||
private KoreaderUser dto;
|
||||
|
||||
@BeforeEach
|
||||
void init() {
|
||||
ownerDto = mock(BookLoreUser.class);
|
||||
when(ownerDto.getId()).thenReturn(123L);
|
||||
when(ownerDto.getUsername()).thenReturn("ownerName");
|
||||
when(authService.getAuthenticatedUser()).thenReturn(ownerDto);
|
||||
|
||||
ownerEntity = new BookLoreUserEntity();
|
||||
ownerEntity.setId(123L);
|
||||
ownerEntity.setUsername("ownerName");
|
||||
|
||||
entity = new KoreaderUserEntity();
|
||||
entity.setId(10L);
|
||||
entity.setBookLoreUser(ownerEntity);
|
||||
entity.setUsername("kvUser");
|
||||
|
||||
dto = new KoreaderUser(10L, "kvUser", null, null, false);
|
||||
when(koreaderUserMapper.toDto(any(KoreaderUserEntity.class))).thenReturn(dto);
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertUser_createsNew_whenAbsent() {
|
||||
when(userRepository.findById(123L)).thenReturn(Optional.of(ownerEntity));
|
||||
when(koreaderUserRepository.findByBookLoreUserId(123L)).thenReturn(Optional.empty());
|
||||
when(koreaderUserRepository.save(any(KoreaderUserEntity.class))).thenAnswer(invocation -> {
|
||||
KoreaderUserEntity arg = invocation.getArgument(0);
|
||||
arg.setId(42L);
|
||||
return arg;
|
||||
});
|
||||
|
||||
when(koreaderUserMapper.toDto(any(KoreaderUserEntity.class))).thenAnswer(invocation -> {
|
||||
KoreaderUserEntity u = invocation.getArgument(0);
|
||||
return new KoreaderUser(u.getId(), u.getUsername(), u.getPassword(), u.getPasswordMD5(), u.isSyncEnabled());
|
||||
});
|
||||
|
||||
KoreaderUser result = service.upsertUser("userA", "passA");
|
||||
|
||||
assertEquals(42L, result.getId());
|
||||
assertEquals("userA", result.getUsername());
|
||||
verify(koreaderUserRepository).save(argThat(u ->
|
||||
u.getBookLoreUser() == ownerEntity &&
|
||||
u.getUsername().equals("userA") &&
|
||||
u.getPasswordMD5().equals(Md5Util.md5Hex("passA"))
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertUser_updatesExisting_whenPresent() {
|
||||
when(userRepository.findById(123L)).thenReturn(Optional.of(ownerEntity));
|
||||
when(koreaderUserRepository.findByBookLoreUserId(123L)).thenReturn(Optional.of(entity));
|
||||
when(koreaderUserRepository.save(entity)).thenReturn(entity);
|
||||
|
||||
KoreaderUser result = service.upsertUser("newName", "newPass");
|
||||
|
||||
assertEquals(dto, result);
|
||||
verify(koreaderUserRepository).save(entity);
|
||||
assertEquals("newName", entity.getUsername());
|
||||
assertEquals(Md5Util.md5Hex("newPass"), entity.getPasswordMD5());
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertUser_throws_whenOwnerMissing() {
|
||||
when(userRepository.findById(123L)).thenReturn(Optional.empty());
|
||||
assertThrows(com.adityachandel.booklore.exception.APIException.class,
|
||||
() -> service.upsertUser("x", "y"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getUser_returnsDto_whenFound() {
|
||||
when(koreaderUserRepository.findByBookLoreUserId(123L)).thenReturn(Optional.of(entity));
|
||||
KoreaderUser result = service.getUser();
|
||||
assertEquals(dto, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getUser_throws_whenNotFound() {
|
||||
when(koreaderUserRepository.findByBookLoreUserId(123L)).thenReturn(Optional.empty());
|
||||
assertThrows(com.adityachandel.booklore.exception.APIException.class, () -> service.getUser());
|
||||
}
|
||||
|
||||
@Test
|
||||
void toggleSync_setsFlag_andSaves() {
|
||||
when(koreaderUserRepository.findByBookLoreUserId(123L)).thenReturn(Optional.of(entity));
|
||||
service.toggleSync(true);
|
||||
assertTrue(entity.isSyncEnabled());
|
||||
verify(koreaderUserRepository).save(entity);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toggleSync_throws_whenEntityMissing() {
|
||||
when(koreaderUserRepository.findByBookLoreUserId(123L)).thenReturn(Optional.empty());
|
||||
assertThrows(com.adityachandel.booklore.exception.APIException.class, () -> service.toggleSync(false));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="book-card"
|
||||
[class.selected]="isSelected"
|
||||
(mouseover)="isHovered = true"
|
||||
(mouseout)="isHovered = false">
|
||||
[class.selected]="isSelected"
|
||||
(mouseover)="isHovered = true"
|
||||
(mouseout)="isHovered = false">
|
||||
|
||||
<div class="cover-container" [ngClass]="{ 'shimmer': !isImageLoaded, 'center-info-btn': readButtonHidden }">
|
||||
<div
|
||||
@@ -16,7 +16,7 @@
|
||||
[class.loaded]="isImageLoaded"
|
||||
alt="Cover of {{ book.metadata?.title }}"
|
||||
loading="lazy"
|
||||
(load)="onImageLoad()" />
|
||||
(load)="onImageLoad()"/>
|
||||
</div>
|
||||
|
||||
@if (book.metadata?.seriesNumber != null) {
|
||||
@@ -52,6 +52,16 @@
|
||||
[ngClass]="{ 'progress-complete': progressPercentage === 100, 'progress-incomplete': progressPercentage < 100 }">
|
||||
</p-progressBar>
|
||||
}
|
||||
@if (koProgressPercentage !== null) {
|
||||
<p-progressBar
|
||||
[value]="koProgressPercentage"
|
||||
[showValue]="false"
|
||||
[ngClass]="[
|
||||
progressPercentage == null ? 'cover-progress-bar-ko-only' : 'cover-progress-bar-ko-both',
|
||||
koProgressPercentage === 100 ? 'progress-complete-ko' : 'progress-incomplete-ko'
|
||||
]">
|
||||
</p-progressBar>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div [hidden]="bottomBarHidden">
|
||||
|
||||
@@ -119,16 +119,29 @@
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.cover-progress-bar {
|
||||
.cover-progress-bar,
|
||||
.cover-progress-bar-ko-both,
|
||||
.cover-progress-bar-ko-only {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 5px;
|
||||
height: 2.5px;
|
||||
z-index: 1;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.cover-progress-bar {
|
||||
bottom: 2.5px;
|
||||
}
|
||||
|
||||
.cover-progress-bar-ko-both {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.cover-progress-bar-ko-only {
|
||||
bottom: 2.5px;
|
||||
}
|
||||
|
||||
.series-number-overlay {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
@@ -166,6 +179,14 @@
|
||||
background-color: #22c55e;
|
||||
}
|
||||
|
||||
::ng-deep .progress-incomplete-ko .p-progressbar-value {
|
||||
background-color: #fbbf24;
|
||||
}
|
||||
|
||||
::ng-deep .progress-complete-ko .p-progressbar-value {
|
||||
background-color: #8b5cf6;
|
||||
}
|
||||
|
||||
::ng-deep p-progressbar {
|
||||
--p-progressbar-border-radius: 0 !important;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {ProgressBar} from 'primeng/progressbar';
|
||||
import {BookMetadataCenterComponent} from '../../../../metadata/book-metadata-center-component/book-metadata-center.component';
|
||||
import {takeUntil} from 'rxjs/operators';
|
||||
import {readStatusLabels} from '../book-filter/book-filter.component';
|
||||
import {ResetProgressTypes} from '../../../../shared/constants/reset-progress-type';
|
||||
|
||||
@Component({
|
||||
selector: 'app-book-card',
|
||||
@@ -83,6 +84,16 @@ export class BookCardComponent implements OnInit, OnDestroy {
|
||||
if (this.book.pdfProgress?.percentage != null) {
|
||||
return this.book.pdfProgress.percentage;
|
||||
}
|
||||
if (this.book.cbxProgress?.percentage != null) {
|
||||
return this.book.cbxProgress.percentage;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
get koProgressPercentage(): number | null {
|
||||
if (this.book.koreaderProgress?.percentage != null) {
|
||||
return this.book.koreaderProgress.percentage;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -266,7 +277,7 @@ export class BookCardComponent implements OnInit, OnDestroy {
|
||||
if (this.hasEditMetadataPermission()) {
|
||||
items.push({
|
||||
label: 'More Actions',
|
||||
icon: 'pi pi-ellipsis-v',
|
||||
icon: 'pi pi-ellipsis-h',
|
||||
items: [
|
||||
{
|
||||
label: 'Read Status',
|
||||
@@ -296,15 +307,15 @@ export class BookCardComponent implements OnInit, OnDestroy {
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Reset Progress',
|
||||
label: 'Reset Booklore Progress',
|
||||
icon: 'pi pi-undo',
|
||||
command: () => {
|
||||
this.bookService.resetProgress(this.book.id).subscribe({
|
||||
this.bookService.resetProgress(this.book.id, ResetProgressTypes.BOOKLORE).subscribe({
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Progress Reset',
|
||||
detail: 'Reading progress has been reset.',
|
||||
detail: 'Booklore reading progress has been reset.',
|
||||
life: 1500
|
||||
});
|
||||
},
|
||||
@@ -312,7 +323,31 @@ export class BookCardComponent implements OnInit, OnDestroy {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Failed',
|
||||
detail: 'Could not reset progress.',
|
||||
detail: 'Could not reset Booklore progress.',
|
||||
life: 1500
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Reset KOReader Progress',
|
||||
icon: 'pi pi-undo',
|
||||
command: () => {
|
||||
this.bookService.resetProgress(this.book.id, ResetProgressTypes.KOREADER).subscribe({
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Progress Reset',
|
||||
detail: 'KOReader reading progress has been reset.',
|
||||
life: 1500
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Failed',
|
||||
detail: 'Could not reset KOReader progress.',
|
||||
life: 1500
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface Book {
|
||||
epubProgress?: EpubProgress;
|
||||
pdfProgress?: PdfProgress;
|
||||
cbxProgress?: CbxProgress;
|
||||
koreaderProgress?: KoReaderProgress;
|
||||
filePath?: string;
|
||||
fileSubPath?: string;
|
||||
fileName?: string;
|
||||
@@ -40,6 +41,10 @@ export interface CbxProgress {
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface KoReaderProgress {
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface BookMetadata {
|
||||
bookId: number;
|
||||
title?: string;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {MenuItem} from 'primeng/api';
|
||||
import {BookService} from './book.service';
|
||||
import {readStatusLabels} from '../components/book-browser/book-filter/book-filter.component';
|
||||
import {ReadStatus} from '../model/book.model';
|
||||
import {ResetProgressTypes} from '../../shared/constants/reset-progress-type';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -74,22 +75,55 @@ export class BookMenuService {
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Reset Progress',
|
||||
label: 'Reset Booklore Progress',
|
||||
icon: 'pi pi-undo',
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: 'Are you sure you want to reset progress for selected books?',
|
||||
message: 'Are you sure you want to reset Booklore reading progress for selected books?',
|
||||
header: 'Confirm Reset',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Yes',
|
||||
rejectLabel: 'No',
|
||||
accept: () => {
|
||||
this.bookService.resetProgress(Array.from(selectedBooks)).subscribe({
|
||||
this.bookService.resetProgress(Array.from(selectedBooks), ResetProgressTypes.BOOKLORE).subscribe({
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Progress Reset',
|
||||
detail: 'Reading progress has been reset.',
|
||||
detail: 'Booklore reading progress has been reset.',
|
||||
life: 1500
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Failed',
|
||||
detail: 'Could not reset progress.',
|
||||
life: 1500
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Reset KOReader Progress',
|
||||
icon: 'pi pi-undo',
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: 'Are you sure you want to reset KOReader reading progress for selected books?',
|
||||
header: 'Confirm Reset',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Yes',
|
||||
rejectLabel: 'No',
|
||||
accept: () => {
|
||||
this.bookService.resetProgress(Array.from(selectedBooks), ResetProgressTypes.KOREADER).subscribe({
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Progress Reset',
|
||||
detail: 'KOReader reading progress has been reset.',
|
||||
life: 1500
|
||||
});
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import {API_CONFIG} from '../../config/api-config';
|
||||
import {FetchMetadataRequest} from '../../metadata/model/request/fetch-metadata-request.model';
|
||||
import {MetadataRefreshRequest} from '../../metadata/model/request/metadata-refresh-request.model';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {ResetProgressType, ResetProgressTypes} from '../../shared/constants/reset-progress-type';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -401,9 +402,10 @@ export class BookService {
|
||||
);
|
||||
}
|
||||
|
||||
resetProgress(bookIds: number | number[]): Observable<Book[]> {
|
||||
resetProgress(bookIds: number | number[], type: ResetProgressType): Observable<Book[]> {
|
||||
const ids = Array.isArray(bookIds) ? bookIds : [bookIds];
|
||||
return this.http.post<Book[]>(`${this.url}/reset-progress`, ids).pipe(
|
||||
const params = new HttpParams().set('type', ResetProgressTypes[type]);
|
||||
return this.http.post<Book[]>(`${this.url}/reset-progress`, ids, {params}).pipe(
|
||||
tap(updatedBooks => updatedBooks.forEach(book => this.handleBookUpdate(book)))
|
||||
);
|
||||
}
|
||||
@@ -412,16 +414,11 @@ export class BookService {
|
||||
const ids = Array.isArray(bookIds) ? bookIds : [bookIds];
|
||||
return this.http.put<Book[]>(`${this.url}/read-status`, {ids, status}).pipe(
|
||||
tap(updatedBooks => {
|
||||
// Update the books in the state with the actual response from the API
|
||||
updatedBooks.forEach(updatedBook => this.handleBookUpdate(updatedBook));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getMagicShelfBookCount(number: number) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
/*------------------ All the websocket handlers go below ------------------*/
|
||||
|
||||
|
||||
@@ -38,7 +38,10 @@ export class AuthenticationSettingsComponent implements OnInit {
|
||||
{label: 'Upload Books', value: 'permissionUpload', selected: false},
|
||||
{label: 'Download Books', value: 'permissionDownload', selected: false},
|
||||
{label: 'Edit Book Metadata', value: 'permissionEditMetadata', selected: false},
|
||||
{label: 'Email Book', value: 'permissionEmailBook', selected: false}
|
||||
{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}
|
||||
];
|
||||
|
||||
internalAuthEnabled = true;
|
||||
|
||||
36
booklore-ui/src/app/koreader-service.ts
Normal file
36
booklore-ui/src/app/koreader-service.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {Observable} from 'rxjs';
|
||||
import {API_CONFIG} from './config/api-config';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
|
||||
|
||||
export interface KoreaderUser {
|
||||
username: string;
|
||||
password: string;
|
||||
syncEnabled: boolean;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class KoreaderService {
|
||||
|
||||
private readonly url = `${API_CONFIG.BASE_URL}/api/v1/koreader-users`;
|
||||
|
||||
private http = inject(HttpClient);
|
||||
|
||||
createUser(username: string, password: string): Observable<KoreaderUser> {
|
||||
const payload: any = {username, password};
|
||||
return this.http.put<KoreaderUser>(`${this.url}/me`, payload);
|
||||
}
|
||||
|
||||
getUser(): Observable<KoreaderUser> {
|
||||
return this.http.get<KoreaderUser>(`${this.url}/me`);
|
||||
}
|
||||
|
||||
toggleSync(enabled: boolean): Observable<void> {
|
||||
return this.http.patch<void>(`${this.url}/me/sync`, null, {
|
||||
params: {enabled: enabled.toString()}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="relative w-[175px] md:w-[250px]">
|
||||
<img
|
||||
[attr.src]="urlHelper.getCoverUrl(book?.metadata!.bookId, book?.metadata!.coverUpdatedOn)"
|
||||
class="rounded-xl w-full object-cover"
|
||||
class="rounded-lg w-full object-cover"
|
||||
alt="Cover of {{ book?.metadata!.title }}"
|
||||
loading="lazy"
|
||||
/>
|
||||
@@ -17,19 +17,32 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (getProgressPercent(book) !== null) {
|
||||
<div class="absolute bottom-0 left-0 right-0">
|
||||
<p-progressBar
|
||||
[value]="getProgressPercent(book)"
|
||||
[showValue]="false"
|
||||
[ngClass]="{
|
||||
'rounded-b-xl': true,
|
||||
'progress-complete': getProgressPercent(book) === 100,
|
||||
'progress-incomplete': getProgressPercent(book)! < 100
|
||||
}"
|
||||
[style]="{ height: '8px' }">
|
||||
</p-progressBar>
|
||||
</div>
|
||||
@let progress = getProgressPercent(book);
|
||||
@if (progress !== null) {
|
||||
<p-progressBar
|
||||
[value]="progress"
|
||||
[showValue]="false"
|
||||
class="cover-progress-bar"
|
||||
[ngClass]="{
|
||||
'rounded-b-xl': false,
|
||||
'progress-complete': progress === 100,
|
||||
'progress-incomplete': progress < 100
|
||||
}"
|
||||
[style]="{ height: '6px' }"
|
||||
></p-progressBar>
|
||||
}
|
||||
|
||||
@if (getKoProgressPercent(book) !== null) {
|
||||
<p-progressBar
|
||||
[value]="getKoProgressPercent(book)"
|
||||
[showValue]="false"
|
||||
[ngClass]="[
|
||||
getProgressPercent(book) == null ? 'cover-progress-bar-ko-only' : 'cover-progress-bar-ko-both',
|
||||
getKoProgressPercent(book) === 100 ? 'progress-complete-ko' : 'progress-incomplete-ko'
|
||||
]"
|
||||
[style]="{ height: '6px' }"
|
||||
>
|
||||
</p-progressBar>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -213,7 +226,7 @@
|
||||
}
|
||||
|
||||
<div class="px-1 md:px-0">
|
||||
<div class="grid md:grid-cols-3 gap-y-2.5 text-sm pt-2 md:pt-4 pb-2 text-gray-300 max-w-6xl">
|
||||
<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">
|
||||
<p class="whitespace-nowrap max-w-[250px] overflow-hidden text-ellipsis">
|
||||
<span class="font-bold">Publisher: </span>
|
||||
@if (book?.metadata?.publisher) {
|
||||
@@ -226,6 +239,7 @@
|
||||
</p>
|
||||
<p><strong>Published:</strong> {{ book?.metadata!.publishedDate || '-' }}</p>
|
||||
<p><strong>Language:</strong> {{ book?.metadata!.language || '-' }}</p>
|
||||
<p><strong>File Size:</strong> {{ getFileSizeInMB(book) }}</p>
|
||||
<p class="whitespace-nowrap flex items-center gap-2">
|
||||
<span class="font-bold">File Type:</span>
|
||||
<span
|
||||
@@ -234,7 +248,40 @@
|
||||
{{ getFileExtension(book?.filePath) || '-' }}
|
||||
</span>
|
||||
</p>
|
||||
<p><strong>File Size:</strong> {{ getFileSizeInMB(book) }}</p>
|
||||
<p class="whitespace-nowrap flex items-center">
|
||||
<span class="font-bold mr-2">BookLore Progress:</span>
|
||||
<span class="inline-flex items-center">
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 rounded-full text-xs font-bold text-white"
|
||||
[ngClass]="getProgressColorClass(getProgressPercent(book))">
|
||||
{{ getProgressPercent(book) !== null ? getProgressPercent(book) + '%' : 'N/A' }}
|
||||
</span>
|
||||
@if (getProgressPercent(book) !== null) {
|
||||
<p-button
|
||||
pTooltip="Reset progress"
|
||||
tooltipPosition="bottom"
|
||||
icon="pi pi-refresh"
|
||||
size="small"
|
||||
severity="danger"
|
||||
text
|
||||
class="ml-1 custom-button-padding"
|
||||
(onClick)="resetProgress(book, ResetProgressTypes.BOOKLORE)">
|
||||
</p-button>
|
||||
}
|
||||
</span>
|
||||
</p>
|
||||
<p class="whitespace-nowrap flex items-center gap-2">
|
||||
<span class="font-bold">Metadata Match:</span>
|
||||
@if (book?.metadataMatchScore != null) {
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 rounded-lg text-xs font-bold text-gray-200 border"
|
||||
[ngClass]="getMatchScoreColorClass(book?.metadataMatchScore!)">
|
||||
{{ (book?.metadataMatchScore!) | number:'1.0-0' }}%
|
||||
</span>
|
||||
} @else {
|
||||
<span>-</span>
|
||||
}
|
||||
</p>
|
||||
<p>
|
||||
<strong>ISBN:</strong>
|
||||
@if (book?.metadata?.isbn13 || book?.metadata?.isbn10) {
|
||||
@@ -262,40 +309,30 @@
|
||||
</span>
|
||||
}
|
||||
</p>
|
||||
<p class="whitespace-nowrap flex items-center">
|
||||
<span class="font-bold mr-2">Progress:</span>
|
||||
<span class="inline-flex items-center">
|
||||
@if (book?.koreaderProgress && book.koreaderProgress?.percentage != null) {
|
||||
<p class="whitespace-nowrap flex items-center">
|
||||
<span class="font-bold mr-2">KOReader Progress:</span>
|
||||
<span class="inline-flex items-center">
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 rounded-full text-xs font-bold text-white"
|
||||
[ngClass]="getProgressColorClass(getProgressPercent(book))">
|
||||
{{ getProgressPercent(book) !== null ? getProgressPercent(book) + '%' : 'N/A' }}
|
||||
[ngClass]="getKoProgressColorClass(getKOReaderPercentage(book))">
|
||||
{{ getKOReaderPercentage(book) + '%' }}
|
||||
</span>
|
||||
@if (getProgressPercent(book) !== null) {
|
||||
<p-button
|
||||
pTooltip="Reset progress"
|
||||
tooltipPosition="bottom"
|
||||
icon="pi pi-refresh"
|
||||
size="small"
|
||||
severity="danger"
|
||||
text
|
||||
class="ml-1 custom-button-padding"
|
||||
(onClick)="resetProgress(book)">
|
||||
@if (getKOReaderPercentage(book) !== null) {
|
||||
<p-button
|
||||
pTooltip="Reset progress"
|
||||
tooltipPosition="bottom"
|
||||
icon="pi pi-refresh"
|
||||
size="small"
|
||||
severity="danger"
|
||||
text
|
||||
class="ml-1 custom-button-padding"
|
||||
(onClick)="resetProgress(book, ResetProgressTypes.KOREADER)">
|
||||
</p-button>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</p>
|
||||
<p class="whitespace-nowrap flex items-center gap-2">
|
||||
<span class="font-bold">Metadata Match:</span>
|
||||
@if (book?.metadataMatchScore != null) {
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 rounded-lg text-xs font-bold text-gray-200 border"
|
||||
[ngClass]="getMatchScoreColorClass(book?.metadataMatchScore!)">
|
||||
{{ (book?.metadataMatchScore!) | number:'1.0-0' }}%
|
||||
</span>
|
||||
} @else {
|
||||
<span>-</span>
|
||||
}
|
||||
</p>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -80,10 +80,50 @@
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.cover-progress-bar,
|
||||
.cover-progress-bar-ko-both,
|
||||
.cover-progress-bar-ko-only {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cover-progress-bar {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.cover-progress-bar-ko-both {
|
||||
bottom: 0;
|
||||
height: 12px;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.cover-progress-bar-ko-only {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
::ng-deep .p-inputchips {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
::ng-deep .progress-incomplete .p-progressbar-value {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
|
||||
::ng-deep .progress-complete .p-progressbar-value {
|
||||
background-color: #22c55e;
|
||||
}
|
||||
|
||||
::ng-deep .progress-incomplete-ko .p-progressbar-value {
|
||||
background-color: #fbbf24;
|
||||
}
|
||||
|
||||
::ng-deep .progress-complete-ko .p-progressbar-value {
|
||||
background-color: #8b5cf6;
|
||||
}
|
||||
|
||||
::ng-deep .custom-button-padding .p-button {
|
||||
padding-block: 0rem !important;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {filter, map, switchMap, take, tap} from 'rxjs/operators';
|
||||
import {Menu} from 'primeng/menu';
|
||||
import {InfiniteScrollDirective} from 'ngx-infinite-scroll';
|
||||
import {BookCardLiteComponent} from '../../../book/components/book-card-lite/book-card-lite-component';
|
||||
import {ResetProgressType, ResetProgressTypes} from '../../../shared/constants/reset-progress-type';
|
||||
|
||||
@Component({
|
||||
selector: 'app-metadata-viewer',
|
||||
@@ -74,6 +75,7 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
|
||||
{value: ReadStatus.PARTIALLY_READ, label: 'Partially Read'},
|
||||
{value: ReadStatus.ABANDONED, label: 'Abandoned'},
|
||||
{value: ReadStatus.WONT_READ, label: 'Won\'t Read'},
|
||||
{value: ReadStatus.UNSET, label: 'Unset'},
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -301,7 +303,7 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
|
||||
});
|
||||
}
|
||||
|
||||
resetProgress(book: Book): void {
|
||||
resetProgress(book: Book, type: ResetProgressType): void {
|
||||
this.confirmationService.confirm({
|
||||
message: `Reset reading progress for "${book.metadata?.title}"?`,
|
||||
header: 'Confirm Reset',
|
||||
@@ -310,7 +312,7 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
|
||||
rejectLabel: 'Cancel',
|
||||
acceptButtonStyleClass: 'p-button-danger',
|
||||
accept: () => {
|
||||
this.bookService.resetProgress(book.id).subscribe({
|
||||
this.bookService.resetProgress(book.id, type).subscribe({
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
@@ -429,11 +431,24 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
|
||||
return sizeKb != null ? `${(sizeKb / 1024).toFixed(2)} MB` : '-';
|
||||
}
|
||||
|
||||
getProgressPercent(book: Book | null): number | undefined {
|
||||
if (!book) return;
|
||||
return book.bookType === 'PDF' ? book.pdfProgress?.percentage
|
||||
: book.bookType === 'CBX' ? book.cbxProgress?.percentage
|
||||
: book.epubProgress?.percentage;
|
||||
getProgressPercent(book: Book): number | null {
|
||||
if (book.epubProgress?.percentage != null) {
|
||||
return book.epubProgress.percentage;
|
||||
}
|
||||
if (book.pdfProgress?.percentage != null) {
|
||||
return book.pdfProgress.percentage;
|
||||
}
|
||||
if (book.cbxProgress?.percentage != null) {
|
||||
return book.cbxProgress.percentage;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getKoProgressPercent(book: Book): number | null {
|
||||
if (book.koreaderProgress?.percentage != null) {
|
||||
return book.koreaderProgress.percentage;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getFileExtension(filePath?: string): string | null {
|
||||
@@ -520,6 +535,16 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
|
||||
return 'bg-blue-500';
|
||||
}
|
||||
|
||||
getKoProgressColorClass(progress: number | null | undefined): string {
|
||||
if (progress == null) return 'bg-gray-600';
|
||||
return 'bg-amber-500';
|
||||
}
|
||||
|
||||
getKOReaderPercentage(book: Book): number | null {
|
||||
const p = book?.koreaderProgress?.percentage;
|
||||
return p != null ? Math.round(p * 10) / 10 : null;
|
||||
}
|
||||
|
||||
getRatingTooltip(book: Book, source: 'amazon' | 'goodreads' | 'hardcover'): string {
|
||||
const meta = book?.metadata;
|
||||
if (!meta) return '';
|
||||
@@ -566,4 +591,6 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
protected readonly ResetProgressTypes = ResetProgressTypes;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<div class="w-full h-[calc(100dvh-10.5rem)] md:h-[calc(100dvh-11.65rem)] overflow-y-auto border rounded-lg enclosing-container">
|
||||
<div class="pt-2">
|
||||
<app-koreader-settings-component></app-koreader-settings-component>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
.enclosing-container {
|
||||
border-color: var(--p-content-border-color);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {KoreaderSettingsComponent} from '../koreader-settings-component/koreader-settings-component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-device-settings-component',
|
||||
imports: [
|
||||
KoreaderSettingsComponent
|
||||
],
|
||||
templateUrl: './device-settings-component.html',
|
||||
styleUrl: './device-settings-component.scss'
|
||||
})
|
||||
export class DeviceSettingsComponent {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<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>
|
||||
@@ -0,0 +1,105 @@
|
||||
import {Component, inject, OnInit} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {InputText} from 'primeng/inputtext';
|
||||
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';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-koreader-settings-component',
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
InputText,
|
||||
ToggleSwitch,
|
||||
Button,
|
||||
ToastModule
|
||||
],
|
||||
providers: [MessageService],
|
||||
templateUrl: './koreader-settings-component.html',
|
||||
styleUrls: ['./koreader-settings-component.scss']
|
||||
})
|
||||
export class KoreaderSettingsComponent implements OnInit {
|
||||
editMode = true;
|
||||
showPassword = false;
|
||||
koReaderSyncEnabled = false;
|
||||
koReaderUsername = '';
|
||||
koReaderPassword = '';
|
||||
credentialsSaved = false;
|
||||
readonly koreaderEndpoint = `${window.location.origin}/api/koreader`;
|
||||
|
||||
private readonly messageService = inject(MessageService);
|
||||
private readonly koreaderService = inject(KoreaderService);
|
||||
|
||||
ngOnInit() {
|
||||
this.koreaderService.getUser().subscribe({
|
||||
next: koreaderUser => {
|
||||
this.koReaderUsername = koreaderUser.username;
|
||||
this.koReaderPassword = koreaderUser.password;
|
||||
this.koReaderSyncEnabled = koreaderUser.syncEnabled;
|
||||
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.'});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get canSave(): boolean {
|
||||
const u = this.koReaderUsername?.trim() ?? '';
|
||||
const p = this.koReaderPassword ?? '';
|
||||
return u.length > 0 && p.length >= 6;
|
||||
}
|
||||
|
||||
onEditSave() {
|
||||
if (!this.editMode) {
|
||||
this.saveCredentials();
|
||||
}
|
||||
this.editMode = !this.editMode;
|
||||
}
|
||||
|
||||
onToggleEnabled(enabled: boolean) {
|
||||
this.koreaderService.toggleSync(enabled).subscribe({
|
||||
next: () => {
|
||||
this.koReaderSyncEnabled = enabled;
|
||||
this.messageService.add({severity: 'success', summary: 'Sync Updated', detail: `KOReader sync has been ${enabled ? 'enabled' : 'disabled'}.`});
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({severity: 'error', summary: 'Update Failed', detail: 'Unable to update KOReader sync setting. Please try again.'});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleShowPassword() {
|
||||
this.showPassword = !this.showPassword;
|
||||
}
|
||||
|
||||
saveCredentials() {
|
||||
this.koreaderService.createUser(this.koReaderUsername, this.koReaderPassword)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.credentialsSaved = true;
|
||||
this.messageService.add({severity: 'success', summary: 'Saved', detail: 'KOReader account saved successfully.'});
|
||||
},
|
||||
error: () =>
|
||||
this.messageService.add({severity: 'error', summary: 'Error', detail: 'Failed to save KOReader credentials. Please try again.'})
|
||||
});
|
||||
}
|
||||
|
||||
copyText(text: string) {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text).catch(err => {
|
||||
console.error('Copy failed', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,11 @@
|
||||
<i class="pi pi-globe"></i> OPDS
|
||||
</p-tab>
|
||||
}
|
||||
@if (userData.permissions.admin || userData.permissions.canSyncKoReader) {
|
||||
<p-tab [value]="SettingsTab.DeviceSettings">
|
||||
<i class="pi pi-mobile"></i> Devices
|
||||
</p-tab>
|
||||
}
|
||||
</p-tablist>
|
||||
<p-tabpanels>
|
||||
<p-tabpanel [value]="SettingsTab.ReaderSettings">
|
||||
@@ -57,6 +62,11 @@
|
||||
<app-opds-settings></app-opds-settings>
|
||||
</p-tabpanel>
|
||||
}
|
||||
@if (userData.permissions.admin || userData.permissions.canSyncKoReader) {
|
||||
<p-tabpanel [value]="SettingsTab.DeviceSettings">
|
||||
<app-device-settings-component></app-device-settings-component>
|
||||
</p-tabpanel>
|
||||
}
|
||||
</p-tabpanels>
|
||||
</p-tabs>
|
||||
</div>
|
||||
|
||||
@@ -12,10 +12,12 @@ import {ViewPreferencesParentComponent} from './view-preferences-parent/view-pre
|
||||
import {ReaderPreferences} from './reader-preferences/reader-preferences.component';
|
||||
import {MetadataSettingsComponent} from './metadata-settings-component/metadata-settings-component';
|
||||
import {OpdsSettingsComponent} from './global-preferences/opds-settings/opds-settings.component';
|
||||
import {DeviceSettingsComponent} from './device-settings-component/device-settings-component';
|
||||
|
||||
export enum SettingsTab {
|
||||
ReaderSettings = 'reader-settings',
|
||||
ViewPreferences = 'view-settings',
|
||||
DeviceSettings = 'device-settings',
|
||||
UserManagement = 'user-management',
|
||||
EmailSettings = 'email-settings',
|
||||
MetadataSettings = 'metadata-settings',
|
||||
@@ -40,7 +42,8 @@ export enum SettingsTab {
|
||||
ViewPreferencesParentComponent,
|
||||
ReaderPreferences,
|
||||
MetadataSettingsComponent,
|
||||
OpdsSettingsComponent
|
||||
OpdsSettingsComponent,
|
||||
DeviceSettingsComponent
|
||||
],
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrl: './settings.component.scss'
|
||||
|
||||
@@ -76,11 +76,26 @@
|
||||
<label>Edit Metadata</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<p-checkbox formControlName="permissionManipulateLibrary" [binary]="true"></p-checkbox>
|
||||
<label>Manage Library</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<p-checkbox formControlName="permissionEmailBook" [binary]="true"></p-checkbox>
|
||||
<label>Email Book</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<p-checkbox formControlName="permissionDeleteBook" [binary]="true"></p-checkbox>
|
||||
<label>Delete Book</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<p-checkbox formControlName="permissionSyncKoreader" [binary]="true"></p-checkbox>
|
||||
<label>KOReader 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>
|
||||
|
||||
@@ -47,7 +47,10 @@ export class CreateUserDialogComponent implements OnInit {
|
||||
permissionUpload: [false],
|
||||
permissionDownload: [false],
|
||||
permissionEditMetadata: [false],
|
||||
permissionManipulateLibrary: [false],
|
||||
permissionEmailBook: [false],
|
||||
permissionDeleteBook: [false],
|
||||
permissionSyncKoreader: [false],
|
||||
permissionAdmin: [false],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<th style="width: 80px;">Manage Library</th>
|
||||
<th style="width: 80px;">Email Books</th>
|
||||
<th style="width: 80px;">Delete Books</th>
|
||||
<th style="width: 80px;">KOReader Sync</th>
|
||||
<th style="width: 120px;">Edit</th>
|
||||
<th style="width: 80px;">Change Password</th>
|
||||
<th style="width: 80px;">Delete</th>
|
||||
@@ -123,6 +124,14 @@
|
||||
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canDeleteBook" disabled></p-checkbox>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@if (user.isEditing) {
|
||||
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canSyncKoReader"></p-checkbox>
|
||||
}
|
||||
@if (!user.isEditing) {
|
||||
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canSyncKoReader" disabled></p-checkbox>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<ng-container>
|
||||
@if (!user.isEditing) {
|
||||
|
||||
@@ -88,6 +88,7 @@ export interface UserSettings {
|
||||
metadataCenterViewMode: 'route' | 'dialog';
|
||||
entityViewPreferences: EntityViewPreferences;
|
||||
tableColumnPreference?: TableColumnPreference[];
|
||||
koReaderEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
@@ -104,6 +105,7 @@ export interface User {
|
||||
canDeleteBook: boolean;
|
||||
canEditMetadata: boolean;
|
||||
canManipulateLibrary: boolean;
|
||||
canSyncKoReader: boolean;
|
||||
};
|
||||
userSettings: UserSettings;
|
||||
provisioningMethod?: 'LOCAL' | 'OIDC' | 'REMOTE';
|
||||
@@ -199,6 +201,12 @@ export class UserService {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
updateUserSettingV2(userId: number, key: string, value: any): Observable<void> {
|
||||
const payload = { key, value };
|
||||
return this.http.put<void>(`${this.userUrl}/${userId}/settings`, payload);
|
||||
}
|
||||
|
||||
private startWebSocket(): void {
|
||||
const token = this.getToken();
|
||||
if (token) {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export const ResetProgressTypes = {
|
||||
KOREADER: 'KOREADER',
|
||||
BOOKLORE: 'BOOKLORE'
|
||||
} as const;
|
||||
|
||||
export type ResetProgressType = keyof typeof ResetProgressTypes;
|
||||
Reference in New Issue
Block a user