Add koreader sync support for book progress tracking

This commit is contained in:
Aditya Chandel
2025-08-08 09:38:20 -06:00
committed by GitHub
parent fa54fdec34
commit 9ae1c6e2dc
74 changed files with 1826 additions and 179 deletions

View File

@@ -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

View File

@@ -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);

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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()) {

View File

@@ -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));

View File

@@ -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));

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -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"));
}
}

View File

@@ -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();
}
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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());

View File

@@ -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;

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

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

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,5 @@
package com.adityachandel.booklore.model.enums;
public enum ResetProgressType {
BOOKLORE, KOREADER
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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"
));

View File

@@ -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);

View File

@@ -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();

View File

@@ -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());
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -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"));
}
}

View File

@@ -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());
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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">

View File

@@ -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;
}

View File

@@ -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
});
}

View File

@@ -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;

View File

@@ -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
});
},

View File

@@ -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 ------------------*/

View File

@@ -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;

View 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()}
});
}
}

View File

@@ -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">

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -0,0 +1,3 @@
.enclosing-container {
border-color: var(--p-content-border-color);
}

View File

@@ -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 {
}

View File

@@ -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>

View File

@@ -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);
});
}
}

View File

@@ -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>

View File

@@ -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'

View File

@@ -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>

View File

@@ -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],
});
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -0,0 +1,6 @@
export const ResetProgressTypes = {
KOREADER: 'KOREADER',
BOOKLORE: 'BOOKLORE'
} as const;
export type ResetProgressType = keyof typeof ResetProgressTypes;