mirror of
https://github.com/adityachandelgit/BookLore.git
synced 2026-03-16 16:42:08 -05:00
Merge pull request #1245 from booklore-app/develop
Merge develop into master for the release
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
# BookLore
|
||||

|
||||

|
||||

|
||||
|
||||

|
||||

|
||||

|
||||
[](https://discord.gg/Ee5hd458Uz)
|
||||
[](https://opencollective.com/booklore)
|
||||
[](https://venmo.com/AdityaChandel)
|
||||

|
||||
[](https://discord.gg/Ee5hd458Uz)
|
||||
[](https://opencollective.com/booklore)
|
||||
[](https://venmo.com/AdityaChandel)
|
||||
|
||||
> 🚨 **Important Announcement:**
|
||||
> Docker images have moved to new repositories:
|
||||
|
||||
-3
@@ -7,9 +7,7 @@ import com.adityachandel.booklore.exception.ApiError;
|
||||
import com.adityachandel.booklore.model.dto.BookLoreUser;
|
||||
import com.adityachandel.booklore.model.dto.request.UserLoginRequest;
|
||||
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
|
||||
import com.adityachandel.booklore.model.entity.OpdsUserEntity;
|
||||
import com.adityachandel.booklore.model.entity.RefreshTokenEntity;
|
||||
import com.adityachandel.booklore.repository.OpdsUserRepository;
|
||||
import com.adityachandel.booklore.repository.RefreshTokenRepository;
|
||||
import com.adityachandel.booklore.repository.UserRepository;
|
||||
import com.adityachandel.booklore.service.user.DefaultSettingInitializer;
|
||||
@@ -19,7 +17,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
|
||||
+6
-15
@@ -2,10 +2,10 @@ package com.adityachandel.booklore.config.security.service;
|
||||
|
||||
import com.adityachandel.booklore.config.security.userdetails.OpdsUserDetails;
|
||||
import com.adityachandel.booklore.exception.ApiError;
|
||||
import com.adityachandel.booklore.mapper.OpdsUserMapper;
|
||||
import com.adityachandel.booklore.mapper.OpdsUserV2Mapper;
|
||||
import com.adityachandel.booklore.model.dto.OpdsUserV2;
|
||||
import com.adityachandel.booklore.model.entity.OpdsUserV2Entity;
|
||||
import com.adityachandel.booklore.repository.OpdsUserV2Repository;
|
||||
import com.adityachandel.booklore.repository.OpdsUserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
@@ -16,23 +16,14 @@ import org.springframework.stereotype.Service;
|
||||
@RequiredArgsConstructor
|
||||
public class OpdsUserDetailsService implements UserDetailsService {
|
||||
|
||||
private final OpdsUserRepository opdsUserRepository;
|
||||
private final OpdsUserV2Repository opdsUserV2Repository;
|
||||
private final OpdsUserMapper opdsUserMapper;
|
||||
private final OpdsUserV2Mapper opdsUserV2Mapper;
|
||||
|
||||
@Override
|
||||
public OpdsUserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||
return opdsUserRepository.findByUsername(username)
|
||||
.map(user -> {
|
||||
var mappedUser = opdsUserMapper.toOpdsUser(user);
|
||||
return new OpdsUserDetails(null, mappedUser);
|
||||
})
|
||||
.orElseGet(() -> {
|
||||
var userV2 = opdsUserV2Repository.findByUsername(username)
|
||||
.orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(username));
|
||||
var mappedCredential = opdsUserV2Mapper.toDto(userV2);
|
||||
return new OpdsUserDetails(mappedCredential, null);
|
||||
});
|
||||
OpdsUserV2Entity userV2 = opdsUserV2Repository.findByUsername(username)
|
||||
.orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(username));
|
||||
OpdsUserV2 mappedCredential = opdsUserV2Mapper.toDto(userV2);
|
||||
return new OpdsUserDetails(mappedCredential, null);
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -2,11 +2,13 @@ package com.adityachandel.booklore.controller;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.BookReview;
|
||||
import com.adityachandel.booklore.service.BookReviewService;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@@ -17,8 +19,12 @@ public class BookReviewController {
|
||||
private final BookReviewService bookReviewService;
|
||||
|
||||
@GetMapping("/book/{bookId}")
|
||||
public List<BookReview> listByBook(@PathVariable Long bookId) {
|
||||
return bookReviewService.getByBookId(bookId);
|
||||
public ResponseEntity<List<BookReview>> listByBook(@PathVariable @Positive(message = "Book ID must be positive") Long bookId) {
|
||||
List<BookReview> reviews = bookReviewService.getByBookId(bookId);
|
||||
if (reviews.isEmpty()) {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
return ResponseEntity.ok(reviews);
|
||||
}
|
||||
|
||||
@PostMapping("/book/{bookId}/refresh")
|
||||
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
package com.adityachandel.booklore.controller;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.OpdsUser;
|
||||
import com.adityachandel.booklore.model.dto.request.OpdsUserCreateRequest;
|
||||
import com.adityachandel.booklore.model.dto.request.PasswordResetRequest;
|
||||
import com.adityachandel.booklore.service.opds.OpdsUserService;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@AllArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/opds-users")
|
||||
public class OpdsUserController {
|
||||
|
||||
private final OpdsUserService opdsUserService;
|
||||
|
||||
@PreAuthorize("@securityUtil.isAdmin()")
|
||||
@PostMapping
|
||||
public ResponseEntity<Void> createOpdsUser(@RequestBody OpdsUserCreateRequest request) {
|
||||
opdsUserService.createOpdsUser(request.getUsername(), request.getPassword());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PreAuthorize("@securityUtil.isAdmin()")
|
||||
@GetMapping
|
||||
public ResponseEntity<List<OpdsUser>> listOpdsUsers() {
|
||||
List<OpdsUser> users = opdsUserService.getOpdsUsers();
|
||||
return ResponseEntity.ok(users);
|
||||
}
|
||||
|
||||
@PreAuthorize("@securityUtil.isAdmin()")
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteOpdsUser(@PathVariable Long id) {
|
||||
opdsUserService.deleteOpdsUser(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PreAuthorize("@securityUtil.isAdmin()")
|
||||
@PutMapping("/{userId}/reset-password")
|
||||
public ResponseEntity<Void> resetPassword(@PathVariable("userId") Long userId, @RequestBody PasswordResetRequest resetRequest) {
|
||||
opdsUserService.resetPassword(userId, resetRequest.getPassword());
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import java.util.stream.Collectors;
|
||||
public interface BookMapper {
|
||||
|
||||
@Mapping(source = "library.id", target = "libraryId")
|
||||
@Mapping(source = "library.name", target = "libraryName")
|
||||
@Mapping(source = "libraryPath", target = "libraryPath", qualifiedByName = "mapLibraryPathIdOnly")
|
||||
@Mapping(source = "metadata", target = "metadata")
|
||||
@Mapping(source = "shelves", target = "shelves")
|
||||
@@ -30,6 +31,7 @@ public interface BookMapper {
|
||||
Book toBook(BookEntity bookEntity);
|
||||
|
||||
@Mapping(source = "library.id", target = "libraryId")
|
||||
@Mapping(source = "library.name", target = "libraryName")
|
||||
@Mapping(source = "libraryPath", target = "libraryPath", qualifiedByName = "mapLibraryPathIdOnly")
|
||||
@Mapping(source = "metadata", target = "metadata")
|
||||
@Mapping(source = "shelves", target = "shelves")
|
||||
@@ -37,14 +39,6 @@ public interface BookMapper {
|
||||
@Mapping(source = "additionalFiles", target = "supplementaryFiles", qualifiedByName = "mapSupplementaryFiles")
|
||||
Book toBookWithDescription(BookEntity bookEntity, @Context boolean includeDescription);
|
||||
|
||||
@Mapping(source = "library.id", target = "libraryId")
|
||||
@Mapping(source = "libraryPath", target = "libraryPath", qualifiedByName = "mapLibraryPathIdOnly")
|
||||
@Mapping(target = "metadata", ignore = true)
|
||||
@Mapping(target = "shelves", ignore = true)
|
||||
@Mapping(target = "alternativeFormats", ignore = true)
|
||||
@Mapping(target = "supplementaryFiles", ignore = true)
|
||||
Book toBookWithoutMetadataAndShelves(BookEntity bookEntity);
|
||||
|
||||
default Set<String> mapAuthors(Set<AuthorEntity> authors) {
|
||||
if (authors == null) return null;
|
||||
return authors.stream()
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.adityachandel.booklore.mapper;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.OpdsUser;
|
||||
import com.adityachandel.booklore.model.entity.OpdsUserEntity;
|
||||
import org.mapstruct.Mapper;
|
||||
|
||||
@Mapper(componentModel = "spring")
|
||||
public interface OpdsUserMapper {
|
||||
|
||||
OpdsUser toOpdsUser(OpdsUserEntity opdsUserEntity);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import java.util.stream.Collectors;
|
||||
public interface BookMapperV2 {
|
||||
|
||||
@Mapping(source = "library.id", target = "libraryId")
|
||||
@Mapping(source = "library.name", target = "libraryName")
|
||||
@Mapping(source = "libraryPath", target = "libraryPath", qualifiedByName = "mapLibraryPathIdOnly")
|
||||
@Mapping(target = "metadata", qualifiedByName = "mapMetadata")
|
||||
Book toDTO(BookEntity bookEntity);
|
||||
|
||||
@@ -17,6 +17,7 @@ public class Book {
|
||||
private Long id;
|
||||
private BookFileType bookType;
|
||||
private Long libraryId;
|
||||
private String libraryName;
|
||||
private String fileName;
|
||||
private String filePath;
|
||||
private String fileSubPath;
|
||||
|
||||
+1
@@ -11,4 +11,5 @@ public class MetadataBatchProgressNotification {
|
||||
private int total;
|
||||
private String message;
|
||||
private String status;
|
||||
private boolean isReview;
|
||||
}
|
||||
|
||||
+8
@@ -2,9 +2,17 @@ package com.adityachandel.booklore.model.dto.request;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Data
|
||||
public class FileMoveRequest {
|
||||
private Set<Long> bookIds;
|
||||
private List<Move> moves;
|
||||
|
||||
@Data
|
||||
public static class Move {
|
||||
private Long bookId;
|
||||
private Long targetLibraryId;
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -12,6 +12,7 @@ import lombok.AllArgsConstructor;
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MetadataRefreshOptions {
|
||||
private Long libraryId;
|
||||
@NotNull(message = "Default Provider cannot be null")
|
||||
private MetadataProvider allP1;
|
||||
private MetadataProvider allP2;
|
||||
|
||||
-1
@@ -11,7 +11,6 @@ import java.util.Set;
|
||||
public class MetadataRefreshRequest {
|
||||
@NotNull(message = "Refresh type cannot be null")
|
||||
private RefreshType refreshType;
|
||||
private Boolean quick;
|
||||
private Long libraryId;
|
||||
private Set<Long> bookIds;
|
||||
private MetadataRefreshOptions refreshOptions;
|
||||
|
||||
+1
@@ -7,6 +7,7 @@ public enum AppSettingKey {
|
||||
OIDC_PROVIDER_DETAILS("oidc_provider_details", true, true),
|
||||
|
||||
QUICK_BOOK_MATCH("quick_book_match", true, false),
|
||||
LIBRARY_METADATA_REFRESH_OPTIONS("library_metadata_refresh_options", true, false),
|
||||
OIDC_AUTO_PROVISION_DETAILS("oidc_auto_provision_details", true, false),
|
||||
SIDEBAR_LIBRARY_SORTING("sidebar_library_sorting", true, false),
|
||||
SIDEBAR_SHELF_SORTING("sidebar_shelf_sorting", true, false),
|
||||
|
||||
+4
-1
@@ -6,12 +6,15 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class AppSettings {
|
||||
private MetadataRefreshOptions metadataRefreshOptions;
|
||||
private MetadataRefreshOptions defaultMetadataRefreshOptions;
|
||||
private List<MetadataRefreshOptions> libraryMetadataRefreshOptions;
|
||||
private boolean autoBookSearch;
|
||||
private boolean similarBookRecommendation;
|
||||
private boolean opdsServerEnabled;
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ import java.util.Set;
|
||||
public class MetadataPublicReviewsSettings {
|
||||
|
||||
private boolean downloadEnabled;
|
||||
private boolean autoDownloadEnabled;
|
||||
private Set<ReviewProviderConfig> providers;
|
||||
|
||||
@Builder
|
||||
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
package com.adityachandel.booklore.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "opds_user")
|
||||
public class OpdsUserEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "username", nullable = false)
|
||||
private String username;
|
||||
|
||||
@Column(name = "password", nullable = false)
|
||||
private String password;
|
||||
|
||||
}
|
||||
@@ -13,7 +13,6 @@ public enum Topic {
|
||||
BOOK_METADATA_BATCH_PROGRESS("/queue/book-metadata-batch-progress"),
|
||||
BOOKDROP_FILE("/queue/bookdrop-file"),
|
||||
DUPLICATE_FILE("/queue/duplicate-file"),
|
||||
TASK("/queue/task"),
|
||||
LOG("/queue/log");
|
||||
|
||||
private final String path;
|
||||
|
||||
+4
-1
@@ -174,5 +174,8 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
|
||||
@Query("DELETE FROM BookEntity b WHERE b.deletedAt IS NOT NULL AND b.deletedAt < :cutoff")
|
||||
int deleteAllByDeletedAtBefore(Instant cutoff);
|
||||
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("UPDATE BookEntity b SET b.library.id = :libraryId WHERE b.id = :bookId")
|
||||
void updateLibraryId(@Param("bookId") Long bookId, @Param("libraryId") Long libraryId);
|
||||
}
|
||||
|
||||
+3
-1
@@ -4,7 +4,9 @@ import com.adityachandel.booklore.model.entity.LibraryPathEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface LibraryPathRepository extends JpaRepository<LibraryPathEntity, Long> {
|
||||
|
||||
Optional<LibraryPathEntity> findByLibraryIdAndPath(Long libraryId, String path);
|
||||
}
|
||||
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
package com.adityachandel.booklore.repository;
|
||||
|
||||
import com.adityachandel.booklore.model.entity.OpdsUserEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface OpdsUserRepository extends JpaRepository<OpdsUserEntity, Long> {
|
||||
boolean existsByUsername(String username);
|
||||
|
||||
Optional<OpdsUserEntity> findByUsername(String username);
|
||||
}
|
||||
+32
-17
@@ -37,25 +37,40 @@ public class BookReviewService {
|
||||
private final AuthenticationService authenticationService;
|
||||
|
||||
public List<BookReview> getByBookId(Long bookId) {
|
||||
List<BookReview> reviews = bookReviewRepository.findByBookMetadataBookId(bookId).stream()
|
||||
.map(mapper::toDto)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (!appSettingService.getAppSettings().getMetadataPublicReviewsSettings().isDownloadEnabled() || !reviews.isEmpty()) {
|
||||
return reviews;
|
||||
}
|
||||
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
if (!(user.getPermissions().isAdmin() || user.getPermissions().isCanManipulateLibrary())) {
|
||||
return reviews;
|
||||
}
|
||||
|
||||
BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
bookReviewUpdateService.addReviewsToBook(fetchBookReviews(bookEntity), bookEntity.getMetadata());
|
||||
bookRepository.save(bookEntity);
|
||||
return bookReviewRepository.findByBookMetadataBookId(bookId).stream()
|
||||
|
||||
List<BookReview> existingReviews = bookReviewRepository.findByBookMetadataBookId(bookId).stream()
|
||||
.map(mapper::toDto)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
MetadataPublicReviewsSettings reviewSettings = appSettingService.getAppSettings().getMetadataPublicReviewsSettings();
|
||||
|
||||
// Return existing reviews if download is disabled or reviews already exist
|
||||
if (!reviewSettings.isDownloadEnabled() || !existingReviews.isEmpty()) {
|
||||
return existingReviews;
|
||||
}
|
||||
|
||||
// Check user permissions for auto-download
|
||||
BookLoreUser currentUser = authenticationService.getAuthenticatedUser();
|
||||
boolean hasPermission = currentUser.getPermissions().isAdmin() || currentUser.getPermissions().isCanManipulateLibrary();
|
||||
|
||||
if (!hasPermission || !reviewSettings.isAutoDownloadEnabled()) {
|
||||
return existingReviews;
|
||||
}
|
||||
|
||||
try {
|
||||
List<BookReview> fetchedReviews = fetchBookReviews(bookEntity);
|
||||
if (!fetchedReviews.isEmpty()) {
|
||||
bookReviewUpdateService.addReviewsToBook(fetchedReviews, bookEntity.getMetadata());
|
||||
bookRepository.save(bookEntity);
|
||||
return bookReviewRepository.findByBookMetadataBookId(bookId).stream()
|
||||
.map(mapper::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
return existingReviews;
|
||||
}
|
||||
|
||||
public List<BookReview> fetchBookReviews(BookEntity bookEntity) {
|
||||
@@ -95,7 +110,7 @@ public class BookReviewService {
|
||||
bookRepository.save(bookEntity);
|
||||
|
||||
bookReviewRepository.deleteByBookMetadataBookId(bookId);
|
||||
|
||||
|
||||
List<BookReview> freshReviews = fetchBookReviews(bookEntity);
|
||||
bookReviewUpdateService.addReviewsToBook(freshReviews, bookEntity.getMetadata());
|
||||
bookRepository.save(bookEntity);
|
||||
|
||||
+40
-22
@@ -9,6 +9,7 @@ import com.adityachandel.booklore.model.dto.response.MetadataTaskDetailsResponse
|
||||
import com.adityachandel.booklore.model.entity.MetadataFetchJobEntity;
|
||||
import com.adityachandel.booklore.model.entity.MetadataFetchProposalEntity;
|
||||
import com.adityachandel.booklore.model.enums.FetchedMetadataProposalStatus;
|
||||
import com.adityachandel.booklore.model.enums.MetadataFetchTaskStatus;
|
||||
import com.adityachandel.booklore.repository.MetadataFetchJobRepository;
|
||||
import com.adityachandel.booklore.repository.MetadataFetchProposalRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -30,25 +31,27 @@ public class MetadataTaskService {
|
||||
|
||||
public Optional<MetadataTaskDetailsResponse> getTaskWithProposals(String taskId) {
|
||||
return metadataFetchTaskRepository.findById(taskId)
|
||||
.map(task -> {
|
||||
List<FetchedProposal> proposals = task.getProposals().stream()
|
||||
.filter(p -> p.getStatus() == FetchedMetadataProposalStatus.FETCHED)
|
||||
.map(fetchedProposalMapper::toDto)
|
||||
.toList();
|
||||
.map(this::buildTaskDetailsResponse);
|
||||
}
|
||||
|
||||
MetadataFetchTask taskDto = MetadataFetchTask.builder()
|
||||
.id(task.getTaskId())
|
||||
.status(task.getStatus())
|
||||
.completed(task.getCompletedBooks())
|
||||
.totalBooks(task.getTotalBooksCount())
|
||||
.startedAt(task.getStartedAt())
|
||||
.completedAt(task.getCompletedAt())
|
||||
.initiatedBy(task.getUserId())
|
||||
.proposals(proposals)
|
||||
.build();
|
||||
private MetadataTaskDetailsResponse buildTaskDetailsResponse(MetadataFetchJobEntity task) {
|
||||
List<FetchedProposal> proposals = task.getProposals().stream()
|
||||
.filter(p -> p.getStatus() == FetchedMetadataProposalStatus.FETCHED)
|
||||
.map(fetchedProposalMapper::toDto)
|
||||
.toList();
|
||||
|
||||
return new MetadataTaskDetailsResponse(taskDto);
|
||||
});
|
||||
MetadataFetchTask taskDto = MetadataFetchTask.builder()
|
||||
.id(task.getTaskId())
|
||||
.status(task.getStatus())
|
||||
.completed(task.getCompletedBooks())
|
||||
.totalBooks(task.getTotalBooksCount())
|
||||
.startedAt(task.getStartedAt())
|
||||
.completedAt(task.getCompletedAt())
|
||||
.initiatedBy(task.getUserId())
|
||||
.proposals(proposals)
|
||||
.build();
|
||||
|
||||
return new MetadataTaskDetailsResponse(taskDto);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -87,16 +90,17 @@ public class MetadataTaskService {
|
||||
}
|
||||
|
||||
public List<MetadataBatchProgressNotification> getActiveTasks() {
|
||||
List<MetadataFetchJobEntity> tasks = metadataFetchTaskRepository.findAllWithProposals(); // Ensure this uses a fetch join
|
||||
List<MetadataFetchJobEntity> tasks = metadataFetchTaskRepository.findAllWithProposals();
|
||||
|
||||
return tasks.stream()
|
||||
.filter(task -> task.getStatus() == MetadataFetchTaskStatus.COMPLETED || task.getStatus() == MetadataFetchTaskStatus.ERROR)
|
||||
.map(task -> {
|
||||
List<MetadataFetchProposalEntity> proposals = task.getProposals();
|
||||
List<MetadataFetchProposalEntity> remaining = proposals.stream()
|
||||
.filter(p -> p.getStatus() != FetchedMetadataProposalStatus.REJECTED)
|
||||
.toList();
|
||||
|
||||
int total = remaining.size();
|
||||
int total;
|
||||
long acceptedCount = remaining.stream()
|
||||
.filter(p -> p.getStatus() == FetchedMetadataProposalStatus.ACCEPTED)
|
||||
.count();
|
||||
@@ -104,14 +108,28 @@ public class MetadataTaskService {
|
||||
.filter(p -> p.getStatus() == FetchedMetadataProposalStatus.FETCHED)
|
||||
.count();
|
||||
|
||||
String message = String.format("Metadata review pending for %d of %d books", fetchedCount, total);
|
||||
String message;
|
||||
String status;
|
||||
int completedCount = task.getCompletedBooks() != null ? task.getCompletedBooks() : 0;
|
||||
|
||||
if (task.getStatus() == MetadataFetchTaskStatus.ERROR) {
|
||||
total = task.getTotalBooksCount() != null ? task.getTotalBooksCount() : remaining.size();
|
||||
message = String.format("Metadata fetch failed, processed %d of %d books.", completedCount, total);
|
||||
status = "ERROR";
|
||||
} else {
|
||||
total = remaining.size();
|
||||
message = String.format("Metadata fetch completed! %d books need review.", fetchedCount);
|
||||
status = "COMPLETED";
|
||||
completedCount = (int) acceptedCount;
|
||||
}
|
||||
|
||||
return new MetadataBatchProgressNotification(
|
||||
task.getTaskId(),
|
||||
(int) acceptedCount,
|
||||
completedCount,
|
||||
total,
|
||||
message,
|
||||
"COMPLETED"
|
||||
status,
|
||||
true
|
||||
);
|
||||
})
|
||||
.filter(n -> n.getTotal() > 0)
|
||||
|
||||
+4
-1
@@ -5,10 +5,12 @@ import com.adityachandel.booklore.model.dto.request.MetadataRefreshOptions;
|
||||
import com.adityachandel.booklore.model.dto.settings.*;
|
||||
import com.adityachandel.booklore.model.entity.AppSettingEntity;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import jakarta.transaction.Transactional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -82,7 +84,8 @@ public class AppSettingService {
|
||||
AppSettings.AppSettingsBuilder builder = AppSettings.builder();
|
||||
builder.remoteAuthEnabled(appProperties.getRemoteAuth().isEnabled());
|
||||
|
||||
builder.metadataRefreshOptions(settingPersistenceHelper.getJsonSetting(settingsMap, AppSettingKey.QUICK_BOOK_MATCH, MetadataRefreshOptions.class, settingPersistenceHelper.getDefaultMetadataRefreshOptions(), true));
|
||||
builder.defaultMetadataRefreshOptions(settingPersistenceHelper.getJsonSetting(settingsMap, AppSettingKey.QUICK_BOOK_MATCH, MetadataRefreshOptions.class, settingPersistenceHelper.getDefaultMetadataRefreshOptions(), true));
|
||||
builder.libraryMetadataRefreshOptions(settingPersistenceHelper.getJsonSetting(settingsMap, AppSettingKey.LIBRARY_METADATA_REFRESH_OPTIONS, new TypeReference<List<MetadataRefreshOptions>>() {}, List.of(), true));
|
||||
builder.oidcProviderDetails(settingPersistenceHelper.getJsonSetting(settingsMap, AppSettingKey.OIDC_PROVIDER_DETAILS, OidcProviderDetails.class, null, false));
|
||||
builder.oidcAutoProvisionDetails(settingPersistenceHelper.getJsonSetting(settingsMap, AppSettingKey.OIDC_AUTO_PROVISION_DETAILS, OidcAutoProvisionDetails.class, null, false));
|
||||
builder.metadataProviderSettings(settingPersistenceHelper.getJsonSetting(settingsMap, AppSettingKey.METADATA_PROVIDER_SETTINGS, MetadataProviderSettings.class, settingPersistenceHelper.getDefaultMetadataProviderSettings(), true));
|
||||
|
||||
+19
-1
@@ -6,6 +6,7 @@ import com.adityachandel.booklore.model.entity.AppSettingEntity;
|
||||
import com.adityachandel.booklore.model.enums.MetadataProvider;
|
||||
import com.adityachandel.booklore.repository.AppSettingsRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -36,10 +37,20 @@ public class SettingPersistenceHelper {
|
||||
}
|
||||
|
||||
public <T> T getJsonSetting(Map<String, String> settingsMap, AppSettingKey key, Class<T> clazz, T defaultValue, boolean persistDefault) {
|
||||
return getJsonSettingInternal(settingsMap, key, defaultValue, persistDefault,
|
||||
json -> objectMapper.readValue(json, clazz));
|
||||
}
|
||||
|
||||
public <T> T getJsonSetting(Map<String, String> settingsMap, AppSettingKey key, TypeReference<T> typeReference, T defaultValue, boolean persistDefault) {
|
||||
return getJsonSettingInternal(settingsMap, key, defaultValue, persistDefault,
|
||||
json -> objectMapper.readValue(json, typeReference));
|
||||
}
|
||||
|
||||
private <T> T getJsonSettingInternal(Map<String, String> settingsMap, AppSettingKey key, T defaultValue, boolean persistDefault, JsonDeserializer<T> deserializer) {
|
||||
String json = settingsMap.get(key.toString());
|
||||
if (json != null && !json.isBlank()) {
|
||||
try {
|
||||
return objectMapper.readValue(json, clazz);
|
||||
return deserializer.deserialize(json);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new RuntimeException("Failed to parse " + key, e);
|
||||
}
|
||||
@@ -54,6 +65,11 @@ public class SettingPersistenceHelper {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface JsonDeserializer<T> {
|
||||
T deserialize(String json) throws JsonProcessingException;
|
||||
}
|
||||
|
||||
public String serializeSettingValue(AppSettingKey key, Object val) throws JsonProcessingException {
|
||||
return key.isJson() ? objectMapper.writeValueAsString(val) : val.toString();
|
||||
}
|
||||
@@ -141,6 +157,7 @@ public class SettingPersistenceHelper {
|
||||
);
|
||||
|
||||
return new MetadataRefreshOptions(
|
||||
null,
|
||||
MetadataProvider.GoodReads,
|
||||
MetadataProvider.Amazon,
|
||||
MetadataProvider.Google,
|
||||
@@ -191,6 +208,7 @@ public class SettingPersistenceHelper {
|
||||
public MetadataPublicReviewsSettings getDefaultMetadataPublicReviewsSettings() {
|
||||
return MetadataPublicReviewsSettings.builder()
|
||||
.downloadEnabled(true)
|
||||
.autoDownloadEnabled(false)
|
||||
.providers(Set.of(
|
||||
MetadataPublicReviewsSettings.ReviewProviderConfig.builder()
|
||||
.provider(MetadataProvider.Amazon)
|
||||
|
||||
+5
-5
@@ -3,6 +3,7 @@ package com.adityachandel.booklore.service.bookdrop;
|
||||
import com.adityachandel.booklore.exception.ApiError;
|
||||
import com.adityachandel.booklore.model.dto.Book;
|
||||
import com.adityachandel.booklore.model.dto.BookMetadata;
|
||||
import com.adityachandel.booklore.model.dto.request.MetadataRefreshOptions;
|
||||
import com.adityachandel.booklore.model.dto.request.MetadataRefreshRequest;
|
||||
import com.adityachandel.booklore.model.dto.settings.AppSettings;
|
||||
import com.adityachandel.booklore.model.entity.BookdropFileEntity;
|
||||
@@ -61,13 +62,12 @@ public class BookdropMetadataService {
|
||||
BookdropFileEntity entity = getOrThrow(bookdropFileId);
|
||||
|
||||
AppSettings appSettings = appSettingService.getAppSettings();
|
||||
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
|
||||
.refreshOptions(appSettings.getMetadataRefreshOptions())
|
||||
.build();
|
||||
|
||||
MetadataRefreshOptions refreshOptions = appSettings.getDefaultMetadataRefreshOptions();
|
||||
|
||||
BookMetadata initial = objectMapper.readValue(entity.getOriginalMetadata(), BookMetadata.class);
|
||||
|
||||
List<MetadataProvider> providers = metadataRefreshService.prepareProviders(request);
|
||||
List<MetadataProvider> providers = metadataRefreshService.prepareProviders(refreshOptions);
|
||||
Book book = Book.builder()
|
||||
.fileName(entity.getFileName())
|
||||
.metadata(initial)
|
||||
@@ -82,7 +82,7 @@ public class BookdropMetadataService {
|
||||
}
|
||||
|
||||
Map<MetadataProvider, BookMetadata> metadataMap = metadataRefreshService.fetchMetadataForBook(providers, book);
|
||||
BookMetadata fetchedMetadata = metadataRefreshService.buildFetchMetadata(book.getId(), request, metadataMap);
|
||||
BookMetadata fetchedMetadata = metadataRefreshService.buildFetchMetadata(book.getId(), refreshOptions, metadataMap);
|
||||
String fetchedJson = objectMapper.writeValueAsString(fetchedMetadata);
|
||||
|
||||
entity.setFetchedMetadata(fetchedJson);
|
||||
|
||||
+100
-11
@@ -12,10 +12,13 @@ import com.adityachandel.booklore.util.PathPatternResolver;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static com.adityachandel.booklore.model.enums.PermissionType.ADMIN;
|
||||
import static com.adityachandel.booklore.model.enums.PermissionType.MANIPULATE_LIBRARY;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -30,10 +33,21 @@ public class FileMoveService {
|
||||
private final NotificationService notificationService;
|
||||
private final UnifiedFileMoveService unifiedFileMoveService;
|
||||
|
||||
@Transactional
|
||||
public void moveFiles(FileMoveRequest request) {
|
||||
Set<Long> bookIds = request.getBookIds();
|
||||
log.info("Moving {} books in batches of {}", bookIds.size(), BATCH_SIZE);
|
||||
List<Long> bookIds = request.getBookIds().stream().toList();
|
||||
List<FileMoveRequest.Move> moves = request.getMoves() != null ? request.getMoves() : List.of();
|
||||
|
||||
log.info("Moving {} books with {} specific moves in batches of {}", bookIds.size(), moves.size(), BATCH_SIZE);
|
||||
|
||||
Map<Long, Long> bookToTargetLibraryMap = moves.stream()
|
||||
.collect(Collectors.toMap(
|
||||
FileMoveRequest.Move::getBookId,
|
||||
FileMoveRequest.Move::getTargetLibraryId
|
||||
));
|
||||
|
||||
Map<Long, List<Book>> libraryRemovals = new HashMap<>();
|
||||
Map<Long, List<Book>> libraryAdditions = new HashMap<>();
|
||||
List<Book> allUpdatedBooks = new ArrayList<>();
|
||||
int totalProcessed = 0;
|
||||
int offset = 0;
|
||||
@@ -41,14 +55,17 @@ public class FileMoveService {
|
||||
while (offset < bookIds.size()) {
|
||||
log.info("Processing batch {}/{}", (offset / BATCH_SIZE) + 1, (bookIds.size() + BATCH_SIZE - 1) / BATCH_SIZE);
|
||||
|
||||
List<BookEntity> batchBooks = bookQueryService.findWithMetadataByIdsWithPagination(bookIds, offset, BATCH_SIZE);
|
||||
List<Long> batchBookIds = bookIds.subList(offset, Math.min(offset + BATCH_SIZE, bookIds.size()));
|
||||
Set<Long> batchBookIdSet = new HashSet<>(batchBookIds);
|
||||
|
||||
List<BookEntity> batchBooks = bookQueryService.findWithMetadataByIdsWithPagination(batchBookIdSet, offset, BATCH_SIZE);
|
||||
|
||||
if (batchBooks.isEmpty()) {
|
||||
log.info("No more books at offset {}", offset);
|
||||
break;
|
||||
}
|
||||
|
||||
List<Book> batchUpdatedBooks = processBookChunk(batchBooks);
|
||||
List<Book> batchUpdatedBooks = processBookChunk(batchBooks, bookToTargetLibraryMap, libraryRemovals, libraryAdditions);
|
||||
allUpdatedBooks.addAll(batchUpdatedBooks);
|
||||
|
||||
totalProcessed += batchBooks.size();
|
||||
@@ -59,20 +76,56 @@ public class FileMoveService {
|
||||
|
||||
log.info("Move completed: {} books processed, {} updated", totalProcessed, allUpdatedBooks.size());
|
||||
sendUpdateNotifications(allUpdatedBooks);
|
||||
sendCrossLibraryMoveNotifications(libraryRemovals, libraryAdditions);
|
||||
|
||||
}
|
||||
|
||||
public String generatePathFromPattern(BookEntity book, String pattern) {
|
||||
return PathPatternResolver.resolvePattern(book, pattern);
|
||||
}
|
||||
|
||||
private List<Book> processBookChunk(List<BookEntity> books) {
|
||||
private List<Book> processBookChunk(List<BookEntity> books, Map<Long, Long> bookToTargetLibraryMap, Map<Long, List<Book>> libraryRemovals, Map<Long, List<Book>> libraryAdditions) {
|
||||
List<Book> updatedBooks = new ArrayList<>();
|
||||
|
||||
unifiedFileMoveService.moveBatchBookFiles(books, new UnifiedFileMoveService.BatchMoveCallback() {
|
||||
Map<Long, Long> originalLibraryIds = new HashMap<>();
|
||||
for (BookEntity book : books) {
|
||||
if (book.getLibraryPath() != null && book.getLibraryPath().getLibrary() != null) {
|
||||
originalLibraryIds.put(book.getId(), book.getLibraryPath().getLibrary().getId());
|
||||
}
|
||||
}
|
||||
|
||||
unifiedFileMoveService.moveBatchBookFiles(books, bookToTargetLibraryMap, new UnifiedFileMoveService.BatchMoveCallback() {
|
||||
@Override
|
||||
public void onBookMoved(BookEntity book) {
|
||||
bookRepository.save(book);
|
||||
updatedBooks.add(bookMapper.toBook(book));
|
||||
Long targetLibraryId = bookToTargetLibraryMap.get(book.getId());
|
||||
Long originalSourceLibraryId = originalLibraryIds.get(book.getId());
|
||||
|
||||
log.debug("Processing moved book {}: targetLibraryId={}, originalSourceLibraryId={}", book.getId(), targetLibraryId, originalSourceLibraryId);
|
||||
|
||||
if (targetLibraryId != null && originalSourceLibraryId != null && !targetLibraryId.equals(originalSourceLibraryId)) {
|
||||
log.info("Cross-library move detected for book {}: {} -> {}", book.getId(), originalSourceLibraryId, targetLibraryId);
|
||||
|
||||
Book bookDtoForRemoval = bookMapper.toBookWithDescription(book, false);
|
||||
bookDtoForRemoval.setLibraryId(originalSourceLibraryId);
|
||||
libraryRemovals.computeIfAbsent(originalSourceLibraryId, k -> new ArrayList<>()).add(bookDtoForRemoval);
|
||||
log.debug("Added book {} to removal list for library {}", book.getId(), originalSourceLibraryId);
|
||||
|
||||
bookRepository.updateLibraryId(book.getId(), targetLibraryId);
|
||||
log.debug("Updated library_id for book {} to {}", book.getId(), targetLibraryId);
|
||||
|
||||
BookEntity savedBook = bookRepository.saveAndFlush(book);
|
||||
|
||||
Book updatedBookDto = bookMapper.toBookWithDescription(savedBook, false);
|
||||
updatedBookDto.setLibraryId(targetLibraryId);
|
||||
libraryAdditions.computeIfAbsent(targetLibraryId, k -> new ArrayList<>()).add(updatedBookDto);
|
||||
log.debug("Added book {} to addition list for library {} with libraryId {}", book.getId(), targetLibraryId, updatedBookDto.getLibraryId());
|
||||
|
||||
updatedBooks.add(updatedBookDto);
|
||||
} else {
|
||||
log.debug("Same library move for book {} or no target specified. Target: {}, Original: {}", book.getId(), targetLibraryId, originalSourceLibraryId);
|
||||
BookEntity savedBook = bookRepository.save(book);
|
||||
updatedBooks.add(bookMapper.toBook(savedBook));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -82,6 +135,8 @@ public class FileMoveService {
|
||||
}
|
||||
});
|
||||
|
||||
log.info("Processed {} books, {} library removals tracked, {} library additions tracked", updatedBooks.size(), libraryRemovals.size(), libraryAdditions.size());
|
||||
|
||||
return updatedBooks;
|
||||
}
|
||||
|
||||
@@ -90,4 +145,38 @@ public class FileMoveService {
|
||||
notificationService.sendMessage(Topic.BOOK_METADATA_BATCH_UPDATE, updatedBooks);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendCrossLibraryMoveNotifications(Map<Long, List<Book>> libraryRemovals, Map<Long, List<Book>> libraryAdditions) {
|
||||
log.info("Sending cross-library move notifications: {} removals, {} additions",
|
||||
libraryRemovals.size(), libraryAdditions.size());
|
||||
|
||||
for (Map.Entry<Long, List<Book>> entry : libraryRemovals.entrySet()) {
|
||||
List<Long> removedBookIds = entry.getValue().stream()
|
||||
.map(Book::getId)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
log.info("Notifying removal of {} books from library {}: {}", removedBookIds.size(), entry.getKey(), removedBookIds);
|
||||
try {
|
||||
notificationService.sendMessageToPermissions(Topic.BOOKS_REMOVE, removedBookIds, Set.of(ADMIN, MANIPULATE_LIBRARY));
|
||||
log.info("Successfully sent removal notification for library {}", entry.getKey());
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send removal notification for library {}: {}", entry.getKey(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
for (Map.Entry<Long, List<Book>> entry : libraryAdditions.entrySet()) {
|
||||
List<Book> addedBooks = entry.getValue();
|
||||
|
||||
log.info("Notifying addition of {} books to library {}", addedBooks.size(), entry.getKey());
|
||||
try {
|
||||
for (Book book : addedBooks) {
|
||||
log.debug("Sending BOOK_ADD notification for book {} to library {}", book.getId(), entry.getKey());
|
||||
notificationService.sendMessageToPermissions(Topic.BOOK_ADD, book, Set.of(ADMIN, MANIPULATE_LIBRARY));
|
||||
}
|
||||
log.info("Successfully sent {} addition notifications for library {}", addedBooks.size(), entry.getKey());
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send addition notifications for library {}: {}", entry.getKey(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+109
@@ -4,7 +4,9 @@ import com.adityachandel.booklore.model.dto.BookMetadata;
|
||||
import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity;
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.model.entity.LibraryEntity;
|
||||
import com.adityachandel.booklore.model.entity.LibraryPathEntity;
|
||||
import com.adityachandel.booklore.repository.BookAdditionalFileRepository;
|
||||
import com.adityachandel.booklore.repository.LibraryPathRepository;
|
||||
import com.adityachandel.booklore.service.appsettings.AppSettingService;
|
||||
import com.adityachandel.booklore.util.PathPatternResolver;
|
||||
import lombok.AllArgsConstructor;
|
||||
@@ -30,6 +32,7 @@ public class FileMovingHelper {
|
||||
|
||||
private final BookAdditionalFileRepository bookAdditionalFileRepository;
|
||||
private final AppSettingService appSettingService;
|
||||
private final LibraryPathRepository libraryPathRepository;
|
||||
|
||||
/**
|
||||
* Generates the new file path based on the library's file naming pattern
|
||||
@@ -177,6 +180,53 @@ public class FileMovingHelper {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a book file to a different library with the target library's naming pattern
|
||||
*/
|
||||
public boolean moveBookFileToLibrary(BookEntity book, LibraryEntity targetLibrary) throws IOException {
|
||||
if (!hasRequiredPathComponents(book)) {
|
||||
log.error("Missing required path components for book id {}. Cannot move to different library.", book.getId());
|
||||
return false;
|
||||
}
|
||||
|
||||
Path oldFilePath = book.getFullFilePath();
|
||||
String pattern = getFileNamingPattern(targetLibrary);
|
||||
|
||||
// Generate new file path in target library
|
||||
Path newFilePath = generateNewFilePathForLibrary(book, targetLibrary, pattern);
|
||||
|
||||
if (oldFilePath.equals(newFilePath)) {
|
||||
log.debug("Source and destination paths are identical for book id {}. Skipping cross-library move.", book.getId());
|
||||
return false;
|
||||
}
|
||||
|
||||
moveBookFileAndUpdatePathsForLibrary(book, oldFilePath, newFilePath, targetLibrary);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the new file path for a book in a different library
|
||||
*/
|
||||
public Path generateNewFilePathForLibrary(BookEntity book, LibraryEntity targetLibrary, String pattern) {
|
||||
String newRelativePathStr = PathPatternResolver.resolvePattern(book, pattern);
|
||||
if (newRelativePathStr.startsWith("/") || newRelativePathStr.startsWith("\\")) {
|
||||
newRelativePathStr = newRelativePathStr.substring(1);
|
||||
}
|
||||
|
||||
Path targetLibraryRoot = getLibraryRootPath(targetLibrary);
|
||||
return targetLibraryRoot.resolve(newRelativePathStr).normalize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the root path for a library (uses the first library path)
|
||||
*/
|
||||
private Path getLibraryRootPath(LibraryEntity library) {
|
||||
if (library.getLibraryPaths() == null || library.getLibraryPaths().isEmpty()) {
|
||||
throw new RuntimeException("Library " + library.getName() + " has no paths configured");
|
||||
}
|
||||
return Paths.get(library.getLibraryPaths().getFirst().getPath()).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
private void moveBookFileAndUpdatePaths(BookEntity book, Path oldFilePath, Path newFilePath) throws IOException {
|
||||
moveFile(oldFilePath, newFilePath);
|
||||
updateBookPaths(book, newFilePath);
|
||||
@@ -190,6 +240,65 @@ public class FileMovingHelper {
|
||||
}
|
||||
}
|
||||
|
||||
private void moveBookFileAndUpdatePathsForLibrary(BookEntity book, Path oldFilePath, Path newFilePath, LibraryEntity targetLibrary) throws IOException {
|
||||
moveFile(oldFilePath, newFilePath);
|
||||
updateBookPathsForLibrary(book, newFilePath, targetLibrary);
|
||||
|
||||
// Clean up empty directories in source library
|
||||
// IMPORTANT: We need to collect ALL library roots to avoid deleting any library root directory
|
||||
try {
|
||||
Set<Path> allLibraryRoots = getAllLibraryRoots();
|
||||
deleteEmptyParentDirsUpToLibraryFolders(oldFilePath.getParent(), allLibraryRoots);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to clean up empty directories after moving book ID {}: {}", book.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all library root paths from all libraries to ensure we never delete any library root
|
||||
*/
|
||||
private Set<Path> getAllLibraryRoots() {
|
||||
Set<Path> allRoots = new HashSet<>();
|
||||
try {
|
||||
// Get all libraries and collect their root paths
|
||||
libraryPathRepository.findAll().forEach(libraryPath -> {
|
||||
try {
|
||||
Path rootPath = Paths.get(libraryPath.getPath()).toAbsolutePath().normalize();
|
||||
allRoots.add(rootPath);
|
||||
log.debug("Added library root to protection list: {}", rootPath);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to process library path {}: {}", libraryPath.getPath(), e.getMessage());
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to collect all library roots: {}", e.getMessage());
|
||||
}
|
||||
return allRoots;
|
||||
}
|
||||
|
||||
private void updateBookPathsForLibrary(BookEntity book, Path newFilePath, LibraryEntity targetLibrary) {
|
||||
String newFileName = newFilePath.getFileName().toString();
|
||||
Path targetLibraryRoot = getLibraryRootPath(targetLibrary);
|
||||
Path newRelativeSubPath = targetLibraryRoot.relativize(newFilePath.getParent());
|
||||
String newFileSubPath = newRelativeSubPath.toString().replace('\\', '/');
|
||||
|
||||
// Find or create the appropriate LibraryPathEntity for the target library
|
||||
String targetLibraryPath = targetLibrary.getLibraryPaths().getFirst().getPath();
|
||||
LibraryPathEntity targetLibraryPathEntity = libraryPathRepository
|
||||
.findByLibraryIdAndPath(targetLibrary.getId(), targetLibraryPath)
|
||||
.orElseThrow(() -> new RuntimeException("LibraryPath not found for library " + targetLibrary.getId() + " and path " + targetLibraryPath));
|
||||
|
||||
// Update only the path-related fields, avoid touching library entity relationships
|
||||
// that might trigger cascade operations
|
||||
book.setLibraryPath(targetLibraryPathEntity); // This should update library_path_id
|
||||
book.setFileSubPath(newFileSubPath);
|
||||
book.setFileName(newFileName);
|
||||
|
||||
// DO NOT set book.setLibrary(targetLibrary) to avoid cascade issues
|
||||
log.debug("Updated book {} path references: libraryPathId={}, newPath={}",
|
||||
book.getId(), targetLibraryPathEntity.getId(), newFilePath);
|
||||
}
|
||||
|
||||
private void moveAdditionalFile(BookEntity book, BookAdditionalFileEntity additionalFile, String pattern, Map<String, Integer> fileNameCounter) throws IOException {
|
||||
Path oldAdditionalFilePath = additionalFile.getFullFilePath();
|
||||
if (!Files.exists(oldAdditionalFilePath)) {
|
||||
|
||||
+96
-27
@@ -1,6 +1,8 @@
|
||||
package com.adityachandel.booklore.service.file;
|
||||
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.model.entity.LibraryEntity;
|
||||
import com.adityachandel.booklore.repository.LibraryRepository;
|
||||
import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -20,6 +22,7 @@ public class UnifiedFileMoveService {
|
||||
private final FileMovingHelper fileMovingHelper;
|
||||
private final MonitoredFileOperationService monitoredFileOperationService;
|
||||
private final MonitoringRegistrationService monitoringRegistrationService;
|
||||
private final LibraryRepository libraryRepository;
|
||||
|
||||
/**
|
||||
* Moves a single book file to match the library's file naming pattern.
|
||||
@@ -80,16 +83,16 @@ public class UnifiedFileMoveService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves multiple book files in batches with library-level monitoring protection.
|
||||
* Used for bulk file operations where many files need to be moved.
|
||||
* Moves multiple book files in batches with cross-library support and library-level monitoring protection.
|
||||
* Used for bulk file operations where many files need to be moved, potentially across libraries.
|
||||
*/
|
||||
public void moveBatchBookFiles(List<BookEntity> books, BatchMoveCallback callback) {
|
||||
public void moveBatchBookFiles(List<BookEntity> books, Map<Long, Long> bookToTargetLibraryMap, BatchMoveCallback callback) {
|
||||
if (books.isEmpty()) {
|
||||
log.debug("No books to move");
|
||||
return;
|
||||
}
|
||||
|
||||
Set<Long> libraryIds = new HashSet<>();
|
||||
Set<Long> allLibraryIds = new HashSet<>();
|
||||
Map<Long, Set<Path>> libraryToRootsMap = new HashMap<>();
|
||||
|
||||
// Collect library information for monitoring protection
|
||||
@@ -100,23 +103,39 @@ public class UnifiedFileMoveService {
|
||||
Path oldFilePath = book.getFullFilePath();
|
||||
if (!Files.exists(oldFilePath)) continue;
|
||||
|
||||
Long libraryId = book.getLibraryPath().getLibrary().getId();
|
||||
Path libraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize();
|
||||
// Source library
|
||||
Long sourceLibraryId = book.getLibraryPath().getLibrary().getId();
|
||||
Path sourceLibraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize();
|
||||
libraryToRootsMap.computeIfAbsent(sourceLibraryId, k -> new HashSet<>()).add(sourceLibraryRoot);
|
||||
allLibraryIds.add(sourceLibraryId);
|
||||
|
||||
libraryToRootsMap.computeIfAbsent(libraryId, k -> new HashSet<>()).add(libraryRoot);
|
||||
libraryIds.add(libraryId);
|
||||
// Target library (if different)
|
||||
Long targetLibraryId = bookToTargetLibraryMap.get(book.getId());
|
||||
if (targetLibraryId != null && !targetLibraryId.equals(sourceLibraryId)) {
|
||||
LibraryEntity targetLibrary = libraryRepository.findById(targetLibraryId).orElse(null);
|
||||
if (targetLibrary != null && targetLibrary.getLibraryPaths() != null && !targetLibrary.getLibraryPaths().isEmpty()) {
|
||||
Path targetLibraryRoot = Paths.get(targetLibrary.getLibraryPaths().getFirst().getPath()).toAbsolutePath().normalize();
|
||||
libraryToRootsMap.computeIfAbsent(targetLibraryId, k -> new HashSet<>()).add(targetLibraryRoot);
|
||||
allLibraryIds.add(targetLibraryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister libraries for batch operation
|
||||
// Unregister all affected libraries for batch operation
|
||||
unregisterLibrariesBatch(libraryToRootsMap);
|
||||
|
||||
try {
|
||||
// Small delay to let any pending file watcher events settle
|
||||
try {
|
||||
Thread.sleep(500);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.warn("Interrupted during pre-move delay");
|
||||
}
|
||||
|
||||
// Process each book
|
||||
for (BookEntity book : books) {
|
||||
if (book.getMetadata() == null) continue;
|
||||
|
||||
String pattern = fileMovingHelper.getFileNamingPattern(book.getLibraryPath().getLibrary());
|
||||
|
||||
if (!fileMovingHelper.hasRequiredPathComponents(book)) continue;
|
||||
|
||||
Path oldFilePath = book.getFullFilePath();
|
||||
@@ -128,15 +147,39 @@ public class UnifiedFileMoveService {
|
||||
log.debug("Moving book {}: '{}'", book.getId(), book.getMetadata().getTitle());
|
||||
|
||||
try {
|
||||
boolean moved = fileMovingHelper.moveBookFileIfNeeded(book, pattern);
|
||||
if (moved) {
|
||||
log.debug("Book {} moved successfully", book.getId());
|
||||
callback.onBookMoved(book);
|
||||
Long targetLibraryId = bookToTargetLibraryMap.get(book.getId());
|
||||
boolean moved = false;
|
||||
|
||||
if (targetLibraryId != null && !targetLibraryId.equals(book.getLibraryPath().getLibrary().getId())) {
|
||||
// Cross-library move
|
||||
LibraryEntity targetLibrary = libraryRepository.findById(targetLibraryId).orElse(null);
|
||||
if (targetLibrary != null) {
|
||||
moved = fileMovingHelper.moveBookFileToLibrary(book, targetLibrary);
|
||||
if (moved) {
|
||||
log.debug("Book {} moved to library {} successfully", book.getId(), targetLibraryId);
|
||||
}
|
||||
} else {
|
||||
log.error("Target library {} not found for book {}", targetLibraryId, book.getId());
|
||||
callback.onBookMoveFailed(book, new RuntimeException("Target library not found"));
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Same library move (existing functionality)
|
||||
String pattern = fileMovingHelper.getFileNamingPattern(book.getLibraryPath().getLibrary());
|
||||
moved = fileMovingHelper.moveBookFileIfNeeded(book, pattern);
|
||||
if (moved) {
|
||||
log.debug("Book {} moved within library successfully", book.getId());
|
||||
}
|
||||
}
|
||||
|
||||
// Move additional files if any
|
||||
if (book.getAdditionalFiles() != null && !book.getAdditionalFiles().isEmpty()) {
|
||||
fileMovingHelper.moveAdditionalFiles(book, pattern);
|
||||
if (moved) {
|
||||
callback.onBookMoved(book);
|
||||
|
||||
// Move additional files if any
|
||||
if (book.getAdditionalFiles() != null && !book.getAdditionalFiles().isEmpty()) {
|
||||
String pattern = fileMovingHelper.getFileNamingPattern(book.getLibraryPath().getLibrary());
|
||||
fileMovingHelper.moveAdditionalFiles(book, pattern);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Move failed for book {}: {}", book.getId(), e.getMessage(), e);
|
||||
@@ -144,7 +187,7 @@ public class UnifiedFileMoveService {
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay to let filesystem operations settle
|
||||
// Longer delay to let filesystem operations settle before re-registering monitoring
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException e) {
|
||||
@@ -153,18 +196,27 @@ public class UnifiedFileMoveService {
|
||||
}
|
||||
|
||||
} finally {
|
||||
// Re-register libraries
|
||||
// Re-register all affected libraries
|
||||
registerLibrariesBatch(libraryToRootsMap);
|
||||
}
|
||||
}
|
||||
|
||||
// Overloaded method for backward compatibility with same-library moves
|
||||
public void moveBatchBookFiles(List<BookEntity> books, BatchMoveCallback callback) {
|
||||
moveBatchBookFiles(books, new HashMap<>(), callback);
|
||||
}
|
||||
|
||||
private void unregisterLibrariesBatch(Map<Long, Set<Path>> libraryToRootsMap) {
|
||||
log.debug("Unregistering {} libraries for batch move", libraryToRootsMap.size());
|
||||
|
||||
for (Map.Entry<Long, Set<Path>> entry : libraryToRootsMap.entrySet()) {
|
||||
Long libraryId = entry.getKey();
|
||||
monitoringRegistrationService.unregisterLibrary(libraryId);
|
||||
log.debug("Unregistered library {}", libraryId);
|
||||
try {
|
||||
monitoringRegistrationService.unregisterLibrary(libraryId);
|
||||
log.debug("Unregistered library {}", libraryId);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to unregister library {}: {}", libraryId, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,11 +227,28 @@ public class UnifiedFileMoveService {
|
||||
Long libraryId = entry.getKey();
|
||||
Set<Path> libraryRoots = entry.getValue();
|
||||
|
||||
for (Path libraryRoot : libraryRoots) {
|
||||
if (Files.exists(libraryRoot) && Files.isDirectory(libraryRoot)) {
|
||||
monitoringRegistrationService.registerLibraryPaths(libraryId, libraryRoot);
|
||||
log.debug("Re-registered library {} at {}", libraryId, libraryRoot);
|
||||
// Verify library still exists before re-registering
|
||||
try {
|
||||
LibraryEntity library = libraryRepository.findById(libraryId).orElse(null);
|
||||
if (library == null) {
|
||||
log.warn("Library {} no longer exists, skipping re-registration", libraryId);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (Path libraryRoot : libraryRoots) {
|
||||
if (Files.exists(libraryRoot) && Files.isDirectory(libraryRoot)) {
|
||||
try {
|
||||
monitoringRegistrationService.registerLibraryPaths(libraryId, libraryRoot);
|
||||
log.debug("Re-registered library {} at {}", libraryId, libraryRoot);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to re-register library {} at {}: {}", libraryId, libraryRoot, e.getMessage());
|
||||
}
|
||||
} else {
|
||||
log.debug("Library root {} no longer exists or is not a directory, skipping re-registration", libraryRoot);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error verifying library {} during re-registration: {}", libraryId, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+99
-84
@@ -17,7 +17,6 @@ import com.adityachandel.booklore.model.websocket.Topic;
|
||||
import com.adityachandel.booklore.repository.BookRepository;
|
||||
import com.adityachandel.booklore.repository.LibraryRepository;
|
||||
import com.adityachandel.booklore.repository.MetadataFetchJobRepository;
|
||||
import com.adityachandel.booklore.repository.MetadataFetchProposalRepository;
|
||||
import com.adityachandel.booklore.service.NotificationService;
|
||||
import com.adityachandel.booklore.service.appsettings.AppSettingService;
|
||||
import com.adityachandel.booklore.service.metadata.parser.BookParser;
|
||||
@@ -27,8 +26,6 @@ import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
@@ -46,7 +43,6 @@ public class MetadataRefreshService {
|
||||
|
||||
private final LibraryRepository libraryRepository;
|
||||
private final MetadataFetchJobRepository metadataFetchJobRepository;
|
||||
private final MetadataFetchProposalRepository metadataFetchProposalRepository;
|
||||
private final BookMapper bookMapper;
|
||||
private final BookMetadataUpdater bookMetadataUpdater;
|
||||
private final NotificationService notificationService;
|
||||
@@ -58,50 +54,71 @@ public class MetadataRefreshService {
|
||||
|
||||
|
||||
public void refreshMetadata(MetadataRefreshRequest request, Long userId, String jobId) {
|
||||
final Set<Long> bookIds = null;
|
||||
final int totalBooks;
|
||||
try {
|
||||
if (Boolean.TRUE.equals(request.getQuick())) {
|
||||
AppSettings appSettings = appSettingService.getAppSettings();
|
||||
request.setRefreshOptions(appSettings.getMetadataRefreshOptions());
|
||||
}
|
||||
AppSettings appSettings = appSettingService.getAppSettings();
|
||||
|
||||
List<MetadataProvider> providers = prepareProviders(request);
|
||||
Set<Long> bookIds = getBookEntities(request);
|
||||
final boolean isLibraryRefresh = request.getRefreshType() == MetadataRefreshRequest.RefreshType.LIBRARY;
|
||||
final MetadataRefreshOptions requestRefreshOptions = request.getRefreshOptions();
|
||||
|
||||
boolean isReviewMode = Boolean.TRUE.equals(request.getRefreshOptions().getReviewBeforeApply());
|
||||
MetadataFetchJobEntity task;
|
||||
final boolean useRequestOptions = requestRefreshOptions != null;
|
||||
final MetadataRefreshOptions libraryRefreshOptions = !useRequestOptions && isLibraryRefresh ? resolveMetadataRefreshOptions(request.getLibraryId(), appSettings) : null;
|
||||
final List<MetadataProvider> fixedProviders = useRequestOptions ?
|
||||
prepareProviders(requestRefreshOptions) :
|
||||
(isLibraryRefresh ? prepareProviders(libraryRefreshOptions) : null);
|
||||
|
||||
if (isReviewMode) {
|
||||
task = MetadataFetchJobEntity.builder()
|
||||
.taskId(jobId)
|
||||
.userId(userId)
|
||||
.status(MetadataFetchTaskStatus.IN_PROGRESS)
|
||||
.startedAt(Instant.now())
|
||||
.totalBooksCount(bookIds.size())
|
||||
.completedBooks(0)
|
||||
.build();
|
||||
metadataFetchJobRepository.save(task);
|
||||
} else {
|
||||
task = null;
|
||||
}
|
||||
final Set<Long> actualBookIds = getBookEntities(request);
|
||||
totalBooks = actualBookIds.size();
|
||||
|
||||
MetadataRefreshOptions reviewModeOptions = requestRefreshOptions != null ?
|
||||
requestRefreshOptions :
|
||||
(libraryRefreshOptions != null ? libraryRefreshOptions : appSettings.getDefaultMetadataRefreshOptions());
|
||||
boolean isReviewMode = Boolean.TRUE.equals(reviewModeOptions.getReviewBeforeApply());
|
||||
|
||||
MetadataFetchJobEntity task = MetadataFetchJobEntity.builder()
|
||||
.taskId(jobId)
|
||||
.userId(userId)
|
||||
.status(MetadataFetchTaskStatus.IN_PROGRESS)
|
||||
.startedAt(Instant.now())
|
||||
.totalBooksCount(totalBooks)
|
||||
.completedBooks(0)
|
||||
.build();
|
||||
metadataFetchJobRepository.save(task);
|
||||
|
||||
TransactionTemplate txTemplate = new TransactionTemplate(transactionManager);
|
||||
int completedCount = 0;
|
||||
|
||||
for (Long bookId : bookIds) {
|
||||
checkForInterruption(jobId, task, bookIds.size());
|
||||
for (Long bookId : actualBookIds) {
|
||||
checkForInterruption(jobId, task, totalBooks);
|
||||
int finalCompletedCount = completedCount;
|
||||
txTemplate.execute(status -> {
|
||||
BookEntity book = bookRepository.findAllWithMetadataByIds(Collections.singleton(bookId))
|
||||
.stream().findFirst()
|
||||
.orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
try {
|
||||
checkForInterruption(jobId, task, bookIds.size());
|
||||
checkForInterruption(jobId, task, totalBooks);
|
||||
if (book.getMetadata().areAllFieldsLocked()) {
|
||||
log.info("Skipping locked book: {}", book.getFileName());
|
||||
sendTaskNotification(jobId, "Skipped locked book: " + book.getMetadata().getTitle(), TaskStatus.IN_PROGRESS);
|
||||
sendBatchProgressNotification(jobId, finalCompletedCount, totalBooks, "Skipped locked book: " + book.getMetadata().getTitle(), MetadataFetchTaskStatus.IN_PROGRESS, isReviewMode);
|
||||
return null;
|
||||
}
|
||||
reportProgressIfNeeded(task, jobId, finalCompletedCount, bookIds.size(), book);
|
||||
|
||||
MetadataRefreshOptions refreshOptions;
|
||||
List<MetadataProvider> providers;
|
||||
|
||||
if (useRequestOptions) {
|
||||
refreshOptions = requestRefreshOptions;
|
||||
providers = fixedProviders;
|
||||
} else if (isLibraryRefresh) {
|
||||
refreshOptions = libraryRefreshOptions;
|
||||
providers = fixedProviders;
|
||||
} else {
|
||||
refreshOptions = resolveMetadataRefreshOptions(book.getLibrary().getId(), appSettings);
|
||||
providers = prepareProviders(refreshOptions);
|
||||
}
|
||||
|
||||
reportProgressIfNeeded(task, jobId, finalCompletedCount, totalBooks, book, isReviewMode);
|
||||
Map<MetadataProvider, BookMetadata> metadataMap = fetchMetadataForBook(providers, book);
|
||||
if (providers.contains(GoodReads)) {
|
||||
try {
|
||||
@@ -112,13 +129,16 @@ public class MetadataRefreshService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
BookMetadata fetched = buildFetchMetadata(book.getId(), request, metadataMap);
|
||||
if (isReviewMode) {
|
||||
BookMetadata fetched = buildFetchMetadata(book.getId(), refreshOptions, metadataMap);
|
||||
|
||||
boolean bookReviewMode = Boolean.TRUE.equals(refreshOptions.getReviewBeforeApply());
|
||||
if (bookReviewMode) {
|
||||
saveProposal(task, book.getId(), fetched);
|
||||
} else {
|
||||
updateBookMetadata(book, fetched, request.getRefreshOptions().isRefreshCovers(), request.getRefreshOptions().isMergeCategories());
|
||||
sendTaskProgressNotification(jobId, finalCompletedCount + 1, bookIds.size(), "Metadata updated: " + book.getMetadata().getTitle());
|
||||
updateBookMetadata(book, fetched, refreshOptions.isRefreshCovers(), refreshOptions.isMergeCategories());
|
||||
}
|
||||
|
||||
sendBatchProgressNotification(jobId, finalCompletedCount + 1, totalBooks, "Processed: " + book.getMetadata().getTitle(), MetadataFetchTaskStatus.IN_PROGRESS, bookReviewMode);
|
||||
} catch (Exception e) {
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
log.info("Processing interrupted for book: {}", book.getFileName());
|
||||
@@ -126,7 +146,7 @@ public class MetadataRefreshService {
|
||||
return null;
|
||||
}
|
||||
log.error("Metadata update failed for book: {}", book.getFileName(), e);
|
||||
sendTaskNotification(jobId, String.format("Failed to process: %s - %s", book.getMetadata().getTitle(), e.getMessage()), TaskStatus.FAILED);
|
||||
sendBatchProgressNotification(jobId, finalCompletedCount, totalBooks, String.format("Failed to process: %s - %s", book.getMetadata().getTitle(), e.getMessage()), MetadataFetchTaskStatus.ERROR, isReviewMode);
|
||||
}
|
||||
bookRepository.saveAndFlush(book);
|
||||
return null;
|
||||
@@ -134,26 +154,40 @@ public class MetadataRefreshService {
|
||||
completedCount++;
|
||||
}
|
||||
|
||||
if (isReviewMode) completeTask(task, completedCount, bookIds.size());
|
||||
completeTask(task, completedCount, totalBooks, isReviewMode);
|
||||
log.info("Metadata refresh task {} completed successfully", jobId);
|
||||
|
||||
sendTaskNotification(jobId, String.format("Metadata refresh completed successfully - processed %d books", completedCount), TaskStatus.COMPLETED);
|
||||
|
||||
} catch (RuntimeException e) {
|
||||
if (e.getCause() instanceof InterruptedException) {
|
||||
log.info("Metadata refresh task {} cancelled successfully", jobId);
|
||||
return;
|
||||
}
|
||||
log.error("Fatal error during metadata refresh", e);
|
||||
sendTaskNotification(jobId, "Fatal error during metadata refresh: " + e.getMessage(), TaskStatus.FAILED);
|
||||
int totalBooksForError = 0;
|
||||
sendBatchProgressNotification(jobId, 0, totalBooksForError, "Fatal error during metadata refresh: " + e.getMessage(), MetadataFetchTaskStatus.ERROR, false);
|
||||
throw e;
|
||||
} catch (Exception fatal) {
|
||||
log.error("Fatal error during metadata refresh", fatal);
|
||||
sendTaskNotification(jobId, "Fatal error during metadata refresh: " + fatal.getMessage(), TaskStatus.FAILED);
|
||||
int totalBooksForError = bookIds != null ? bookIds.size() : 0;
|
||||
sendBatchProgressNotification(jobId, 0, totalBooksForError, "Fatal error during metadata refresh: " + fatal.getMessage(), MetadataFetchTaskStatus.ERROR, false);
|
||||
throw fatal;
|
||||
}
|
||||
}
|
||||
|
||||
MetadataRefreshOptions resolveMetadataRefreshOptions(Long libraryId, AppSettings appSettings) {
|
||||
MetadataRefreshOptions defaultOptions = appSettings.getDefaultMetadataRefreshOptions();
|
||||
List<MetadataRefreshOptions> libraryOptions = appSettings.getLibraryMetadataRefreshOptions();
|
||||
|
||||
if (libraryId != null && libraryOptions != null) {
|
||||
return libraryOptions.stream()
|
||||
.filter(options -> libraryId.equals(options.getLibraryId()))
|
||||
.findFirst()
|
||||
.orElse(defaultOptions);
|
||||
}
|
||||
|
||||
return defaultOptions;
|
||||
}
|
||||
|
||||
public Map<MetadataProvider, BookMetadata> fetchMetadataForBook(List<MetadataProvider> providers, Book book) {
|
||||
return providers.stream()
|
||||
.map(provider -> createInterruptibleMetadataFuture(() -> fetchTopMetadataFromAProvider(provider, book)))
|
||||
@@ -212,7 +246,7 @@ public class MetadataRefreshService {
|
||||
private void checkForInterruption(String jobId, MetadataFetchJobEntity task, int totalBooks) {
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
log.info("Metadata refresh task {} cancelled by user request", jobId);
|
||||
sendTaskNotification(jobId, "Task cancelled by user", TaskStatus.CANCELLED);
|
||||
sendBatchProgressNotification(jobId, 0, totalBooks, "Task cancelled by user", MetadataFetchTaskStatus.ERROR, false);
|
||||
if (task != null) {
|
||||
failTask(task, totalBooks);
|
||||
}
|
||||
@@ -220,53 +254,35 @@ public class MetadataRefreshService {
|
||||
}
|
||||
}
|
||||
|
||||
private void sendTaskNotification(String taskId, String message, TaskStatus status) {
|
||||
notificationService.sendMessage(Topic.TASK, TaskMessage.builder()
|
||||
.taskId(taskId)
|
||||
.taskType(EventTaskType.METADATA_REFRESH)
|
||||
.message(message)
|
||||
.status(status)
|
||||
.build());
|
||||
}
|
||||
|
||||
private void sendTaskProgressNotification(String taskId, int current, int total, String message) {
|
||||
sendTaskNotification(taskId, String.format("(%d/%d) %s", current, total, message), TaskStatus.IN_PROGRESS);
|
||||
}
|
||||
|
||||
private void reportProgressIfNeeded(MetadataFetchJobEntity task, String taskId, int completedCount, int total, BookEntity book) {
|
||||
private void reportProgressIfNeeded(MetadataFetchJobEntity task, String taskId, int completedCount, int total, BookEntity book, boolean isReviewMode) {
|
||||
if (task == null) return;
|
||||
task.setCompletedBooks(completedCount);
|
||||
metadataFetchJobRepository.save(task);
|
||||
String message = String.format("Processing '%s'", book.getMetadata().getTitle());
|
||||
sendBatchProgressNotification(taskId, completedCount, total, message, MetadataFetchTaskStatus.IN_PROGRESS, isReviewMode);
|
||||
}
|
||||
|
||||
private void sendBatchProgressNotification(String taskId, int current, int total, String message, MetadataFetchTaskStatus status, boolean isReview) {
|
||||
notificationService.sendMessage(Topic.BOOK_METADATA_BATCH_PROGRESS,
|
||||
new MetadataBatchProgressNotification(
|
||||
taskId, completedCount, total, message, MetadataFetchTaskStatus.IN_PROGRESS.name()
|
||||
taskId, current, total, message, status.name(), isReview
|
||||
));
|
||||
}
|
||||
|
||||
private void completeTask(MetadataFetchJobEntity task, int completed, int total) {
|
||||
private void completeTask(MetadataFetchJobEntity task, int completed, int total, boolean isReviewMode) {
|
||||
task.setStatus(MetadataFetchTaskStatus.COMPLETED);
|
||||
task.setCompletedAt(Instant.now());
|
||||
task.setCompletedBooks(completed);
|
||||
metadataFetchJobRepository.save(task);
|
||||
|
||||
notificationService.sendMessage(Topic.BOOK_METADATA_BATCH_PROGRESS,
|
||||
new MetadataBatchProgressNotification(
|
||||
task.getTaskId(), completed, total, "Metadata batch update completed",
|
||||
MetadataFetchTaskStatus.COMPLETED.name()
|
||||
));
|
||||
sendBatchProgressNotification(task.getTaskId(), completed, total, "Batch metadata fetch successfully completed!", MetadataFetchTaskStatus.COMPLETED, isReviewMode);
|
||||
}
|
||||
|
||||
private void failTask(MetadataFetchJobEntity task, int total) {
|
||||
task.setStatus(MetadataFetchTaskStatus.ERROR);
|
||||
task.setCompletedAt(Instant.now());
|
||||
metadataFetchJobRepository.save(task);
|
||||
|
||||
notificationService.sendMessage(Topic.BOOK_METADATA_BATCH_PROGRESS,
|
||||
new MetadataBatchProgressNotification(
|
||||
task.getTaskId(), 0, total, "Error: " + "Task was cancelled",
|
||||
MetadataFetchTaskStatus.ERROR.name()
|
||||
));
|
||||
sendBatchProgressNotification(task.getTaskId(), 0, total, "Error: " + "Task was cancelled", MetadataFetchTaskStatus.ERROR, false);
|
||||
}
|
||||
|
||||
private void saveProposal(MetadataFetchJobEntity job, Long bookId, BookMetadata metadata) throws JsonProcessingException {
|
||||
@@ -296,13 +312,13 @@ public class MetadataRefreshService {
|
||||
}
|
||||
}
|
||||
|
||||
public List<MetadataProvider> prepareProviders(MetadataRefreshRequest request) {
|
||||
Set<MetadataProvider> allProviders = new HashSet<>(getAllProvidersUsingIndividualFields(request));
|
||||
public List<MetadataProvider> prepareProviders(MetadataRefreshOptions refreshOptions) {
|
||||
Set<MetadataProvider> allProviders = new HashSet<>(getAllProvidersUsingIndividualFields(refreshOptions));
|
||||
return new ArrayList<>(allProviders);
|
||||
}
|
||||
|
||||
protected Set<MetadataProvider> getAllProvidersUsingIndividualFields(MetadataRefreshRequest request) {
|
||||
MetadataRefreshOptions.FieldOptions fieldOptions = request.getRefreshOptions().getFieldOptions();
|
||||
protected Set<MetadataProvider> getAllProvidersUsingIndividualFields(MetadataRefreshOptions refreshOptions) {
|
||||
MetadataRefreshOptions.FieldOptions fieldOptions = refreshOptions.getFieldOptions();
|
||||
Set<MetadataProvider> uniqueProviders = new HashSet<>();
|
||||
|
||||
if (fieldOptions != null) {
|
||||
@@ -349,9 +365,9 @@ public class MetadataRefreshService {
|
||||
.build();
|
||||
}
|
||||
|
||||
public BookMetadata buildFetchMetadata(Long bookId, MetadataRefreshRequest request, Map<MetadataProvider, BookMetadata> metadataMap) {
|
||||
public BookMetadata buildFetchMetadata(Long bookId, MetadataRefreshOptions refreshOptions, Map<MetadataProvider, BookMetadata> metadataMap) {
|
||||
BookMetadata metadata = BookMetadata.builder().bookId(bookId).build();
|
||||
MetadataRefreshOptions.FieldOptions fieldOptions = request.getRefreshOptions().getFieldOptions();
|
||||
MetadataRefreshOptions.FieldOptions fieldOptions = refreshOptions.getFieldOptions();
|
||||
|
||||
metadata.setTitle(resolveFieldAsString(metadataMap, fieldOptions.getTitle(), BookMetadata::getTitle));
|
||||
metadata.setDescription(resolveFieldAsString(metadataMap, fieldOptions.getDescription(), BookMetadata::getDescription));
|
||||
@@ -378,24 +394,24 @@ public class MetadataRefreshService {
|
||||
metadata.setComicvineId(metadataMap.get(Comicvine).getComicvineId());
|
||||
}
|
||||
|
||||
if (request.getRefreshOptions().isMergeCategories()) {
|
||||
if (refreshOptions.isMergeCategories()) {
|
||||
metadata.setCategories(getAllCategories(metadataMap, fieldOptions.getCategories(), BookMetadata::getCategories));
|
||||
} else {
|
||||
metadata.setCategories(resolveFieldAsList(metadataMap, fieldOptions.getCategories(), BookMetadata::getCategories));
|
||||
}
|
||||
metadata.setThumbnailUrl(resolveFieldAsString(metadataMap, fieldOptions.getCover(), BookMetadata::getThumbnailUrl));
|
||||
|
||||
if (request.getRefreshOptions().getAllP4() != null) {
|
||||
setOtherUnspecifiedMetadata(metadataMap, metadata, request.getRefreshOptions().getAllP4());
|
||||
if (refreshOptions.getAllP4() != null) {
|
||||
setOtherUnspecifiedMetadata(metadataMap, metadata, refreshOptions.getAllP4());
|
||||
}
|
||||
if (request.getRefreshOptions().getAllP3() != null) {
|
||||
setOtherUnspecifiedMetadata(metadataMap, metadata, request.getRefreshOptions().getAllP3());
|
||||
if (refreshOptions.getAllP3() != null) {
|
||||
setOtherUnspecifiedMetadata(metadataMap, metadata, refreshOptions.getAllP3());
|
||||
}
|
||||
if (request.getRefreshOptions().getAllP2() != null) {
|
||||
setOtherUnspecifiedMetadata(metadataMap, metadata, request.getRefreshOptions().getAllP2());
|
||||
if (refreshOptions.getAllP2() != null) {
|
||||
setOtherUnspecifiedMetadata(metadataMap, metadata, refreshOptions.getAllP2());
|
||||
}
|
||||
if (request.getRefreshOptions().getAllP1() != null) {
|
||||
setOtherUnspecifiedMetadata(metadataMap, metadata, request.getRefreshOptions().getAllP1());
|
||||
if (refreshOptions.getAllP1() != null) {
|
||||
setOtherUnspecifiedMetadata(metadataMap, metadata, refreshOptions.getAllP1());
|
||||
}
|
||||
|
||||
return metadata;
|
||||
@@ -515,4 +531,3 @@ public class MetadataRefreshService {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-5
@@ -193,8 +193,8 @@ public class AmazonBookParser implements BookParser {
|
||||
}
|
||||
|
||||
private String buildQueryUrl(FetchMetadataRequest fetchMetadataRequest, Book book) {
|
||||
// 1. Prefer ISBN if present
|
||||
if (fetchMetadataRequest.getIsbn() != null && !fetchMetadataRequest.getIsbn().isEmpty()) {
|
||||
String isbnCleaned = ParserUtils.cleanIsbn(fetchMetadataRequest.getIsbn());
|
||||
if (isbnCleaned != null && !isbnCleaned.isEmpty()) {
|
||||
String url = "https://www.amazon."
|
||||
+ appSettingService.getAppSettings().getMetadataProviderSettings().getAmazon().getDomain()
|
||||
+ "/s?k=" + fetchMetadataRequest.getIsbn();
|
||||
@@ -202,7 +202,6 @@ public class AmazonBookParser implements BookParser {
|
||||
return url;
|
||||
}
|
||||
|
||||
// 2. Otherwise, fall back to title + author + filename
|
||||
StringBuilder searchTerm = new StringBuilder();
|
||||
|
||||
String title = fetchMetadataRequest.getTitle();
|
||||
@@ -307,7 +306,8 @@ public class AmazonBookParser implements BookParser {
|
||||
try {
|
||||
Element isbn10Element = doc.select("#rpi-attribute-book_details-isbn10 .rpi-attribute-value span").first();
|
||||
if (isbn10Element != null) {
|
||||
return isbn10Element.text();
|
||||
String rawIsbn = isbn10Element.text();
|
||||
return ParserUtils.cleanIsbn(rawIsbn);
|
||||
}
|
||||
log.warn("Failed to parse isbn10: Element not found.");
|
||||
} catch (Exception e) {
|
||||
@@ -320,7 +320,8 @@ public class AmazonBookParser implements BookParser {
|
||||
try {
|
||||
Element isbn13Element = doc.select("#rpi-attribute-book_details-isbn13 .rpi-attribute-value span").first();
|
||||
if (isbn13Element != null) {
|
||||
return isbn13Element.text();
|
||||
String rawIsbn = isbn13Element.text();
|
||||
return ParserUtils.cleanIsbn(rawIsbn);
|
||||
}
|
||||
log.warn("Failed to parse isbn13: Element not found.");
|
||||
} catch (Exception e) {
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ public class GoodReadsParser implements BookParser {
|
||||
|
||||
@Override
|
||||
public List<BookMetadata> fetchMetadata(Book book, FetchMetadataRequest fetchMetadataRequest) {
|
||||
String isbn = fetchMetadataRequest.getIsbn();
|
||||
String isbn = ParserUtils.cleanIsbn(fetchMetadataRequest.getIsbn());
|
||||
if (isbn != null && !isbn.isBlank()) {
|
||||
log.info("Goodreads Query URL (ISBN): " + BASE_ISBN_URL + "{}", isbn);
|
||||
Document doc = fetchDoc(BASE_ISBN_URL + isbn);
|
||||
|
||||
+1
-4
@@ -41,12 +41,9 @@ public class GoogleParser implements BookParser {
|
||||
|
||||
@Override
|
||||
public List<BookMetadata> fetchMetadata(Book book, FetchMetadataRequest fetchMetadataRequest) {
|
||||
// 1. If ISBN exists, prioritize it
|
||||
if (fetchMetadataRequest.getIsbn() != null && !fetchMetadataRequest.getIsbn().isBlank()) {
|
||||
return getMetadataListByIsbn(fetchMetadataRequest.getIsbn());
|
||||
return getMetadataListByIsbn(ParserUtils.cleanIsbn(fetchMetadataRequest.getIsbn()));
|
||||
}
|
||||
|
||||
// 2. Otherwise fallback to existing title/author search
|
||||
String searchTerm = getSearchTerm(book, fetchMetadataRequest);
|
||||
return searchTerm != null ? getMetadataListByTerm(searchTerm) : List.of();
|
||||
}
|
||||
|
||||
+2
-2
@@ -27,8 +27,8 @@ public class HardcoverParser implements BookParser {
|
||||
|
||||
@Override
|
||||
public List<BookMetadata> fetchMetadata(Book book, FetchMetadataRequest fetchMetadataRequest) {
|
||||
|
||||
boolean searchByIsbn = fetchMetadataRequest.getIsbn() != null && !fetchMetadataRequest.getIsbn().isBlank();
|
||||
String isbnCleaned = ParserUtils.cleanIsbn(fetchMetadataRequest.getIsbn());
|
||||
boolean searchByIsbn = isbnCleaned != null && !isbnCleaned.isBlank();
|
||||
|
||||
List<GraphQLResponse.Hit> hits;
|
||||
if (searchByIsbn) {
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.adityachandel.booklore.service.metadata.parser;
|
||||
|
||||
public class ParserUtils {
|
||||
|
||||
public static String cleanIsbn(String isbn) {
|
||||
if (isbn == null) return null;
|
||||
return isbn.replaceAll("[^0-9]", "");
|
||||
}
|
||||
}
|
||||
+68
-28
@@ -3,7 +3,11 @@ package com.adityachandel.booklore.service.metadata.writer;
|
||||
import com.adityachandel.booklore.model.MetadataClearFlags;
|
||||
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
|
||||
import com.adityachandel.booklore.model.enums.BookFileType;
|
||||
import com.github.junrar.Archive;
|
||||
import com.github.junrar.rarfile.FileHeader;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry;
|
||||
import org.apache.commons.compress.archivers.sevenz.SevenZFile;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
@@ -17,26 +21,19 @@ import javax.xml.transform.Transformer;
|
||||
import javax.xml.transform.TransformerFactory;
|
||||
import javax.xml.transform.dom.DOMSource;
|
||||
import javax.xml.transform.stream.StreamResult;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Comparator;
|
||||
import java.util.Enumeration;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
import com.github.junrar.Archive;
|
||||
import com.github.junrar.rarfile.FileHeader;
|
||||
import org.apache.commons.compress.archivers.sevenz.SevenZFile;
|
||||
import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -45,6 +42,8 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
@Override
|
||||
public void writeMetadataToFile(File file, BookMetadataEntity metadata, String thumbnailUrl, boolean restoreMode, MetadataClearFlags clearFlags) {
|
||||
Path backup = null;
|
||||
Path tempDir = null;
|
||||
Path tempFile = null;
|
||||
boolean writeSucceeded = false;
|
||||
try {
|
||||
// Create a backup next to the source file (temp name, safe to delete later)
|
||||
@@ -82,7 +81,8 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
SevenZArchiveEntry existing = null;
|
||||
for (SevenZArchiveEntry e : sevenZ.getEntries()) {
|
||||
if (e != null && !e.isDirectory() && isComicInfoName(e.getName())) {
|
||||
existing = e; break;
|
||||
existing = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (existing != null) {
|
||||
@@ -147,18 +147,19 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
|
||||
// Repack depending on container type; always write to a temp target then atomic move
|
||||
if (isCbz) {
|
||||
Path temp = Files.createTempFile("cbx_edit", ".cbz");
|
||||
repackZipReplacingComicInfo(file.toPath(), temp, xmlBytes);
|
||||
atomicReplace(temp, file.toPath());
|
||||
tempFile = Files.createTempFile("cbx_edit", ".cbz");
|
||||
repackZipReplacingComicInfo(file.toPath(), tempFile, xmlBytes);
|
||||
atomicReplace(tempFile, file.toPath());
|
||||
tempFile = null; // Successfully moved, don't delete in finally
|
||||
writeSucceeded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCb7) {
|
||||
// Convert to CBZ with updated ComicInfo.xml
|
||||
Path tempZip = Files.createTempFile("cbx_edit", ".cbz");
|
||||
tempFile = Files.createTempFile("cbx_edit", ".cbz");
|
||||
try (SevenZFile sevenZ = new SevenZFile(file);
|
||||
ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(tempZip))) {
|
||||
ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(tempFile))) {
|
||||
for (SevenZArchiveEntry e : sevenZ.getEntries()) {
|
||||
if (e.isDirectory()) continue;
|
||||
String entryName = e.getName();
|
||||
@@ -178,8 +179,12 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
zos.closeEntry();
|
||||
}
|
||||
Path target = file.toPath().resolveSibling(stripExtension(file.getName()) + ".cbz");
|
||||
atomicReplace(tempZip, target);
|
||||
try { Files.deleteIfExists(file.toPath()); } catch (Exception ignored) {}
|
||||
atomicReplace(tempFile, target);
|
||||
tempFile = null; // Successfully moved, don't delete in finally
|
||||
try {
|
||||
Files.deleteIfExists(file.toPath());
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
writeSucceeded = true;
|
||||
return;
|
||||
}
|
||||
@@ -189,7 +194,7 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
boolean rarAvailable = isRarAvailable(rarBin);
|
||||
|
||||
if (rarAvailable) {
|
||||
Path tempDir = Files.createTempDirectory("cbx_rar_");
|
||||
tempDir = Files.createTempDirectory("cbx_rar_");
|
||||
try {
|
||||
// Extract entire RAR into a temp directory
|
||||
try (Archive archive = new Archive(file)) {
|
||||
@@ -234,20 +239,16 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
log.warn("RAR creation failed with exit code {}. Falling back to CBZ conversion for {}", code, file.getName());
|
||||
}
|
||||
} finally {
|
||||
try { // cleanup temp dir
|
||||
java.nio.file.Files.walk(tempDir)
|
||||
.sorted(java.util.Comparator.reverseOrder())
|
||||
.forEach(path -> { try { Files.deleteIfExists(path); } catch (Exception ignore) {} });
|
||||
} catch (Exception ignore) {}
|
||||
// tempDir cleanup will be handled in outer finally block
|
||||
}
|
||||
} else {
|
||||
log.warn("`rar` binary not found. Falling back to CBZ conversion for {}", file.getName());
|
||||
}
|
||||
|
||||
// Fallback: convert the CBR to CBZ containing updated ComicInfo.xml
|
||||
Path tempZip = Files.createTempFile("cbx_edit", ".cbz");
|
||||
tempFile = Files.createTempFile("cbx_edit", ".cbz");
|
||||
try (Archive archive = new Archive(file);
|
||||
ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(tempZip))) {
|
||||
ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(tempFile))) {
|
||||
for (FileHeader fh : archive.getFileHeaders()) {
|
||||
if (fh.isDirectory()) continue;
|
||||
String entryName = fh.getFileName();
|
||||
@@ -265,8 +266,12 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
zos.closeEntry();
|
||||
}
|
||||
Path target = file.toPath().resolveSibling(stripExtension(file.getName()) + ".cbz");
|
||||
atomicReplace(tempZip, target);
|
||||
try { Files.deleteIfExists(file.toPath()); } catch (Exception ignored) {}
|
||||
atomicReplace(tempFile, target);
|
||||
tempFile = null; // Successfully moved, don't delete in finally
|
||||
try {
|
||||
Files.deleteIfExists(file.toPath());
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
writeSucceeded = true;
|
||||
} catch (Exception e) {
|
||||
// Attempt to restore the original file from backup
|
||||
@@ -280,8 +285,27 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
}
|
||||
log.warn("Failed to write metadata for {}: {}", file.getName(), e.getMessage(), e);
|
||||
} finally {
|
||||
// Clean up temporary file if it wasn't successfully moved
|
||||
if (tempFile != null) {
|
||||
try {
|
||||
Files.deleteIfExists(tempFile);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to delete temp file: {}", tempFile, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up temporary directory if it was created
|
||||
if (tempDir != null) {
|
||||
deleteDirectoryRecursively(tempDir);
|
||||
}
|
||||
|
||||
// Clean up backup file if write succeeded
|
||||
if (writeSucceeded && backup != null) {
|
||||
try { Files.deleteIfExists(backup); } catch (Exception ignore) {}
|
||||
try {
|
||||
Files.deleteIfExists(backup);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to delete backup file: {}", backup, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -451,4 +475,20 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
public BookFileType getSupportedBookType() {
|
||||
return BookFileType.CBX;
|
||||
}
|
||||
|
||||
private void deleteDirectoryRecursively(Path dir) {
|
||||
try (var pathStream = Files.walk(dir)) {
|
||||
pathStream
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.forEach(path -> {
|
||||
try {
|
||||
Files.delete(path);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temp file/directory: {}", path, e);
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to clean up temporary directory: {}", dir, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
-13
@@ -32,6 +32,7 @@ import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
@@ -51,7 +52,7 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
log.warn("Failed to create backup of EPUB {}: {}", epubFile.getName(), ex.getMessage());
|
||||
return;
|
||||
}
|
||||
Path tempDir;
|
||||
Path tempDir = null;
|
||||
try {
|
||||
tempDir = Files.createTempDirectory("epub_edit_" + UUID.randomUUID());
|
||||
ZipFile zipFile = new ZipFile(epubFile);
|
||||
@@ -190,6 +191,9 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (tempDir != null) {
|
||||
deleteDirectoryRecursively(tempDir);
|
||||
}
|
||||
if (backupFile.exists()) {
|
||||
try {
|
||||
Files.delete(backupFile.toPath());
|
||||
@@ -252,9 +256,10 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
return;
|
||||
}
|
||||
|
||||
Path tempDir = null;
|
||||
try {
|
||||
File epubFile = new File(bookEntity.getFullFilePath().toUri());
|
||||
Path tempDir = Files.createTempDirectory("epub_cover_" + UUID.randomUUID());
|
||||
tempDir = Files.createTempDirectory("epub_cover_" + UUID.randomUUID());
|
||||
new ZipFile(epubFile).extractAll(tempDir.toString());
|
||||
|
||||
File opfFile = findOpfFile(tempDir.toFile());
|
||||
@@ -286,6 +291,10 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to update EPUB with uploaded cover image: {}", e.getMessage(), e);
|
||||
} finally {
|
||||
if (tempDir != null) {
|
||||
deleteDirectoryRecursively(tempDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,9 +304,10 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
log.warn("Cover update via URL failed: empty or null URL.");
|
||||
return;
|
||||
}
|
||||
Path tempDir = null;
|
||||
try {
|
||||
File epubFile = new File(bookEntity.getFullFilePath().toUri());
|
||||
Path tempDir = Files.createTempDirectory("epub_cover_url_" + UUID.randomUUID());
|
||||
tempDir = Files.createTempDirectory("epub_cover_url_" + UUID.randomUUID());
|
||||
new ZipFile(epubFile).extractAll(tempDir.toString());
|
||||
|
||||
File opfFile = findOpfFile(tempDir.toFile());
|
||||
@@ -333,6 +343,10 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
log.info("Cover image updated in EPUB via URL: {}", epubFile.getName());
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to update EPUB with cover from URL: {}", e.getMessage(), e);
|
||||
} finally {
|
||||
if (tempDir != null) {
|
||||
deleteDirectoryRecursively(tempDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,16 +538,19 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
return null;
|
||||
}
|
||||
|
||||
private String getIdentifierByScheme(Element metadataElement, String scheme) {
|
||||
NodeList identifiers = metadataElement.getElementsByTagNameNS("*", "identifier");
|
||||
for (int i = 0; i < identifiers.getLength(); i++) {
|
||||
Element idElement = (Element) identifiers.item(i);
|
||||
String schemeAttr = idElement.getAttributeNS(OPF_NS, "scheme");
|
||||
if (scheme.equalsIgnoreCase(schemeAttr)) {
|
||||
return idElement.getTextContent();
|
||||
}
|
||||
private void deleteDirectoryRecursively(Path dir) {
|
||||
try (var pathStream = Files.walk(dir)) {
|
||||
pathStream
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.forEach(path -> {
|
||||
try {
|
||||
Files.delete(path);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temp file/directory: {}", path, e);
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to clean up temporary directory: {}", dir, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
package com.adityachandel.booklore.service.opds;
|
||||
|
||||
import com.adityachandel.booklore.exception.ApiError;
|
||||
import com.adityachandel.booklore.mapper.OpdsUserMapper;
|
||||
import com.adityachandel.booklore.model.dto.OpdsUser;
|
||||
import com.adityachandel.booklore.model.entity.OpdsUserEntity;
|
||||
import com.adityachandel.booklore.repository.OpdsUserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OpdsUserService {
|
||||
|
||||
private final OpdsUserRepository opdsUserRepository;
|
||||
private final OpdsUserMapper opdsUserMapper;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public void createOpdsUser(String username, String password) {
|
||||
if (opdsUserRepository.existsByUsername(username)) {
|
||||
throw ApiError.USERNAME_ALREADY_TAKEN.createException(username);
|
||||
}
|
||||
OpdsUserEntity opdsUser = new OpdsUserEntity();
|
||||
opdsUser.setUsername(username);
|
||||
opdsUser.setPassword(passwordEncoder.encode(password));
|
||||
opdsUserRepository.save(opdsUser);
|
||||
}
|
||||
|
||||
public List<OpdsUser> getOpdsUsers() {
|
||||
return opdsUserRepository.findAll()
|
||||
.stream()
|
||||
.map(opdsUserMapper::toOpdsUser)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public void deleteOpdsUser(Long id) {
|
||||
if (!opdsUserRepository.existsById(id)) {
|
||||
throw ApiError.USER_NOT_FOUND.createException(id);
|
||||
}
|
||||
opdsUserRepository.deleteById(id);
|
||||
}
|
||||
|
||||
public void resetPassword(Long id, String newPassword) {
|
||||
OpdsUserEntity opdsUser = opdsUserRepository.findById(id).orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(id));
|
||||
opdsUser.setPassword(passwordEncoder.encode(newPassword));
|
||||
opdsUserRepository.save(opdsUser);
|
||||
}
|
||||
}
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
package com.adityachandel.booklore.config.security.service;
|
||||
|
||||
import com.adityachandel.booklore.config.security.userdetails.OpdsUserDetails;
|
||||
import com.adityachandel.booklore.mapper.OpdsUserMapper;
|
||||
import com.adityachandel.booklore.mapper.OpdsUserV2Mapper;
|
||||
import com.adityachandel.booklore.model.entity.OpdsUserEntity;
|
||||
import com.adityachandel.booklore.model.entity.OpdsUserV2Entity;
|
||||
import com.adityachandel.booklore.repository.OpdsUserRepository;
|
||||
import com.adityachandel.booklore.repository.OpdsUserV2Repository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class OpdsUserDetailsServiceTest {
|
||||
|
||||
private OpdsUserRepository opdsUserRepository;
|
||||
private OpdsUserV2Repository opdsUserV2Repository;
|
||||
private OpdsUserMapper opdsUserMapper;
|
||||
private OpdsUserV2Mapper opdsUserV2Mapper;
|
||||
private OpdsUserDetailsService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
opdsUserRepository = mock(OpdsUserRepository.class);
|
||||
opdsUserV2Repository = mock(OpdsUserV2Repository.class);
|
||||
opdsUserMapper = mock(OpdsUserMapper.class);
|
||||
opdsUserV2Mapper = mock(OpdsUserV2Mapper.class);
|
||||
|
||||
service = new OpdsUserDetailsService(opdsUserRepository, opdsUserV2Repository, opdsUserMapper, opdsUserV2Mapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUserByUsername_primaryRepositoryHit_usesPrimaryMapperAndDoesNotCallV2() {
|
||||
String username = "primaryUser";
|
||||
|
||||
Optional<OpdsUserEntity> primaryEntity = Optional.of(mock(OpdsUserEntity.class));
|
||||
when(opdsUserRepository.findByUsername(username)).thenReturn(primaryEntity);
|
||||
|
||||
OpdsUserDetails result = service.loadUserByUsername(username);
|
||||
|
||||
assertNotNull(result, "Expected non-null OpdsUserDetails when primary repo returns a user");
|
||||
verify(opdsUserMapper, times(1)).toOpdsUser(any(OpdsUserEntity.class));
|
||||
verify(opdsUserV2Repository, never()).findByUsername(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUserByUsername_primaryEmpty_v2RepositoryHit_usesV2Mapper() {
|
||||
String username = "v2User";
|
||||
|
||||
when(opdsUserRepository.findByUsername(username)).thenReturn(Optional.empty());
|
||||
|
||||
Optional<OpdsUserV2Entity> v2Entity = Optional.of(mock(OpdsUserV2Entity.class));
|
||||
when(opdsUserV2Repository.findByUsername(username)).thenReturn(v2Entity);
|
||||
|
||||
OpdsUserDetails result = service.loadUserByUsername(username);
|
||||
|
||||
assertNotNull(result, "Expected non-null OpdsUserDetails when v2 repo returns a user");
|
||||
verify(opdsUserRepository, times(1)).findByUsername(username);
|
||||
verify(opdsUserV2Repository, times(1)).findByUsername(username);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUserByUsername_bothRepositoriesEmpty_throwsException() {
|
||||
String username = "missingUser";
|
||||
|
||||
when(opdsUserRepository.findByUsername(username)).thenReturn(Optional.empty());
|
||||
when(opdsUserV2Repository.findByUsername(username)).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(Exception.class, () -> service.loadUserByUsername(username), "Expected an exception when user not found in either repository");
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -108,6 +108,7 @@ class BookReviewServiceTest {
|
||||
|
||||
return MetadataPublicReviewsSettings.builder()
|
||||
.downloadEnabled(enabled)
|
||||
.autoDownloadEnabled(enabled)
|
||||
.providers(configs)
|
||||
.build();
|
||||
}
|
||||
@@ -115,11 +116,13 @@ class BookReviewServiceTest {
|
||||
@Test
|
||||
void getByBookId_returnsExistingReviews_whenReviewsExist() {
|
||||
Long bookId = 1L;
|
||||
BookEntity bookEntity = new BookEntity();
|
||||
BookReviewEntity entity = createBookReviewEntity(MetadataProvider.Amazon);
|
||||
BookReview dto = createBookReview(MetadataProvider.Amazon);
|
||||
AppSettings appSettings = new AppSettings();
|
||||
appSettings.setMetadataPublicReviewsSettings(createReviewSettings(true));
|
||||
|
||||
when(bookRepository.findById(bookId)).thenReturn(Optional.of(bookEntity));
|
||||
when(bookReviewRepository.findByBookMetadataBookId(bookId))
|
||||
.thenReturn(Collections.singletonList(entity));
|
||||
when(mapper.toDto(entity)).thenReturn(dto);
|
||||
@@ -135,9 +138,11 @@ class BookReviewServiceTest {
|
||||
@Test
|
||||
void getByBookId_returnsEmptyList_whenDownloadDisabled() {
|
||||
Long bookId = 1L;
|
||||
BookEntity bookEntity = new BookEntity();
|
||||
AppSettings appSettings = new AppSettings();
|
||||
appSettings.setMetadataPublicReviewsSettings(createReviewSettings(false));
|
||||
|
||||
when(bookRepository.findById(bookId)).thenReturn(Optional.of(bookEntity));
|
||||
when(bookReviewRepository.findByBookMetadataBookId(bookId))
|
||||
.thenReturn(Collections.emptyList());
|
||||
when(appSettingService.getAppSettings()).thenReturn(appSettings);
|
||||
@@ -151,10 +156,12 @@ class BookReviewServiceTest {
|
||||
@Test
|
||||
void getByBookId_returnsEmptyList_whenUserLacksPermissions() {
|
||||
Long bookId = 1L;
|
||||
BookEntity bookEntity = new BookEntity();
|
||||
AppSettings appSettings = new AppSettings();
|
||||
appSettings.setMetadataPublicReviewsSettings(createReviewSettings(true, MetadataProvider.Amazon));
|
||||
BookLoreUser user = createUser(false, false);
|
||||
|
||||
when(bookRepository.findById(bookId)).thenReturn(Optional.of(bookEntity));
|
||||
when(bookReviewRepository.findByBookMetadataBookId(bookId))
|
||||
.thenReturn(Collections.emptyList());
|
||||
when(appSettingService.getAppSettings()).thenReturn(appSettings);
|
||||
@@ -239,14 +246,7 @@ class BookReviewServiceTest {
|
||||
@Test
|
||||
void getByBookId_throwsException_whenBookNotFound() {
|
||||
Long bookId = 1L;
|
||||
AppSettings appSettings = new AppSettings();
|
||||
appSettings.setMetadataPublicReviewsSettings(createReviewSettings(true, MetadataProvider.Amazon));
|
||||
BookLoreUser user = createUser(true, false);
|
||||
|
||||
when(bookReviewRepository.findByBookMetadataBookId(bookId))
|
||||
.thenReturn(Collections.emptyList());
|
||||
when(appSettingService.getAppSettings()).thenReturn(appSettings);
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
|
||||
when(bookRepository.findById(bookId)).thenReturn(Optional.empty());
|
||||
|
||||
RuntimeException exception = assertThrows(RuntimeException.class,
|
||||
|
||||
+5
-2
@@ -183,6 +183,7 @@ class MetadataTaskServiceTest {
|
||||
MetadataFetchProposalEntity p2 = mock(MetadataFetchProposalEntity.class);
|
||||
|
||||
when(job1.getTaskId()).thenReturn("task1");
|
||||
when(job1.getStatus()).thenReturn(MetadataFetchTaskStatus.COMPLETED);
|
||||
when(job1.getProposals()).thenReturn(List.of(p1, p2));
|
||||
when(p1.getStatus()).thenReturn(FetchedMetadataProposalStatus.ACCEPTED);
|
||||
when(p2.getStatus()).thenReturn(FetchedMetadataProposalStatus.REJECTED);
|
||||
@@ -190,6 +191,7 @@ class MetadataTaskServiceTest {
|
||||
MetadataFetchJobEntity job2 = mock(MetadataFetchJobEntity.class);
|
||||
MetadataFetchProposalEntity p3 = mock(MetadataFetchProposalEntity.class);
|
||||
when(job2.getTaskId()).thenReturn("task2");
|
||||
when(job2.getStatus()).thenReturn(MetadataFetchTaskStatus.COMPLETED);
|
||||
when(job2.getProposals()).thenReturn(List.of(p3));
|
||||
when(p3.getStatus()).thenReturn(FetchedMetadataProposalStatus.FETCHED);
|
||||
|
||||
@@ -204,14 +206,14 @@ class MetadataTaskServiceTest {
|
||||
.findFirst().orElseThrow();
|
||||
assertThat(n1.getTotal()).isEqualTo(1);
|
||||
assertThat(n1.getCompleted()).isEqualTo(1);
|
||||
assertThat(n1.getMessage()).contains("Metadata review pending for 0 of 1 books");
|
||||
assertThat(n1.getMessage()).contains("Metadata fetch completed! 0 books need review.");
|
||||
|
||||
MetadataBatchProgressNotification n2 = notifications.stream()
|
||||
.filter(n -> n.getTaskId().equals("task2"))
|
||||
.findFirst().orElseThrow();
|
||||
assertThat(n2.getTotal()).isEqualTo(1);
|
||||
assertThat(n2.getCompleted()).isEqualTo(0);
|
||||
assertThat(n2.getMessage()).contains("Metadata review pending for 1 of 1 books");
|
||||
assertThat(n2.getMessage()).contains("Metadata fetch completed! 1 books need review.");
|
||||
|
||||
verify(jobRepository).findAllWithProposals();
|
||||
}
|
||||
@@ -221,6 +223,7 @@ class MetadataTaskServiceTest {
|
||||
MetadataFetchJobEntity job = mock(MetadataFetchJobEntity.class);
|
||||
MetadataFetchProposalEntity p1 = mock(MetadataFetchProposalEntity.class);
|
||||
when(job.getTaskId()).thenReturn("task1");
|
||||
when(job.getStatus()).thenReturn(MetadataFetchTaskStatus.COMPLETED);
|
||||
when(job.getProposals()).thenReturn(List.of(p1));
|
||||
when(p1.getStatus()).thenReturn(FetchedMetadataProposalStatus.REJECTED);
|
||||
|
||||
|
||||
+50
-32
@@ -22,6 +22,7 @@ import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.IntStream;
|
||||
@@ -83,29 +84,30 @@ class FileMoveServiceTest {
|
||||
Set<Long> bookIds = Set.of(1L, 2L);
|
||||
FileMoveRequest request = new FileMoveRequest();
|
||||
request.setBookIds(bookIds);
|
||||
request.setMoves(List.of());
|
||||
|
||||
List<BookEntity> batchBooks = List.of(bookEntity1, bookEntity2);
|
||||
when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100))
|
||||
.thenReturn(batchBooks);
|
||||
when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 100, 100))
|
||||
.thenReturn(List.of());
|
||||
|
||||
when(bookRepository.save(bookEntity1)).thenReturn(bookEntity1);
|
||||
when(bookRepository.save(bookEntity2)).thenReturn(bookEntity2);
|
||||
when(bookMapper.toBook(bookEntity1)).thenReturn(book1);
|
||||
when(bookMapper.toBook(bookEntity2)).thenReturn(book2);
|
||||
|
||||
doAnswer(invocation -> {
|
||||
UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1);
|
||||
UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(2);
|
||||
callback.onBookMoved(bookEntity1);
|
||||
callback.onBookMoved(bookEntity2);
|
||||
return null;
|
||||
}).when(unifiedFileMoveService).moveBatchBookFiles(eq(batchBooks), any());
|
||||
}).when(unifiedFileMoveService).moveBatchBookFiles(eq(batchBooks), eq(Map.of()), any());
|
||||
|
||||
// When
|
||||
fileMoveService.moveFiles(request);
|
||||
|
||||
// Then
|
||||
verify(bookQueryService).findWithMetadataByIdsWithPagination(bookIds, 0, 100);
|
||||
verify(unifiedFileMoveService).moveBatchBookFiles(eq(batchBooks), any());
|
||||
verify(unifiedFileMoveService).moveBatchBookFiles(eq(batchBooks), eq(Map.of()), any());
|
||||
verify(bookRepository).save(bookEntity1);
|
||||
verify(bookRepository).save(bookEntity2);
|
||||
verify(notificationService).sendMessage(eq(Topic.BOOK_METADATA_BATCH_UPDATE), eq(List.of(book1, book2)));
|
||||
@@ -119,38 +121,48 @@ class FileMoveServiceTest {
|
||||
.collect(Collectors.toSet());
|
||||
FileMoveRequest request = new FileMoveRequest();
|
||||
request.setBookIds(bookIds);
|
||||
request.setMoves(List.of());
|
||||
|
||||
// Create subset for first batch (first 100 items)
|
||||
Set<Long> firstBatchIds = IntStream.rangeClosed(1, 100)
|
||||
.mapToObj(i -> (long) i)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// Create subset for second batch (remaining 50 items)
|
||||
Set<Long> secondBatchIds = IntStream.rangeClosed(101, 150)
|
||||
.mapToObj(i -> (long) i)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// First batch
|
||||
when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100))
|
||||
when(bookQueryService.findWithMetadataByIdsWithPagination(firstBatchIds, 0, 100))
|
||||
.thenReturn(List.of(bookEntity1));
|
||||
// Second batch
|
||||
when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 100, 100))
|
||||
when(bookQueryService.findWithMetadataByIdsWithPagination(secondBatchIds, 100, 100))
|
||||
.thenReturn(List.of(bookEntity2));
|
||||
// Third batch - empty
|
||||
when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 200, 100))
|
||||
.thenReturn(List.of());
|
||||
|
||||
when(book1.getId()).thenReturn(1L);
|
||||
when(book2.getId()).thenReturn(2L);
|
||||
when(bookRepository.save(bookEntity1)).thenReturn(bookEntity1);
|
||||
when(bookRepository.save(bookEntity2)).thenReturn(bookEntity2);
|
||||
when(bookMapper.toBook(bookEntity1)).thenReturn(book1);
|
||||
when(bookMapper.toBook(bookEntity2)).thenReturn(book2);
|
||||
|
||||
doAnswer(invocation -> {
|
||||
UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1);
|
||||
UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(2);
|
||||
List<BookEntity> books = invocation.getArgument(0);
|
||||
for (BookEntity book : books) {
|
||||
callback.onBookMoved(book);
|
||||
}
|
||||
return null;
|
||||
}).when(unifiedFileMoveService).moveBatchBookFiles(any(), any());
|
||||
}).when(unifiedFileMoveService).moveBatchBookFiles(any(), eq(Map.of()), any());
|
||||
|
||||
// When
|
||||
fileMoveService.moveFiles(request);
|
||||
|
||||
// Then
|
||||
verify(bookQueryService).findWithMetadataByIdsWithPagination(bookIds, 0, 100);
|
||||
verify(bookQueryService).findWithMetadataByIdsWithPagination(bookIds, 100, 100);
|
||||
verify(unifiedFileMoveService, times(2)).moveBatchBookFiles(any(), any());
|
||||
verify(bookQueryService).findWithMetadataByIdsWithPagination(firstBatchIds, 0, 100);
|
||||
verify(bookQueryService).findWithMetadataByIdsWithPagination(secondBatchIds, 100, 100);
|
||||
verify(unifiedFileMoveService, times(2)).moveBatchBookFiles(any(), eq(Map.of()), any());
|
||||
verify(bookRepository).save(bookEntity1);
|
||||
verify(bookRepository).save(bookEntity2);
|
||||
verify(notificationService).sendMessage(eq(Topic.BOOK_METADATA_BATCH_UPDATE), eq(List.of(book1, book2)));
|
||||
@@ -162,6 +174,7 @@ class FileMoveServiceTest {
|
||||
Set<Long> bookIds = Set.of(1L, 2L);
|
||||
FileMoveRequest request = new FileMoveRequest();
|
||||
request.setBookIds(bookIds);
|
||||
request.setMoves(List.of());
|
||||
|
||||
when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100))
|
||||
.thenReturn(List.of());
|
||||
@@ -171,7 +184,7 @@ class FileMoveServiceTest {
|
||||
|
||||
// Then
|
||||
verify(bookQueryService).findWithMetadataByIdsWithPagination(bookIds, 0, 100);
|
||||
verify(unifiedFileMoveService, never()).moveBatchBookFiles(any(), any());
|
||||
verify(unifiedFileMoveService, never()).moveBatchBookFiles(any(), any(), any());
|
||||
verify(bookRepository, never()).save(any());
|
||||
verify(notificationService, never()).sendMessage(any(), any());
|
||||
}
|
||||
@@ -182,20 +195,22 @@ class FileMoveServiceTest {
|
||||
Set<Long> bookIds = Set.of(1L, 2L);
|
||||
FileMoveRequest request = new FileMoveRequest();
|
||||
request.setBookIds(bookIds);
|
||||
request.setMoves(List.of());
|
||||
|
||||
List<BookEntity> batchBooks = List.of(bookEntity1, bookEntity2);
|
||||
when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100))
|
||||
.thenReturn(batchBooks);
|
||||
|
||||
when(bookRepository.save(bookEntity1)).thenReturn(bookEntity1);
|
||||
when(bookMapper.toBook(bookEntity1)).thenReturn(book1);
|
||||
|
||||
RuntimeException moveException = new RuntimeException("File move failed");
|
||||
doAnswer(invocation -> {
|
||||
UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1);
|
||||
UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(2);
|
||||
callback.onBookMoved(bookEntity1);
|
||||
callback.onBookMoveFailed(bookEntity2, moveException);
|
||||
return null;
|
||||
}).when(unifiedFileMoveService).moveBatchBookFiles(eq(batchBooks), any());
|
||||
|
||||
when(bookMapper.toBook(bookEntity1)).thenReturn(book1);
|
||||
}).when(unifiedFileMoveService).moveBatchBookFiles(eq(batchBooks), eq(Map.of()), any());
|
||||
|
||||
// When & Then
|
||||
RuntimeException exception = assertThrows(RuntimeException.class, () -> {
|
||||
@@ -214,13 +229,14 @@ class FileMoveServiceTest {
|
||||
Set<Long> bookIds = Set.of();
|
||||
FileMoveRequest request = new FileMoveRequest();
|
||||
request.setBookIds(bookIds);
|
||||
request.setMoves(List.of());
|
||||
|
||||
// When
|
||||
fileMoveService.moveFiles(request);
|
||||
|
||||
// Then: service should not call pagination when bookIds is empty
|
||||
verify(bookQueryService, never()).findWithMetadataByIdsWithPagination(anySet(), anyInt(), anyInt());
|
||||
verify(unifiedFileMoveService, never()).moveBatchBookFiles(any(), any());
|
||||
verify(unifiedFileMoveService, never()).moveBatchBookFiles(any(), any(), any());
|
||||
verify(notificationService, never()).sendMessage(any(), any());
|
||||
}
|
||||
|
||||
@@ -230,27 +246,27 @@ class FileMoveServiceTest {
|
||||
Set<Long> bookIds = Set.of(1L);
|
||||
FileMoveRequest request = new FileMoveRequest();
|
||||
request.setBookIds(bookIds);
|
||||
request.setMoves(List.of());
|
||||
|
||||
when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100))
|
||||
.thenReturn(List.of(bookEntity1));
|
||||
when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 100, 100))
|
||||
.thenReturn(List.of());
|
||||
|
||||
when(book1.getId()).thenReturn(1L);
|
||||
when(bookRepository.save(bookEntity1)).thenReturn(bookEntity1);
|
||||
when(bookMapper.toBook(bookEntity1)).thenReturn(book1);
|
||||
|
||||
doAnswer(invocation -> {
|
||||
UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1);
|
||||
UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(2);
|
||||
callback.onBookMoved(bookEntity1);
|
||||
return null;
|
||||
}).when(unifiedFileMoveService).moveBatchBookFiles(any(), any());
|
||||
}).when(unifiedFileMoveService).moveBatchBookFiles(eq(List.of(bookEntity1)), eq(Map.of()), any());
|
||||
|
||||
// When
|
||||
fileMoveService.moveFiles(request);
|
||||
|
||||
// Then
|
||||
verify(bookQueryService).findWithMetadataByIdsWithPagination(bookIds, 0, 100);
|
||||
verify(unifiedFileMoveService).moveBatchBookFiles(eq(List.of(bookEntity1)), any());
|
||||
verify(unifiedFileMoveService).moveBatchBookFiles(eq(List.of(bookEntity1)), eq(Map.of()), any());
|
||||
verify(bookRepository).save(bookEntity1);
|
||||
verify(notificationService).sendMessage(eq(Topic.BOOK_METADATA_BATCH_UPDATE), eq(List.of(book1)));
|
||||
}
|
||||
@@ -306,21 +322,22 @@ class FileMoveServiceTest {
|
||||
Set<Long> bookIds = Set.of(1L, 2L);
|
||||
FileMoveRequest request = new FileMoveRequest();
|
||||
request.setBookIds(bookIds);
|
||||
request.setMoves(List.of());
|
||||
|
||||
when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100))
|
||||
.thenReturn(List.of(bookEntity1, bookEntity2));
|
||||
when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 100, 100))
|
||||
.thenReturn(List.of());
|
||||
|
||||
when(bookRepository.save(bookEntity1)).thenReturn(bookEntity1);
|
||||
when(bookRepository.save(bookEntity2)).thenReturn(bookEntity2);
|
||||
when(bookMapper.toBook(bookEntity1)).thenReturn(book1);
|
||||
when(bookMapper.toBook(bookEntity2)).thenReturn(book2);
|
||||
|
||||
doAnswer(invocation -> {
|
||||
UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1);
|
||||
UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(2);
|
||||
callback.onBookMoved(bookEntity1);
|
||||
callback.onBookMoved(bookEntity2);
|
||||
return null;
|
||||
}).when(unifiedFileMoveService).moveBatchBookFiles(any(), any());
|
||||
}).when(unifiedFileMoveService).moveBatchBookFiles(any(), eq(Map.of()), any());
|
||||
|
||||
// When
|
||||
fileMoveService.moveFiles(request);
|
||||
@@ -341,16 +358,17 @@ class FileMoveServiceTest {
|
||||
Set<Long> bookIds = Set.of(1L, 2L);
|
||||
FileMoveRequest request = new FileMoveRequest();
|
||||
request.setBookIds(bookIds);
|
||||
request.setMoves(List.of());
|
||||
|
||||
when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100))
|
||||
.thenReturn(List.of(bookEntity1, bookEntity2));
|
||||
|
||||
RuntimeException moveException = new RuntimeException("All moves failed");
|
||||
doAnswer(invocation -> {
|
||||
UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1);
|
||||
UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(2);
|
||||
callback.onBookMoveFailed(bookEntity1, moveException);
|
||||
return null;
|
||||
}).when(unifiedFileMoveService).moveBatchBookFiles(any(), any());
|
||||
}).when(unifiedFileMoveService).moveBatchBookFiles(any(), eq(Map.of()), any());
|
||||
|
||||
// When & Then
|
||||
assertThrows(RuntimeException.class, () -> {
|
||||
|
||||
+12
-9
@@ -1,16 +1,12 @@
|
||||
package com.adityachandel.booklore.service.file;
|
||||
|
||||
import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity;
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
|
||||
import com.adityachandel.booklore.model.entity.LibraryEntity;
|
||||
import com.adityachandel.booklore.model.entity.LibraryPathEntity;
|
||||
import com.adityachandel.booklore.model.entity.*;
|
||||
import com.adityachandel.booklore.repository.LibraryRepository;
|
||||
import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
@@ -19,9 +15,10 @@ import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static java.util.Collections.singletonList;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@@ -37,6 +34,9 @@ class UnifiedFileMoveServiceTest {
|
||||
@Mock
|
||||
MonitoringRegistrationService monitoringRegistrationService;
|
||||
|
||||
@Mock
|
||||
LibraryRepository libraryRepository;
|
||||
|
||||
@InjectMocks
|
||||
UnifiedFileMoveService service;
|
||||
|
||||
@@ -63,7 +63,6 @@ class UnifiedFileMoveServiceTest {
|
||||
@Test
|
||||
void moveSingleBookFile_skipsWhenNoLibrary() {
|
||||
BookEntity book = new BookEntity();
|
||||
// no libraryPath set
|
||||
service.moveSingleBookFile(book);
|
||||
verifyNoInteractions(monitoredFileOperationService);
|
||||
verifyNoInteractions(fileMovingHelper);
|
||||
@@ -124,6 +123,8 @@ class UnifiedFileMoveServiceTest {
|
||||
|
||||
@Test
|
||||
void moveBatchBookFiles_movesBooks_and_callsCallback_and_reRegistersLibraries() throws Exception {
|
||||
when(libraryRepository.findById(10L)).thenReturn(Optional.of(library));
|
||||
|
||||
BookEntity b1 = new BookEntity();
|
||||
b1.setId(11L);
|
||||
b1.setLibraryPath(libraryPath);
|
||||
@@ -182,6 +183,8 @@ class UnifiedFileMoveServiceTest {
|
||||
|
||||
@Test
|
||||
void moveBatchBookFiles_callsOnBookMoveFailed_onIOException() throws Exception {
|
||||
when(libraryRepository.findById(10L)).thenReturn(Optional.of(library));
|
||||
|
||||
BookEntity b = new BookEntity();
|
||||
b.setId(21L);
|
||||
b.setLibraryPath(libraryPath);
|
||||
|
||||
+525
@@ -0,0 +1,525 @@
|
||||
package com.adityachandel.booklore.service.metadata;
|
||||
|
||||
import com.adityachandel.booklore.mapper.BookMapper;
|
||||
import com.adityachandel.booklore.model.MetadataUpdateWrapper;
|
||||
import com.adityachandel.booklore.model.dto.Book;
|
||||
import com.adityachandel.booklore.model.dto.BookMetadata;
|
||||
import com.adityachandel.booklore.model.dto.request.FetchMetadataRequest;
|
||||
import com.adityachandel.booklore.model.dto.request.MetadataRefreshOptions;
|
||||
import com.adityachandel.booklore.model.dto.request.MetadataRefreshRequest;
|
||||
import com.adityachandel.booklore.model.dto.settings.AppSettings;
|
||||
import com.adityachandel.booklore.model.entity.*;
|
||||
import com.adityachandel.booklore.model.enums.MetadataProvider;
|
||||
import com.adityachandel.booklore.model.websocket.Topic;
|
||||
import com.adityachandel.booklore.repository.BookRepository;
|
||||
import com.adityachandel.booklore.repository.LibraryRepository;
|
||||
import com.adityachandel.booklore.repository.MetadataFetchJobRepository;
|
||||
import com.adityachandel.booklore.service.NotificationService;
|
||||
import com.adityachandel.booklore.service.appsettings.AppSettingService;
|
||||
import com.adityachandel.booklore.service.metadata.parser.BookParser;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class MetadataRefreshServiceTest {
|
||||
|
||||
@Mock
|
||||
private LibraryRepository libraryRepository;
|
||||
@Mock
|
||||
private MetadataFetchJobRepository metadataFetchJobRepository;
|
||||
@Mock
|
||||
private BookMapper bookMapper;
|
||||
@Mock
|
||||
private BookMetadataUpdater bookMetadataUpdater;
|
||||
@Mock
|
||||
private NotificationService notificationService;
|
||||
@Mock
|
||||
private AppSettingService appSettingService;
|
||||
@Mock
|
||||
private Map<MetadataProvider, BookParser> parserMap;
|
||||
@Mock
|
||||
private ObjectMapper objectMapper;
|
||||
@Mock
|
||||
private BookRepository bookRepository;
|
||||
@Mock
|
||||
private PlatformTransactionManager transactionManager;
|
||||
@Mock
|
||||
private BookParser goodreadsParser;
|
||||
@Mock
|
||||
private BookParser googleParser;
|
||||
@Mock
|
||||
private BookParser hardcoverParser;
|
||||
|
||||
@InjectMocks
|
||||
private MetadataRefreshService metadataRefreshService;
|
||||
|
||||
private AppSettings appSettings;
|
||||
private MetadataRefreshOptions defaultOptions;
|
||||
private MetadataRefreshOptions libraryOptions;
|
||||
private BookEntity testBook;
|
||||
private LibraryEntity testLibrary;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
setupDefaultOptions();
|
||||
setupLibraryOptions();
|
||||
setupAppSettings();
|
||||
setupTestEntities();
|
||||
}
|
||||
|
||||
private void setupDefaultOptions() {
|
||||
MetadataRefreshOptions.FieldProvider titleProvider = new MetadataRefreshOptions.FieldProvider(
|
||||
null, null, MetadataProvider.Google, MetadataProvider.GoodReads);
|
||||
MetadataRefreshOptions.FieldProvider descriptionProvider = new MetadataRefreshOptions.FieldProvider(
|
||||
null, null, null, MetadataProvider.Google);
|
||||
MetadataRefreshOptions.FieldProvider authorsProvider = new MetadataRefreshOptions.FieldProvider(
|
||||
null, null, null, MetadataProvider.GoodReads);
|
||||
MetadataRefreshOptions.FieldProvider categoriesProvider = new MetadataRefreshOptions.FieldProvider(
|
||||
null, null, null, MetadataProvider.Google);
|
||||
MetadataRefreshOptions.FieldProvider coverProvider = new MetadataRefreshOptions.FieldProvider(
|
||||
null, null, null, MetadataProvider.GoodReads);
|
||||
|
||||
MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions(
|
||||
titleProvider, null, descriptionProvider, authorsProvider, null, null,
|
||||
null, null, null, null, null, null, categoriesProvider, coverProvider);
|
||||
|
||||
defaultOptions = new MetadataRefreshOptions(
|
||||
null, MetadataProvider.GoodReads, MetadataProvider.Google, null, null,
|
||||
true, false, false, fieldOptions);
|
||||
}
|
||||
|
||||
private void setupLibraryOptions() {
|
||||
MetadataRefreshOptions.FieldProvider titleProvider = new MetadataRefreshOptions.FieldProvider(
|
||||
null, null, null, MetadataProvider.Google);
|
||||
|
||||
MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions(
|
||||
titleProvider, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null);
|
||||
|
||||
libraryOptions = new MetadataRefreshOptions(
|
||||
1L, MetadataProvider.Google, null, null, null,
|
||||
false, true, true, fieldOptions);
|
||||
}
|
||||
|
||||
private void setupAppSettings() {
|
||||
appSettings = AppSettings.builder()
|
||||
.defaultMetadataRefreshOptions(defaultOptions)
|
||||
.libraryMetadataRefreshOptions(List.of(libraryOptions))
|
||||
.build();
|
||||
}
|
||||
|
||||
private void setupTestEntities() {
|
||||
testLibrary = new LibraryEntity();
|
||||
testLibrary.setId(1L);
|
||||
testLibrary.setName("Test Library");
|
||||
|
||||
// Create AuthorEntity for proper type compatibility
|
||||
AuthorEntity authorEntity = new AuthorEntity();
|
||||
authorEntity.setName("Test Author");
|
||||
|
||||
BookMetadataEntity metadata = new BookMetadataEntity();
|
||||
metadata.setTitle("Test Book");
|
||||
metadata.setAuthors(Set.of(authorEntity));
|
||||
|
||||
testBook = new BookEntity();
|
||||
testBook.setId(1L);
|
||||
testBook.setFileName("test-book.epub");
|
||||
testBook.setLibrary(testLibrary);
|
||||
testBook.setMetadata(metadata);
|
||||
}
|
||||
|
||||
private void setupBasicMocks() {
|
||||
when(appSettingService.getAppSettings()).thenReturn(appSettings);
|
||||
}
|
||||
|
||||
private void setupBookRepositoryMocks() {
|
||||
when(bookRepository.findAllWithMetadataByIds(Set.of(1L))).thenReturn(List.of(testBook));
|
||||
}
|
||||
|
||||
private void setupParserMocksForGoodreadsAndGoogle() {
|
||||
BookMetadata goodreadsMetadata = BookMetadata.builder()
|
||||
.provider(MetadataProvider.GoodReads)
|
||||
.title("Goodreads Title")
|
||||
.authors(Set.of("Author 1"))
|
||||
.build();
|
||||
|
||||
BookMetadata googleMetadata = BookMetadata.builder()
|
||||
.provider(MetadataProvider.Google)
|
||||
.description("Google Description")
|
||||
.categories(Set.of("Fiction"))
|
||||
.build();
|
||||
|
||||
when(parserMap.get(MetadataProvider.GoodReads)).thenReturn(goodreadsParser);
|
||||
when(parserMap.get(MetadataProvider.Google)).thenReturn(googleParser);
|
||||
|
||||
when(goodreadsParser.fetchTopMetadata(any(Book.class), any(FetchMetadataRequest.class)))
|
||||
.thenReturn(null);
|
||||
when(googleParser.fetchTopMetadata(any(Book.class), any(FetchMetadataRequest.class)))
|
||||
.thenReturn(googleMetadata);
|
||||
|
||||
Book book = Book.builder()
|
||||
.id(1L)
|
||||
.fileName("test-book.epub")
|
||||
.metadata(BookMetadata.builder().title("Test Book").authors(Set.of("Test Author")).build())
|
||||
.build();
|
||||
when(bookMapper.toBook(testBook)).thenReturn(book);
|
||||
}
|
||||
|
||||
private void setupParserMocksForGoogle() {
|
||||
BookMetadata googleMetadata = BookMetadata.builder()
|
||||
.provider(MetadataProvider.Google)
|
||||
.title("Google Title")
|
||||
.description("Google Description")
|
||||
.categories(Set.of("Fiction"))
|
||||
.build();
|
||||
|
||||
when(parserMap.get(MetadataProvider.Google)).thenReturn(googleParser);
|
||||
when(googleParser.fetchTopMetadata(any(Book.class), any(FetchMetadataRequest.class)))
|
||||
.thenReturn(googleMetadata);
|
||||
|
||||
Book book = Book.builder()
|
||||
.id(1L)
|
||||
.fileName("test-book.epub")
|
||||
.metadata(BookMetadata.builder().title("Test Book").authors(Set.of("Test Author")).build())
|
||||
.build();
|
||||
when(bookMapper.toBook(testBook)).thenReturn(book);
|
||||
}
|
||||
|
||||
private void setupParserMocksForHardcover() {
|
||||
BookMetadata hardcoverMetadata = BookMetadata.builder()
|
||||
.provider(MetadataProvider.Hardcover)
|
||||
.title("Hardcover Title")
|
||||
.build();
|
||||
|
||||
when(parserMap.get(MetadataProvider.Hardcover)).thenReturn(hardcoverParser);
|
||||
when(hardcoverParser.fetchTopMetadata(any(Book.class), any(FetchMetadataRequest.class)))
|
||||
.thenReturn(hardcoverMetadata);
|
||||
|
||||
Book book = Book.builder()
|
||||
.id(1L)
|
||||
.fileName("test-book.epub")
|
||||
.metadata(BookMetadata.builder().title("Test Book").authors(Set.of("Test Author")).build())
|
||||
.build();
|
||||
when(bookMapper.toBook(testBook)).thenReturn(book);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRefreshMetadata_WithRequestOptions_ShouldUseRequestOptions() {
|
||||
// Given
|
||||
MetadataRefreshOptions.FieldProvider titleProvider = new MetadataRefreshOptions.FieldProvider(
|
||||
null, null, null, MetadataProvider.Hardcover);
|
||||
MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions(
|
||||
titleProvider, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null);
|
||||
|
||||
MetadataRefreshOptions requestOptions = new MetadataRefreshOptions(
|
||||
null, MetadataProvider.Hardcover, null, null, null,
|
||||
true, false, false, fieldOptions);
|
||||
|
||||
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
|
||||
.refreshType(MetadataRefreshRequest.RefreshType.BOOKS)
|
||||
.bookIds(Set.of(1L))
|
||||
.refreshOptions(requestOptions)
|
||||
.build();
|
||||
|
||||
setupBasicMocks();
|
||||
setupBookRepositoryMocks();
|
||||
setupParserMocksForHardcover();
|
||||
|
||||
metadataRefreshService.refreshMetadata(request, 1L, "job-1");
|
||||
|
||||
verify(bookRepository).findAllWithMetadataByIds(Set.of(1L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRefreshMetadata_LibraryRefresh_ShouldUseLibraryOptions() {
|
||||
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
|
||||
.refreshType(MetadataRefreshRequest.RefreshType.LIBRARY)
|
||||
.libraryId(1L)
|
||||
.build();
|
||||
|
||||
setupBasicMocks();
|
||||
when(libraryRepository.findById(1L)).thenReturn(Optional.of(testLibrary));
|
||||
when(bookRepository.findBookIdsByLibraryId(1L)).thenReturn(Set.of(1L));
|
||||
setupBookRepositoryMocks();
|
||||
setupParserMocksForGoogle();
|
||||
|
||||
metadataRefreshService.refreshMetadata(request, 1L, "job-1");
|
||||
|
||||
verify(libraryRepository).findById(1L);
|
||||
verify(bookRepository).findBookIdsByLibraryId(1L);
|
||||
verify(bookRepository).findAllWithMetadataByIds(Set.of(1L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRefreshMetadata_BookRefresh_ShouldUsePerBookLibraryOptions() {
|
||||
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
|
||||
.refreshType(MetadataRefreshRequest.RefreshType.BOOKS)
|
||||
.bookIds(Set.of(1L))
|
||||
.build();
|
||||
|
||||
setupBasicMocks();
|
||||
setupBookRepositoryMocks();
|
||||
setupParserMocksForGoogle();
|
||||
|
||||
metadataRefreshService.refreshMetadata(request, 1L, "job-1");
|
||||
|
||||
verify(bookRepository).findAllWithMetadataByIds(Set.of(1L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRefreshMetadata_WithReviewMode_ShouldCreateTaskAndProposals() throws JsonProcessingException {
|
||||
MetadataRefreshOptions reviewOptions = new MetadataRefreshOptions(
|
||||
null, MetadataProvider.GoodReads, MetadataProvider.Google, null, null,
|
||||
true, false, true, defaultOptions.getFieldOptions());
|
||||
|
||||
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
|
||||
.refreshType(MetadataRefreshRequest.RefreshType.BOOKS)
|
||||
.bookIds(Set.of(1L))
|
||||
.refreshOptions(reviewOptions)
|
||||
.build();
|
||||
|
||||
setupBasicMocks();
|
||||
setupBookRepositoryMocks();
|
||||
setupParserMocksForGoodreadsAndGoogle();
|
||||
when(objectMapper.writeValueAsString(any())).thenReturn("{}");
|
||||
|
||||
MetadataFetchJobEntity savedTask = new MetadataFetchJobEntity();
|
||||
when(metadataFetchJobRepository.save(any(MetadataFetchJobEntity.class))).thenReturn(savedTask);
|
||||
|
||||
metadataRefreshService.refreshMetadata(request, 1L, "job-1");
|
||||
|
||||
ArgumentCaptor<MetadataFetchJobEntity> taskCaptor = ArgumentCaptor.forClass(MetadataFetchJobEntity.class);
|
||||
verify(metadataFetchJobRepository, atLeast(1)).save(taskCaptor.capture());
|
||||
|
||||
MetadataFetchJobEntity capturedTask = taskCaptor.getValue();
|
||||
assertNotNull(capturedTask);
|
||||
verify(objectMapper).writeValueAsString(any(BookMetadata.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRefreshMetadata_LockedBook_ShouldSkip() {
|
||||
BookMetadataEntity lockedMetadata = spy(testBook.getMetadata());
|
||||
when(lockedMetadata.areAllFieldsLocked()).thenReturn(true);
|
||||
testBook.setMetadata(lockedMetadata);
|
||||
|
||||
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
|
||||
.refreshType(MetadataRefreshRequest.RefreshType.BOOKS)
|
||||
.bookIds(Set.of(1L))
|
||||
.build();
|
||||
|
||||
setupBasicMocks();
|
||||
setupBookRepositoryMocks();
|
||||
|
||||
metadataRefreshService.refreshMetadata(request, 1L, "job-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRefreshMetadata_BookNotFound_ShouldThrowException() {
|
||||
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
|
||||
.refreshType(MetadataRefreshRequest.RefreshType.BOOKS)
|
||||
.bookIds(Set.of(999L))
|
||||
.build();
|
||||
|
||||
setupBasicMocks();
|
||||
when(bookRepository.findAllWithMetadataByIds(Set.of(999L))).thenReturn(Collections.emptyList());
|
||||
|
||||
assertThrows(RuntimeException.class, () ->
|
||||
metadataRefreshService.refreshMetadata(request, 1L, "job-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRefreshMetadata_LibraryNotFound_ShouldThrowException() {
|
||||
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
|
||||
.refreshType(MetadataRefreshRequest.RefreshType.LIBRARY)
|
||||
.libraryId(999L)
|
||||
.build();
|
||||
|
||||
setupBasicMocks();
|
||||
when(libraryRepository.findById(999L)).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(RuntimeException.class, () ->
|
||||
metadataRefreshService.refreshMetadata(request, 1L, "job-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testResolveMetadataRefreshOptions_WithLibraryId_ShouldReturnLibraryOptions() {
|
||||
MetadataRefreshOptions result = metadataRefreshService.resolveMetadataRefreshOptions(1L, appSettings);
|
||||
|
||||
assertEquals(libraryOptions, result);
|
||||
assertTrue(result.getReviewBeforeApply());
|
||||
assertFalse(result.isRefreshCovers());
|
||||
assertTrue(result.isMergeCategories());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testResolveMetadataRefreshOptions_WithoutLibraryId_ShouldReturnDefaultOptions() {
|
||||
MetadataRefreshOptions result = metadataRefreshService.resolveMetadataRefreshOptions(null, appSettings);
|
||||
|
||||
assertEquals(defaultOptions, result);
|
||||
assertFalse(result.getReviewBeforeApply());
|
||||
assertTrue(result.isRefreshCovers());
|
||||
assertFalse(result.isMergeCategories());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testResolveMetadataRefreshOptions_NonExistentLibrary_ShouldReturnDefaultOptions() {
|
||||
MetadataRefreshOptions result = metadataRefreshService.resolveMetadataRefreshOptions(999L, appSettings);
|
||||
|
||||
assertEquals(defaultOptions, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPrepareProviders_ShouldReturnUniqueProviders() {
|
||||
List<MetadataProvider> providers = metadataRefreshService.prepareProviders(defaultOptions);
|
||||
|
||||
assertNotNull(providers);
|
||||
assertTrue(providers.contains(MetadataProvider.GoodReads));
|
||||
assertTrue(providers.contains(MetadataProvider.Google));
|
||||
assertEquals(2, providers.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFetchMetadataForBook_ShouldReturnMetadataMap() {
|
||||
Book book = Book.builder()
|
||||
.id(1L)
|
||||
.fileName("test.epub")
|
||||
.metadata(BookMetadata.builder().title("Test").build())
|
||||
.build();
|
||||
|
||||
BookMetadata goodreadsMetadata = BookMetadata.builder()
|
||||
.provider(MetadataProvider.GoodReads)
|
||||
.title("Goodreads Title")
|
||||
.build();
|
||||
|
||||
when(parserMap.get(MetadataProvider.GoodReads)).thenReturn(goodreadsParser);
|
||||
when(goodreadsParser.fetchTopMetadata(eq(book), any(FetchMetadataRequest.class)))
|
||||
.thenReturn(goodreadsMetadata);
|
||||
|
||||
List<MetadataProvider> providers = List.of(MetadataProvider.GoodReads);
|
||||
|
||||
Map<MetadataProvider, BookMetadata> result = metadataRefreshService.fetchMetadataForBook(providers, book);
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals(goodreadsMetadata, result.get(MetadataProvider.GoodReads));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuildFetchMetadata_ShouldCombineMetadataCorrectly() {
|
||||
Map<MetadataProvider, BookMetadata> metadataMap = new HashMap<>();
|
||||
metadataMap.put(MetadataProvider.GoodReads, BookMetadata.builder()
|
||||
.title("Goodreads Title")
|
||||
.authors(Set.of("Author 1"))
|
||||
.goodreadsId("gr123")
|
||||
.build());
|
||||
metadataMap.put(MetadataProvider.Google, BookMetadata.builder()
|
||||
.description("Google Description")
|
||||
.categories(Set.of("Fiction"))
|
||||
.googleId("google123")
|
||||
.build());
|
||||
|
||||
BookMetadata result = metadataRefreshService.buildFetchMetadata(1L, defaultOptions, metadataMap);
|
||||
|
||||
assertEquals("Goodreads Title", result.getTitle());
|
||||
assertEquals("Google Description", result.getDescription());
|
||||
assertEquals(Set.of("Author 1"), result.getAuthors());
|
||||
assertEquals(Set.of("Fiction"), result.getCategories());
|
||||
assertEquals("gr123", result.getGoodreadsId());
|
||||
assertEquals("google123", result.getGoogleId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuildFetchMetadata_WithMergeCategories_ShouldMergeAllCategories() {
|
||||
MetadataRefreshOptions.FieldProvider titleProvider = new MetadataRefreshOptions.FieldProvider(
|
||||
null, null, null, MetadataProvider.Google);
|
||||
MetadataRefreshOptions.FieldProvider descriptionProvider = new MetadataRefreshOptions.FieldProvider(
|
||||
null, null, null, MetadataProvider.Google);
|
||||
MetadataRefreshOptions.FieldProvider authorsProvider = new MetadataRefreshOptions.FieldProvider(
|
||||
null, null, null, MetadataProvider.Google);
|
||||
MetadataRefreshOptions.FieldProvider categoriesProvider = new MetadataRefreshOptions.FieldProvider(
|
||||
null, null, MetadataProvider.Google, MetadataProvider.GoodReads);
|
||||
MetadataRefreshOptions.FieldProvider coverProvider = new MetadataRefreshOptions.FieldProvider(
|
||||
null, null, null, MetadataProvider.Google);
|
||||
|
||||
MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions(
|
||||
titleProvider, null, descriptionProvider, authorsProvider, null, null,
|
||||
null, null, null, null, null, null, categoriesProvider, coverProvider);
|
||||
|
||||
MetadataRefreshOptions mergeOptions = new MetadataRefreshOptions(
|
||||
null, MetadataProvider.GoodReads, MetadataProvider.Google, null, null,
|
||||
true, true, false, fieldOptions);
|
||||
|
||||
Map<MetadataProvider, BookMetadata> metadataMap = new HashMap<>();
|
||||
metadataMap.put(MetadataProvider.GoodReads, BookMetadata.builder()
|
||||
.categories(Set.of("Fiction", "Drama"))
|
||||
.build());
|
||||
metadataMap.put(MetadataProvider.Google, BookMetadata.builder()
|
||||
.categories(Set.of("Literature", "Fiction"))
|
||||
.build());
|
||||
|
||||
BookMetadata result = metadataRefreshService.buildFetchMetadata(1L, mergeOptions, metadataMap);
|
||||
|
||||
assertNotNull(result.getCategories());
|
||||
Set<String> expectedCategories = Set.of("Fiction", "Drama", "Literature");
|
||||
|
||||
assertEquals(3, result.getCategories().size(), "Should have 3 unique categories when merging");
|
||||
assertTrue(result.getCategories().containsAll(expectedCategories), "Should contain all expected categories");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetBookEntities_WithLibraryRefresh_ShouldReturnLibraryBooks() {
|
||||
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
|
||||
.refreshType(MetadataRefreshRequest.RefreshType.LIBRARY)
|
||||
.libraryId(1L)
|
||||
.build();
|
||||
|
||||
when(libraryRepository.findById(1L)).thenReturn(Optional.of(testLibrary));
|
||||
when(bookRepository.findBookIdsByLibraryId(1L)).thenReturn(Set.of(1L, 2L, 3L));
|
||||
|
||||
Set<Long> result = metadataRefreshService.getBookEntities(request);
|
||||
|
||||
assertEquals(Set.of(1L, 2L, 3L), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetBookEntities_WithBooksRefresh_ShouldReturnRequestedBooks() {
|
||||
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
|
||||
.refreshType(MetadataRefreshRequest.RefreshType.BOOKS)
|
||||
.bookIds(Set.of(1L, 2L))
|
||||
.build();
|
||||
|
||||
Set<Long> result = metadataRefreshService.getBookEntities(request);
|
||||
|
||||
assertEquals(Set.of(1L, 2L), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateBookMetadata_ShouldCallUpdaterAndNotification() {
|
||||
BookMetadata metadata = BookMetadata.builder()
|
||||
.title("Updated Title")
|
||||
.build();
|
||||
Book book = Book.builder().id(1L).build();
|
||||
|
||||
when(bookMapper.toBook(testBook)).thenReturn(book);
|
||||
|
||||
metadataRefreshService.updateBookMetadata(testBook, metadata, true, false);
|
||||
|
||||
verify(bookMetadataUpdater).setBookMetadata(eq(testBook), any(MetadataUpdateWrapper.class), eq(true), eq(false));
|
||||
verify(notificationService).sendMessage(eq(Topic.BOOK_METADATA_UPDATE), eq(book));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import {Component, computed, inject, OnDestroy, OnInit} from '@angular/core';
|
||||
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
|
||||
import {RxStompService} from './shared/websocket/rx-stomp.service';
|
||||
import {BookService} from './book/service/book.service';
|
||||
import {NotificationEventService} from './shared/websocket/notification-event.service';
|
||||
import {parseLogNotification, parseTaskMessage} from './shared/websocket/model/log-notification.model';
|
||||
import {parseLogNotification} from './shared/websocket/model/log-notification.model';
|
||||
import {ConfirmDialog} from 'primeng/confirmdialog';
|
||||
import {Toast} from 'primeng/toast';
|
||||
import {RouterOutlet} from '@angular/router';
|
||||
@@ -11,7 +11,6 @@ import {AppConfigService} from './core/service/app-config.service';
|
||||
import {MetadataBatchProgressNotification} from './core/model/metadata-batch-progress.model';
|
||||
import {MetadataProgressService} from './core/service/metadata-progress-service';
|
||||
import {BookdropFileNotification, BookdropFileService} from './bookdrop/bookdrop-file.service';
|
||||
import {TaskEventService} from './shared/websocket/task-event.service';
|
||||
import {DuplicateFileNotification} from './shared/websocket/model/duplicate-file-notification.model';
|
||||
import {DuplicateFileService} from './shared/websocket/duplicate-file.service';
|
||||
import {Subscription} from 'rxjs';
|
||||
@@ -34,7 +33,6 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
private notificationEventService = inject(NotificationEventService);
|
||||
private metadataProgressService = inject(MetadataProgressService);
|
||||
private bookdropFileService = inject(BookdropFileService);
|
||||
private taskEventService = inject(TaskEventService);
|
||||
private duplicateFileService = inject(DuplicateFileService);
|
||||
private appConfigService = inject(AppConfigService); // Keep it here to ensure the service is initialized
|
||||
|
||||
@@ -80,11 +78,6 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
this.notificationEventService.handleNewNotification(logNotification);
|
||||
})
|
||||
);
|
||||
this.subscriptions.push(
|
||||
this.rxStompService.watch('/user/queue/task').subscribe(msg =>
|
||||
this.taskEventService.handleTaskMessage(parseTaskMessage(msg.body))
|
||||
)
|
||||
);
|
||||
this.subscriptions.push(
|
||||
this.rxStompService.watch('/user/queue/duplicate-file').subscribe(msg =>
|
||||
this.duplicateFileService.addDuplicateFile(JSON.parse(msg.body) as DuplicateFileNotification)
|
||||
|
||||
@@ -73,7 +73,7 @@ export class BookDialogHelperService {
|
||||
});
|
||||
}
|
||||
|
||||
openMultibookMetadataEditerDialog(bookIds: Set<number>): DynamicDialogRef {
|
||||
openMultibookMetadataEditorDialog(bookIds: Set<number>): DynamicDialogRef {
|
||||
return this.dialogService.open(MultiBookMetadataEditorComponent, {
|
||||
header: 'Bulk Edit Metadata',
|
||||
showHeader: false,
|
||||
@@ -96,12 +96,16 @@ export class BookDialogHelperService {
|
||||
return this.dialogService.open(FileMoverComponent, {
|
||||
header: `Organize Book Files (${count} book${count !== 1 ? 's' : ''})`,
|
||||
showHeader: true,
|
||||
maximizable: true,
|
||||
modal: true,
|
||||
closable: true,
|
||||
closeOnEscape: false,
|
||||
dismissableMask: false,
|
||||
style: {
|
||||
width: '90vw'
|
||||
width: '85vw',
|
||||
height: '80vh',
|
||||
maxHeight: '95vh',
|
||||
maxWidth: '97.5vw'
|
||||
},
|
||||
data: {
|
||||
bookIds: selectedBooks
|
||||
|
||||
@@ -345,7 +345,7 @@
|
||||
icon="pi pi-arrows-h"
|
||||
severity="info"
|
||||
(click)="moveFiles()"
|
||||
pTooltip="Move Books"
|
||||
pTooltip="Organize Files"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ import {MagicShelf, MagicShelfService} from '../../../magic-shelf.service';
|
||||
import {BookRuleEvaluatorService} from '../../../book-rule-evaluator.service';
|
||||
import {GroupRule} from '../../../magic-shelf-component/magic-shelf-component';
|
||||
import {SidebarFilterTogglePrefService} from './filters/sidebar-filter-toggle-pref-service';
|
||||
import {MetadataRefreshRequest} from '../../../metadata/model/request/metadata-refresh-request.model';
|
||||
import {MetadataRefreshType} from '../../../metadata/model/request/metadata-refresh-type.enum';
|
||||
|
||||
export enum EntityType {
|
||||
LIBRARY = 'Library',
|
||||
@@ -153,7 +155,7 @@ export class BookBrowserComponent implements OnInit {
|
||||
|
||||
@ViewChild(BookTableComponent)
|
||||
bookTableComponent!: BookTableComponent;
|
||||
@ViewChild(BookFilterComponent, { static: false })
|
||||
@ViewChild(BookFilterComponent, {static: false})
|
||||
bookFilterComponent!: BookFilterComponent;
|
||||
|
||||
get currentCardSize() {
|
||||
@@ -211,9 +213,10 @@ export class BookBrowserComponent implements OnInit {
|
||||
});
|
||||
|
||||
this.metadataMenuItems = this.bookMenuService.getMetadataMenuItems(
|
||||
() => this.updateMetadata(),
|
||||
() => this.autoFetchMetadata(),
|
||||
() => this.fetchMetadata(),
|
||||
() => this.bulkEditMetadata(),
|
||||
() => this.multiBookEditMetadata()
|
||||
() => this.multiBookEditMetadata(),
|
||||
);
|
||||
this.tieredMenuItems = this.bookMenuService.getTieredMenuItems(this.selectedBooks);
|
||||
|
||||
@@ -246,7 +249,7 @@ export class BookBrowserComponent implements OnInit {
|
||||
});
|
||||
|
||||
this.selectedFilter.next(parsedFilters);
|
||||
if(this.bookFilterComponent) {
|
||||
if (this.bookFilterComponent) {
|
||||
this.bookFilterComponent.setFilters?.(parsedFilters);
|
||||
this.bookFilterComponent.onFiltersChanged?.();
|
||||
}
|
||||
@@ -549,7 +552,15 @@ export class BookBrowserComponent implements OnInit {
|
||||
this.dynamicDialogRef = this.dialogHelperService.openLockUnlockMetadataDialog(this.selectedBooks);
|
||||
}
|
||||
|
||||
updateMetadata(): void {
|
||||
autoFetchMetadata(): void {
|
||||
const metadataRefreshRequest: MetadataRefreshRequest = {
|
||||
refreshType: MetadataRefreshType.BOOKS,
|
||||
bookIds: Array.from(this.selectedBooks),
|
||||
};
|
||||
this.bookService.autoRefreshMetadata(metadataRefreshRequest).subscribe();
|
||||
}
|
||||
|
||||
fetchMetadata(): void {
|
||||
this.dialogHelperService.openMetadataRefreshDialog(this.selectedBooks);
|
||||
}
|
||||
|
||||
@@ -558,7 +569,7 @@ export class BookBrowserComponent implements OnInit {
|
||||
}
|
||||
|
||||
multiBookEditMetadata(): void {
|
||||
this.dialogHelperService.openMultibookMetadataEditerDialog(this.selectedBooks);
|
||||
this.dialogHelperService.openMultibookMetadataEditorDialog(this.selectedBooks);
|
||||
}
|
||||
|
||||
moveFiles() {
|
||||
@@ -695,9 +706,9 @@ export class BookBrowserComponent implements OnInit {
|
||||
map(filtered =>
|
||||
(filtered.loaded && !filtered.error)
|
||||
? ({
|
||||
...filtered,
|
||||
books: this.sortService.applySort(filtered.books || [], this.bookSorter.selectedSort!)
|
||||
})
|
||||
...filtered,
|
||||
books: this.sortService.applySort(filtered.books || [], this.bookSorter.selectedSort!)
|
||||
})
|
||||
: filtered
|
||||
)
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ import {take, takeUntil} from 'rxjs/operators';
|
||||
import {readStatusLabels} from '../book-filter/book-filter.component';
|
||||
import {ResetProgressTypes} from '../../../../shared/constants/reset-progress-type';
|
||||
import {ReadStatusHelper} from '../../../helpers/read-status.helper';
|
||||
import {BookDialogHelperService} from '../BookDialogHelperService';
|
||||
|
||||
@Component({
|
||||
selector: 'app-book-card',
|
||||
@@ -63,6 +64,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
private router = inject(Router);
|
||||
protected urlHelper = inject(UrlHelperService);
|
||||
private confirmationService = inject(ConfirmationService);
|
||||
private bookDialogHelperService = inject(BookDialogHelperService);
|
||||
|
||||
private userPermissions: any;
|
||||
private metadataCenterViewMode: 'route' | 'dialog' = 'route';
|
||||
@@ -153,7 +155,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
const hasNoAlternativeFormats = !this.book.alternativeFormats || this.book.alternativeFormats.length === 0;
|
||||
const hasNoSupplementaryFiles = !this.book.supplementaryFiles || this.book.supplementaryFiles.length === 0;
|
||||
return (this.hasDownloadPermission() || this.hasDeleteBookPermission()) &&
|
||||
hasNoAlternativeFormats && hasNoSupplementaryFiles;
|
||||
hasNoAlternativeFormats && hasNoSupplementaryFiles;
|
||||
}
|
||||
|
||||
private initMenu() {
|
||||
@@ -194,7 +196,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
if (this.hasDownloadPermission()) {
|
||||
const hasAdditionalFiles = (this.book.alternativeFormats && this.book.alternativeFormats.length > 0) ||
|
||||
(this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0);
|
||||
(this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0);
|
||||
|
||||
if (hasAdditionalFiles) {
|
||||
const downloadItems = this.getDownloadMenuItems();
|
||||
@@ -224,7 +226,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
if (this.hasDeleteBookPermission()) {
|
||||
const hasAdditionalFiles = (this.book.alternativeFormats && this.book.alternativeFormats.length > 0) ||
|
||||
(this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0);
|
||||
(this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0);
|
||||
|
||||
if (hasAdditionalFiles) {
|
||||
const deleteItems = this.getDeleteMenuItems();
|
||||
@@ -333,7 +335,6 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
icon: 'pi pi-bolt',
|
||||
command: () => {
|
||||
const metadataRefreshRequest: MetadataRefreshRequest = {
|
||||
quick: true,
|
||||
refreshType: MetadataRefreshType.BOOKS,
|
||||
bookIds: [this.book.id],
|
||||
};
|
||||
@@ -341,8 +342,8 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Advanced Fetch',
|
||||
icon: 'pi pi-database',
|
||||
label: 'Custom Fetch',
|
||||
icon: 'pi pi-sync',
|
||||
command: () => {
|
||||
this.dialogService.open(MetadataFetchOptionsComponent, {
|
||||
header: 'Metadata Refresh Options',
|
||||
@@ -370,6 +371,13 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
label: 'More Actions',
|
||||
icon: 'pi pi-ellipsis-h',
|
||||
items: [
|
||||
{
|
||||
label: 'Organize File',
|
||||
icon: 'pi pi-arrows-h',
|
||||
command: () => {
|
||||
this.bookDialogHelperService.openFileMoverDialog(new Set([this.book.id]));
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Read Status',
|
||||
icon: 'pi pi-book',
|
||||
@@ -509,7 +517,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
// Add separator if there are additional files
|
||||
if (this.hasAdditionalFiles()) {
|
||||
items.push({ separator: true });
|
||||
items.push({separator: true});
|
||||
}
|
||||
|
||||
// Add alternative formats
|
||||
@@ -526,8 +534,8 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
// Add separator if both alternative formats and supplementary files exist
|
||||
if (this.book.alternativeFormats && this.book.alternativeFormats.length > 0 &&
|
||||
this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0) {
|
||||
items.push({ separator: true });
|
||||
this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0) {
|
||||
items.push({separator: true});
|
||||
}
|
||||
|
||||
// Add supplementary files
|
||||
@@ -569,7 +577,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
// Add separator if there are additional files
|
||||
if (this.hasAdditionalFiles()) {
|
||||
items.push({ separator: true });
|
||||
items.push({separator: true});
|
||||
}
|
||||
|
||||
// Add alternative formats
|
||||
@@ -586,8 +594,8 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
// Add separator if both alternative formats and supplementary files exist
|
||||
if (this.book.alternativeFormats && this.book.alternativeFormats.length > 0 &&
|
||||
this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0) {
|
||||
items.push({ separator: true });
|
||||
this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0) {
|
||||
items.push({separator: true});
|
||||
}
|
||||
|
||||
// Add supplementary files
|
||||
@@ -607,7 +615,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
private hasAdditionalFiles(): boolean {
|
||||
return !!(this.book.alternativeFormats && this.book.alternativeFormats.length > 0) ||
|
||||
!!(this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0);
|
||||
!!(this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0);
|
||||
}
|
||||
|
||||
private downloadAdditionalFile(bookId: number, fileId: number): void {
|
||||
@@ -707,26 +715,26 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.toggleCardSelection(!this.isSelected)
|
||||
}
|
||||
|
||||
toggleCardSelection(selected: boolean):void {
|
||||
if (!this.isCheckboxEnabled) {
|
||||
return;
|
||||
}
|
||||
toggleCardSelection(selected: boolean): void {
|
||||
if (!this.isCheckboxEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSelected = selected;
|
||||
const shiftKey = this.lastMouseEvent?.shiftKey ?? false;
|
||||
this.isSelected = selected;
|
||||
const shiftKey = this.lastMouseEvent?.shiftKey ?? false;
|
||||
|
||||
this.checkboxClick.emit({
|
||||
index: this.index,
|
||||
bookId: this.book.id,
|
||||
selected: selected,
|
||||
shiftKey: shiftKey,
|
||||
});
|
||||
this.checkboxClick.emit({
|
||||
index: this.index,
|
||||
bookId: this.book.id,
|
||||
selected: selected,
|
||||
shiftKey: shiftKey,
|
||||
});
|
||||
|
||||
if (this.onBookSelect) {
|
||||
this.onBookSelect(this.book.id, selected);
|
||||
}
|
||||
if (this.onBookSelect) {
|
||||
this.onBookSelect(this.book.id, selected);
|
||||
}
|
||||
|
||||
this.lastMouseEvent = null;
|
||||
this.lastMouseEvent = null;
|
||||
}
|
||||
|
||||
toggleSelection(event: CheckboxChangeEvent): void {
|
||||
|
||||
@@ -6,14 +6,32 @@
|
||||
<span class="text-gray-400">Getting latest reviews...</span>
|
||||
</div>
|
||||
} @else if (reviews?.length === 0) {
|
||||
<div class="text-center p-8 text-gray-400">
|
||||
<i class="pi pi-comments text-4xl mb-4 block"></i>
|
||||
<p class="text-gray-200">No reviews available for this book</p>
|
||||
<div class="text-center p-8 text-gray-400 empty-state">
|
||||
<div class="empty-state-icon">
|
||||
@if (hasPermission && !reviewsLocked && reviewDownloadEnabled) {
|
||||
<p-button
|
||||
outlined
|
||||
rounded
|
||||
icon="pi pi-download"
|
||||
severity="primary"
|
||||
(click)="fetchNewReviews()"
|
||||
pTooltip="Fetch Reviews"
|
||||
tooltipPosition="top"
|
||||
class="action-btn floating-btn">
|
||||
</p-button>
|
||||
}
|
||||
</div>
|
||||
<p class="text-gray-200 empty-state-title">No reviews available for this book</p>
|
||||
@if (!reviewDownloadEnabled) {
|
||||
<p class="text-sm text-amber-400 mt-2">
|
||||
<p class="text-sm text-amber-400 mt-2 empty-state-subtitle">
|
||||
Book review downloads are currently disabled. Enable this in Metadata Settings to fetch reviews.
|
||||
</p>
|
||||
} @else if (hasPermission && !reviewsLocked) {
|
||||
<p class="text-sm text-gray-400 mt-2 empty-state-subtitle">
|
||||
Click "Fetch Reviews" to download reviews from configured providers
|
||||
</p>
|
||||
}
|
||||
<div class="empty-state-decoration"></div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="reviews-scroll-container">
|
||||
|
||||
@@ -54,6 +54,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
position: relative;
|
||||
padding: 3rem !important;
|
||||
|
||||
.empty-state-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state-subtitle {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.empty-state-decoration {
|
||||
position: absolute;
|
||||
top: 20%;
|
||||
right: 15%;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
filter: blur(40px);
|
||||
animation: float 4s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.reviews-scroll-container {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface Book extends FileInfo {
|
||||
id: number;
|
||||
bookType: BookType;
|
||||
libraryId: number;
|
||||
libraryName: string;
|
||||
metadata?: BookMetadata;
|
||||
shelves?: Shelf[];
|
||||
lastReadTime?: string;
|
||||
|
||||
@@ -16,12 +16,21 @@ export class BookMenuService {
|
||||
bookService = inject(BookService);
|
||||
|
||||
|
||||
getMetadataMenuItems(updateMetadata: () => void, bulkEditMetadata: () => void, multiBookEditMetadata: () => void): MenuItem[] {
|
||||
getMetadataMenuItems(
|
||||
autoFetchMetadata: () => void,
|
||||
fetchMetadata: () => void,
|
||||
bulkEditMetadata: () => void,
|
||||
multiBookEditMetadata: () => void): MenuItem[] {
|
||||
return [
|
||||
{
|
||||
label: 'Refresh Metadata',
|
||||
label: 'Auto Fetch Metadata',
|
||||
icon: 'pi pi-bolt',
|
||||
command: autoFetchMetadata
|
||||
},
|
||||
{
|
||||
label: 'Custom Fetch Metadata',
|
||||
icon: 'pi pi-sync',
|
||||
command: updateMetadata
|
||||
command: fetchMetadata
|
||||
},
|
||||
{
|
||||
label: 'Bulk Metadata Editor',
|
||||
|
||||
@@ -3,6 +3,7 @@ import {ConfirmationService, MenuItem, MessageService} from 'primeng/api';
|
||||
import {Router} from '@angular/router';
|
||||
import {LibraryService} from './library.service';
|
||||
import {ShelfService} from './shelf.service';
|
||||
import {BookService} from './book.service';
|
||||
import {Library} from '../model/library.model';
|
||||
import {Shelf} from '../model/shelf.model';
|
||||
import {DialogService} from 'primeng/dynamicdialog';
|
||||
@@ -22,6 +23,7 @@ export class LibraryShelfMenuService {
|
||||
private messageService = inject(MessageService);
|
||||
private libraryService = inject(LibraryService);
|
||||
private shelfService = inject(ShelfService);
|
||||
private bookService = inject(BookService);
|
||||
private router = inject(Router);
|
||||
private dialogService = inject(DialogService);
|
||||
private magicShelfService = inject(MagicShelfService);
|
||||
@@ -116,8 +118,8 @@ export class LibraryShelfMenuService {
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Refresh Books Metadata',
|
||||
icon: 'pi pi-database',
|
||||
label: 'Custom Fetch Metadata',
|
||||
icon: 'pi pi-sync',
|
||||
command: () => {
|
||||
this.dialogService.open(MetadataFetchOptionsComponent, {
|
||||
header: 'Metadata Refresh Options',
|
||||
@@ -129,6 +131,16 @@ export class LibraryShelfMenuService {
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Auto Fetch Metadata',
|
||||
icon: 'pi pi-bolt',
|
||||
command: () => {
|
||||
this.bookService.autoRefreshMetadata({
|
||||
refreshType: MetadataRefreshType.LIBRARY,
|
||||
libraryId: entity?.id || undefined
|
||||
}).subscribe();
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
<div class="flex flex-col p-4 live-border">
|
||||
@if (tasks$ | async; as tasks) {
|
||||
@if (tasks.length === 0) {
|
||||
<div class="text-center text-zinc-400">
|
||||
<p class="text-sm">No active tasks</p>
|
||||
</div>
|
||||
} @else {
|
||||
@for (task of tasks; track task.taskId; let i = $index) {
|
||||
@if (i > 0) {
|
||||
<div class="border-t border-zinc-700 my-4"></div>
|
||||
}
|
||||
<div class="flex flex-col space-y-1.5">
|
||||
<p class="text-xs text-zinc-400">{{ task.timestamp }}</p>
|
||||
|
||||
<div class="flex flex-col space-y-0.5">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<span class="px-1.5 py-0.5 text-xs rounded-xl font-bold" [class]="getStatusClasses(task.status)">
|
||||
{{ getStatusText(task.status) }}
|
||||
</span>
|
||||
@if (task.title) {
|
||||
<h4 class="font-bold text-zinc-200">Task: {{ task.title }}</h4>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
@if (task.status === TaskStatus.IN_PROGRESS && task.cancellable) {
|
||||
<p-button
|
||||
(onClick)="cancelTask(task.taskId)" icon="pi pi-ban" severity="danger" size="small" rounded text>
|
||||
</p-button>
|
||||
}
|
||||
<p-button
|
||||
(onClick)="removeTask(task.taskId)" icon="pi pi-times" severity="secondary" size="small" rounded text>
|
||||
</p-button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="font-normal text-zinc-300">{{ task.message }}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
.live-border {
|
||||
background: var(--card-background);
|
||||
border: 1px solid var(--primary-color);
|
||||
border-radius: 0.5rem;
|
||||
|
||||
}
|
||||
|
||||
-84
@@ -1,84 +0,0 @@
|
||||
import {Component, inject} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {TaskMessage, TaskStatus} from '../../../shared/websocket/model/log-notification.model';
|
||||
import {TaskEventService} from '../../../shared/websocket/task-event.service';
|
||||
import {TaskService} from '../../../shared/services/task.service';
|
||||
import {Observable, take} from 'rxjs';
|
||||
import {Button} from 'primeng/button';
|
||||
import {MessageService} from 'primeng/api';
|
||||
|
||||
@Component({
|
||||
selector: 'app-live-task-event-box',
|
||||
standalone: true,
|
||||
imports: [CommonModule, Button],
|
||||
templateUrl: './live-task-event-box.component.html',
|
||||
styleUrls: ['./live-task-event-box.component.scss'],
|
||||
host: {
|
||||
class: 'config-panel'
|
||||
}
|
||||
})
|
||||
export class LiveTaskEventBoxComponent {
|
||||
tasks$: Observable<TaskMessage[]>;
|
||||
TaskStatus = TaskStatus;
|
||||
|
||||
private taskEventService = inject(TaskEventService);
|
||||
private taskService = inject(TaskService);
|
||||
private messageService = inject(MessageService);
|
||||
|
||||
constructor() {
|
||||
this.tasks$ = this.taskEventService.tasks$;
|
||||
}
|
||||
|
||||
cancelTask(taskId: string): void {
|
||||
this.taskService.cancelTask(taskId).pipe(take(1)).subscribe({
|
||||
next: (response) => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Success',
|
||||
detail: `Task cancellation scheduled`
|
||||
});
|
||||
},
|
||||
error: (error) => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Cancellation failed',
|
||||
detail: error.error?.error || 'Failed to cancel task'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
removeTask(taskId: string): void {
|
||||
this.taskEventService.removeTask(taskId);
|
||||
}
|
||||
|
||||
getStatusClasses(status: TaskStatus): string {
|
||||
switch (status) {
|
||||
case TaskStatus.IN_PROGRESS:
|
||||
return 'bg-blue-700 text-zinc-100';
|
||||
case TaskStatus.COMPLETED:
|
||||
return 'bg-green-700 text-zinc-100';
|
||||
case TaskStatus.FAILED:
|
||||
return 'bg-red-700 text-zinc-100';
|
||||
case TaskStatus.CANCELLED:
|
||||
return 'bg-gray-700 text-zinc-100';
|
||||
default:
|
||||
return 'bg-zinc-700 text-zinc-100';
|
||||
}
|
||||
}
|
||||
|
||||
getStatusText(status: TaskStatus): string {
|
||||
switch (status) {
|
||||
case TaskStatus.IN_PROGRESS:
|
||||
return 'Running';
|
||||
case TaskStatus.COMPLETED:
|
||||
return 'Completed';
|
||||
case TaskStatus.FAILED:
|
||||
return 'Failed';
|
||||
case TaskStatus.CANCELLED:
|
||||
return 'Cancelled';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
-18
@@ -4,12 +4,29 @@
|
||||
<div class="task-card p-4">
|
||||
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-sm font-semibold text-[var(--primary-color)]">Metadata Fetch Task</p>
|
||||
<p-tag
|
||||
[value]="getStatusLabel(task.value.status)"
|
||||
[severity]="getTagSeverity(task.value.status)"
|
||||
class="text-xs"
|
||||
></p-tag>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-semibold text-[var(--primary-color)]">Metadata Fetch Task</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
@if (task.value.review) {
|
||||
<p-tag
|
||||
value="Review"
|
||||
severity="warn"
|
||||
class="text-xs">
|
||||
</p-tag>
|
||||
} @else {
|
||||
<p-tag
|
||||
value="Auto"
|
||||
severity="success"
|
||||
class="text-xs">
|
||||
</p-tag>
|
||||
}
|
||||
<p-tag
|
||||
[value]="getStatusLabel(task.value.status)"
|
||||
[severity]="getTagSeverity(task.value.status)"
|
||||
class="text-xs"
|
||||
></p-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-message text-gray-200">
|
||||
@@ -29,27 +46,43 @@
|
||||
|
||||
@if (task.value.status === 'COMPLETED' || task.value.status === 'ERROR') {
|
||||
<div class="action-buttons mt-4 flex justify-end gap-2">
|
||||
<p-button
|
||||
label="Review"
|
||||
severity="info"
|
||||
icon="pi pi-search"
|
||||
size="small"
|
||||
outlined
|
||||
(click)="reviewTask(task.key)">
|
||||
</p-button>
|
||||
@if (task.value.review) {
|
||||
<p-button
|
||||
label="Review"
|
||||
severity="info"
|
||||
icon="pi pi-search"
|
||||
size="small"
|
||||
outlined
|
||||
(click)="reviewTask(task.key)">
|
||||
</p-button>
|
||||
}
|
||||
|
||||
<p-button
|
||||
label="Discard"
|
||||
[label]="task.value.review ? 'Discard' : 'Dismiss'"
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
[severity]="task.value.review ? 'danger' : 'secondary'"
|
||||
size="small"
|
||||
pTooltip="Deletes the fetched metadata"
|
||||
tooltipPosition="top"
|
||||
[pTooltip]="task.value.review ? 'Preserve current metadata, discard new fetched metadata' : ''"
|
||||
[tooltipPosition]="task.value.review ? 'top' : undefined"
|
||||
outlined
|
||||
(click)="clearTask(task.key)">
|
||||
</p-button>
|
||||
</div>
|
||||
} @else if (task.value.status === 'IN_PROGRESS') {
|
||||
<div class="action-buttons mt-4 flex justify-end gap-2">
|
||||
<p-button
|
||||
label="Cancel"
|
||||
icon="pi pi-stop"
|
||||
severity="danger"
|
||||
size="small"
|
||||
pTooltip="Cancel this task"
|
||||
tooltipPosition="top"
|
||||
outlined
|
||||
(click)="cancelTask(task.key)">
|
||||
</p-button>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
@if (idx < Object.keys(activeTasks).length - 1) {
|
||||
|
||||
+6
@@ -26,3 +26,9 @@
|
||||
text-align: right;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.review-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
+38
-6
@@ -7,15 +7,13 @@ import {ButtonModule} from 'primeng/button';
|
||||
import {Divider} from 'primeng/divider';
|
||||
import {Tooltip} from 'primeng/tooltip';
|
||||
import {DialogService} from 'primeng/dynamicdialog';
|
||||
import {MessageService} from 'primeng/api';
|
||||
|
||||
import {
|
||||
MetadataBatchProgressNotification,
|
||||
MetadataBatchStatus,
|
||||
MetadataBatchStatusLabels
|
||||
} from '../../model/metadata-batch-progress.model';
|
||||
import {MetadataBatchProgressNotification, MetadataBatchStatus, MetadataBatchStatusLabels} from '../../model/metadata-batch-progress.model';
|
||||
import {MetadataProgressService} from '../../service/metadata-progress-service';
|
||||
import {MetadataReviewDialogComponent} from '../../../metadata/metadata-review-dialog-component/metadata-review-dialog-component';
|
||||
import {MetadataTaskService} from '../../../book/service/metadata-task';
|
||||
import {TaskService} from '../../../shared/services/task.service';
|
||||
import {Tag} from 'primeng/tag';
|
||||
|
||||
@Component({
|
||||
@@ -32,6 +30,8 @@ export class MetadataProgressWidgetComponent implements OnInit, OnDestroy {
|
||||
private dialogService = inject(DialogService);
|
||||
private metadataProgressService = inject(MetadataProgressService);
|
||||
private metadataTaskService = inject(MetadataTaskService);
|
||||
private taskService = inject(TaskService);
|
||||
private messageService = inject(MessageService);
|
||||
|
||||
private lastUpdateMap = new Map<string, number>();
|
||||
private timeoutHandles = new Map<string, any>();
|
||||
@@ -87,7 +87,9 @@ export class MetadataProgressWidgetComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
getProgressPercent(task: MetadataBatchProgressNotification): number {
|
||||
return task.total > 0 ? Math.round((task.completed / task.total) * 100) : 0;
|
||||
if (task.total <= 0) return 0;
|
||||
if (task.status === 'COMPLETED') return 100;
|
||||
return Math.round((task.completed / task.total) * 100);
|
||||
}
|
||||
|
||||
clearTask(taskId: string): void {
|
||||
@@ -110,6 +112,36 @@ export class MetadataProgressWidgetComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
cancelTask(taskId: string): void {
|
||||
this.taskService.cancelTask(taskId).subscribe({
|
||||
next: () => {
|
||||
const task = this.activeTasks[taskId];
|
||||
if (task) {
|
||||
this.activeTasks[taskId] = {
|
||||
...task,
|
||||
status: MetadataBatchStatus.ERROR,
|
||||
message: 'Task cancelled by user'
|
||||
};
|
||||
this.activeTasks = {...this.activeTasks};
|
||||
}
|
||||
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Cancellation Scheduled',
|
||||
detail: 'Task cancellation has been successfully scheduled'
|
||||
});
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Failed to cancel task:', error);
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Cancel Failed',
|
||||
detail: 'Failed to cancel the task. Please try again.'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
|
||||
-3
@@ -3,9 +3,6 @@
|
||||
@if (hasDuplicateFiles$ | async) {
|
||||
<app-duplicate-files-notification/>
|
||||
}
|
||||
@if (hasActiveTasks$ | async) {
|
||||
<app-live-task-event-box/>
|
||||
}
|
||||
@if (hasMetadataTasks$ | async) {
|
||||
<app-metadata-progress-widget/>
|
||||
}
|
||||
|
||||
-8
@@ -1,9 +1,7 @@
|
||||
import {Component, inject} from '@angular/core';
|
||||
import {LiveNotificationBoxComponent} from '../live-notification-box/live-notification-box.component';
|
||||
import {LiveTaskEventBoxComponent} from '../live-task-event-box/live-task-event-box.component';
|
||||
import {MetadataProgressWidgetComponent} from '../metadata-progress-widget-component/metadata-progress-widget-component';
|
||||
import {MetadataProgressService} from '../../service/metadata-progress-service';
|
||||
import {TaskEventService} from '../../../shared/websocket/task-event.service';
|
||||
import {map} from 'rxjs/operators';
|
||||
import {AsyncPipe} from '@angular/common';
|
||||
import {BookdropFilesWidgetComponent} from '../../../bookdrop/bookdrop-files-widget-component/bookdrop-files-widget.component';
|
||||
@@ -15,7 +13,6 @@ import {DuplicateFileService} from '../../../shared/websocket/duplicate-file.ser
|
||||
selector: 'app-unified-notification-popover-component',
|
||||
imports: [
|
||||
LiveNotificationBoxComponent,
|
||||
LiveTaskEventBoxComponent,
|
||||
MetadataProgressWidgetComponent,
|
||||
AsyncPipe,
|
||||
BookdropFilesWidgetComponent,
|
||||
@@ -29,7 +26,6 @@ export class UnifiedNotificationBoxComponent {
|
||||
metadataProgressService = inject(MetadataProgressService);
|
||||
bookdropFileService = inject(BookdropFileService);
|
||||
duplicateFileService = inject(DuplicateFileService);
|
||||
taskEventService = inject(TaskEventService);
|
||||
|
||||
hasMetadataTasks$ = this.metadataProgressService.activeTasks$.pipe(
|
||||
map(tasks => Object.keys(tasks).length > 0)
|
||||
@@ -40,8 +36,4 @@ export class UnifiedNotificationBoxComponent {
|
||||
hasDuplicateFiles$ = this.duplicateFileService.duplicateFiles$.pipe(
|
||||
map(files => files && files.length > 0)
|
||||
);
|
||||
|
||||
hasActiveTasks$ = this.taskEventService.tasks$.pipe(
|
||||
map(tasks => tasks.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ export interface ReviewProviderConfig {
|
||||
|
||||
export interface PublicReviewSettings {
|
||||
downloadEnabled: boolean;
|
||||
autoDownloadEnabled: boolean;
|
||||
providers: ReviewProviderConfig[];
|
||||
}
|
||||
|
||||
@@ -105,7 +106,8 @@ export interface KoboSettings {
|
||||
export interface AppSettings {
|
||||
autoBookSearch: boolean;
|
||||
similarBookRecommendation: boolean;
|
||||
metadataRefreshOptions: MetadataRefreshOptions;
|
||||
defaultMetadataRefreshOptions: MetadataRefreshOptions;
|
||||
libraryMetadataRefreshOptions: MetadataRefreshOptions[];
|
||||
uploadPattern: string;
|
||||
opdsServerEnabled: boolean;
|
||||
remoteAuthEnabled: boolean;
|
||||
@@ -126,6 +128,7 @@ export enum AppSettingKey {
|
||||
QUICK_BOOK_MATCH = 'QUICK_BOOK_MATCH',
|
||||
AUTO_BOOK_SEARCH = 'AUTO_BOOK_SEARCH',
|
||||
SIMILAR_BOOK_RECOMMENDATION = 'SIMILAR_BOOK_RECOMMENDATION',
|
||||
LIBRARY_METADATA_REFRESH_OPTIONS = 'LIBRARY_METADATA_REFRESH_OPTIONS',
|
||||
UPLOAD_FILE_PATTERN = 'UPLOAD_FILE_PATTERN',
|
||||
OPDS_SERVER_ENABLED = 'OPDS_SERVER_ENABLED',
|
||||
OIDC_ENABLED = 'OIDC_ENABLED',
|
||||
|
||||
@@ -16,4 +16,5 @@ export interface MetadataBatchProgressNotification {
|
||||
total: number;
|
||||
message: string;
|
||||
status: MetadataBatchStatus;
|
||||
review: boolean;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,125 @@ export class AppConfigService {
|
||||
950: '#0c0a09'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'iron',
|
||||
palette: {
|
||||
0: '#ffffff',
|
||||
50: '#f1f3f4',
|
||||
100: '#e3e5e6',
|
||||
200: '#d2d4d6',
|
||||
300: '#b8bbc0',
|
||||
400: '#9ca0a6',
|
||||
500: '#7d8288',
|
||||
600: '#646a70',
|
||||
700: '#4c5258',
|
||||
800: '#353a40',
|
||||
900: '#1a1d20',
|
||||
950: '#0d0f11'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'steel',
|
||||
palette: {
|
||||
0: '#ffffff',
|
||||
50: '#f3f4f6',
|
||||
100: '#e5e7eb',
|
||||
200: '#d1d5db',
|
||||
300: '#b0b7c3',
|
||||
400: '#8b93a6',
|
||||
500: '#6b7280',
|
||||
600: '#545861',
|
||||
700: '#3f4347',
|
||||
800: '#2c2f33',
|
||||
900: '#16181b',
|
||||
950: '#0b0d0e'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'carbon',
|
||||
palette: {
|
||||
0: '#ffffff',
|
||||
50: '#eef0f2',
|
||||
100: '#dde1e6',
|
||||
200: '#c7cdd5',
|
||||
300: '#a8b0bc',
|
||||
400: '#8691a0',
|
||||
500: '#697080',
|
||||
600: '#545a66',
|
||||
700: '#41464f',
|
||||
800: '#303439',
|
||||
900: '#181a1d',
|
||||
950: '#0c0e10'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ash',
|
||||
palette: {
|
||||
0: '#ffffff',
|
||||
50: '#f4f6f8',
|
||||
100: '#e6e9ed',
|
||||
200: '#d3d8de',
|
||||
300: '#b4bcc7',
|
||||
400: '#919ca9',
|
||||
500: '#71808a',
|
||||
600: '#5a666f',
|
||||
700: '#464f56',
|
||||
800: '#34393e',
|
||||
900: '#1a1e21',
|
||||
950: '#0d1012'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'smoke',
|
||||
palette: {
|
||||
0: '#ffffff',
|
||||
50: '#f6f6f7',
|
||||
100: '#ebebed',
|
||||
200: '#dadadd',
|
||||
300: '#bbbcc1',
|
||||
400: '#989aa1',
|
||||
500: '#797c84',
|
||||
600: '#63666d',
|
||||
700: '#4f5257',
|
||||
800: '#3a3d42',
|
||||
900: '#1d2023',
|
||||
950: '#0e1011'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'midnight-blue',
|
||||
palette: {
|
||||
0: '#ffffff',
|
||||
50: '#fcfcfd',
|
||||
100: '#f7f8fb',
|
||||
200: '#f0f2f7',
|
||||
300: '#e5e8f0',
|
||||
400: '#d3d8e3',
|
||||
500: '#5c6b7a',
|
||||
600: '#4a5866',
|
||||
700: '#3b4651',
|
||||
800: '#2d353e',
|
||||
900: '#1f252c',
|
||||
950: '#121518'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'charcoal',
|
||||
palette: {
|
||||
0: '#ffffff',
|
||||
50: '#f0f0f0',
|
||||
100: '#e5e5e5',
|
||||
200: '#d1d1d1',
|
||||
300: '#b8b8b8',
|
||||
400: '#9a9a9a',
|
||||
500: '#7d7d7d',
|
||||
600: '#666666',
|
||||
700: '#525252',
|
||||
800: '#3d3d3d',
|
||||
900: '#2a2a2a',
|
||||
950: '#141414'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'soho',
|
||||
palette: {
|
||||
@@ -157,6 +276,57 @@ export class AppConfigService {
|
||||
900: '#183240',
|
||||
950: '#0c1920'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'light-slate',
|
||||
palette: {
|
||||
0: '#ffffff',
|
||||
50: '#fcfcfd',
|
||||
100: '#f8fafc',
|
||||
200: '#f1f5f9',
|
||||
300: '#e2e8f0',
|
||||
400: '#cbd5e1',
|
||||
500: '#94a3b8',
|
||||
600: '#64748b',
|
||||
700: '#475569',
|
||||
800: '#334155',
|
||||
900: '#1e293b',
|
||||
950: '#0f172a'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'olive',
|
||||
palette: {
|
||||
0: '#ffffff',
|
||||
50: '#fcfcfb',
|
||||
100: '#f8f9f6',
|
||||
200: '#f1f4ed',
|
||||
300: '#e6eadc',
|
||||
400: '#d4dfc5',
|
||||
500: '#6a755e',
|
||||
600: '#555d4c',
|
||||
700: '#434a3d',
|
||||
800: '#333930',
|
||||
900: '#232824',
|
||||
950: '#131614'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'crimson',
|
||||
palette: {
|
||||
0: '#ffffff',
|
||||
50: '#fcfbfb',
|
||||
100: '#f8f5f5',
|
||||
200: '#f1ebeb',
|
||||
300: '#e6d9d9',
|
||||
400: '#d4c3c3',
|
||||
500: '#755e5e',
|
||||
600: '#5d4c4c',
|
||||
700: '#4a3d3d',
|
||||
800: '#382f2f',
|
||||
900: '#242020',
|
||||
950: '#141212'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -23,11 +23,11 @@ export class MetadataProgressService implements OnDestroy {
|
||||
constructor() {
|
||||
const sub = this.userService.userState$
|
||||
.pipe(
|
||||
filter(user => !!user),
|
||||
filter(userState => !!userState?.user),
|
||||
take(1)
|
||||
)
|
||||
.subscribe(user => {
|
||||
if (!this.hasMetadataPermissions(user)) {
|
||||
.subscribe(userState => {
|
||||
if (!this.hasMetadataPermissions(userState.user)) {
|
||||
return;
|
||||
}
|
||||
const activeTasksSub = this.metadataTaskService.getActiveTasks().subscribe({
|
||||
@@ -67,7 +67,7 @@ export class MetadataProgressService implements OnDestroy {
|
||||
}
|
||||
|
||||
private hasMetadataPermissions(user: any): boolean {
|
||||
return user?.permissions?.admin || user?.permissions?.canEditMetadata;
|
||||
return !!(user?.permissions?.admin || user?.permissions?.canEditMetadata);
|
||||
}
|
||||
|
||||
private initializeActiveTasks(tasks: MetadataBatchProgressNotification[]): void {
|
||||
|
||||
@@ -23,7 +23,6 @@ import {MetadataBatchProgressNotification} from '../../../core/model/metadata-ba
|
||||
import {UnifiedNotificationBoxComponent} from '../../../core/component/unified-notification-popover-component/unified-notification-popover-component';
|
||||
import {BookdropFileService} from '../../../bookdrop/bookdrop-file.service';
|
||||
import {DialogLauncherService} from '../../../dialog-launcher.service';
|
||||
import {TaskEventService} from '../../../shared/websocket/task-event.service';
|
||||
import {DuplicateFileService} from '../../../shared/websocket/duplicate-file.service';
|
||||
|
||||
@Component({
|
||||
@@ -82,12 +81,10 @@ export class AppTopBarComponent implements OnDestroy {
|
||||
private metadataProgressService: MetadataProgressService,
|
||||
private bookdropFileService: BookdropFileService,
|
||||
private dialogLauncher: DialogLauncherService,
|
||||
private taskEventService: TaskEventService,
|
||||
private duplicateFileService: DuplicateFileService
|
||||
) {
|
||||
this.subscribeToMetadataProgress();
|
||||
this.subscribeToNotifications();
|
||||
this.subscribeToTaskEvents();
|
||||
this.subscribeToDuplicateFiles();
|
||||
|
||||
this.metadataProgressService.activeTasks$
|
||||
@@ -178,16 +175,6 @@ export class AppTopBarComponent implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private subscribeToTaskEvents() {
|
||||
this.taskEventService.tasks$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((tasks) => {
|
||||
if (tasks.length > 0) {
|
||||
this.triggerPulseEffect();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private subscribeToDuplicateFiles() {
|
||||
this.duplicateFileService.duplicateFiles$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
@@ -207,7 +194,7 @@ export class AppTopBarComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
private updateCompletedTaskCount() {
|
||||
const completedMetadataTasks = Object.values(this.latestTasks).filter(task => task.status === 'COMPLETED').length;
|
||||
const completedMetadataTasks = Object.values(this.latestTasks).length;
|
||||
const bookdropFileTaskCount = this.latestHasPendingFiles ? 1 : 0;
|
||||
const duplicateFileTaskCount = this.latestHasDuplicateFiles ? 1 : 0;
|
||||
this.completedTaskCount = completedMetadataTasks + bookdropFileTaskCount + duplicateFileTaskCount;
|
||||
|
||||
+12
-3
@@ -1,11 +1,12 @@
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {Component, computed, effect, inject} from '@angular/core';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {$t} from '@primeng/themes';
|
||||
import Aura from '@primeng/themes/aura';
|
||||
import {ButtonModule} from 'primeng/button';
|
||||
import {RadioButtonModule} from 'primeng/radiobutton';
|
||||
import {ToggleSwitchModule} from 'primeng/toggleswitch';
|
||||
|
||||
import Aura from '../theme-palette-extend';
|
||||
|
||||
import {AppConfigService} from '../../../core/service/app-config.service';
|
||||
import {FaviconService} from './favicon-service';
|
||||
|
||||
@@ -53,7 +54,15 @@ export class ThemeConfiguratorComponent {
|
||||
|
||||
readonly primaryColors = computed<Palette[]>(() => {
|
||||
const presetPalette = (Aura.primitive ?? {}) as Record<string, ColorPalette>;
|
||||
const colors = ['emerald', 'green', 'lime', 'orange', 'amber', 'yellow', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose'];
|
||||
const colors = [
|
||||
'emerald', 'green', 'lime', 'orange', 'amber', 'yellow',
|
||||
'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet',
|
||||
'purple', 'fuchsia', 'pink', 'rose', 'red',
|
||||
'coralSunset', 'roseBlush', 'melonBlush', 'cottonCandy',
|
||||
'apricotSunrise', 'antiqueBronze', 'butteryYellow', 'vanillaCream',
|
||||
'citrusMint', 'freshMint', 'sagePearl', 'skyBlue','periwinkleCream',
|
||||
'pastelRoyalBlue', 'lavenderDream', 'dustyNeutral'
|
||||
];
|
||||
return [{name: 'noir', palette: {}}].concat(
|
||||
colors.map(name => ({
|
||||
name,
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import Aura from '@primeng/themes/aura';
|
||||
|
||||
type ColorPalette = Record<string, string>;
|
||||
|
||||
const customPalettes: Record<string, ColorPalette> = {
|
||||
coralSunset: {
|
||||
50: '#fef7f0',
|
||||
100: '#feede1',
|
||||
200: '#fcd9c2',
|
||||
300: '#f9be9e',
|
||||
400: '#f59673',
|
||||
500: '#ef7550',
|
||||
600: '#de5a3a',
|
||||
700: '#b94730',
|
||||
800: '#943a2e',
|
||||
900: '#78322a',
|
||||
950: '#401814'
|
||||
},
|
||||
roseBlush: {
|
||||
50: '#fef7f7',
|
||||
100: '#feebeb',
|
||||
200: '#fddcdc',
|
||||
300: '#fbbfbf',
|
||||
400: '#f69393',
|
||||
500: '#ed6767',
|
||||
600: '#d93f3f',
|
||||
700: '#b73030',
|
||||
800: '#982d2d',
|
||||
900: '#7f2d2d',
|
||||
950: '#451313'
|
||||
},
|
||||
melonBlush: {
|
||||
50: '#fffafb',
|
||||
100: '#fff2f4',
|
||||
200: '#ffdfe7',
|
||||
300: '#ffcbd4',
|
||||
400: '#ffaebb',
|
||||
500: '#ff90a2',
|
||||
600: '#ff6f88',
|
||||
700: '#e8506b',
|
||||
800: '#b7384d',
|
||||
900: '#7f2231',
|
||||
950: '#3f1118'
|
||||
},
|
||||
cottonCandy: {
|
||||
50: '#fff9fb',
|
||||
100: '#fff0f6',
|
||||
200: '#ffdfee',
|
||||
300: '#ffc0e0',
|
||||
400: '#ffa3cf',
|
||||
500: '#ff86bf',
|
||||
600: '#ff69ad',
|
||||
700: '#e14f93',
|
||||
800: '#b93a72',
|
||||
900: '#86284f',
|
||||
950: '#42142a'
|
||||
},
|
||||
apricotSunrise: {
|
||||
50: '#fffaf7',
|
||||
100: '#fff1e8',
|
||||
200: '#ffe2c9',
|
||||
300: '#ffd2a8',
|
||||
400: '#ffbf88',
|
||||
500: '#ffad68',
|
||||
600: '#ff974f',
|
||||
700: '#e67a3b',
|
||||
800: '#b6572b',
|
||||
900: '#7f351b',
|
||||
950: '#3f190f'
|
||||
},
|
||||
antiqueBronze: {
|
||||
50: '#faf7f2',
|
||||
100: '#f4ede0',
|
||||
200: '#e8d9c0',
|
||||
300: '#d9c199',
|
||||
400: '#c8a56f',
|
||||
500: '#b8904f',
|
||||
600: '#a67c44',
|
||||
700: '#8a6439',
|
||||
800: '#715233',
|
||||
900: '#5c442c',
|
||||
950: '#302316'
|
||||
},
|
||||
butteryYellow: {
|
||||
50: '#fffef7',
|
||||
100: '#fffceb',
|
||||
200: '#fff7d1',
|
||||
300: '#ffeea7',
|
||||
400: '#ffe072',
|
||||
500: '#ffcf45',
|
||||
600: '#ffb61f',
|
||||
700: '#ff9b0a',
|
||||
800: '#cc7606',
|
||||
900: '#a35f0c',
|
||||
950: '#633204'
|
||||
},
|
||||
vanillaCream: {
|
||||
50: '#fefdfb',
|
||||
100: '#fefbf6',
|
||||
200: '#fdf5ea',
|
||||
300: '#fbecd5',
|
||||
400: '#f7dbb5',
|
||||
500: '#f1c589',
|
||||
600: '#e8a95c',
|
||||
700: '#d88d3e',
|
||||
800: '#b47134',
|
||||
900: '#915c2e',
|
||||
950: '#4e2f17'
|
||||
},
|
||||
citrusMint: {
|
||||
50: '#fbfff9',
|
||||
100: '#f4fff2',
|
||||
200: '#e8ffd9',
|
||||
300: '#d1ffbc',
|
||||
400: '#b7ffa0',
|
||||
500: '#96ff84',
|
||||
600: '#76e96e',
|
||||
700: '#4fb84c',
|
||||
800: '#2f7f32',
|
||||
900: '#1a4f20',
|
||||
950: '#0a2410'
|
||||
},
|
||||
freshMint: {
|
||||
50: '#f8fff9',
|
||||
100: '#f0fff3',
|
||||
200: '#dcffea',
|
||||
300: '#bfffd6',
|
||||
400: '#99ffc2',
|
||||
500: '#7fffae',
|
||||
600: '#5fe592',
|
||||
700: '#3fbf75',
|
||||
800: '#2d8f55',
|
||||
900: '#1e5f39',
|
||||
950: '#0f2f1d'
|
||||
},
|
||||
sagePearl: {
|
||||
50: '#fbfdfb',
|
||||
100: '#f4f8f4',
|
||||
200: '#e6efe6',
|
||||
300: '#d1e6d1',
|
||||
400: '#bcdcbf',
|
||||
500: '#9fcfa8',
|
||||
600: '#84b792',
|
||||
700: '#5f8f6e',
|
||||
800: '#3f6648',
|
||||
900: '#25442e',
|
||||
950: '#0f2118'
|
||||
},
|
||||
skyBlue: {
|
||||
50: '#f7fbff',
|
||||
100: '#eef6ff',
|
||||
200: '#dceefd',
|
||||
300: '#c1e8ff',
|
||||
400: '#99dfff',
|
||||
500: '#6fc8ff',
|
||||
600: '#48afff',
|
||||
700: '#2e88e6',
|
||||
800: '#2360b3',
|
||||
900: '#153d80',
|
||||
950: '#071d40'
|
||||
},
|
||||
periwinkleCream: {
|
||||
50: '#fbfbff',
|
||||
100: '#f2f3ff',
|
||||
200: '#e7eaff',
|
||||
300: '#d2dcff',
|
||||
400: '#b9c9ff',
|
||||
500: '#9fb2ff',
|
||||
600: '#7f97ff',
|
||||
700: '#5f78e6',
|
||||
800: '#4559b3',
|
||||
900: '#2b387f',
|
||||
950: '#151c40'
|
||||
},
|
||||
pastelRoyalBlue: {
|
||||
50: '#f6fbff',
|
||||
100: '#eef6ff',
|
||||
200: '#dbeeff',
|
||||
300: '#b9ddff',
|
||||
400: '#92c7ff',
|
||||
500: '#63aaff',
|
||||
600: '#3b88ff',
|
||||
700: '#2e66cc',
|
||||
800: '#235099',
|
||||
900: '#183366',
|
||||
950: '#0d1a33'
|
||||
},
|
||||
lavenderDream: {
|
||||
50: '#fbf7ff',
|
||||
100: '#f6eeff',
|
||||
200: '#ebdcff',
|
||||
300: '#dcc0ff',
|
||||
400: '#caa1ff',
|
||||
500: '#b47fff',
|
||||
600: '#9366e6',
|
||||
700: '#6f45b3',
|
||||
800: '#4c2f80',
|
||||
900: '#2d1b4f',
|
||||
950: '#15082a'
|
||||
},
|
||||
dustyNeutral: {
|
||||
50: '#faf9f7',
|
||||
100: '#f3f1ed',
|
||||
200: '#e8e3db',
|
||||
300: '#d8cfc2',
|
||||
400: '#c4b5a3',
|
||||
500: '#b39c85',
|
||||
600: '#a28a70',
|
||||
700: '#87725d',
|
||||
800: '#6f5e4e',
|
||||
900: '#5a4e42',
|
||||
950: '#2f2821'
|
||||
}
|
||||
};
|
||||
|
||||
if (!Aura.primitive) {
|
||||
Aura.primitive = {};
|
||||
}
|
||||
|
||||
Object.assign(Aura.primitive, customPalettes);
|
||||
|
||||
export default Aura;
|
||||
+53
-54
@@ -7,46 +7,46 @@ import {
|
||||
OnInit,
|
||||
Output,
|
||||
} from "@angular/core";
|
||||
import { InputText } from "primeng/inputtext";
|
||||
import { Button } from "primeng/button";
|
||||
import { Divider } from "primeng/divider";
|
||||
import {InputText} from "primeng/inputtext";
|
||||
import {Button} from "primeng/button";
|
||||
import {Divider} from "primeng/divider";
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
} from "@angular/forms";
|
||||
import { Observable } from "rxjs";
|
||||
import { AsyncPipe } from "@angular/common";
|
||||
import { MessageService } from "primeng/api";
|
||||
import {Observable} from "rxjs";
|
||||
import {AsyncPipe} from "@angular/common";
|
||||
import {MessageService} from "primeng/api";
|
||||
import {
|
||||
Book,
|
||||
BookMetadata,
|
||||
MetadataClearFlags,
|
||||
MetadataUpdateWrapper,
|
||||
} from "../../../book/model/book.model";
|
||||
import { UrlHelperService } from "../../../utilities/service/url-helper.service";
|
||||
import {UrlHelperService} from "../../../utilities/service/url-helper.service";
|
||||
import {
|
||||
FileUpload,
|
||||
FileUploadErrorEvent,
|
||||
FileUploadEvent,
|
||||
} from "primeng/fileupload";
|
||||
import { HttpResponse } from "@angular/common/http";
|
||||
import { BookService } from "../../../book/service/book.service";
|
||||
import { ProgressSpinner } from "primeng/progressspinner";
|
||||
import { Tooltip } from "primeng/tooltip";
|
||||
import { filter, take } from "rxjs/operators";
|
||||
import { MetadataRestoreDialogComponent } from "../../../book/components/book-browser/metadata-restore-dialog-component/metadata-restore-dialog-component";
|
||||
import { DialogService } from "primeng/dynamicdialog";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { MetadataRefreshRequest } from "../../model/request/metadata-refresh-request.model";
|
||||
import { MetadataRefreshType } from "../../model/request/metadata-refresh-type.enum";
|
||||
import { AutoComplete } from "primeng/autocomplete";
|
||||
import { Textarea } from "primeng/textarea";
|
||||
import { IftaLabel } from "primeng/iftalabel";
|
||||
import { CoverSearchComponent } from "../../cover-search/cover-search.component";
|
||||
import { Image } from "primeng/image";
|
||||
import { LazyLoadImageModule } from "ng-lazyload-image";
|
||||
import {HttpResponse} from "@angular/common/http";
|
||||
import {BookService} from "../../../book/service/book.service";
|
||||
import {ProgressSpinner} from "primeng/progressspinner";
|
||||
import {Tooltip} from "primeng/tooltip";
|
||||
import {filter, take} from "rxjs/operators";
|
||||
import {MetadataRestoreDialogComponent} from "../../../book/components/book-browser/metadata-restore-dialog-component/metadata-restore-dialog-component";
|
||||
import {DialogService} from "primeng/dynamicdialog";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {MetadataRefreshRequest} from "../../model/request/metadata-refresh-request.model";
|
||||
import {MetadataRefreshType} from "../../model/request/metadata-refresh-type.enum";
|
||||
import {AutoComplete} from "primeng/autocomplete";
|
||||
import {Textarea} from "primeng/textarea";
|
||||
import {IftaLabel} from "primeng/iftalabel";
|
||||
import {CoverSearchComponent} from "../../cover-search/cover-search.component";
|
||||
import {Image} from "primeng/image";
|
||||
import {LazyLoadImageModule} from "ng-lazyload-image";
|
||||
|
||||
@Component({
|
||||
selector: "app-metadata-editor",
|
||||
@@ -270,36 +270,36 @@ export class MetadataEditorComponent implements OnInit {
|
||||
});
|
||||
|
||||
const lockableFields: { key: keyof BookMetadata; control: string }[] = [
|
||||
{ key: "titleLocked", control: "title" },
|
||||
{ key: "subtitleLocked", control: "subtitle" },
|
||||
{ key: "authorsLocked", control: "authors" },
|
||||
{ key: "categoriesLocked", control: "categories" },
|
||||
{ key: "publisherLocked", control: "publisher" },
|
||||
{ key: "publishedDateLocked", control: "publishedDate" },
|
||||
{ key: "languageLocked", control: "language" },
|
||||
{ key: "isbn10Locked", control: "isbn10" },
|
||||
{ key: "isbn13Locked", control: "isbn13" },
|
||||
{ key: "asinLocked", control: "asin" },
|
||||
{ key: "amazonReviewCountLocked", control: "amazonReviewCount" },
|
||||
{ key: "amazonRatingLocked", control: "amazonRating" },
|
||||
{ key: "personalRatingLocked", control: "personalRating" },
|
||||
{ key: "goodreadsIdLocked", control: "goodreadsId" },
|
||||
{ key: "comicvineIdLocked", control: "comicvineId" },
|
||||
{ key: "goodreadsReviewCountLocked", control: "goodreadsReviewCount" },
|
||||
{ key: "goodreadsRatingLocked", control: "goodreadsRating" },
|
||||
{ key: "hardcoverIdLocked", control: "hardcoverId" },
|
||||
{ key: "hardcoverReviewCountLocked", control: "hardcoverReviewCount" },
|
||||
{ key: "hardcoverRatingLocked", control: "hardcoverRating" },
|
||||
{ key: "googleIdLocked", control: "googleId" },
|
||||
{ key: "pageCountLocked", control: "pageCount" },
|
||||
{ key: "descriptionLocked", control: "description" },
|
||||
{ key: "seriesNameLocked", control: "seriesName" },
|
||||
{ key: "seriesNumberLocked", control: "seriesNumber" },
|
||||
{ key: "seriesTotalLocked", control: "seriesTotal" },
|
||||
{ key: "coverLocked", control: "thumbnailUrl" },
|
||||
{key: "titleLocked", control: "title"},
|
||||
{key: "subtitleLocked", control: "subtitle"},
|
||||
{key: "authorsLocked", control: "authors"},
|
||||
{key: "categoriesLocked", control: "categories"},
|
||||
{key: "publisherLocked", control: "publisher"},
|
||||
{key: "publishedDateLocked", control: "publishedDate"},
|
||||
{key: "languageLocked", control: "language"},
|
||||
{key: "isbn10Locked", control: "isbn10"},
|
||||
{key: "isbn13Locked", control: "isbn13"},
|
||||
{key: "asinLocked", control: "asin"},
|
||||
{key: "amazonReviewCountLocked", control: "amazonReviewCount"},
|
||||
{key: "amazonRatingLocked", control: "amazonRating"},
|
||||
{key: "personalRatingLocked", control: "personalRating"},
|
||||
{key: "goodreadsIdLocked", control: "goodreadsId"},
|
||||
{key: "comicvineIdLocked", control: "comicvineId"},
|
||||
{key: "goodreadsReviewCountLocked", control: "goodreadsReviewCount"},
|
||||
{key: "goodreadsRatingLocked", control: "goodreadsRating"},
|
||||
{key: "hardcoverIdLocked", control: "hardcoverId"},
|
||||
{key: "hardcoverReviewCountLocked", control: "hardcoverReviewCount"},
|
||||
{key: "hardcoverRatingLocked", control: "hardcoverRating"},
|
||||
{key: "googleIdLocked", control: "googleId"},
|
||||
{key: "pageCountLocked", control: "pageCount"},
|
||||
{key: "descriptionLocked", control: "description"},
|
||||
{key: "seriesNameLocked", control: "seriesName"},
|
||||
{key: "seriesNumberLocked", control: "seriesNumber"},
|
||||
{key: "seriesTotalLocked", control: "seriesTotal"},
|
||||
{key: "coverLocked", control: "thumbnailUrl"},
|
||||
];
|
||||
|
||||
for (const { key, control } of lockableFields) {
|
||||
for (const {key, control} of lockableFields) {
|
||||
const isLocked = metadata[key] === true;
|
||||
const formControl = this.metadataForm.get(control);
|
||||
if (formControl) {
|
||||
@@ -512,7 +512,7 @@ export class MetadataEditorComponent implements OnInit {
|
||||
cover: false,
|
||||
};
|
||||
|
||||
return { metadata, clearFlags };
|
||||
return {metadata, clearFlags};
|
||||
}
|
||||
|
||||
private updateMetadata(shouldLockAllFields: boolean | undefined): void {
|
||||
@@ -616,7 +616,7 @@ export class MetadataEditorComponent implements OnInit {
|
||||
this.bookService.getComicInfoMetadata(this.currentBookId).subscribe({
|
||||
next: (metadata) => {
|
||||
console.log("Retrieved ComicInfo.xml metadata:", metadata);
|
||||
|
||||
|
||||
if (metadata) {
|
||||
this.originalMetadata = structuredClone(metadata);
|
||||
this.populateFormFromMetadata(metadata);
|
||||
@@ -662,7 +662,6 @@ export class MetadataEditorComponent implements OnInit {
|
||||
this.refreshingBookIds.add(bookId);
|
||||
this.isAutoFetching = true;
|
||||
const request: MetadataRefreshRequest = {
|
||||
quick: true,
|
||||
refreshType: MetadataRefreshType.BOOKS,
|
||||
bookIds: [bookId],
|
||||
};
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ export class MetadataSearcherComponent implements OnInit, OnDestroy {
|
||||
provider: this.providers,
|
||||
title: book.metadata?.title ?? '',
|
||||
author: book.metadata?.authors?.[0] ?? '',
|
||||
isbn: book.metadata?.isbn10 ?? book.metadata?.isbn13 ?? ''
|
||||
isbn: book.metadata?.isbn13 ?? book.metadata?.isbn10 ?? ''
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+11
-3
@@ -69,7 +69,9 @@
|
||||
<div class="flex flex-col items-center md:items-start gap-1">
|
||||
<div class="flex items-center gap-2 justify-center md:justify-start flex-wrap text-center md:text-left">
|
||||
<h2 class="text-2xl md:text-3xl font-extrabold leading-tight">
|
||||
{{ book?.metadata?.title }}@if (book?.metadata?.subtitle) {: {{ book.metadata?.subtitle }}}
|
||||
{{ book?.metadata?.title }}@if (book?.metadata?.subtitle) {
|
||||
: {{ book.metadata?.subtitle }}
|
||||
}
|
||||
</h2>
|
||||
<i
|
||||
class="pi align-middle"
|
||||
@@ -229,6 +231,12 @@
|
||||
|
||||
<div class="px-1 md:px-0">
|
||||
<div class="grid md:grid-cols-4 gap-y-2.5 text-sm pt-2 md:pt-4 pb-2 text-gray-300 md:min-w-[60rem] md:max-w-[100rem]">
|
||||
<p class="whitespace-nowrap max-w-[250px] overflow-hidden text-ellipsis">
|
||||
<span class="font-bold">Library: </span>
|
||||
@if (book?.libraryName) {
|
||||
<span>{{ book.libraryName }}</span>
|
||||
}
|
||||
</p>
|
||||
<p class="whitespace-nowrap max-w-[250px] overflow-hidden text-ellipsis">
|
||||
<span class="font-bold">Publisher: </span>
|
||||
@if (book?.metadata?.publisher) {
|
||||
@@ -241,7 +249,6 @@
|
||||
</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
|
||||
@@ -378,6 +385,7 @@
|
||||
</div>
|
||||
}
|
||||
<p><strong>Page Count:</strong> {{ book?.metadata!.pageCount || '-' }}</p>
|
||||
<p><strong>File Size:</strong> {{ getFileSizeInMB(book) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -470,7 +478,7 @@
|
||||
}
|
||||
@if (userState.user!.permissions.canDeleteBook || userState.user!.permissions.admin) {
|
||||
@if (otherItems$ | async; as otherItems) {
|
||||
<p-button icon="pi pi-ellipsis-v" outlined severity="danger" (click)="entitymenu.toggle($event)"></p-button>
|
||||
<p-button icon="pi pi-ellipsis-v" outlined severity="primary" (click)="entitymenu.toggle($event)"></p-button>
|
||||
<p-tieredMenu #entitymenu [model]="otherItems" [popup]="true" appendTo="body"></p-tieredMenu>
|
||||
}
|
||||
}
|
||||
|
||||
+16
-3
@@ -37,6 +37,7 @@ import {ProgressSpinner} from 'primeng/progressspinner';
|
||||
import {TieredMenu} from 'primeng/tieredmenu';
|
||||
import {AdditionalFileUploaderComponent} from '../../../book/components/additional-file-uploader/additional-file-uploader.component';
|
||||
import {Image} from 'primeng/image';
|
||||
import {BookDialogHelperService} from '../../../book/components/book-browser/BookDialogHelperService';
|
||||
|
||||
@Component({
|
||||
selector: 'app-metadata-viewer',
|
||||
@@ -58,6 +59,8 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
|
||||
protected urlHelper = inject(UrlHelperService);
|
||||
protected userService = inject(UserService);
|
||||
private confirmationService = inject(ConfirmationService);
|
||||
private bookDialogHelperService = inject(BookDialogHelperService);
|
||||
|
||||
private router = inject(Router);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
private dialogRef?: DynamicDialogRef;
|
||||
@@ -112,8 +115,8 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
|
||||
filter((book): book is Book => book !== null),
|
||||
map((book): MenuItem[] => [
|
||||
{
|
||||
label: 'Granular Refresh',
|
||||
icon: 'pi pi-database',
|
||||
label: 'Custom Fetch',
|
||||
icon: 'pi pi-sync',
|
||||
command: () => {
|
||||
this.dialogService.open(MetadataFetchOptionsComponent, {
|
||||
header: 'Metadata Refresh Options',
|
||||
@@ -200,6 +203,13 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Organize Files',
|
||||
icon: 'pi pi-arrows-h',
|
||||
command: () => {
|
||||
this.openFileMoverDialog(book.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Delete Book',
|
||||
icon: 'pi pi-trash',
|
||||
@@ -383,7 +393,6 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
|
||||
quickRefresh(bookId: number) {
|
||||
this.isAutoFetching = true;
|
||||
const request: MetadataRefreshRequest = {
|
||||
quick: true,
|
||||
refreshType: MetadataRefreshType.BOOKS,
|
||||
bookIds: [bookId],
|
||||
};
|
||||
@@ -803,6 +812,10 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
|
||||
this.editDateFinished = null;
|
||||
}
|
||||
|
||||
openFileMoverDialog(bookId: number): void {
|
||||
this.bookDialogHelperService.openFileMoverDialog(new Set([bookId]));
|
||||
}
|
||||
|
||||
protected readonly ResetProgressTypes = ResetProgressTypes;
|
||||
protected readonly ReadStatus = ReadStatus;
|
||||
}
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@
|
||||
Checking <strong>Clear</strong> will remove that field’s metadata from <strong>all selected books</strong>.
|
||||
</div>
|
||||
|
||||
<form [formGroup]="metadataForm" (ngSubmit)="onSubmit()" class="flex flex-col gap-4 w-full p-4">
|
||||
<form [formGroup]="metadataForm" (ngSubmit)="onSubmit()" (keydown)="onFormKeydown($event)" class="flex flex-col gap-4 w-full p-4">
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="authors" class="flex items-center justify-between gap-2">
|
||||
|
||||
+10
@@ -101,6 +101,16 @@ export class BulkMetadataUpdateComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
onFormKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Enter') {
|
||||
if ((event.target as HTMLElement)?.tagName === 'BUTTON' &&
|
||||
(event.target as HTMLButtonElement)?.type === 'submit') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (!this.metadataForm.valid) return;
|
||||
|
||||
|
||||
+30
-5
@@ -3,16 +3,41 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-1.5 text-left font-semibold text-gray-300">Book Field</th>
|
||||
<th class="px-4 py-1.5 text-left font-semibold text-gray-300">4th Priority</th>
|
||||
<th class="px-4 py-1.5 text-left font-semibold text-gray-300">3rd Priority</th>
|
||||
<th class="px-4 py-1.5 text-left font-semibold text-gray-300">2nd Priority</th>
|
||||
<th class="px-4 py-1.5 text-left font-semibold text-gray-300">1st Priority</th>
|
||||
<th class="px-4 py-1.5 text-left font-semibold text-gray-300">
|
||||
4th Priority
|
||||
<i class="pi pi-question-circle ml-1 text-xs"
|
||||
pTooltip="Last fallback option - only used if 1st, 2nd, and 3rd priorities fail or are empty"
|
||||
tooltipPosition="top"></i>
|
||||
</th>
|
||||
<th class="px-4 py-1.5 text-left font-semibold text-gray-300">
|
||||
3rd Priority
|
||||
<i class="pi pi-question-circle ml-1 text-xs"
|
||||
pTooltip="Third choice - used if 1st and 2nd priorities don't have data"
|
||||
tooltipPosition="top"></i>
|
||||
</th>
|
||||
<th class="px-4 py-1.5 text-left font-semibold text-gray-300">
|
||||
2nd Priority
|
||||
<i class="pi pi-question-circle ml-1 text-xs"
|
||||
pTooltip="Second choice - used if 1st priority doesn't have data"
|
||||
tooltipPosition="top"></i>
|
||||
</th>
|
||||
<th class="px-4 py-1.5 text-left font-semibold text-gray-300">
|
||||
1st Priority
|
||||
<i class="pi pi-question-circle ml-1 text-xs"
|
||||
pTooltip="First choice - always tried first for this field"
|
||||
tooltipPosition="top"></i>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td class="text-sm px-4 py-1.5 text-gray-300">All Other Fields</td>
|
||||
<td class="text-sm px-4 py-1.5 text-gray-300">
|
||||
All Other Fields
|
||||
<i class="pi pi-question-circle ml-1 text-xs"
|
||||
pTooltip="Quick way to set the same provider priority for all fields at once"
|
||||
tooltipPosition="right"></i>
|
||||
</td>
|
||||
<td class="px-4 py-1.5">
|
||||
<p-select [options]="providers" [(ngModel)]="allP4.value"
|
||||
(onChange)="syncProvider($event, 'p4')"
|
||||
|
||||
+30
-6
@@ -47,6 +47,8 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
|
||||
|
||||
private messageService = inject(MessageService);
|
||||
|
||||
private justSubmitted = false;
|
||||
|
||||
private initializeFieldOptions(): FieldOptions {
|
||||
return this.fields.reduce((acc, field) => {
|
||||
acc[field] = {p1: null, p2: null, p3: null, p4: null};
|
||||
@@ -55,12 +57,12 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['currentMetadataOptions'] && this.currentMetadataOptions) {
|
||||
if (changes['currentMetadataOptions'] && this.currentMetadataOptions && !this.justSubmitted) {
|
||||
this.refreshCovers = this.currentMetadataOptions.refreshCovers || false;
|
||||
this.mergeCategories = this.currentMetadataOptions.mergeCategories || false;
|
||||
this.reviewBeforeApply = this.currentMetadataOptions.reviewBeforeApply || false;
|
||||
|
||||
const backendFieldOptions = this.currentMetadataOptions.fieldOptions as FieldOptions || {};
|
||||
const backendFieldOptions = this.deepCloneFieldOptions(this.currentMetadataOptions.fieldOptions as FieldOptions || {});
|
||||
for (const field of this.fields) {
|
||||
if (!backendFieldOptions[field]) {
|
||||
backendFieldOptions[field] = {p1: null, p2: null, p3: null, p4: null};
|
||||
@@ -70,13 +72,26 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
|
||||
}
|
||||
this.fieldOptions = backendFieldOptions;
|
||||
|
||||
this.allP1.value = this.currentMetadataOptions.allP1 || null;
|
||||
this.allP2.value = this.currentMetadataOptions.allP2 || null;
|
||||
this.allP3.value = this.currentMetadataOptions.allP3 || null;
|
||||
this.allP4.value = this.currentMetadataOptions.allP4 || null;
|
||||
this.allP1 = {placeholder: 'Set All', value: this.currentMetadataOptions.allP1 || null};
|
||||
this.allP2 = {placeholder: 'Set All', value: this.currentMetadataOptions.allP2 || null};
|
||||
this.allP3 = {placeholder: 'Set All', value: this.currentMetadataOptions.allP3 || null};
|
||||
this.allP4 = {placeholder: 'Set All', value: this.currentMetadataOptions.allP4 || null};
|
||||
}
|
||||
}
|
||||
|
||||
private deepCloneFieldOptions(fieldOptions: FieldOptions): FieldOptions {
|
||||
const cloned = {} as FieldOptions;
|
||||
for (const field of this.fields) {
|
||||
cloned[field] = {
|
||||
p1: fieldOptions[field]?.p1 || null,
|
||||
p2: fieldOptions[field]?.p2 || null,
|
||||
p3: fieldOptions[field]?.p3 || null,
|
||||
p4: fieldOptions[field]?.p4 || null
|
||||
};
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
syncProvider(event: SelectChangeEvent, providerType: keyof FieldProvider) {
|
||||
for (const field of Object.keys(this.fieldOptions)) {
|
||||
this.fieldOptions[field as keyof FieldOptions][providerType] = event.value;
|
||||
@@ -89,7 +104,10 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
|
||||
);
|
||||
|
||||
if (allFieldsHaveProvider) {
|
||||
this.justSubmitted = true;
|
||||
|
||||
const metadataRefreshOptions: MetadataRefreshOptions = {
|
||||
libraryId: null,
|
||||
allP1: this.allP1.value,
|
||||
allP2: this.allP2.value,
|
||||
allP3: this.allP3.value,
|
||||
@@ -99,7 +117,12 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
|
||||
reviewBeforeApply: this.reviewBeforeApply,
|
||||
fieldOptions: this.fieldOptions
|
||||
};
|
||||
|
||||
this.metadataOptionsSubmitted.emit(metadataRefreshOptions);
|
||||
|
||||
setTimeout(() => {
|
||||
this.justSubmitted = false;
|
||||
}, 1000);
|
||||
} else {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
@@ -111,6 +134,7 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.justSubmitted = false;
|
||||
this.allP1.value = null;
|
||||
this.allP2.value = null;
|
||||
this.allP3.value = null;
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ export class MetadataFetchOptionsComponent {
|
||||
filter(settings => settings != null),
|
||||
take(1)
|
||||
).subscribe(settings => {
|
||||
this.currentMetadataOptions = settings?.metadataRefreshOptions;
|
||||
this.currentMetadataOptions = settings?.defaultMetadataRefreshOptions;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export interface MetadataRefreshOptions {
|
||||
libraryId: number | null;
|
||||
allP4: string | null;
|
||||
allP3: string | null;
|
||||
allP2: string | null;
|
||||
|
||||
@@ -2,7 +2,6 @@ import {MetadataRefreshType} from './metadata-refresh-type.enum';
|
||||
import {MetadataRefreshOptions} from './metadata-refresh-options.model';
|
||||
|
||||
export interface MetadataRefreshRequest {
|
||||
quick?: boolean;
|
||||
refreshType: MetadataRefreshType;
|
||||
libraryId?: number;
|
||||
bookIds?: number[];
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ export class MultiBookMetadataFetchComponent implements OnInit, OnDestroy {
|
||||
this.appSettingsService.appSettings$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(settings => {
|
||||
this.currentMetadataOptions = settings!.metadataRefreshOptions;
|
||||
this.currentMetadataOptions = settings!.defaultMetadataRefreshOptions;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+15
-4
@@ -30,7 +30,15 @@
|
||||
</div>
|
||||
|
||||
<div class="config-field">
|
||||
<label class="config-label">Cookie (Optional)</label>
|
||||
<div class="config-label-row">
|
||||
<label class="config-label">Cookie (Optional, but highly recommended)</label>
|
||||
<i class="pi pi-external-link external-link-icon"
|
||||
pTooltip="Click to view Amazon cookie setup documentation"
|
||||
tooltipPosition="right"
|
||||
(click)="navigateToAmazonCookieDocumentation()"
|
||||
style="cursor: pointer;">
|
||||
</i>
|
||||
</div>
|
||||
<p class="config-description">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
Providing your Amazon session cookie allows the app to access richer book metadata and bypass rate limits. Optional but recommended for best results.
|
||||
@@ -39,7 +47,8 @@
|
||||
type="text"
|
||||
pInputText
|
||||
placeholder="Paste your Amazon cookie"
|
||||
class="config-input"
|
||||
fluid
|
||||
class="max-w-[60rem]"
|
||||
[(ngModel)]="amazonCookie" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,7 +91,8 @@
|
||||
type="text"
|
||||
pInputText
|
||||
placeholder="Enter Hardcover API token"
|
||||
class="config-input"
|
||||
fluid
|
||||
class="max-w-[60rem]"
|
||||
[(ngModel)]="hardcoverToken"
|
||||
(ngModelChange)="onTokenChange($event)" />
|
||||
</div>
|
||||
@@ -108,7 +118,8 @@
|
||||
type="text"
|
||||
pInputText
|
||||
placeholder="Enter Comic Vine API token"
|
||||
class="config-input"
|
||||
fluid
|
||||
class="max-w-[60rem]"
|
||||
[(ngModel)]="comicvineToken"
|
||||
(ngModelChange)="onComicTokenChange($event)" />
|
||||
</div>
|
||||
|
||||
+15
-6
@@ -102,6 +102,21 @@
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.config-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
.config-label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.external-link-icon {
|
||||
color: #0ea5e9 !important;
|
||||
font-size: 0.875rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.config-description {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -118,11 +133,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.config-input {
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.config-select {
|
||||
width: 100%;
|
||||
max-width: 13rem;
|
||||
@@ -134,4 +144,3 @@
|
||||
justify-content: flex-start;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -9,6 +9,7 @@ import {filter, take} from 'rxjs/operators';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {AppSettingKey} from '../../../core/model/app-settings.model';
|
||||
import {Select} from 'primeng/select';
|
||||
import {Tooltip} from 'primeng/tooltip';
|
||||
|
||||
@Component({
|
||||
selector: 'app-metadata-provider-settings',
|
||||
@@ -19,7 +20,8 @@ import {Select} from 'primeng/select';
|
||||
InputText,
|
||||
Button,
|
||||
FormsModule,
|
||||
Select
|
||||
Select,
|
||||
Tooltip
|
||||
],
|
||||
templateUrl: './metadata-provider-settings.component.html',
|
||||
styleUrl: './metadata-provider-settings.component.scss'
|
||||
@@ -142,4 +144,8 @@ export class MetadataProviderSettingsComponent implements OnInit {
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
navigateToAmazonCookieDocumentation() {
|
||||
window.open('https://booklore-app.github.io/booklore-docs/docs/metadata/amazon-cookie', '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
<div class="main-container enclosing-container">
|
||||
<div class="settings-header">
|
||||
<h2 class="settings-title">
|
||||
<i class="pi pi-database"></i>
|
||||
Library Metadata Configuration
|
||||
</h2>
|
||||
<p class="settings-description">
|
||||
Configure metadata fetch behavior for your libraries. Set global defaults and create library-specific overrides to control how book metadata is retrieved from different providers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-content">
|
||||
<div class="preferences-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">
|
||||
<i class="pi pi-globe"></i>
|
||||
Default Metadata Settings
|
||||
</h3>
|
||||
<p class="section-description">
|
||||
Set global default provider priorities for book metadata fields. These settings apply to all libraries unless overridden below.
|
||||
BookLore tries providers from left to right (1st → 2nd → 3rd → 4th priority) for each book field until it finds data.
|
||||
The system checks your 1st priority provider first - if that provider doesn't have the specific field (like description or author),
|
||||
it automatically moves to your 2nd priority, then 3rd, and finally 4th. Leave a priority empty to skip it entirely.
|
||||
For example, if Amazon (1st) has no description but Google Books (2nd) does, Google's description will be used.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<app-metadata-advanced-fetch-options
|
||||
[currentMetadataOptions]="defaultMetadataOptions"
|
||||
[submitButtonLabel]="'Save Default Settings'"
|
||||
(metadataOptionsSubmitted)="onDefaultMetadataOptionsSubmitted($event)">
|
||||
</app-metadata-advanced-fetch-options>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preferences-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">
|
||||
<i class="pi pi-book"></i>
|
||||
Library-Specific Overrides
|
||||
</h3>
|
||||
<p class="section-description">
|
||||
Override the default priority settings for specific libraries. For example, you might prefer Amazon for fiction but Google Books for technical books. Each library can have its own provider priority order while falling back to defaults for unspecified fields.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
@if (libraries$ | async; as libraries) {
|
||||
<p-accordion>
|
||||
@for (library of libraries; track trackByLibrary($index, library); let i = $index) {
|
||||
<p-accordion-panel [value]="i">
|
||||
<p-accordion-header>
|
||||
<div class="accordion-header-content">
|
||||
<div class="library-info">
|
||||
<i [class]="'pi pi-' + library.icon" class="library-icon"></i>
|
||||
<span class="library-name">{{ library.name }}</span>
|
||||
<span class="dot-separator">•</span>
|
||||
@if (hasLibraryOverride(library.id!)) {
|
||||
<span class="override-indicator custom">
|
||||
<i class="pi pi-check-circle"></i>
|
||||
Custom Settings
|
||||
</span>
|
||||
} @else {
|
||||
<span class="default-indicator default">
|
||||
<i class="pi pi-globe"></i>
|
||||
Default Settings
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div class="accordion-content-wrapper">
|
||||
<app-metadata-advanced-fetch-options
|
||||
[currentMetadataOptions]="getLibraryOptions(library.id!)"
|
||||
[submitButtonLabel]="'Save ' + library.name + ' Settings'"
|
||||
(metadataOptionsSubmitted)="onLibraryMetadataOptionsSubmitted(library.id!, $event)">
|
||||
</app-metadata-advanced-fetch-options>
|
||||
</div>
|
||||
</p-accordion-content>
|
||||
</p-accordion-panel>
|
||||
}
|
||||
</p-accordion>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
.main-container {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
height: calc(100dvh - 10.5rem);
|
||||
overflow-y: auto;
|
||||
border-width: 1px;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
height: calc(100dvh - 11.65rem);
|
||||
}
|
||||
}
|
||||
|
||||
.enclosing-container {
|
||||
border-color: var(--p-content-border-color);
|
||||
background: var(--p-content-background);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--p-text-color);
|
||||
margin: 0 0 0.75rem 0;
|
||||
|
||||
.pi {
|
||||
color: var(--p-primary-color);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-description {
|
||||
color: var(--p-text-muted-color);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.preferences-section {
|
||||
@media (min-width: 768px) {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.section-description {
|
||||
color: var(--p-text-muted-color);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
margin: 0.5rem 0 0 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
|
||||
.pi {
|
||||
color: var(--p-primary-color);
|
||||
margin-top: 0.125rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--p-text-color);
|
||||
margin: 0 0 0.5rem 0;
|
||||
|
||||
.pi {
|
||||
color: var(--p-primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
border: 1px solid var(--p-content-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--p-content-background);
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
|
||||
app-metadata-advanced-fetch-options {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
::ng-deep {
|
||||
> div,
|
||||
> .container,
|
||||
> .main-container {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep {
|
||||
.p-accordion {
|
||||
.p-accordion-panel {
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--p-content-border-color);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.p-accordion-header {
|
||||
background: var(--p-surface-100);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--p-surface-200);
|
||||
}
|
||||
|
||||
.p-accordion-header-content {
|
||||
width: 100%;
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.p-accordion-content {
|
||||
border: none;
|
||||
border-top: 1px solid var(--p-content-border-color);
|
||||
|
||||
.p-accordion-content-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 1rem;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.library-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
.library-icon {
|
||||
font-size: 1.2rem;
|
||||
color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
.library-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.dot-separator {
|
||||
color: var(--p-text-muted-color);
|
||||
font-weight: bold;
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0.25rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.override-indicator,
|
||||
.default-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
|
||||
&.custom {
|
||||
color: var(--p-green-500);
|
||||
}
|
||||
|
||||
&.default {
|
||||
color: var(--p-blue-500);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-content-wrapper {
|
||||
padding: 1.5rem;
|
||||
|
||||
app-metadata-advanced-fetch-options {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
::ng-deep {
|
||||
> div,
|
||||
> .container,
|
||||
> .main-container {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
import {Component, inject, OnInit} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {Observable} from 'rxjs';
|
||||
import {map} from 'rxjs/operators';
|
||||
import {AccordionModule} from 'primeng/accordion';
|
||||
import {MessageService} from 'primeng/api';
|
||||
|
||||
import {Library} from '../../book/model/library.model';
|
||||
import {LibraryService} from '../../book/service/library.service';
|
||||
import {MetadataRefreshOptions} from '../../metadata/model/request/metadata-refresh-options.model';
|
||||
import {MetadataAdvancedFetchOptionsComponent} from '../../metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component';
|
||||
import {AppSettings, AppSettingKey} from '../../core/model/app-settings.model';
|
||||
import {AppSettingsService} from '../../core/service/app-settings.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-library-metadata-settings-component',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, MetadataAdvancedFetchOptionsComponent, AccordionModule],
|
||||
templateUrl: './library-metadata-settings.component.html',
|
||||
styleUrls: ['./library-metadata-settings.component.scss']
|
||||
})
|
||||
export class LibraryMetadataSettingsComponent implements OnInit {
|
||||
private libraryService = inject(LibraryService);
|
||||
private appSettingsService = inject(AppSettingsService);
|
||||
private messageService = inject(MessageService);
|
||||
|
||||
libraries$: Observable<Library[]> = this.libraryService.libraryState$.pipe(
|
||||
map(state => state.libraries || [])
|
||||
);
|
||||
|
||||
defaultMetadataOptions: MetadataRefreshOptions = this.getDefaultMetadataOptions();
|
||||
libraryMetadataOptions: { [libraryId: number]: MetadataRefreshOptions } = {};
|
||||
|
||||
ngOnInit() {
|
||||
this.appSettingsService.appSettings$.subscribe(appSettings => {
|
||||
if (appSettings) {
|
||||
this.defaultMetadataOptions = appSettings.defaultMetadataRefreshOptions;
|
||||
this.initializeLibraryOptions(appSettings);
|
||||
this.updateLibraryOptionsFromSettings(appSettings);
|
||||
}
|
||||
});
|
||||
|
||||
this.libraries$.subscribe(libraries => {
|
||||
libraries.forEach(library => {
|
||||
if (library.id && !this.libraryMetadataOptions[library.id]) {
|
||||
const libraryOptions = this.getLibrarySpecificOptions(library.id);
|
||||
if (libraryOptions) {
|
||||
this.libraryMetadataOptions[library.id] = libraryOptions;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onDefaultMetadataOptionsSubmitted(options: MetadataRefreshOptions) {
|
||||
this.defaultMetadataOptions = options;
|
||||
this.saveDefaultMetadataOptions(options);
|
||||
}
|
||||
|
||||
onLibraryMetadataOptionsSubmitted(libraryId: number, options: MetadataRefreshOptions) {
|
||||
this.libraryMetadataOptions[libraryId] = {...options, libraryId};
|
||||
this.saveLibraryMetadataOptions();
|
||||
}
|
||||
|
||||
hasLibraryOverride(libraryId: number): boolean {
|
||||
return libraryId in this.libraryMetadataOptions;
|
||||
}
|
||||
|
||||
getLibraryOptions(libraryId: number): MetadataRefreshOptions {
|
||||
return this.libraryMetadataOptions[libraryId] || {...this.defaultMetadataOptions, libraryId};
|
||||
}
|
||||
|
||||
trackByLibrary(index: number, library: Library): number | undefined {
|
||||
return library.id;
|
||||
}
|
||||
|
||||
private saveDefaultMetadataOptions(options: MetadataRefreshOptions) {
|
||||
const settingsToSave = [
|
||||
{
|
||||
key: AppSettingKey.QUICK_BOOK_MATCH,
|
||||
newValue: options
|
||||
}
|
||||
];
|
||||
|
||||
this.appSettingsService.saveSettings(settingsToSave).subscribe({
|
||||
next: () => {
|
||||
this.showMessage('success', 'Settings Saved', 'Default metadata options have been saved successfully.');
|
||||
this.updateLibrariesUsingDefaults();
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error saving default metadata options:', error);
|
||||
this.showMessage('error', 'Save Failed', 'Failed to save default metadata options. Please try again.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private saveLibraryMetadataOptions() {
|
||||
const libraryOptionsArray = Object.values(this.libraryMetadataOptions).filter(option =>
|
||||
option.libraryId !== null && option.libraryId !== undefined
|
||||
);
|
||||
|
||||
const settingsToSave = [
|
||||
{
|
||||
key: AppSettingKey.LIBRARY_METADATA_REFRESH_OPTIONS,
|
||||
newValue: libraryOptionsArray
|
||||
}
|
||||
];
|
||||
|
||||
this.appSettingsService.saveSettings(settingsToSave).subscribe({
|
||||
next: () => {
|
||||
this.showMessage('success', 'Settings Saved', 'Library metadata options have been saved successfully.');
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error saving library metadata options:', error);
|
||||
this.showMessage('error', 'Save Failed', 'Failed to save library metadata options. Please try again.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private updateLibrariesUsingDefaults() {
|
||||
Object.keys(this.libraryMetadataOptions).forEach(libraryIdStr => {
|
||||
const libraryId = parseInt(libraryIdStr, 10);
|
||||
if (!this.hasLibrarySpecificOptionsInSettings(libraryId)) {
|
||||
delete this.libraryMetadataOptions[libraryId];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private showMessage(severity: 'success' | 'error', summary: string, detail: string) {
|
||||
this.messageService.add({
|
||||
severity,
|
||||
summary,
|
||||
detail,
|
||||
life: 5000
|
||||
});
|
||||
}
|
||||
|
||||
private initializeLibraryOptions(appSettings: AppSettings) {
|
||||
if (appSettings?.libraryMetadataRefreshOptions) {
|
||||
appSettings.libraryMetadataRefreshOptions.forEach(option => {
|
||||
if (option.libraryId) {
|
||||
this.libraryMetadataOptions[option.libraryId] = option;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private updateLibraryOptionsFromSettings(appSettings: AppSettings) {
|
||||
Object.keys(this.libraryMetadataOptions).forEach(libraryIdStr => {
|
||||
const libraryId = parseInt(libraryIdStr, 10);
|
||||
if (!this.hasLibrarySpecificOptions(libraryId)) {
|
||||
this.libraryMetadataOptions[libraryId] = {...this.defaultMetadataOptions};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private hasLibrarySpecificOptions(libraryId: number): boolean {
|
||||
return libraryId in this.libraryMetadataOptions;
|
||||
}
|
||||
|
||||
private hasLibrarySpecificOptionsInSettings(libraryId: number): boolean {
|
||||
let hasOptions = false;
|
||||
this.appSettingsService.appSettings$.subscribe(settings => {
|
||||
hasOptions = settings?.libraryMetadataRefreshOptions?.some(
|
||||
option => option.libraryId === libraryId
|
||||
) || false;
|
||||
}).unsubscribe();
|
||||
|
||||
return hasOptions;
|
||||
}
|
||||
|
||||
private getLibrarySpecificOptions(libraryId: number): MetadataRefreshOptions | null {
|
||||
let libraryOptions: MetadataRefreshOptions | null = null;
|
||||
this.appSettingsService.appSettings$.subscribe(settings => {
|
||||
libraryOptions = settings?.libraryMetadataRefreshOptions?.find(
|
||||
option => option.libraryId === libraryId
|
||||
) || null;
|
||||
}).unsubscribe();
|
||||
|
||||
return libraryOptions;
|
||||
}
|
||||
|
||||
private getDefaultMetadataOptions(): MetadataRefreshOptions {
|
||||
return {
|
||||
libraryId: null,
|
||||
allP1: null,
|
||||
allP2: null,
|
||||
allP3: null,
|
||||
allP4: null,
|
||||
refreshCovers: false,
|
||||
mergeCategories: false,
|
||||
reviewBeforeApply: false,
|
||||
fieldOptions: {
|
||||
title: {p1: null, p2: null, p3: null, p4: null},
|
||||
subtitle: {p1: null, p2: null, p3: null, p4: null},
|
||||
description: {p1: null, p2: null, p3: null, p4: null},
|
||||
authors: {p1: null, p2: null, p3: null, p4: null},
|
||||
publisher: {p1: null, p2: null, p3: null, p4: null},
|
||||
publishedDate: {p1: null, p2: null, p3: null, p4: null},
|
||||
seriesName: {p1: null, p2: null, p3: null, p4: null},
|
||||
seriesNumber: {p1: null, p2: null, p3: null, p4: null},
|
||||
seriesTotal: {p1: null, p2: null, p3: null, p4: null},
|
||||
isbn13: {p1: null, p2: null, p3: null, p4: null},
|
||||
isbn10: {p1: null, p2: null, p3: null, p4: null},
|
||||
language: {p1: null, p2: null, p3: null, p4: null},
|
||||
categories: {p1: null, p2: null, p3: null, p4: null},
|
||||
cover: {p1: null, p2: null, p3: null, p4: null}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+1
-21
@@ -1,7 +1,7 @@
|
||||
<div class="main-container enclosing-container">
|
||||
<div class="settings-header">
|
||||
<h2 class="settings-title">
|
||||
<i class="pi pi-database"></i>
|
||||
<i class="pi pi-sliders-h"></i>
|
||||
Metadata Settings
|
||||
</h2>
|
||||
<p class="settings-description">
|
||||
@@ -67,26 +67,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preferences-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">
|
||||
<i class="pi pi-search"></i>
|
||||
Quick Book Match Preferences
|
||||
</h3>
|
||||
<p class="section-description">
|
||||
Choose how metadata fields (title, description, authors, categories, cover) are retrieved by setting priority providers. You can apply the same provider settings to all fields or customize them individually. Enable 'Refresh Covers' to update book covers, and 'Merge Categories' to consolidate categories/genres from all sources while preserving the existing categories/genres in the book.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<app-metadata-advanced-fetch-options
|
||||
(metadataOptionsSubmitted)="onMetadataSubmit($event)"
|
||||
[currentMetadataOptions]="currentMetadataOptions"
|
||||
[submitButtonLabel]="'Save'">
|
||||
</app-metadata-advanced-fetch-options>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preferences-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">
|
||||
|
||||
+3
-4
@@ -1,5 +1,4 @@
|
||||
import {Component, inject, OnInit} from '@angular/core';
|
||||
import {MetadataAdvancedFetchOptionsComponent} from '../../metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component';
|
||||
import {MetadataProviderSettingsComponent} from '../global-preferences/metadata-provider-settings/metadata-provider-settings.component';
|
||||
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
|
||||
import {MetadataRefreshOptions} from '../../metadata/model/request/metadata-refresh-options.model';
|
||||
@@ -12,12 +11,12 @@ import {MetadataMatchWeightsComponent} from '../global-preferences/metadata-matc
|
||||
import {ToggleSwitch} from 'primeng/toggleswitch';
|
||||
import {MetadataPersistenceSettingsComponent} from './metadata-persistence-settings-component/metadata-persistence-settings-component';
|
||||
import {PublicReviewsSettingsComponent} from './public-reviews-settings-component/public-reviews-settings-component';
|
||||
import {LibraryMetadataSettingsComponent} from '../library-metadata-settings-component/library-metadata-settings.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-metadata-settings-component',
|
||||
standalone: true,
|
||||
imports: [
|
||||
MetadataAdvancedFetchOptionsComponent,
|
||||
MetadataProviderSettingsComponent,
|
||||
ReactiveFormsModule,
|
||||
FormsModule,
|
||||
@@ -67,8 +66,8 @@ export class MetadataSettingsComponent implements OnInit {
|
||||
}
|
||||
|
||||
private initializeSettings(settings: AppSettings): void {
|
||||
if (settings.metadataRefreshOptions) {
|
||||
this.currentMetadataOptions = settings.metadataRefreshOptions;
|
||||
if (settings.defaultMetadataRefreshOptions) {
|
||||
this.currentMetadataOptions = settings.defaultMetadataRefreshOptions;
|
||||
}
|
||||
|
||||
this.metadataDownloadOnBookdrop = settings.metadataDownloadOnBookdrop ?? true;
|
||||
|
||||
+17
-1
@@ -10,12 +10,28 @@
|
||||
</div>
|
||||
<p class="setting-description">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
Automatically download user reviews from enabled platforms (Amazon, Goodreads, Hardcover) when fetching book metadata. Reviews will be stored with your books for offline access.
|
||||
Enable downloading user reviews from configured platforms (Amazon, Goodreads, Hardcover). Reviews will be stored with your books for offline access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (publicReviewSettings.downloadEnabled) {
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label-row">
|
||||
<label class="setting-label">Auto Download on Book Open</label>
|
||||
<p-toggleswitch
|
||||
[(ngModel)]="publicReviewSettings.autoDownloadEnabled"
|
||||
(onChange)="onAutoDownloadToggle($event.checked)">
|
||||
</p-toggleswitch>
|
||||
</div>
|
||||
<p class="setting-description">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
Automatically fetch reviews when you open a book's details page. When disabled, reviews will only be downloaded manually or during metadata refresh, reducing background network activity and improving performance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="review-sources-section">
|
||||
<div class="sources-header">
|
||||
<h4 class="subsection-title">Review Sources</h4>
|
||||
|
||||
+6
@@ -25,6 +25,7 @@ export class PublicReviewsSettingsComponent implements OnInit {
|
||||
|
||||
publicReviewSettings: PublicReviewSettings = {
|
||||
downloadEnabled: true,
|
||||
autoDownloadEnabled: false,
|
||||
providers: [...DEFAULT_PROVIDERS]
|
||||
};
|
||||
|
||||
@@ -42,6 +43,11 @@ export class PublicReviewsSettingsComponent implements OnInit {
|
||||
this.settingsHelper.saveSetting(AppSettingKey.METADATA_PUBLIC_REVIEWS_SETTINGS, this.publicReviewSettings);
|
||||
}
|
||||
|
||||
onAutoDownloadToggle(checked: boolean): void {
|
||||
this.publicReviewSettings.autoDownloadEnabled = checked;
|
||||
this.settingsHelper.saveSetting(AppSettingKey.METADATA_PUBLIC_REVIEWS_SETTINGS, this.publicReviewSettings);
|
||||
}
|
||||
|
||||
onProviderToggle(providerName: string, enabled: boolean): void {
|
||||
this.updateProviderProperty(providerName, 'enabled', enabled);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="settings-header">
|
||||
<h2 class="settings-title">
|
||||
<i class="pi pi-server"></i>
|
||||
OPDS Settings v2
|
||||
OPDS Settings (New)
|
||||
</h2>
|
||||
<p class="settings-description">
|
||||
Manage your OPDS credentials and control how your book collection is shared with reading apps.
|
||||
@@ -44,7 +44,7 @@
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">
|
||||
<i class="pi pi-link"></i>
|
||||
OPDS Endpoint
|
||||
OPDS Endpoints
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -52,28 +52,59 @@
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label-row">
|
||||
<label class="setting-label">Endpoint URL</label>
|
||||
<label class="setting-label">OPDS v1 Catalog</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
id="endpoint-url"
|
||||
id="endpoint-url-v1"
|
||||
class="endpoint-input"
|
||||
fluid
|
||||
type="text"
|
||||
pInputText
|
||||
[value]="opdsEndpoint"
|
||||
[value]="opdsV1Endpoint"
|
||||
readonly/>
|
||||
<p-button
|
||||
icon="pi pi-copy"
|
||||
severity="info"
|
||||
outlined
|
||||
size="small"
|
||||
(onClick)="copyEndpoint()">
|
||||
(onClick)="copyV1Endpoint()">
|
||||
</p-button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="setting-description">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
Use this URL to connect your reading apps to your OPDS catalog.
|
||||
OPDS v1 specification - Compatible with most reading apps and OPDS clients.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoint-divider"></div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label-row">
|
||||
<label class="setting-label">OPDS v2 Catalog</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
id="endpoint-url-v2"
|
||||
class="endpoint-input"
|
||||
fluid
|
||||
type="text"
|
||||
pInputText
|
||||
[value]="opdsV2Endpoint"
|
||||
readonly/>
|
||||
<p-button
|
||||
icon="pi pi-copy"
|
||||
severity="info"
|
||||
outlined
|
||||
size="small"
|
||||
(onClick)="copyV2Endpoint()">
|
||||
</p-button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="setting-description">
|
||||
<i class="pi pi-exclamation-triangle"></i>
|
||||
OPDS v2 specification - Note: Not all clients support v2. Use v1 for better compatibility.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -132,13 +132,14 @@
|
||||
.setting-label {
|
||||
margin-bottom: 0;
|
||||
flex-shrink: 0;
|
||||
min-width: 180px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
@@ -182,6 +183,18 @@
|
||||
min-width: 25rem;
|
||||
}
|
||||
|
||||
.endpoint-divider {
|
||||
height: 1px;
|
||||
background: var(--p-content-border-color);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.setting-description {
|
||||
.pi-exclamation-triangle {
|
||||
color: var(--p-orange-500);
|
||||
}
|
||||
}
|
||||
|
||||
.table-card {
|
||||
border: 1px solid var(--p-content-border-color);
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -39,6 +39,8 @@ import {AppSettingKey} from '../../core/model/app-settings.model';
|
||||
export class OpdsSettingsV2 implements OnInit, OnDestroy {
|
||||
|
||||
opdsEndpoint = `${API_CONFIG.BASE_URL}/api/v2/opds/catalog`;
|
||||
opdsV1Endpoint = `${API_CONFIG.BASE_URL}/api/v2/opds/catalog`;
|
||||
opdsV2Endpoint = `${API_CONFIG.BASE_URL}/api/v2/opds`;
|
||||
opdsEnabled = false;
|
||||
|
||||
private opdsService = inject(OpdsV2Service);
|
||||
@@ -157,6 +159,18 @@ export class OpdsSettingsV2 implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
copyV1Endpoint(): void {
|
||||
navigator.clipboard.writeText(this.opdsV1Endpoint).then(() => {
|
||||
this.showMessage('success', 'Copied', 'OPDS v1 endpoint copied to clipboard');
|
||||
});
|
||||
}
|
||||
|
||||
copyV2Endpoint(): void {
|
||||
navigator.clipboard.writeText(this.opdsV2Endpoint).then(() => {
|
||||
this.showMessage('success', 'Copied', 'OPDS v2 endpoint copied to clipboard');
|
||||
});
|
||||
}
|
||||
|
||||
toggleOpdsServer(): void {
|
||||
this.saveSetting(AppSettingKey.OPDS_SERVER_ENABLED, this.opdsEnabled);
|
||||
if (this.opdsEnabled) {
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
<div class="main-container enclosing-container">
|
||||
<div class="settings-header">
|
||||
<h2 class="settings-title">
|
||||
<i class="pi pi-server"></i>
|
||||
OPDS Settings v1 (Legacy)
|
||||
</h2>
|
||||
<p class="settings-description">
|
||||
Legacy OPDS server settings for managing your book collection access.
|
||||
</p>
|
||||
|
||||
<div class="deprecation-notice">
|
||||
<i class="pi pi-exclamation-triangle"></i>
|
||||
<div>
|
||||
<p class="notice-title">Deprecated:</p>
|
||||
<p class="notice-text">
|
||||
OPDS v1 support will be removed in a future release.
|
||||
Please migrate to <strong>OPDS v2</strong> for continued support and improvements.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-content">
|
||||
<div class="preferences-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">
|
||||
<i class="pi pi-power-off"></i>
|
||||
Server Control
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label-row">
|
||||
<label class="setting-label">OPDS Server Enabled</label>
|
||||
<p-toggleswitch
|
||||
[(ngModel)]="opdsEnabled"
|
||||
(onChange)="toggleOpdsServer()">
|
||||
</p-toggleswitch>
|
||||
</div>
|
||||
<p class="setting-description">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
Enable or disable the OPDS server to control access to your book collection through reading apps.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (opdsEnabled) {
|
||||
<div class="preferences-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">
|
||||
<i class="pi pi-link"></i>
|
||||
OPDS Endpoint
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label-row">
|
||||
<label class="setting-label">Endpoint URL</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
id="endpoint-url"
|
||||
class="endpoint-input"
|
||||
type="text"
|
||||
pInputText
|
||||
[value]="opdsEndpoint"
|
||||
readonly/>
|
||||
<p-button
|
||||
icon="pi pi-copy"
|
||||
severity="info"
|
||||
outlined
|
||||
size="small"
|
||||
(onClick)="copyOpdsEndpoint()">
|
||||
</p-button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="setting-description">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
Use this URL to connect your reading apps to your OPDS catalog.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preferences-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">
|
||||
<i class="pi pi-users"></i>
|
||||
OPDS Users
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<p-table [value]="users">
|
||||
<ng-template pTemplate="header">
|
||||
<tr>
|
||||
<th>
|
||||
<div class="header-content">
|
||||
<i class="pi pi-user"></i>
|
||||
<span>Username</span>
|
||||
</div>
|
||||
</th>
|
||||
<th>
|
||||
<div class="header-content">
|
||||
<i class="pi pi-key"></i>
|
||||
<span>Password</span>
|
||||
</div>
|
||||
</th>
|
||||
<th class="actions-header">
|
||||
<div class="header-content">
|
||||
<i class="pi pi-cog"></i>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="body" let-user>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="user-info">
|
||||
<div class="user-avatar">
|
||||
{{ user.username.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<span class="username">{{ user.username }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<p-password
|
||||
fluid
|
||||
class="w-32 md:w-56"
|
||||
[(ngModel)]="dummyPassword"
|
||||
[feedback]="false"
|
||||
size="small"
|
||||
[disabled]="true"
|
||||
[toggleMask]="false">
|
||||
</p-password>
|
||||
<i
|
||||
class="pi pi-info-circle text-gray-400"
|
||||
pTooltip="The password is hidden for security reasons and is only visible once during creation. You can reset the password if needed."
|
||||
tooltipPosition="right"
|
||||
style="cursor: pointer;">
|
||||
</i>
|
||||
</div>
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<div class="action-buttons">
|
||||
<p-button
|
||||
icon="pi pi-key"
|
||||
severity="warn"
|
||||
size="small"
|
||||
[outlined]="true"
|
||||
[rounded]="true"
|
||||
(onClick)="openResetPasswordDialog(user)"
|
||||
pTooltip="Reset password">
|
||||
</p-button>
|
||||
<p-button
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
size="small"
|
||||
[outlined]="true"
|
||||
[rounded]="true"
|
||||
(onClick)="deleteUser(user.id)"
|
||||
pTooltip="Delete user">
|
||||
</p-button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="emptymessage">
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
<div class="empty-message">
|
||||
<i class="pi pi-users"></i>
|
||||
<p class="empty-title">No users found</p>
|
||||
<p class="empty-subtitle">Create your first OPDS user to get started</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p-dialog
|
||||
header="Create User"
|
||||
[(visible)]="createUserDialogVisible"
|
||||
[modal]="true"
|
||||
styleClass="user-dialog"
|
||||
[style]="{width: '400px'}">
|
||||
<div class="dialog-form">
|
||||
<div class="form-field">
|
||||
<label for="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
pInputText
|
||||
[(ngModel)]="newUser.username"
|
||||
placeholder="Enter username"/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="label-with-info">
|
||||
<label for="password">Password</label>
|
||||
<i
|
||||
class="pi pi-info-circle text-yellow-500"
|
||||
pTooltip="Password can only be seen now. It won't be retrievable later. Please save it securely."
|
||||
tooltipPosition="right"
|
||||
style="cursor: pointer;">
|
||||
</i>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
pInputText
|
||||
[(ngModel)]="newUser.password"
|
||||
placeholder="Enter password"/>
|
||||
</div>
|
||||
</div>
|
||||
<ng-template pTemplate="footer">
|
||||
<div class="dialog-actions">
|
||||
<p-button
|
||||
label="Cancel"
|
||||
severity="secondary"
|
||||
outlined="true"
|
||||
(onClick)="cancelCreateUser()">
|
||||
</p-button>
|
||||
<p-button
|
||||
label="Create"
|
||||
severity="success"
|
||||
[disabled]="!newUser.username || !newUser.password"
|
||||
(onClick)="saveNewUser()">
|
||||
</p-button>
|
||||
</div>
|
||||
</ng-template>
|
||||
</p-dialog>
|
||||
|
||||
<p-dialog
|
||||
header="Reset User Password"
|
||||
[(visible)]="resetPasswordDialogVisible"
|
||||
[modal]="true"
|
||||
styleClass="user-dialog"
|
||||
[style]="{width: '400px'}">
|
||||
<div class="dialog-form">
|
||||
<p class="reset-info">Reset password for: <strong>{{ selectedUser?.username }}</strong></p>
|
||||
<div class="form-field">
|
||||
<div class="label-with-info">
|
||||
<label for="newPassword">New Password</label>
|
||||
<i
|
||||
class="pi pi-info-circle text-yellow-500"
|
||||
pTooltip="Password can only be seen now. It won't be retrievable later. Please save it securely."
|
||||
tooltipPosition="right"
|
||||
style="cursor: pointer;">
|
||||
</i>
|
||||
</div>
|
||||
<input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
pInputText
|
||||
[(ngModel)]="newPassword"
|
||||
placeholder="Enter new password"/>
|
||||
</div>
|
||||
</div>
|
||||
<ng-template pTemplate="footer">
|
||||
<div class="dialog-actions">
|
||||
<p-button
|
||||
label="Cancel"
|
||||
severity="secondary"
|
||||
outlined="true"
|
||||
(onClick)="cancelResetPassword()">
|
||||
</p-button>
|
||||
<p-button
|
||||
label="Reset"
|
||||
severity="success"
|
||||
[disabled]="!newPassword"
|
||||
(onClick)="confirmResetPassword()">
|
||||
</p-button>
|
||||
</div>
|
||||
</ng-template>
|
||||
</p-dialog>
|
||||
</div>
|
||||
@@ -1,377 +0,0 @@
|
||||
.main-container {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
height: calc(100dvh - 10.5rem);
|
||||
overflow-y: auto;
|
||||
border-width: 1px;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
height: calc(100dvh - 11.65rem);
|
||||
}
|
||||
}
|
||||
|
||||
.enclosing-container {
|
||||
border-color: var(--p-content-border-color);
|
||||
background: var(--p-content-background);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--p-text-color);
|
||||
margin: 0 0 0.75rem 0;
|
||||
|
||||
.pi {
|
||||
color: var(--p-primary-color);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-description {
|
||||
color: var(--p-text-muted-color);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.deprecation-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--p-red-500);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--p-red-400);
|
||||
|
||||
.pi {
|
||||
color: var(--p-red-500);
|
||||
margin-top: 0.125rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.notice-title {
|
||||
font-weight: 600;
|
||||
color: var(--p-red-500);
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.notice-text {
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.preferences-section {
|
||||
@media (min-width: 768px) {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--p-text-color);
|
||||
margin: 0;
|
||||
|
||||
.pi {
|
||||
color: var(--p-primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
border: 1px solid var(--p-content-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--p-content-background);
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 2rem;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.setting-label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: var(--p-text-color);
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.setting-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.setting-label {
|
||||
margin-bottom: 0;
|
||||
flex-shrink: 0;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.setting-description {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
color: var(--p-text-muted-color);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
|
||||
.pi {
|
||||
color: var(--p-primary-color);
|
||||
margin-top: 0.125rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.endpoint-input {
|
||||
min-width: 9rem;
|
||||
max-width: 30rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
border: 1px solid var(--p-content-border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--p-content-background);
|
||||
}
|
||||
|
||||
.p-datatable {
|
||||
.p-datatable-table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.p-datatable-thead > tr > th {
|
||||
background: var(--p-surface-100);
|
||||
border-bottom: 2px solid var(--p-content-border-color);
|
||||
padding: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--p-text-color);
|
||||
}
|
||||
|
||||
.p-datatable-tbody > tr {
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--p-surface-50);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.p-datatable-tbody > tr > td {
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.p-datatable th .header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--p-primary-color);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: var(--p-text-muted-color);
|
||||
|
||||
.pi {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--p-surface-400);
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-subtitle {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.user-dialog {
|
||||
.p-dialog-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.p-dialog-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
color: var(--p-text-color);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--p-primary-color);
|
||||
box-shadow: 0 0 0 2px var(--p-primary-color-alpha-20);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--p-text-muted-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label-with-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.reset-info {
|
||||
color: var(--p-text-color);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.custom-border {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--card-border);
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import {Component, inject, OnInit} from '@angular/core';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
|
||||
import {TableModule} from 'primeng/table';
|
||||
import {Button} from 'primeng/button';
|
||||
import {InputText} from 'primeng/inputtext';
|
||||
import {Dialog} from 'primeng/dialog';
|
||||
import {Tooltip} from 'primeng/tooltip';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {filter, take} from 'rxjs/operators';
|
||||
|
||||
import {OpdsUser, OpdsUserService} from './opds-user.service';
|
||||
import {AppSettingsService} from '../../core/service/app-settings.service';
|
||||
import {AppSettingKey, AppSettings} from '../../core/model/app-settings.model';
|
||||
import {Password} from 'primeng/password';
|
||||
import {API_CONFIG} from '../../config/api-config';
|
||||
import {ToggleSwitch} from 'primeng/toggleswitch';
|
||||
|
||||
@Component({
|
||||
selector: 'app-opds-settings',
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
TableModule,
|
||||
Button,
|
||||
InputText,
|
||||
Dialog,
|
||||
Tooltip,
|
||||
Password,
|
||||
ToggleSwitch
|
||||
],
|
||||
templateUrl: './opds-settings.component.html',
|
||||
styleUrl: './opds-settings.component.scss'
|
||||
})
|
||||
export class OpdsSettingsComponent implements OnInit {
|
||||
opdsEnabled = false;
|
||||
users: OpdsUser[] = [];
|
||||
selectedUser: OpdsUser | null = null;
|
||||
createUserDialogVisible = false;
|
||||
resetPasswordDialogVisible = false;
|
||||
newUser = {username: '', password: ''};
|
||||
newPassword = '';
|
||||
opdsEndpoint: string = `${API_CONFIG.BASE_URL}/api/v1/opds/catalog`;
|
||||
dummyPassword: string = '********************************';
|
||||
|
||||
private readonly appSettings$ = inject(AppSettingsService).appSettings$;
|
||||
private readonly opdsUserService = inject(OpdsUserService);
|
||||
private readonly messageService = inject(MessageService);
|
||||
private readonly appSettingsService = inject(AppSettingsService);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.appSettings$
|
||||
.pipe(
|
||||
filter((settings): settings is AppSettings => settings != null),
|
||||
take(1)
|
||||
)
|
||||
.subscribe(settings => {
|
||||
this.opdsEnabled = settings.opdsServerEnabled ?? false;
|
||||
if (this.opdsEnabled) {
|
||||
this.loadUsers();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleOpdsServer(): void {
|
||||
this.saveSetting(AppSettingKey.OPDS_SERVER_ENABLED, this.opdsEnabled);
|
||||
if (this.opdsEnabled) {
|
||||
this.loadUsers();
|
||||
} else {
|
||||
this.users = [];
|
||||
}
|
||||
}
|
||||
|
||||
loadUsers(): void {
|
||||
this.opdsUserService.getUsers().subscribe({
|
||||
next: (users) => this.users = users,
|
||||
error: () => this.showError('Load Users Failed', 'Unable to fetch users.')
|
||||
});
|
||||
}
|
||||
|
||||
openCreateUserDialog(): void {
|
||||
this.newUser = {username: '', password: ''};
|
||||
this.createUserDialogVisible = true;
|
||||
}
|
||||
|
||||
cancelCreateUser(): void {
|
||||
this.createUserDialogVisible = false;
|
||||
}
|
||||
|
||||
saveNewUser(): void {
|
||||
const {username, password} = this.newUser;
|
||||
|
||||
if (username.trim() && password.trim()) {
|
||||
this.opdsUserService.createUser(this.newUser).subscribe({
|
||||
next: () => {
|
||||
this.createUserDialogVisible = false;
|
||||
this.loadUsers();
|
||||
this.showSuccess('User Created', 'New OPDS user created successfully.');
|
||||
},
|
||||
error: (err) => {
|
||||
const errorMessage = err?.error?.message || 'Unable to create user.';
|
||||
this.showError('Create User Failed', errorMessage);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.showWarning('Invalid Input', 'Username and Password are required.');
|
||||
}
|
||||
}
|
||||
|
||||
openResetPasswordDialog(user: OpdsUser): void {
|
||||
this.selectedUser = user;
|
||||
this.newPassword = '';
|
||||
this.resetPasswordDialogVisible = true;
|
||||
}
|
||||
|
||||
cancelResetPassword(): void {
|
||||
this.resetPasswordDialogVisible = false;
|
||||
this.selectedUser = null;
|
||||
this.newPassword = '';
|
||||
}
|
||||
|
||||
copyOpdsEndpoint() {
|
||||
navigator.clipboard.writeText(this.opdsEndpoint)
|
||||
.then(() => {
|
||||
})
|
||||
.catch(err => {
|
||||
});
|
||||
}
|
||||
|
||||
confirmResetPassword(): void {
|
||||
if (this.selectedUser && this.newPassword.trim()) {
|
||||
this.opdsUserService.resetPassword(this.selectedUser.id, this.newPassword).subscribe({
|
||||
next: () => {
|
||||
this.resetPasswordDialogVisible = false;
|
||||
this.showSuccess('Password Reset', 'User password reset successfully.');
|
||||
},
|
||||
error: (err) => {
|
||||
const errorMessage = err?.error?.message || 'Unable to reset password.';
|
||||
this.showError('Reset Password Failed', errorMessage);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.showWarning('Invalid Input', 'New password is required.');
|
||||
}
|
||||
}
|
||||
|
||||
deleteUser(userId: number): void {
|
||||
this.opdsUserService.deleteUser(userId).subscribe({
|
||||
next: () => {
|
||||
this.loadUsers();
|
||||
this.showSuccess('User Deleted', 'User deleted successfully.');
|
||||
},
|
||||
error: () => {
|
||||
this.showError('Delete User Failed', 'Unable to delete user.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private saveSetting(key: string, value: unknown): void {
|
||||
this.appSettingsService.saveSettings([{key, newValue: value}]).subscribe({
|
||||
next: () => {
|
||||
let successMessage = 'Settings saved successfully.';
|
||||
if (key === AppSettingKey.OPDS_SERVER_ENABLED) {
|
||||
successMessage = (value === true)
|
||||
? 'OPDS Server Enabled.'
|
||||
: 'OPDS Server Disabled.';
|
||||
}
|
||||
this.showSuccess('Settings Saved', successMessage);
|
||||
},
|
||||
error: () => {
|
||||
this.showError('Error', 'There was an error saving the settings.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private showSuccess(summary: string, detail: string): void {
|
||||
this.messageService.add({severity: 'success', summary, detail});
|
||||
}
|
||||
|
||||
private showError(summary: string, detail: string): void {
|
||||
this.messageService.add({severity: 'error', summary, detail});
|
||||
}
|
||||
|
||||
private showWarning(summary: string, detail: string): void {
|
||||
this.messageService.add({severity: 'warn', summary, detail});
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {API_CONFIG} from '../../config/api-config';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Observable} from 'rxjs';
|
||||
|
||||
export interface OpdsUser {
|
||||
id: number;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class OpdsUserService {
|
||||
|
||||
private readonly url = `${API_CONFIG.BASE_URL}/api/v1/opds-users`;
|
||||
|
||||
private http = inject(HttpClient);
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
createUser(userData: Omit<OpdsUser, 'id'>): Observable<void> {
|
||||
return this.http.post<void>(this.url, userData);
|
||||
}
|
||||
|
||||
getUsers(): Observable<OpdsUser[]> {
|
||||
return this.http.get<OpdsUser[]>(this.url);
|
||||
}
|
||||
|
||||
deleteUser(userId: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.url}/${userId}`);
|
||||
}
|
||||
|
||||
resetPassword(resetPasswordUserId: number, newPassword: string): Observable<void> {
|
||||
const resetPayload = {
|
||||
password: newPassword
|
||||
};
|
||||
return this.http.put<void>(`${this.url}/${resetPasswordUserId}/reset-password`, resetPayload);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,10 @@
|
||||
</p-tab>
|
||||
@if (userState.user.permissions.admin) {
|
||||
<p-tab [value]="SettingsTab.MetadataSettings">
|
||||
<i class="pi pi-sliders-h"></i> Metadata
|
||||
<i class="pi pi-sliders-h"></i> Metadata 1
|
||||
</p-tab>
|
||||
<p-tab [value]="SettingsTab.LibraryMetadataSettings">
|
||||
<i class="pi pi-database"></i> Metadata 2
|
||||
</p-tab>
|
||||
<p-tab [value]="SettingsTab.ApplicationSettings">
|
||||
<i class="pi pi-cog"></i> Application
|
||||
@@ -28,12 +31,9 @@
|
||||
<p-tab [value]="SettingsTab.AuthenticationSettings">
|
||||
<i class="pi pi-lock"></i> Authentication
|
||||
</p-tab>
|
||||
<p-tab [value]="SettingsTab.Opds">
|
||||
<i class="pi pi-globe"></i> OPDS (Deprecated)
|
||||
</p-tab>
|
||||
}
|
||||
<p-tab [value]="SettingsTab.OpdsV2">
|
||||
<i class="pi pi-globe"></i> OPDS v2
|
||||
<i class="pi pi-globe"></i> OPDS
|
||||
</p-tab>
|
||||
<p-tab [value]="SettingsTab.DeviceSettings">
|
||||
<i class="pi pi-mobile"></i> Devices
|
||||
@@ -50,6 +50,9 @@
|
||||
<p-tabpanel [value]="SettingsTab.MetadataSettings">
|
||||
<app-metadata-settings-component></app-metadata-settings-component>
|
||||
</p-tabpanel>
|
||||
<p-tabpanel [value]="SettingsTab.LibraryMetadataSettings">
|
||||
<app-library-metadata-settings-component></app-library-metadata-settings-component>
|
||||
</p-tabpanel>
|
||||
<p-tabpanel [value]="SettingsTab.ApplicationSettings">
|
||||
<app-global-preferences></app-global-preferences>
|
||||
</p-tabpanel>
|
||||
@@ -65,9 +68,6 @@
|
||||
<p-tabpanel [value]="SettingsTab.AuthenticationSettings">
|
||||
<app-authentication-settings></app-authentication-settings>
|
||||
</p-tabpanel>
|
||||
<p-tabpanel [value]="SettingsTab.Opds">
|
||||
<app-opds-settings></app-opds-settings>
|
||||
</p-tabpanel>
|
||||
}
|
||||
<p-tabpanel [value]="SettingsTab.OpdsV2">
|
||||
<app-opds-settings-v2></app-opds-settings-v2>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
|
||||
import {Tab, TabList, TabPanel, TabPanels, Tabs} from 'primeng/tabs';
|
||||
import {UserService} from './user-management/user.service';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import {AsyncPipe} from '@angular/common';
|
||||
import {EmailComponent} from './email/email.component';
|
||||
import {GlobalPreferencesComponent} from './global-preferences/global-preferences.component';
|
||||
import {ActivatedRoute, Router} from '@angular/router';
|
||||
@@ -11,10 +11,10 @@ import {AuthenticationSettingsComponent} from '../core/security/oauth2-managemen
|
||||
import {ViewPreferencesParentComponent} from './view-preferences-parent/view-preferences-parent.component';
|
||||
import {ReaderPreferences} from './reader-preferences/reader-preferences.component';
|
||||
import {MetadataSettingsComponent} from './metadata-settings-component/metadata-settings-component';
|
||||
import {OpdsSettingsComponent} from './opds-settings/opds-settings.component';
|
||||
import {DeviceSettingsComponent} from './device-settings-component/device-settings-component';
|
||||
import {FileNamingPatternComponent} from './file-naming-pattern/file-naming-pattern.component';
|
||||
import {OpdsSettingsV2} from './opds-settings-v2/opds-settings-v2';
|
||||
import {LibraryMetadataSettingsComponent} from './library-metadata-settings-component/library-metadata-settings.component';
|
||||
|
||||
export enum SettingsTab {
|
||||
ReaderSettings = 'reader',
|
||||
@@ -24,10 +24,10 @@ export enum SettingsTab {
|
||||
EmailSettings = 'email',
|
||||
NamingPattern = 'naming-pattern',
|
||||
MetadataSettings = 'metadata',
|
||||
LibraryMetadataSettings = 'metadata-library',
|
||||
ApplicationSettings = 'application',
|
||||
AuthenticationSettings = 'authentication',
|
||||
OpdsV2 = 'opds-v2',
|
||||
Opds = 'opds'
|
||||
OpdsV2 = 'opds'
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -46,10 +46,10 @@ export enum SettingsTab {
|
||||
ViewPreferencesParentComponent,
|
||||
ReaderPreferences,
|
||||
MetadataSettingsComponent,
|
||||
OpdsSettingsComponent,
|
||||
DeviceSettingsComponent,
|
||||
FileNamingPatternComponent,
|
||||
OpdsSettingsV2
|
||||
OpdsSettingsV2,
|
||||
LibraryMetadataSettingsComponent
|
||||
],
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrl: './settings.component.scss'
|
||||
@@ -75,7 +75,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
this._activeTab = value;
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { tab: value },
|
||||
queryParams: {tab: value},
|
||||
queryParamsHandling: 'merge'
|
||||
});
|
||||
}
|
||||
@@ -89,7 +89,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
this._activeTab = SettingsTab.ReaderSettings;
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { tab: this._activeTab },
|
||||
queryParams: {tab: this._activeTab},
|
||||
queryParamsHandling: 'merge',
|
||||
replaceUrl: true
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user