mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
feat: comic metadata picker, lazy-load providers, and UI improvements (#2679)
* feat: add comic metadata support to metadata picker and fix Comicvine parser * feat: lazy-load Comicvine issue details on selection * feat: lazy-load detail metadata for Amazon, GoodReads, and Audible parsers * fix: prevent spurious comic_metadata row creation for non-comic books * fix: extract rich previews from Audible search and reorder picker sections * feat: redesign metadata editor layout with collapsible sections and boolean selects * fix: implement per-field comic metadata locks replacing grouped locks * feat: add comic metadata filters and fix visibleFilters backend support * fix: use human-readable role labels in comic creator filter * fix: auto-populate comic metadata from ComicVine during metadata fetch * refactor: clean up ComicvineBookParser remove duplication and comments * fix: use ComicMetadata webLink for ComicVine favicon URL * fix: cache library options to prevent Set All dropdowns from resetting * fix: stream book content from disk instead of loading entire file into memory * fix: increase max visible filters from 15 to 20 * feat: replace filter multiselect with drag-and-drop reorderable list * fix: use audiobook-specific cover paths and cache busting for audiobook thumbnail updates * chore: enforce mandatory screenshots and stricter testing requirements in PR template * fix: update BookServiceTest to match Resource return type after streaming change --------- Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
17
.github/pull_request_template.md
vendored
17
.github/pull_request_template.md
vendored
@@ -18,26 +18,33 @@
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
<!-- What did you test? What edge cases did you cover? What could still break? -->
|
||||
<!-- MANDATORY. PRs without this filled out will be closed.
|
||||
- What exact steps did you follow to verify the fix or feature works?
|
||||
- How did you manually regression test existing functionality?
|
||||
- What edge cases did you cover?
|
||||
-->
|
||||
|
||||
## 📸 Screenshots _(if applicable)_
|
||||
## 📸 Screenshots / Video (MANDATORY)
|
||||
|
||||
<!-- Attach screenshots or videos for UI changes -->
|
||||
> Due to an increase in untested, AI-generated PRs, we have made this section mandatory for all submissions. Please include screenshots or a screen recording that demonstrates your change working end-to-end (e.g., the bug fix in action, the new feature working, API responses, or test output for backend changes). **PRs that do not include this will be closed without review.** Thank you for helping us keep the project stable.
|
||||
|
||||
<!-- Attach screenshots or screen recordings here -->
|
||||
|
||||
---
|
||||
|
||||
## ✅ Pre-Submission Checklist
|
||||
|
||||
> **All boxes must be checked before requesting review.** Incomplete PRs will be closed without review, no exceptions.
|
||||
> **All boxes must be checked before requesting review.** Incomplete PRs will be closed without review. No exceptions.
|
||||
|
||||
- [ ] Code follows project style guidelines and conventions
|
||||
- [ ] Branch is up to date with `develop` (merge conflicts resolved)
|
||||
- [ ] Automated tests added or updated to cover changes (backend **and** frontend)
|
||||
- [ ] All tests pass locally (`./gradlew test` for backend, `ng test` for frontend)
|
||||
- [ ] Changes manually verified in local dev environment (including related features)
|
||||
- [ ] Screenshots or video proving the change works are attached above **(MANDATORY)**
|
||||
- [ ] Flyway migration versioning is correct _(if schema was modified)_
|
||||
- [ ] Documentation PR submitted to [booklore-docs](https://github.com/booklore-app/booklore-docs) _(if user-facing changes)_
|
||||
- [ ] PR is reasonably scoped (PRs over 500+ changed lines will be closed, split into smaller PRs)
|
||||
- [ ] PR is reasonably scoped (PRs over 1000+ changed lines will be closed, split into smaller PRs)
|
||||
|
||||
### 🤖 AI-Assisted Contributions
|
||||
|
||||
|
||||
@@ -32,13 +32,11 @@ import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -125,9 +123,9 @@ public class BookController {
|
||||
@ApiResponse(responseCode = "200", description = "Book content returned successfully")
|
||||
@GetMapping("/{bookId}/content")
|
||||
@CheckBookAccess(bookIdParam = "bookId")
|
||||
public ResponseEntity<ByteArrayResource> getBookContent(
|
||||
public ResponseEntity<Resource> getBookContent(
|
||||
@Parameter(description = "ID of the book") @PathVariable long bookId,
|
||||
@Parameter(description = "Optional book type for alternative format (e.g., EPUB, PDF, MOBI)") @RequestParam(required = false) String bookType) throws IOException {
|
||||
@Parameter(description = "Optional book type for alternative format (e.g., EPUB, PDF, MOBI)") @RequestParam(required = false) String bookType) {
|
||||
return bookService.getBookContent(bookId, bookType);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.booklore.model.MetadataUpdateWrapper;
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
import org.booklore.model.dto.request.*;
|
||||
import org.booklore.model.entity.BookEntity;
|
||||
import org.booklore.model.enums.MetadataProvider;
|
||||
import org.booklore.model.enums.MetadataReplaceMode;
|
||||
import org.booklore.repository.BookRepository;
|
||||
import org.booklore.service.metadata.BookMetadataService;
|
||||
@@ -136,5 +137,19 @@ public class MetadataController {
|
||||
metadataManagementService.deleteMetadata(request.getMetadataType(), request.getValuesToDelete());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@Operation(summary = "Get detailed metadata from provider", description = "Fetch full metadata details for a specific item from a provider. Requires metadata edit permission or admin.")
|
||||
@ApiResponse(responseCode = "200", description = "Detailed metadata returned successfully")
|
||||
@GetMapping("/metadata/detail/{provider}/{providerItemId}")
|
||||
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
|
||||
public ResponseEntity<BookMetadata> getDetailedProviderMetadata(
|
||||
@Parameter(description = "Metadata provider") @PathVariable MetadataProvider provider,
|
||||
@Parameter(description = "Provider-specific item ID") @PathVariable String providerItemId) {
|
||||
BookMetadata metadata = bookMetadataService.getDetailedProviderMetadata(provider, providerItemId);
|
||||
if (metadata == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,14 +75,12 @@ public class GlobalExceptionHandler {
|
||||
}
|
||||
|
||||
@ExceptionHandler(AsyncRequestNotUsableException.class)
|
||||
public ResponseEntity<ErrorResponse> handleAsyncRequestNotUsableException(AsyncRequestNotUsableException ex) {
|
||||
public void handleAsyncRequestNotUsableException(AsyncRequestNotUsableException ex) {
|
||||
if (ex.getCause() instanceof ClientAbortException) {
|
||||
log.info("Request was canceled by client: {}", ex.getMessage());
|
||||
} else {
|
||||
log.error("Unexpected error occurred during async request handling: ", ex);
|
||||
}
|
||||
ErrorResponse errorResponse = new ErrorResponse(HttpStatus.OK.value(), "Request was canceled by the client.");
|
||||
return new ResponseEntity<>(errorResponse, HttpStatus.OK);
|
||||
}
|
||||
|
||||
@ExceptionHandler(InterruptedException.class)
|
||||
|
||||
@@ -59,6 +59,8 @@ public class BookLoreUserTransformer {
|
||||
case TABLE_COLUMN_PREFERENCE -> userSettings.setTableColumnPreference(objectMapper.readValue(value, new TypeReference<>() {
|
||||
}));
|
||||
case DASHBOARD_CONFIG -> userSettings.setDashboardConfig(objectMapper.readValue(value, BookLoreUser.UserSettings.DashboardConfig.class));
|
||||
case VISIBLE_FILTERS -> userSettings.setVisibleFilters(objectMapper.readValue(value, new TypeReference<>() {
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
switch (settingKey) {
|
||||
|
||||
@@ -77,6 +77,7 @@ public class BookLoreUser {
|
||||
public boolean koReaderEnabled;
|
||||
public boolean enableSeriesView;
|
||||
public boolean autoSaveMetadata;
|
||||
public List<String> visibleFilters;
|
||||
public DashboardConfig dashboardConfig;
|
||||
|
||||
@Data
|
||||
|
||||
@@ -47,7 +47,23 @@ public class ComicMetadata {
|
||||
private Boolean volumeNameLocked;
|
||||
private Boolean volumeNumberLocked;
|
||||
private Boolean storyArcLocked;
|
||||
private Boolean storyArcNumberLocked;
|
||||
private Boolean alternateSeriesLocked;
|
||||
private Boolean alternateIssueLocked;
|
||||
private Boolean imprintLocked;
|
||||
private Boolean formatLocked;
|
||||
private Boolean blackAndWhiteLocked;
|
||||
private Boolean mangaLocked;
|
||||
private Boolean readingDirectionLocked;
|
||||
private Boolean webLinkLocked;
|
||||
private Boolean notesLocked;
|
||||
private Boolean creatorsLocked;
|
||||
private Boolean pencillersLocked;
|
||||
private Boolean inkersLocked;
|
||||
private Boolean coloristsLocked;
|
||||
private Boolean letterersLocked;
|
||||
private Boolean coverArtistsLocked;
|
||||
private Boolean editorsLocked;
|
||||
private Boolean charactersLocked;
|
||||
private Boolean teamsLocked;
|
||||
private Boolean locationsLocked;
|
||||
|
||||
@@ -22,7 +22,8 @@ public enum UserSettingKey {
|
||||
ENABLE_SERIES_VIEW("enableSeriesView", false),
|
||||
HARDCOVER_API_KEY("hardcoverApiKey", false),
|
||||
HARDCOVER_SYNC_ENABLED("hardcoverSyncEnabled", false),
|
||||
AUTO_SAVE_METADATA("autoSaveMetadata", false);
|
||||
AUTO_SAVE_METADATA("autoSaveMetadata", false),
|
||||
VISIBLE_FILTERS("visibleFilters", true);
|
||||
|
||||
|
||||
private final String dbKey;
|
||||
|
||||
@@ -418,6 +418,9 @@ public class BookMetadataEntity {
|
||||
this.abridgedLocked = lock;
|
||||
this.ageRatingLocked = lock;
|
||||
this.contentRatingLocked = lock;
|
||||
if (this.comicMetadata != null) {
|
||||
this.comicMetadata.applyLockToAllFields(lock);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean areAllFieldsLocked() {
|
||||
@@ -463,6 +466,7 @@ public class BookMetadataEntity {
|
||||
&& Boolean.TRUE.equals(this.abridgedLocked)
|
||||
&& Boolean.TRUE.equals(this.ageRatingLocked)
|
||||
&& Boolean.TRUE.equals(this.contentRatingLocked)
|
||||
&& (this.comicMetadata == null || this.comicMetadata.areAllFieldsLocked())
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,10 +122,74 @@ public class ComicMetadataEntity {
|
||||
@Builder.Default
|
||||
private Boolean storyArcLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "story_arc_number_locked")
|
||||
@Builder.Default
|
||||
private Boolean storyArcNumberLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "alternate_series_locked")
|
||||
@Builder.Default
|
||||
private Boolean alternateSeriesLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "alternate_issue_locked")
|
||||
@Builder.Default
|
||||
private Boolean alternateIssueLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "imprint_locked")
|
||||
@Builder.Default
|
||||
private Boolean imprintLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "format_locked")
|
||||
@Builder.Default
|
||||
private Boolean formatLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "black_and_white_locked")
|
||||
@Builder.Default
|
||||
private Boolean blackAndWhiteLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "manga_locked")
|
||||
@Builder.Default
|
||||
private Boolean mangaLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "reading_direction_locked")
|
||||
@Builder.Default
|
||||
private Boolean readingDirectionLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "web_link_locked")
|
||||
@Builder.Default
|
||||
private Boolean webLinkLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "notes_locked")
|
||||
@Builder.Default
|
||||
private Boolean notesLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "creators_locked")
|
||||
@Builder.Default
|
||||
private Boolean creatorsLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "pencillers_locked")
|
||||
@Builder.Default
|
||||
private Boolean pencillersLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "inkers_locked")
|
||||
@Builder.Default
|
||||
private Boolean inkersLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "colorists_locked")
|
||||
@Builder.Default
|
||||
private Boolean coloristsLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "letterers_locked")
|
||||
@Builder.Default
|
||||
private Boolean letterersLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "cover_artists_locked")
|
||||
@Builder.Default
|
||||
private Boolean coverArtistsLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "editors_locked")
|
||||
@Builder.Default
|
||||
private Boolean editorsLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "characters_locked")
|
||||
@Builder.Default
|
||||
private Boolean charactersLocked = Boolean.FALSE;
|
||||
@@ -143,7 +207,23 @@ public class ComicMetadataEntity {
|
||||
this.volumeNameLocked = lock;
|
||||
this.volumeNumberLocked = lock;
|
||||
this.storyArcLocked = lock;
|
||||
this.storyArcNumberLocked = lock;
|
||||
this.alternateSeriesLocked = lock;
|
||||
this.alternateIssueLocked = lock;
|
||||
this.imprintLocked = lock;
|
||||
this.formatLocked = lock;
|
||||
this.blackAndWhiteLocked = lock;
|
||||
this.mangaLocked = lock;
|
||||
this.readingDirectionLocked = lock;
|
||||
this.webLinkLocked = lock;
|
||||
this.notesLocked = lock;
|
||||
this.creatorsLocked = lock;
|
||||
this.pencillersLocked = lock;
|
||||
this.inkersLocked = lock;
|
||||
this.coloristsLocked = lock;
|
||||
this.letterersLocked = lock;
|
||||
this.coverArtistsLocked = lock;
|
||||
this.editorsLocked = lock;
|
||||
this.charactersLocked = lock;
|
||||
this.teamsLocked = lock;
|
||||
this.locationsLocked = lock;
|
||||
@@ -154,7 +234,23 @@ public class ComicMetadataEntity {
|
||||
&& Boolean.TRUE.equals(this.volumeNameLocked)
|
||||
&& Boolean.TRUE.equals(this.volumeNumberLocked)
|
||||
&& Boolean.TRUE.equals(this.storyArcLocked)
|
||||
&& Boolean.TRUE.equals(this.storyArcNumberLocked)
|
||||
&& Boolean.TRUE.equals(this.alternateSeriesLocked)
|
||||
&& Boolean.TRUE.equals(this.alternateIssueLocked)
|
||||
&& Boolean.TRUE.equals(this.imprintLocked)
|
||||
&& Boolean.TRUE.equals(this.formatLocked)
|
||||
&& Boolean.TRUE.equals(this.blackAndWhiteLocked)
|
||||
&& Boolean.TRUE.equals(this.mangaLocked)
|
||||
&& Boolean.TRUE.equals(this.readingDirectionLocked)
|
||||
&& Boolean.TRUE.equals(this.webLinkLocked)
|
||||
&& Boolean.TRUE.equals(this.notesLocked)
|
||||
&& Boolean.TRUE.equals(this.creatorsLocked)
|
||||
&& Boolean.TRUE.equals(this.pencillersLocked)
|
||||
&& Boolean.TRUE.equals(this.inkersLocked)
|
||||
&& Boolean.TRUE.equals(this.coloristsLocked)
|
||||
&& Boolean.TRUE.equals(this.letterersLocked)
|
||||
&& Boolean.TRUE.equals(this.coverArtistsLocked)
|
||||
&& Boolean.TRUE.equals(this.editorsLocked)
|
||||
&& Boolean.TRUE.equals(this.charactersLocked)
|
||||
&& Boolean.TRUE.equals(this.teamsLocked)
|
||||
&& Boolean.TRUE.equals(this.locationsLocked);
|
||||
|
||||
@@ -20,7 +20,7 @@ import java.util.Set;
|
||||
public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpecificationExecutor<BookEntity> {
|
||||
Optional<BookEntity> findBookByIdAndLibraryId(long id, long libraryId);
|
||||
|
||||
@EntityGraph(attributePaths = { "metadata", "shelves", "libraryPath", "library", "bookFiles" })
|
||||
@EntityGraph(attributePaths = { "metadata", "metadata.comicMetadata", "shelves", "libraryPath", "library", "bookFiles" })
|
||||
@Query("SELECT b FROM BookEntity b LEFT JOIN FETCH b.bookFiles bf WHERE b.id = :id AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
Optional<BookEntity> findByIdWithBookFiles(@Param("id") Long id);
|
||||
|
||||
@@ -46,19 +46,19 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
|
||||
@Query("SELECT b.id FROM BookEntity b WHERE b.libraryPath.id IN :libraryPathIds AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<Long> findAllBookIdsByLibraryPathIdIn(@Param("libraryPathIds") Collection<Long> libraryPathIds);
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@EntityGraph(attributePaths = {"metadata", "metadata.comicMetadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@Query("SELECT b FROM BookEntity b WHERE (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithMetadata();
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@EntityGraph(attributePaths = {"metadata", "metadata.comicMetadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@Query("SELECT b FROM BookEntity b WHERE b.id IN :bookIds AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithMetadataByIds(@Param("bookIds") Set<Long> bookIds);
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@EntityGraph(attributePaths = {"metadata", "metadata.comicMetadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@Query("SELECT b FROM BookEntity b WHERE b.id IN :bookIds AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findWithMetadataByIdsWithPagination(@Param("bookIds") Set<Long> bookIds, Pageable pageable);
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@EntityGraph(attributePaths = {"metadata", "metadata.comicMetadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@Query("SELECT b FROM BookEntity b WHERE b.library.id = :libraryId AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithMetadataByLibraryId(@Param("libraryId") Long libraryId);
|
||||
|
||||
@@ -66,15 +66,15 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
|
||||
@Query("SELECT b FROM BookEntity b WHERE b.library.id = :libraryId AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllByLibraryIdWithFiles(@Param("libraryId") Long libraryId);
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@EntityGraph(attributePaths = {"metadata", "metadata.comicMetadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@Query("SELECT b FROM BookEntity b WHERE b.library.id IN :libraryIds AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithMetadataByLibraryIds(@Param("libraryIds") Collection<Long> libraryIds);
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@EntityGraph(attributePaths = {"metadata", "metadata.comicMetadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@Query("SELECT DISTINCT b FROM BookEntity b JOIN b.shelves s WHERE s.id = :shelfId AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithMetadataByShelfId(@Param("shelfId") Long shelfId);
|
||||
|
||||
@EntityGraph(attributePaths = { "metadata", "shelves", "libraryPath", "bookFiles" })
|
||||
@EntityGraph(attributePaths = { "metadata", "metadata.comicMetadata", "shelves", "libraryPath", "bookFiles" })
|
||||
@Query("SELECT DISTINCT b FROM BookEntity b JOIN b.bookFiles bf WHERE bf.isBookFormat = true AND bf.fileSizeKb IS NULL AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithMetadataByFileSizeKbIsNull();
|
||||
|
||||
@@ -279,7 +279,7 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
|
||||
* Find books by series name for a library when groupUnknown=true.
|
||||
* Uses the first bookFile.fileName as fallback when metadata.seriesName is null.
|
||||
*/
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@EntityGraph(attributePaths = {"metadata", "metadata.comicMetadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@Query("""
|
||||
SELECT DISTINCT b FROM BookEntity b
|
||||
LEFT JOIN b.metadata m
|
||||
@@ -309,7 +309,7 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
|
||||
* Find books by series name for a library when groupUnknown=false.
|
||||
* Matches by series name, or by title/filename for books without series.
|
||||
*/
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@EntityGraph(attributePaths = {"metadata", "metadata.comicMetadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@Query("""
|
||||
SELECT b FROM BookEntity b
|
||||
LEFT JOIN b.metadata m
|
||||
|
||||
@@ -23,8 +23,8 @@ import org.booklore.util.FileUtils;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.UrlResource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -34,7 +34,6 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.MalformedURLException;
|
||||
import java.nio.file.Files;
|
||||
@@ -293,11 +292,11 @@ public class BookService {
|
||||
bookDownloadService.downloadAllBookFiles(bookId, response);
|
||||
}
|
||||
|
||||
public ResponseEntity<ByteArrayResource> getBookContent(long bookId) throws IOException {
|
||||
public ResponseEntity<Resource> getBookContent(long bookId) {
|
||||
return getBookContent(bookId, null);
|
||||
}
|
||||
|
||||
public ResponseEntity<ByteArrayResource> getBookContent(long bookId, String bookType) throws IOException {
|
||||
public ResponseEntity<Resource> getBookContent(long bookId, String bookType) {
|
||||
BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
String filePath;
|
||||
if (bookType != null) {
|
||||
@@ -310,11 +309,15 @@ public class BookService {
|
||||
} else {
|
||||
filePath = FileUtils.getBookFullPath(bookEntity);
|
||||
}
|
||||
try (FileInputStream inputStream = new FileInputStream(filePath)) {
|
||||
File file = new File(filePath);
|
||||
if (!file.exists()) {
|
||||
throw ApiError.FILE_NOT_FOUND.createException(filePath);
|
||||
}
|
||||
Resource resource = new FileSystemResource(file);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.body(new ByteArrayResource(inputStream.readAllBytes()));
|
||||
}
|
||||
.contentLength(file.length())
|
||||
.body(resource);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.booklore.service.NotificationService;
|
||||
import org.booklore.service.book.BookQueryService;
|
||||
import org.booklore.service.metadata.extractor.CbxMetadataExtractor;
|
||||
import org.booklore.service.metadata.parser.BookParser;
|
||||
import org.booklore.service.metadata.parser.DetailedMetadataProvider;
|
||||
import org.booklore.util.FileUtils;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -82,6 +83,14 @@ public class BookMetadataService {
|
||||
}
|
||||
|
||||
|
||||
public BookMetadata getDetailedProviderMetadata(MetadataProvider provider, String providerItemId) {
|
||||
BookParser parser = getParser(provider);
|
||||
if (parser instanceof DetailedMetadataProvider detailedProvider) {
|
||||
return detailedProvider.fetchDetailedMetadata(providerItemId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private BookParser getParser(MetadataProvider provider) {
|
||||
BookParser parser = parserMap.get(provider);
|
||||
if (parser == null) {
|
||||
|
||||
@@ -88,7 +88,8 @@ public class BookMetadataUpdater {
|
||||
return;
|
||||
}
|
||||
|
||||
if (metadata.areAllFieldsLocked() && hasValueChanges) {
|
||||
boolean hasLockChanges = MetadataChangeDetector.hasLockChanges(newMetadata, metadata);
|
||||
if (metadata.areAllFieldsLocked() && hasValueChanges && !hasLockChanges) {
|
||||
log.warn("All fields are locked for book ID {}. Skipping update.", bookId);
|
||||
return;
|
||||
}
|
||||
@@ -106,7 +107,7 @@ public class BookMetadataUpdater {
|
||||
updateMoodsIfNeeded(newMetadata, metadata, clearFlags, mergeMoods, replaceMode);
|
||||
updateTagsIfNeeded(newMetadata, metadata, clearFlags, mergeTags, replaceMode);
|
||||
bookReviewUpdateService.updateBookReviews(newMetadata, metadata, clearFlags, mergeCategories);
|
||||
updateThumbnailIfNeeded(bookId, bookEntity, newMetadata, metadata, updateThumbnail);
|
||||
updateThumbnailIfNeeded(bookId, bookEntity, newMetadata, metadata, updateThumbnail, bookType);
|
||||
updateAudiobookMetadataIfNeeded(bookEntity, newMetadata, metadata, clearFlags, replaceMode);
|
||||
updateComicMetadataIfNeeded(newMetadata, metadata, replaceMode);
|
||||
updateLocks(newMetadata, metadata);
|
||||
@@ -130,7 +131,9 @@ public class BookMetadataUpdater {
|
||||
}
|
||||
File file = new File(bookEntity.getFullFilePath().toUri());
|
||||
writer.saveMetadataToFile(file, metadata, thumbnailUrl, clearFlags);
|
||||
String newHash = FileFingerprint.generateHash(bookEntity.getFullFilePath());
|
||||
String newHash = file.isDirectory()
|
||||
? FileFingerprint.generateFolderHash(bookEntity.getFullFilePath())
|
||||
: FileFingerprint.generateHash(bookEntity.getFullFilePath());
|
||||
bookEntity.setMetadataForWriteUpdatedAt(Instant.now());
|
||||
primaryFile.setCurrentHash(newHash);
|
||||
bookRepository.save(bookEntity);
|
||||
@@ -388,9 +391,11 @@ public class BookMetadataUpdater {
|
||||
|
||||
ComicMetadataEntity comic = e.getComicMetadata();
|
||||
if (comic == null) {
|
||||
if (!hasComicData(comicDto) && !hasComicLocks(comicDto)) {
|
||||
return;
|
||||
}
|
||||
comic = ComicMetadataEntity.builder()
|
||||
.bookId(e.getBookId())
|
||||
.bookMetadata(e)
|
||||
.build();
|
||||
e.setComicMetadata(comic);
|
||||
}
|
||||
@@ -402,16 +407,16 @@ public class BookMetadataUpdater {
|
||||
handleFieldUpdate(c.getVolumeNameLocked(), false, comicDto.getVolumeName(), v -> c.setVolumeName(nullIfBlank(v)), c::getVolumeName, replaceMode);
|
||||
handleFieldUpdate(c.getVolumeNumberLocked(), false, comicDto.getVolumeNumber(), c::setVolumeNumber, c::getVolumeNumber, replaceMode);
|
||||
handleFieldUpdate(c.getStoryArcLocked(), false, comicDto.getStoryArc(), v -> c.setStoryArc(nullIfBlank(v)), c::getStoryArc, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getStoryArcNumber(), c::setStoryArcNumber, c::getStoryArcNumber, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getAlternateSeries(), v -> c.setAlternateSeries(nullIfBlank(v)), c::getAlternateSeries, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getAlternateIssue(), v -> c.setAlternateIssue(nullIfBlank(v)), c::getAlternateIssue, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getImprint(), v -> c.setImprint(nullIfBlank(v)), c::getImprint, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getFormat(), v -> c.setFormat(nullIfBlank(v)), c::getFormat, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getBlackAndWhite(), c::setBlackAndWhite, c::getBlackAndWhite, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getManga(), c::setManga, c::getManga, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getReadingDirection(), v -> c.setReadingDirection(nullIfBlank(v)), c::getReadingDirection, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getWebLink(), v -> c.setWebLink(nullIfBlank(v)), c::getWebLink, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getNotes(), v -> c.setNotes(nullIfBlank(v)), c::getNotes, replaceMode);
|
||||
handleFieldUpdate(c.getStoryArcNumberLocked(), false, comicDto.getStoryArcNumber(), c::setStoryArcNumber, c::getStoryArcNumber, replaceMode);
|
||||
handleFieldUpdate(c.getAlternateSeriesLocked(), false, comicDto.getAlternateSeries(), v -> c.setAlternateSeries(nullIfBlank(v)), c::getAlternateSeries, replaceMode);
|
||||
handleFieldUpdate(c.getAlternateIssueLocked(), false, comicDto.getAlternateIssue(), v -> c.setAlternateIssue(nullIfBlank(v)), c::getAlternateIssue, replaceMode);
|
||||
handleFieldUpdate(c.getImprintLocked(), false, comicDto.getImprint(), v -> c.setImprint(nullIfBlank(v)), c::getImprint, replaceMode);
|
||||
handleFieldUpdate(c.getFormatLocked(), false, comicDto.getFormat(), v -> c.setFormat(nullIfBlank(v)), c::getFormat, replaceMode);
|
||||
handleFieldUpdate(c.getBlackAndWhiteLocked(), false, comicDto.getBlackAndWhite(), c::setBlackAndWhite, c::getBlackAndWhite, replaceMode);
|
||||
handleFieldUpdate(c.getMangaLocked(), false, comicDto.getManga(), c::setManga, c::getManga, replaceMode);
|
||||
handleFieldUpdate(c.getReadingDirectionLocked(), false, comicDto.getReadingDirection(), v -> c.setReadingDirection(nullIfBlank(v)), c::getReadingDirection, replaceMode);
|
||||
handleFieldUpdate(c.getWebLinkLocked(), false, comicDto.getWebLink(), v -> c.setWebLink(nullIfBlank(v)), c::getWebLink, replaceMode);
|
||||
handleFieldUpdate(c.getNotesLocked(), false, comicDto.getNotes(), v -> c.setNotes(nullIfBlank(v)), c::getNotes, replaceMode);
|
||||
|
||||
// Update relationships if not locked
|
||||
if (!Boolean.TRUE.equals(c.getCharactersLocked())) {
|
||||
@@ -423,21 +428,35 @@ public class BookMetadataUpdater {
|
||||
if (!Boolean.TRUE.equals(c.getLocationsLocked())) {
|
||||
updateComicLocations(c, comicDto.getLocations(), replaceMode);
|
||||
}
|
||||
if (!Boolean.TRUE.equals(c.getCreatorsLocked())) {
|
||||
updateComicCreators(c, comicDto, replaceMode);
|
||||
}
|
||||
updateComicCreatorsPerRole(c, comicDto, replaceMode);
|
||||
|
||||
// Update locks if provided
|
||||
if (comicDto.getIssueNumberLocked() != null) c.setIssueNumberLocked(comicDto.getIssueNumberLocked());
|
||||
if (comicDto.getVolumeNameLocked() != null) c.setVolumeNameLocked(comicDto.getVolumeNameLocked());
|
||||
if (comicDto.getVolumeNumberLocked() != null) c.setVolumeNumberLocked(comicDto.getVolumeNumberLocked());
|
||||
if (comicDto.getStoryArcLocked() != null) c.setStoryArcLocked(comicDto.getStoryArcLocked());
|
||||
if (comicDto.getStoryArcNumberLocked() != null) c.setStoryArcNumberLocked(comicDto.getStoryArcNumberLocked());
|
||||
if (comicDto.getAlternateSeriesLocked() != null) c.setAlternateSeriesLocked(comicDto.getAlternateSeriesLocked());
|
||||
if (comicDto.getAlternateIssueLocked() != null) c.setAlternateIssueLocked(comicDto.getAlternateIssueLocked());
|
||||
if (comicDto.getImprintLocked() != null) c.setImprintLocked(comicDto.getImprintLocked());
|
||||
if (comicDto.getFormatLocked() != null) c.setFormatLocked(comicDto.getFormatLocked());
|
||||
if (comicDto.getBlackAndWhiteLocked() != null) c.setBlackAndWhiteLocked(comicDto.getBlackAndWhiteLocked());
|
||||
if (comicDto.getMangaLocked() != null) c.setMangaLocked(comicDto.getMangaLocked());
|
||||
if (comicDto.getReadingDirectionLocked() != null) c.setReadingDirectionLocked(comicDto.getReadingDirectionLocked());
|
||||
if (comicDto.getWebLinkLocked() != null) c.setWebLinkLocked(comicDto.getWebLinkLocked());
|
||||
if (comicDto.getNotesLocked() != null) c.setNotesLocked(comicDto.getNotesLocked());
|
||||
if (comicDto.getCreatorsLocked() != null) c.setCreatorsLocked(comicDto.getCreatorsLocked());
|
||||
if (comicDto.getPencillersLocked() != null) c.setPencillersLocked(comicDto.getPencillersLocked());
|
||||
if (comicDto.getInkersLocked() != null) c.setInkersLocked(comicDto.getInkersLocked());
|
||||
if (comicDto.getColoristsLocked() != null) c.setColoristsLocked(comicDto.getColoristsLocked());
|
||||
if (comicDto.getLetterersLocked() != null) c.setLetterersLocked(comicDto.getLetterersLocked());
|
||||
if (comicDto.getCoverArtistsLocked() != null) c.setCoverArtistsLocked(comicDto.getCoverArtistsLocked());
|
||||
if (comicDto.getEditorsLocked() != null) c.setEditorsLocked(comicDto.getEditorsLocked());
|
||||
if (comicDto.getCharactersLocked() != null) c.setCharactersLocked(comicDto.getCharactersLocked());
|
||||
if (comicDto.getTeamsLocked() != null) c.setTeamsLocked(comicDto.getTeamsLocked());
|
||||
if (comicDto.getLocationsLocked() != null) c.setLocationsLocked(comicDto.getLocationsLocked());
|
||||
|
||||
comicMetadataRepository.save(c);
|
||||
e.setComicMetadata(comicMetadataRepository.save(c));
|
||||
|
||||
comicCharacterRepository.deleteOrphaned();
|
||||
comicTeamRepository.deleteOrphaned();
|
||||
@@ -445,6 +464,59 @@ public class BookMetadataUpdater {
|
||||
comicCreatorRepository.deleteOrphaned();
|
||||
}
|
||||
|
||||
private boolean hasComicData(ComicMetadata dto) {
|
||||
return StringUtils.hasText(dto.getIssueNumber())
|
||||
|| StringUtils.hasText(dto.getVolumeName())
|
||||
|| dto.getVolumeNumber() != null
|
||||
|| StringUtils.hasText(dto.getStoryArc())
|
||||
|| dto.getStoryArcNumber() != null
|
||||
|| StringUtils.hasText(dto.getAlternateSeries())
|
||||
|| StringUtils.hasText(dto.getAlternateIssue())
|
||||
|| StringUtils.hasText(dto.getImprint())
|
||||
|| StringUtils.hasText(dto.getFormat())
|
||||
|| dto.getBlackAndWhite() != null
|
||||
|| dto.getManga() != null
|
||||
|| StringUtils.hasText(dto.getReadingDirection())
|
||||
|| StringUtils.hasText(dto.getWebLink())
|
||||
|| StringUtils.hasText(dto.getNotes())
|
||||
|| (dto.getCharacters() != null && !dto.getCharacters().isEmpty())
|
||||
|| (dto.getTeams() != null && !dto.getTeams().isEmpty())
|
||||
|| (dto.getLocations() != null && !dto.getLocations().isEmpty())
|
||||
|| (dto.getPencillers() != null && !dto.getPencillers().isEmpty())
|
||||
|| (dto.getInkers() != null && !dto.getInkers().isEmpty())
|
||||
|| (dto.getColorists() != null && !dto.getColorists().isEmpty())
|
||||
|| (dto.getLetterers() != null && !dto.getLetterers().isEmpty())
|
||||
|| (dto.getCoverArtists() != null && !dto.getCoverArtists().isEmpty())
|
||||
|| (dto.getEditors() != null && !dto.getEditors().isEmpty());
|
||||
}
|
||||
|
||||
private boolean hasComicLocks(ComicMetadata dto) {
|
||||
return Boolean.TRUE.equals(dto.getIssueNumberLocked())
|
||||
|| Boolean.TRUE.equals(dto.getVolumeNameLocked())
|
||||
|| Boolean.TRUE.equals(dto.getVolumeNumberLocked())
|
||||
|| Boolean.TRUE.equals(dto.getStoryArcLocked())
|
||||
|| Boolean.TRUE.equals(dto.getStoryArcNumberLocked())
|
||||
|| Boolean.TRUE.equals(dto.getAlternateSeriesLocked())
|
||||
|| Boolean.TRUE.equals(dto.getAlternateIssueLocked())
|
||||
|| Boolean.TRUE.equals(dto.getImprintLocked())
|
||||
|| Boolean.TRUE.equals(dto.getFormatLocked())
|
||||
|| Boolean.TRUE.equals(dto.getBlackAndWhiteLocked())
|
||||
|| Boolean.TRUE.equals(dto.getMangaLocked())
|
||||
|| Boolean.TRUE.equals(dto.getReadingDirectionLocked())
|
||||
|| Boolean.TRUE.equals(dto.getWebLinkLocked())
|
||||
|| Boolean.TRUE.equals(dto.getNotesLocked())
|
||||
|| Boolean.TRUE.equals(dto.getCreatorsLocked())
|
||||
|| Boolean.TRUE.equals(dto.getPencillersLocked())
|
||||
|| Boolean.TRUE.equals(dto.getInkersLocked())
|
||||
|| Boolean.TRUE.equals(dto.getColoristsLocked())
|
||||
|| Boolean.TRUE.equals(dto.getLetterersLocked())
|
||||
|| Boolean.TRUE.equals(dto.getCoverArtistsLocked())
|
||||
|| Boolean.TRUE.equals(dto.getEditorsLocked())
|
||||
|| Boolean.TRUE.equals(dto.getCharactersLocked())
|
||||
|| Boolean.TRUE.equals(dto.getTeamsLocked())
|
||||
|| Boolean.TRUE.equals(dto.getLocationsLocked());
|
||||
}
|
||||
|
||||
private void updateComicCharacters(ComicMetadataEntity c, Set<String> characters, MetadataReplaceMode mode) {
|
||||
if (characters == null || characters.isEmpty()) {
|
||||
if (mode == MetadataReplaceMode.REPLACE_ALL) {
|
||||
@@ -511,38 +583,43 @@ public class BookMetadataUpdater {
|
||||
.forEach(entity -> c.getLocations().add(entity));
|
||||
}
|
||||
|
||||
private void updateComicCreators(ComicMetadataEntity c, ComicMetadata dto, MetadataReplaceMode mode) {
|
||||
private void updateComicCreatorsPerRole(ComicMetadataEntity c, ComicMetadata dto, MetadataReplaceMode mode) {
|
||||
if (c.getCreatorMappings() == null) {
|
||||
c.setCreatorMappings(new HashSet<>());
|
||||
}
|
||||
|
||||
boolean hasNewCreators = (dto.getPencillers() != null && !dto.getPencillers().isEmpty()) ||
|
||||
(dto.getInkers() != null && !dto.getInkers().isEmpty()) ||
|
||||
(dto.getColorists() != null && !dto.getColorists().isEmpty()) ||
|
||||
(dto.getLetterers() != null && !dto.getLetterers().isEmpty()) ||
|
||||
(dto.getCoverArtists() != null && !dto.getCoverArtists().isEmpty()) ||
|
||||
(dto.getEditors() != null && !dto.getEditors().isEmpty());
|
||||
updateCreatorRole(c, dto.getPencillers(), ComicCreatorRole.PENCILLER, c.getPencillersLocked(), mode);
|
||||
updateCreatorRole(c, dto.getInkers(), ComicCreatorRole.INKER, c.getInkersLocked(), mode);
|
||||
updateCreatorRole(c, dto.getColorists(), ComicCreatorRole.COLORIST, c.getColoristsLocked(), mode);
|
||||
updateCreatorRole(c, dto.getLetterers(), ComicCreatorRole.LETTERER, c.getLetterersLocked(), mode);
|
||||
updateCreatorRole(c, dto.getCoverArtists(), ComicCreatorRole.COVER_ARTIST, c.getCoverArtistsLocked(), mode);
|
||||
updateCreatorRole(c, dto.getEditors(), ComicCreatorRole.EDITOR, c.getEditorsLocked(), mode);
|
||||
}
|
||||
|
||||
if (!hasNewCreators) {
|
||||
private void updateCreatorRole(ComicMetadataEntity c, Set<String> names, ComicCreatorRole role, Boolean locked, MetadataReplaceMode mode) {
|
||||
if (Boolean.TRUE.equals(locked)) return;
|
||||
|
||||
boolean hasNewNames = names != null && !names.isEmpty();
|
||||
Set<ComicCreatorMappingEntity> existingForRole = c.getCreatorMappings().stream()
|
||||
.filter(m -> m.getRole() == role)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
if (!hasNewNames) {
|
||||
if (mode == MetadataReplaceMode.REPLACE_ALL) {
|
||||
c.getCreatorMappings().clear();
|
||||
c.getCreatorMappings().removeAll(existingForRole);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode == MetadataReplaceMode.REPLACE_ALL || mode == MetadataReplaceMode.REPLACE_WHEN_PROVIDED) {
|
||||
c.getCreatorMappings().clear();
|
||||
}
|
||||
if (mode == MetadataReplaceMode.REPLACE_MISSING && !c.getCreatorMappings().isEmpty()) {
|
||||
if (mode == MetadataReplaceMode.REPLACE_MISSING && !existingForRole.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
addCreatorsWithRole(c, dto.getPencillers(), ComicCreatorRole.PENCILLER);
|
||||
addCreatorsWithRole(c, dto.getInkers(), ComicCreatorRole.INKER);
|
||||
addCreatorsWithRole(c, dto.getColorists(), ComicCreatorRole.COLORIST);
|
||||
addCreatorsWithRole(c, dto.getLetterers(), ComicCreatorRole.LETTERER);
|
||||
addCreatorsWithRole(c, dto.getCoverArtists(), ComicCreatorRole.COVER_ARTIST);
|
||||
addCreatorsWithRole(c, dto.getEditors(), ComicCreatorRole.EDITOR);
|
||||
if (mode == MetadataReplaceMode.REPLACE_ALL || mode == MetadataReplaceMode.REPLACE_WHEN_PROVIDED || mode == null) {
|
||||
c.getCreatorMappings().removeAll(existingForRole);
|
||||
}
|
||||
|
||||
addCreatorsWithRole(c, names, role);
|
||||
}
|
||||
|
||||
private void addCreatorsWithRole(ComicMetadataEntity comic, Set<String> names, ComicCreatorRole role) {
|
||||
@@ -562,16 +639,22 @@ public class BookMetadataUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
private void updateThumbnailIfNeeded(long bookId, BookEntity bookEntity, BookMetadata m, BookMetadataEntity e, boolean set) {
|
||||
private void updateThumbnailIfNeeded(long bookId, BookEntity bookEntity, BookMetadata m, BookMetadataEntity e, boolean set, BookFileType bookType) {
|
||||
if (Boolean.TRUE.equals(e.getCoverLocked())) {
|
||||
return;
|
||||
}
|
||||
if (!set) return;
|
||||
if (!StringUtils.hasText(m.getThumbnailUrl()) || isLocalOrPrivateUrl(m.getThumbnailUrl())) return;
|
||||
try {
|
||||
if (bookType == BookFileType.AUDIOBOOK) {
|
||||
if (Boolean.TRUE.equals(e.getAudiobookCoverLocked())) return;
|
||||
fileService.createAudiobookThumbnailFromUrl(bookId, m.getThumbnailUrl());
|
||||
bookEntity.getMetadata().setAudiobookCoverUpdatedOn(Instant.now());
|
||||
} else {
|
||||
fileService.createThumbnailFromUrl(bookId, m.getThumbnailUrl());
|
||||
bookEntity.setBookCoverHash(BookCoverUtils.generateCoverHash());
|
||||
bookEntity.getMetadata().setCoverUpdatedOn(Instant.now());
|
||||
}
|
||||
bookEntity.setBookCoverHash(BookCoverUtils.generateCoverHash());
|
||||
} catch (Exception ex) {
|
||||
log.warn("Failed to download cover for book {}: {}", bookId, ex.getMessage());
|
||||
}
|
||||
|
||||
@@ -614,6 +614,10 @@ public class MetadataRefreshService {
|
||||
metadata.setComicvineId(existingMetadata.getComicvineId());
|
||||
}
|
||||
|
||||
if (metadataMap.containsKey(Comicvine) && metadataMap.get(Comicvine).getComicMetadata() != null) {
|
||||
metadata.setComicMetadata(metadataMap.get(Comicvine).getComicMetadata());
|
||||
}
|
||||
|
||||
if (enabledFields.isLubimyczytacId()) {
|
||||
if (metadataMap.containsKey(Lubimyczytac)) {
|
||||
metadata.setLubimyczytacId(metadataMap.get(Lubimyczytac).getLubimyczytacId());
|
||||
|
||||
@@ -31,7 +31,7 @@ import java.util.stream.Collectors;
|
||||
@Slf4j
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
public class AmazonBookParser implements BookParser {
|
||||
public class AmazonBookParser implements BookParser, DetailedMetadataProvider {
|
||||
|
||||
private static final Pattern TRAILING_BR_TAGS_PATTERN = Pattern.compile("(\\s*<br\\s*/?>\\s*)+$");
|
||||
private static final Pattern LEADING_BR_TAGS_PATTERN = Pattern.compile("^(\\s*<br\\s*/?>\\s*)+");
|
||||
@@ -104,32 +104,70 @@ public class AmazonBookParser implements BookParser {
|
||||
|
||||
@Override
|
||||
public List<BookMetadata> fetchMetadata(Book book, FetchMetadataRequest fetchMetadataRequest) {
|
||||
LinkedList<String> amazonBookIds = Optional.ofNullable(getAmazonBookIds(book, fetchMetadataRequest))
|
||||
.map(list -> list.stream()
|
||||
.limit(COUNT_DETAILED_METADATA_TO_GET)
|
||||
.collect(Collectors.toCollection(LinkedList::new)))
|
||||
.orElse(new LinkedList<>());
|
||||
if (amazonBookIds.isEmpty()) {
|
||||
return null;
|
||||
String queryUrl = buildQueryUrl(fetchMetadataRequest, book);
|
||||
if (queryUrl == null) {
|
||||
log.error("Query URL is null, cannot proceed.");
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<BookMetadata> fetchedBookMetadata = new ArrayList<>();
|
||||
for (String amazonBookId : amazonBookIds) {
|
||||
if (amazonBookId == null || amazonBookId.isBlank()) {
|
||||
log.debug("Skipping null or blank Amazon book ID.");
|
||||
try {
|
||||
Document doc = fetchDocument(queryUrl);
|
||||
return extractSearchPreviews(doc);
|
||||
} catch (AmazonAntiScrapingException e) {
|
||||
log.debug("Aborting Amazon search due to anti-scraping (503).");
|
||||
return Collections.emptyList();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to fetch Amazon search results: {}", e.getMessage(), e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
private List<BookMetadata> extractSearchPreviews(Document doc) {
|
||||
Element searchResults = doc.select("span[data-component-type=s-search-results]").first();
|
||||
if (searchResults == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Elements items = searchResults.select("div[role=listitem][data-index]");
|
||||
List<BookMetadata> previews = new ArrayList<>();
|
||||
for (Element item : items) {
|
||||
if (previews.size() >= COUNT_DETAILED_METADATA_TO_GET) break;
|
||||
|
||||
if (item.text().contains("Collects books from")) continue;
|
||||
|
||||
Element titleDiv = item.selectFirst("div[data-cy=title-recipe]");
|
||||
if (titleDiv == null) continue;
|
||||
|
||||
String titleText = titleDiv.text().trim();
|
||||
if (titleText.isEmpty()) continue;
|
||||
|
||||
String lowerTitle = titleText.toLowerCase();
|
||||
if (lowerTitle.contains("books set") || lowerTitle.contains("box set")
|
||||
|| lowerTitle.contains("collection set") || lowerTitle.contains("summary & study guide")) {
|
||||
continue;
|
||||
}
|
||||
BookMetadata metadata = getBookMetadata(amazonBookId);
|
||||
if (metadata == null) {
|
||||
log.debug("Skipping null metadata for ID: {}", amazonBookId);
|
||||
continue;
|
||||
|
||||
String asin = extractAmazonBookId(item);
|
||||
if (asin == null || asin.isBlank()) continue;
|
||||
|
||||
String thumbnailUrl = null;
|
||||
Element img = item.selectFirst("img.s-image");
|
||||
if (img != null) {
|
||||
thumbnailUrl = img.attr("src");
|
||||
if (thumbnailUrl.isBlank()) thumbnailUrl = null;
|
||||
}
|
||||
if (metadata.getTitle() == null || metadata.getTitle().isBlank() || metadata.getAuthors() == null || metadata.getAuthors().isEmpty()) {
|
||||
log.debug("Skipping metadata with missing title or author for ID: {}", amazonBookId);
|
||||
continue;
|
||||
|
||||
previews.add(BookMetadata.builder()
|
||||
.asin(asin)
|
||||
.title(titleText)
|
||||
.thumbnailUrl(thumbnailUrl)
|
||||
.provider(MetadataProvider.Amazon)
|
||||
.build());
|
||||
}
|
||||
fetchedBookMetadata.add(metadata);
|
||||
return previews;
|
||||
}
|
||||
return fetchedBookMetadata;
|
||||
|
||||
@Override
|
||||
public BookMetadata fetchDetailedMetadata(String asin) {
|
||||
return getBookMetadata(asin);
|
||||
}
|
||||
|
||||
private LinkedList<String> getAmazonBookIds(Book book, FetchMetadataRequest request) {
|
||||
|
||||
@@ -33,10 +33,9 @@ import java.util.stream.Collectors;
|
||||
@Slf4j
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
public class AudibleParser implements BookParser {
|
||||
public class AudibleParser implements BookParser, DetailedMetadataProvider {
|
||||
|
||||
private static final long MIN_REQUEST_INTERVAL_MS = 1500;
|
||||
private static final int MAX_DETAILED_RESULTS = 3;
|
||||
private static final String DEFAULT_DOMAIN = "com";
|
||||
|
||||
private static final Pattern NON_ALPHANUMERIC_PATTERN = Pattern.compile("[^\\p{L}\\p{M}0-9]");
|
||||
@@ -75,34 +74,125 @@ public class AudibleParser implements BookParser {
|
||||
|
||||
@Override
|
||||
public List<BookMetadata> fetchMetadata(Book book, FetchMetadataRequest fetchMetadataRequest) {
|
||||
List<String> audibleIds = Optional.ofNullable(getAudibleIds(book, fetchMetadataRequest))
|
||||
.map(list -> list.stream()
|
||||
.limit(MAX_DETAILED_RESULTS)
|
||||
.collect(Collectors.toList()))
|
||||
.orElse(new ArrayList<>());
|
||||
String queryUrl = buildQueryUrl(fetchMetadataRequest, book);
|
||||
if (queryUrl == null) {
|
||||
log.error("Query URL is null, cannot proceed with Audible search.");
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try {
|
||||
enforceRateLimit();
|
||||
Document doc = fetchDocument(queryUrl);
|
||||
return extractSearchPreviews(doc);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to fetch Audible search results: {}", e.getMessage(), e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
if (audibleIds.isEmpty()) {
|
||||
private List<BookMetadata> extractSearchPreviews(Document doc) {
|
||||
Elements allLinks = doc.select("a[href*='/pd/']");
|
||||
List<BookMetadata> previews = new ArrayList<>();
|
||||
Set<String> seenAsins = new HashSet<>();
|
||||
|
||||
for (Element link : allLinks) {
|
||||
String href = link.attr("href");
|
||||
Matcher matcher = ASIN_PATTERN.matcher(href);
|
||||
if (!matcher.find()) continue;
|
||||
|
||||
String asin = matcher.group(1);
|
||||
if (seenAsins.contains(asin)) continue;
|
||||
|
||||
String title = link.text().trim();
|
||||
if (title.isEmpty()) continue;
|
||||
|
||||
seenAsins.add(asin);
|
||||
|
||||
Element container = findProductContainer(link);
|
||||
|
||||
String thumbnailUrl = extractPreviewThumbnail(container, link);
|
||||
Set<String> authors = extractPreviewAuthors(container);
|
||||
String narrator = extractPreviewNarrator(container);
|
||||
|
||||
BookMetadata.BookMetadataBuilder builder = BookMetadata.builder()
|
||||
.audibleId(asin)
|
||||
.title(title)
|
||||
.thumbnailUrl(thumbnailUrl)
|
||||
.provider(MetadataProvider.Audible);
|
||||
|
||||
if (!authors.isEmpty()) {
|
||||
builder.authors(authors);
|
||||
}
|
||||
if (narrator != null) {
|
||||
builder.narrator(narrator);
|
||||
}
|
||||
|
||||
previews.add(builder.build());
|
||||
}
|
||||
return previews;
|
||||
}
|
||||
|
||||
private Element findProductContainer(Element titleLink) {
|
||||
Element current = titleLink.parent();
|
||||
for (int i = 0; i < 8 && current != null; i++) {
|
||||
if (current.selectFirst("img[src*='images/I/']") != null) {
|
||||
return current;
|
||||
}
|
||||
current = current.parent();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<BookMetadata> fetchedMetadata = new ArrayList<>();
|
||||
for (String audibleId : audibleIds) {
|
||||
if (audibleId == null || audibleId.isBlank()) {
|
||||
log.debug("Skipping null or blank Audible ID.");
|
||||
continue;
|
||||
private String extractPreviewThumbnail(Element container, Element titleLink) {
|
||||
if (container != null) {
|
||||
Element img = container.selectFirst("img[src*='images/I/']");
|
||||
if (img != null) {
|
||||
String src = img.attr("src");
|
||||
if (!src.isBlank()) return src;
|
||||
}
|
||||
BookMetadata metadata = getBookMetadata(audibleId);
|
||||
if (metadata == null) {
|
||||
log.debug("Skipping null metadata for Audible ID: {}", audibleId);
|
||||
continue;
|
||||
}
|
||||
if (metadata.getTitle() == null || metadata.getTitle().isBlank()) {
|
||||
log.debug("Skipping metadata with missing title for Audible ID: {}", audibleId);
|
||||
continue;
|
||||
Element img = titleLink.selectFirst("img");
|
||||
if (img == null && titleLink.parent() != null) {
|
||||
img = titleLink.parent().selectFirst("img");
|
||||
}
|
||||
fetchedMetadata.add(metadata);
|
||||
if (img != null) {
|
||||
String src = img.attr("src");
|
||||
if (!src.isBlank()) return src;
|
||||
}
|
||||
return fetchedMetadata;
|
||||
return null;
|
||||
}
|
||||
|
||||
private Set<String> extractPreviewAuthors(Element container) {
|
||||
Set<String> authors = new LinkedHashSet<>();
|
||||
if (container == null) return authors;
|
||||
|
||||
for (Element authorLink : container.select("a[href*='/author/']")) {
|
||||
String name = authorLink.text().trim();
|
||||
if (!name.isEmpty()) {
|
||||
authors.add(name);
|
||||
}
|
||||
}
|
||||
return authors;
|
||||
}
|
||||
|
||||
private String extractPreviewNarrator(Element container) {
|
||||
if (container == null) return null;
|
||||
|
||||
for (Element el : container.getElementsContainingOwnText("Narrated by:")) {
|
||||
Element parent = el.parent();
|
||||
if (parent == null) continue;
|
||||
String text = parent.text();
|
||||
int idx = text.indexOf("Narrated by:");
|
||||
if (idx >= 0) {
|
||||
String narrator = text.substring(idx + "Narrated by:".length()).trim();
|
||||
if (!narrator.isEmpty()) return narrator;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BookMetadata fetchDetailedMetadata(String audibleId) {
|
||||
return getBookMetadata(audibleId);
|
||||
}
|
||||
|
||||
private List<String> getAudibleIds(Book book, FetchMetadataRequest request) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.booklore.model.dto.Book;
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
import org.booklore.model.dto.ComicMetadata;
|
||||
import org.booklore.model.dto.request.FetchMetadataRequest;
|
||||
import org.booklore.model.dto.response.comicvineapi.Comic;
|
||||
import org.booklore.model.dto.response.comicvineapi.ComicvineApiResponse;
|
||||
@@ -33,7 +34,7 @@ import java.util.stream.Collectors;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ComicvineBookParser implements BookParser {
|
||||
public class ComicvineBookParser implements BookParser, DetailedMetadataProvider {
|
||||
|
||||
private static final String COMICVINE_URL = "https://comicvine.gamespot.com/api/";
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
@@ -45,12 +46,10 @@ public class ComicvineBookParser implements BookParser {
|
||||
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
|
||||
private static final Pattern SPECIAL_ISSUE_PATTERN = Pattern.compile("(annual|special|one-?shot)\\s+(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern YEAR_PATTERN = Pattern.compile("\\(?(\\d{4})\\)?");
|
||||
private static final Pattern COMICVINE_ID_PATTERN = Pattern.compile("/(\\d+)/?$");
|
||||
private static final long MIN_REQUEST_INTERVAL_MS = 2000;
|
||||
|
||||
// Field lists to minimize API calls by getting all useful data in one request
|
||||
private static final String VOLUME_FIELDS = "id,name,publisher,start_year,count_of_issues,description,deck,image,site_detail_url,aliases,first_issue,last_issue";
|
||||
private static final String ISSUE_LIST_FIELDS = "api_detail_url,cover_date,store_date,description,deck,id,image,issue_number,name,volume,site_detail_url,aliases,person_credits";
|
||||
private static final String ISSUE_LIST_FIELDS = "api_detail_url,cover_date,store_date,description,deck,id,image,issue_number,name,volume,site_detail_url,aliases,person_credits,character_credits,team_credits,story_arc_credits,location_credits";
|
||||
private static final String ISSUE_DETAIL_FIELDS = "api_detail_url,cover_date,store_date,description,deck,id,image,issue_number,name,person_credits,volume,site_detail_url,aliases,character_credits,team_credits,story_arc_credits,location_credits";
|
||||
private static final String SEARCH_FIELDS = "api_detail_url,cover_date,store_date,description,deck,id,image,issue_number,name,publisher,volume,site_detail_url,resource_type,start_year,count_of_issues,aliases,person_credits";
|
||||
private static final Pattern ISSUE_NUMBER_PATTERN = Pattern.compile("issue\\s*#?\\d+");
|
||||
@@ -78,17 +77,15 @@ public class ComicvineBookParser implements BookParser {
|
||||
}
|
||||
|
||||
boolean isExpired() {
|
||||
return System.currentTimeMillis() - timestamp > 600_000; // 10 minutes cache
|
||||
return System.currentTimeMillis() - timestamp > 600_000;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BookMetadata> fetchMetadata(Book book, FetchMetadataRequest fetchMetadataRequest) {
|
||||
// 1. Try ISBN Search
|
||||
String isbn = ParserUtils.cleanIsbn(fetchMetadataRequest.getIsbn());
|
||||
if (isbn != null && !isbn.isEmpty()) {
|
||||
log.info("Comicvine: Searching by ISBN: {}", isbn);
|
||||
// ComicVine "search" endpoint with "query" parameter performs a general search.
|
||||
List<BookMetadata> results = searchGeneral(isbn);
|
||||
if (!results.isEmpty()) return results;
|
||||
log.info("Comicvine: ISBN search returned no results, falling back to Title/Term.");
|
||||
@@ -105,13 +102,21 @@ public class ComicvineBookParser implements BookParser {
|
||||
@Override
|
||||
public BookMetadata fetchTopMetadata(Book book, FetchMetadataRequest fetchMetadataRequest) {
|
||||
List<BookMetadata> metadataList = fetchMetadata(book, fetchMetadataRequest);
|
||||
return metadataList.isEmpty() ? null : metadataList.getFirst();
|
||||
if (metadataList.isEmpty()) return null;
|
||||
|
||||
BookMetadata top = metadataList.getFirst();
|
||||
if (top.getComicvineId() != null && (top.getComicMetadata() == null || !hasComicDetails(top.getComicMetadata()))) {
|
||||
BookMetadata detailed = fetchDetailedMetadata(top.getComicvineId());
|
||||
if (detailed != null && detailed.getComicMetadata() != null) {
|
||||
top.setComicMetadata(detailed.getComicMetadata());
|
||||
}
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
public List<BookMetadata> getMetadataListByTerm(String term) {
|
||||
SeriesAndIssue seriesAndIssue = extractSeriesAndIssue(term);
|
||||
|
||||
// Strategy 1: Precise structured search if issue number is present (most efficient)
|
||||
if (seriesAndIssue.issue() != null) {
|
||||
log.info("Attempting structured search for Series: '{}', Issue: '{}', Year: '{}', Type: '{}'",
|
||||
seriesAndIssue.series(), seriesAndIssue.issue(), seriesAndIssue.year(), seriesAndIssue.issueType());
|
||||
@@ -121,20 +126,17 @@ public class ComicvineBookParser implements BookParser {
|
||||
}
|
||||
log.info("Structured search yielded no results, trying alternative strategies.");
|
||||
|
||||
// Strategy 2: Try with cleaned series name variations
|
||||
List<BookMetadata> alternativeResults = tryAlternativeSeriesNames(seriesAndIssue);
|
||||
if (!alternativeResults.isEmpty()) {
|
||||
return alternativeResults;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: General search (fallback)
|
||||
List<BookMetadata> results = searchGeneral(term);
|
||||
if (!results.isEmpty()) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Strategy 4: If general search failed with structured match, try modified term
|
||||
if (seriesAndIssue.issue() != null && seriesAndIssue.remainder() != null && !seriesAndIssue.remainder().isBlank()) {
|
||||
String modifiedTerm = seriesAndIssue.series() + " " + seriesAndIssue.remainder();
|
||||
if (seriesAndIssue.year() != null) {
|
||||
@@ -205,7 +207,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
// Sort volumes by relevance using scoring
|
||||
volumes.sort((v1, v2) -> {
|
||||
int score1 = calculateVolumeScore(v1, finalSeriesName, normalizedIssue, extractedYear);
|
||||
int score2 = calculateVolumeScore(v2, finalSeriesName, normalizedIssue, extractedYear);
|
||||
@@ -213,7 +214,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
});
|
||||
|
||||
List<BookMetadata> results = new ArrayList<>();
|
||||
// Limit to top 3 volumes to minimize API calls
|
||||
int limit = Math.min(volumes.size(), 3);
|
||||
|
||||
for (int i = 0; i < limit; i++) {
|
||||
@@ -224,7 +224,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
if (!issues.isEmpty()) {
|
||||
results.addAll(issues);
|
||||
|
||||
// If we found a result in a high-score volume (e.g. year matched), stop.
|
||||
if (extractedYear != null && matchesYear(volume, extractedYear)) {
|
||||
log.info("Found match in year-aligned volume '{}' ({}). Stopping further volume searches.", volume.getName(), volume.getStartYear());
|
||||
break;
|
||||
@@ -283,10 +282,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for volumes using the /volumes endpoint with filter (more efficient than /search)
|
||||
* Falls back to /search if /volumes returns no results
|
||||
*/
|
||||
private List<Comic> searchVolumes(String seriesName) {
|
||||
String cacheKey = seriesName.toLowerCase();
|
||||
CachedVolumes cached = volumeCache.get(cacheKey);
|
||||
@@ -298,7 +293,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
String apiToken = getApiToken();
|
||||
if (apiToken == null) return Collections.emptyList();
|
||||
|
||||
// First try using /volumes endpoint with name filter (more efficient, allows up to 100 results)
|
||||
URI uri = UriComponentsBuilder.fromUriString(COMICVINE_URL)
|
||||
.path("/volumes/")
|
||||
.queryParam("api_key", apiToken)
|
||||
@@ -313,7 +307,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
ComicvineApiResponse response = sendRequest(uri, ComicvineApiResponse.class);
|
||||
List<Comic> volumes = response != null && response.getResults() != null ? response.getResults() : Collections.emptyList();
|
||||
|
||||
// If no results, try search endpoint as fallback
|
||||
if (volumes.isEmpty()) {
|
||||
log.debug("No volumes found via /volumes filter, trying /search for '{}'", seriesName);
|
||||
volumes = searchVolumesViaSearch(seriesName, apiToken);
|
||||
@@ -346,9 +339,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
return response != null && response.getResults() != null ? response.getResults() : Collections.emptyList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for issues in a specific volume. Now passes the full volume object to enrich metadata.
|
||||
*/
|
||||
private List<BookMetadata> searchIssuesInVolume(Comic volume, String issueNumber) {
|
||||
String apiToken = getApiToken();
|
||||
if (apiToken == null) return Collections.emptyList();
|
||||
@@ -357,7 +347,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
log.debug("searchIssuesInVolume: volumeId='{}', original='{}', normalized='{}'",
|
||||
volume.getId(), issueNumber, normalizedIssue);
|
||||
|
||||
// Use /issues endpoint with filter - can return up to 100 results but we only need matching issues
|
||||
URI uri = UriComponentsBuilder.fromUriString(COMICVINE_URL)
|
||||
.path("/issues/")
|
||||
.queryParam("api_key", apiToken)
|
||||
@@ -379,18 +368,24 @@ public class ComicvineBookParser implements BookParser {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
if (firstIssue.getPersonCredits() != null && !firstIssue.getPersonCredits().isEmpty()) {
|
||||
log.debug("Issue {} has person_credits, using basic metadata (saving 1 API call)", firstIssue.getId());
|
||||
boolean hasCredits = hasAnyCredits(firstIssue.getPersonCredits(), firstIssue.getCharacterCredits(),
|
||||
firstIssue.getTeamCredits(), firstIssue.getStoryArcCredits(), firstIssue.getLocationCredits());
|
||||
|
||||
if (hasCredits) {
|
||||
log.debug("Issue {} has credits from list endpoint, using directly", firstIssue.getId());
|
||||
return Collections.singletonList(convertToBookMetadata(firstIssue, volume));
|
||||
}
|
||||
|
||||
BookMetadata detailed = fetchIssueDetails(firstIssue.getId(), volume);
|
||||
|
||||
return Collections.singletonList(Objects.requireNonNullElseGet(detailed, () -> convertToBookMetadata(firstIssue, volume)));
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BookMetadata fetchDetailedMetadata(String comicvineId) {
|
||||
return fetchIssueDetails(Integer.parseInt(comicvineId), null);
|
||||
}
|
||||
|
||||
private BookMetadata fetchIssueDetails(int issueId, Comic volumeContext) {
|
||||
String apiToken = getApiToken();
|
||||
@@ -406,7 +401,7 @@ public class ComicvineBookParser implements BookParser {
|
||||
|
||||
ComicvineIssueResponse response = sendRequest(uri, ComicvineIssueResponse.class);
|
||||
if (response != null && response.getResults() != null) {
|
||||
return convertToBookMetadata(response.getResults(), volumeContext);
|
||||
return convertToBookMetadata(response.getResults(), issueId, volumeContext);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -540,7 +535,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private String extractEndpointFromUri(URI uri) {
|
||||
String path = uri.getPath();
|
||||
if (path == null || path.isEmpty()) return "unknown";
|
||||
@@ -549,7 +543,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
int lastSlash = path.lastIndexOf('/');
|
||||
if (lastSlash >= 0 && lastSlash < path.length() - 1) {
|
||||
String segment = path.substring(lastSlash + 1);
|
||||
// If segment looks like an ID (4000-12345), include the parent
|
||||
if (ID_FORMAT_PATTERN.matcher(segment).matches()) {
|
||||
int prevSlash = path.lastIndexOf('/', lastSlash - 1);
|
||||
if (prevSlash >= 0) {
|
||||
@@ -562,12 +555,10 @@ public class ComicvineBookParser implements BookParser {
|
||||
}
|
||||
|
||||
private BookMetadata convertToBookMetadata(Comic comic, Comic volumeContext) {
|
||||
// Check if this is a Volume (from search results)
|
||||
if ("volume".equalsIgnoreCase(comic.getResourceType())) {
|
||||
return buildVolumeMetadata(comic);
|
||||
}
|
||||
|
||||
// Get publisher from volume context if available
|
||||
String publisher = null;
|
||||
Integer seriesTotal = null;
|
||||
if (volumeContext != null) {
|
||||
@@ -577,10 +568,9 @@ public class ComicvineBookParser implements BookParser {
|
||||
seriesTotal = volumeContext.getCountOfIssues();
|
||||
}
|
||||
|
||||
String volumeName = comic.getVolume() != null ? comic.getVolume().getName() : null;
|
||||
Set<String> authors = extractAuthors(comic.getPersonCredits());
|
||||
Set<String> tags = extractTags(comic);
|
||||
String formattedTitle = formatTitle(comic.getVolume() != null ? comic.getVolume().getName() : null, comic.getIssueNumber(), comic.getName());
|
||||
|
||||
String formattedTitle = formatTitle(volumeName, comic.getIssueNumber(), comic.getName());
|
||||
String dateToUse = comic.getStoreDate() != null ? comic.getStoreDate() : comic.getCoverDate();
|
||||
|
||||
String description = comic.getDescription();
|
||||
@@ -588,26 +578,31 @@ public class ComicvineBookParser implements BookParser {
|
||||
description = comic.getDeck();
|
||||
}
|
||||
|
||||
ComicMetadata comicMetadata = buildComicMetadata(
|
||||
comic.getIssueNumber(), volumeName,
|
||||
comic.getPersonCredits(), comic.getCharacterCredits(),
|
||||
comic.getTeamCredits(), comic.getStoryArcCredits(),
|
||||
comic.getLocationCredits(), comic.getSiteDetailUrl());
|
||||
|
||||
BookMetadata metadata = BookMetadata.builder()
|
||||
.provider(MetadataProvider.Comicvine)
|
||||
.comicvineId(String.valueOf(comic.getId()))
|
||||
.title(formattedTitle)
|
||||
.authors(authors)
|
||||
.tags(tags.isEmpty() ? null : tags)
|
||||
.thumbnailUrl(comic.getImage() != null ? comic.getImage().getMediumUrl() : null)
|
||||
.description(description)
|
||||
.seriesName(comic.getVolume() != null ? comic.getVolume().getName() : null)
|
||||
.seriesName(volumeName)
|
||||
.seriesNumber(safeParseFloat(comic.getIssueNumber()))
|
||||
.seriesTotal(seriesTotal)
|
||||
.publisher(publisher)
|
||||
.publishedDate(safeParseDate(dateToUse))
|
||||
.externalUrl(comic.getSiteDetailUrl())
|
||||
.comicMetadata(comicMetadata)
|
||||
.build();
|
||||
|
||||
if (metadata.getSeriesName() == null || metadata.getSeriesNumber() == null) {
|
||||
log.warn("Incomplete metadata for issue {}: missing series name or number", metadata.getComicvineId());
|
||||
}
|
||||
|
||||
if (metadata.getAuthors().isEmpty()) {
|
||||
log.debug("No authors found for issue {} ({})", metadata.getComicvineId(), metadata.getTitle());
|
||||
}
|
||||
@@ -633,124 +628,121 @@ public class ComicvineBookParser implements BookParser {
|
||||
.build();
|
||||
}
|
||||
|
||||
private BookMetadata convertToBookMetadata(ComicvineIssueResponse.IssueResults issue, Comic volumeContext) {
|
||||
// Extract ID from api_detail_url: "https://comicvine.gamespot.com/api/issue/4000-12345/"
|
||||
String comicvineId = null;
|
||||
if (issue.getApiDetailUrl() != null) {
|
||||
Matcher matcher = COMICVINE_ID_PATTERN.matcher(issue.getApiDetailUrl());
|
||||
if (matcher.find()) {
|
||||
comicvineId = matcher.group(1);
|
||||
}
|
||||
private BookMetadata convertToBookMetadata(ComicvineIssueResponse.IssueResults issue, int issueId, Comic volumeContext) {
|
||||
Comic comic = new Comic();
|
||||
comic.setId(issueId);
|
||||
comic.setIssueNumber(issue.getIssueNumber());
|
||||
comic.setVolume(issue.getVolume());
|
||||
comic.setName(issue.getName());
|
||||
comic.setPersonCredits(issue.getPersonCredits());
|
||||
comic.setCharacterCredits(issue.getCharacterCredits());
|
||||
comic.setTeamCredits(issue.getTeamCredits());
|
||||
comic.setStoryArcCredits(issue.getStoryArcCredits());
|
||||
comic.setLocationCredits(issue.getLocationCredits());
|
||||
comic.setImage(issue.getImage());
|
||||
comic.setDescription(issue.getDescription());
|
||||
comic.setDeck(issue.getDeck());
|
||||
comic.setStoreDate(issue.getStoreDate());
|
||||
comic.setCoverDate(issue.getCoverDate());
|
||||
comic.setSiteDetailUrl(issue.getSiteDetailUrl());
|
||||
return convertToBookMetadata(comic, volumeContext);
|
||||
}
|
||||
|
||||
// Get publisher and series total from volume context
|
||||
String publisher = null;
|
||||
Integer seriesTotal = null;
|
||||
if (volumeContext != null) {
|
||||
if (volumeContext.getPublisher() != null) {
|
||||
publisher = volumeContext.getPublisher().getName();
|
||||
}
|
||||
seriesTotal = volumeContext.getCountOfIssues();
|
||||
private boolean hasComicDetails(ComicMetadata comic) {
|
||||
return hasNonEmptySet(comic.getCharacters())
|
||||
|| hasNonEmptySet(comic.getTeams())
|
||||
|| hasNonEmptySet(comic.getLocations())
|
||||
|| hasNonEmptySet(comic.getPencillers())
|
||||
|| hasNonEmptySet(comic.getInkers())
|
||||
|| hasNonEmptySet(comic.getColorists())
|
||||
|| hasNonEmptySet(comic.getLetterers())
|
||||
|| hasNonEmptySet(comic.getCoverArtists())
|
||||
|| hasNonEmptySet(comic.getEditors());
|
||||
}
|
||||
|
||||
Set<String> authors = extractAuthors(issue.getPersonCredits());
|
||||
Set<String> tags = extractTagsFromIssue(issue);
|
||||
String formattedTitle = formatTitle(issue.getVolume() != null ? issue.getVolume().getName() : null, issue.getIssueNumber(), issue.getName());
|
||||
|
||||
// Prefer store_date (actual release date) over cover_date (printed date)
|
||||
String dateToUse = issue.getStoreDate() != null ? issue.getStoreDate() : issue.getCoverDate();
|
||||
|
||||
// Use deck (brief summary) if description is very long or missing
|
||||
String description = issue.getDescription();
|
||||
if ((description == null || description.isEmpty()) && issue.getDeck() != null) {
|
||||
description = issue.getDeck();
|
||||
private boolean hasNonEmptySet(Set<?> set) {
|
||||
return set != null && !set.isEmpty();
|
||||
}
|
||||
|
||||
BookMetadata metadata = BookMetadata.builder()
|
||||
.provider(MetadataProvider.Comicvine)
|
||||
.comicvineId(comicvineId)
|
||||
.title(formattedTitle)
|
||||
.authors(authors)
|
||||
.tags(tags.isEmpty() ? null : tags)
|
||||
.thumbnailUrl(issue.getImage() != null ? issue.getImage().getMediumUrl() : null)
|
||||
.description(description)
|
||||
.seriesName(issue.getVolume() != null ? issue.getVolume().getName() : null)
|
||||
.seriesNumber(safeParseFloat(issue.getIssueNumber()))
|
||||
.seriesTotal(seriesTotal)
|
||||
.publisher(publisher)
|
||||
.publishedDate(safeParseDate(dateToUse))
|
||||
.externalUrl(issue.getSiteDetailUrl())
|
||||
.build();
|
||||
|
||||
if (metadata.getSeriesName() == null || metadata.getSeriesNumber() == null) {
|
||||
log.warn("Incomplete metadata for issue {}: missing series name or number", comicvineId);
|
||||
private boolean hasAnyCredits(List<?>... creditLists) {
|
||||
for (List<?> list : creditLists) {
|
||||
if (list != null && !list.isEmpty()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (metadata.getAuthors().isEmpty()) {
|
||||
log.debug("No authors found for issue {} ({})", comicvineId, metadata.getTitle());
|
||||
private ComicMetadata buildComicMetadata(
|
||||
String issueNumber,
|
||||
String volumeName,
|
||||
List<Comic.PersonCredit> personCredits,
|
||||
List<Comic.CharacterCredit> characterCredits,
|
||||
List<Comic.TeamCredit> teamCredits,
|
||||
List<Comic.StoryArcCredit> storyArcCredits,
|
||||
List<Comic.LocationCredit> locationCredits,
|
||||
String siteDetailUrl) {
|
||||
|
||||
ComicMetadata.ComicMetadataBuilder builder = ComicMetadata.builder();
|
||||
|
||||
builder.issueNumber(issueNumber);
|
||||
builder.volumeName(volumeName);
|
||||
|
||||
if (storyArcCredits != null && !storyArcCredits.isEmpty()) {
|
||||
builder.storyArc(storyArcCredits.getFirst().getName());
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private Set<String> extractTags(Comic comic) {
|
||||
Set<String> tags = new LinkedHashSet<>();
|
||||
|
||||
if (comic.getStoryArcCredits() != null) {
|
||||
comic.getStoryArcCredits().stream()
|
||||
.map(Comic.StoryArcCredit::getName)
|
||||
if (personCredits != null && !personCredits.isEmpty()) {
|
||||
// "artist" in Comicvine means both pencils and inks — map to pencillers
|
||||
Set<String> pencillers = extractByRole(personCredits, "pencil");
|
||||
personCredits.stream()
|
||||
.filter(pc -> pc.getRole() != null)
|
||||
.filter(pc -> Arrays.stream(pc.getRole().toLowerCase().split(","))
|
||||
.map(String::trim)
|
||||
.anyMatch(r -> r.equals("artist")))
|
||||
.map(Comic.PersonCredit::getName)
|
||||
.filter(name -> name != null && !name.isEmpty())
|
||||
.limit(5) // Limit to top 5 story arcs
|
||||
.forEach(tags::add);
|
||||
.forEach(pencillers::add);
|
||||
builder.pencillers(pencillers);
|
||||
builder.inkers(extractByRole(personCredits, "ink"));
|
||||
builder.colorists(extractByRole(personCredits, "color"));
|
||||
builder.letterers(extractByRole(personCredits, "letter"));
|
||||
builder.coverArtists(extractByRole(personCredits, "cover"));
|
||||
builder.editors(extractByRole(personCredits, "editor"));
|
||||
}
|
||||
|
||||
if (comic.getCharacterCredits() != null) {
|
||||
comic.getCharacterCredits().stream()
|
||||
if (characterCredits != null && !characterCredits.isEmpty()) {
|
||||
builder.characters(characterCredits.stream()
|
||||
.map(Comic.CharacterCredit::getName)
|
||||
.filter(name -> name != null && !name.isEmpty())
|
||||
.limit(10) // Limit to top 10 characters
|
||||
.forEach(tags::add);
|
||||
.filter(n -> n != null && !n.isEmpty())
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new)));
|
||||
}
|
||||
|
||||
if (comic.getTeamCredits() != null) {
|
||||
comic.getTeamCredits().stream()
|
||||
if (teamCredits != null && !teamCredits.isEmpty()) {
|
||||
builder.teams(teamCredits.stream()
|
||||
.map(Comic.TeamCredit::getName)
|
||||
.filter(n -> n != null && !n.isEmpty())
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new)));
|
||||
}
|
||||
|
||||
if (locationCredits != null && !locationCredits.isEmpty()) {
|
||||
builder.locations(locationCredits.stream()
|
||||
.map(Comic.LocationCredit::getName)
|
||||
.filter(n -> n != null && !n.isEmpty())
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new)));
|
||||
}
|
||||
|
||||
if (siteDetailUrl != null && !siteDetailUrl.isEmpty()) {
|
||||
builder.webLink(siteDetailUrl);
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private Set<String> extractByRole(List<Comic.PersonCredit> personCredits, String roleFragment) {
|
||||
return personCredits.stream()
|
||||
.filter(pc -> pc.getRole() != null && pc.getRole().toLowerCase().contains(roleFragment))
|
||||
.map(Comic.PersonCredit::getName)
|
||||
.filter(name -> name != null && !name.isEmpty())
|
||||
.limit(5) // Limit to top 5 teams
|
||||
.forEach(tags::add);
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
private Set<String> extractTagsFromIssue(ComicvineIssueResponse.IssueResults issue) {
|
||||
Set<String> tags = new LinkedHashSet<>();
|
||||
|
||||
if (issue.getStoryArcCredits() != null) {
|
||||
issue.getStoryArcCredits().stream()
|
||||
.map(Comic.StoryArcCredit::getName)
|
||||
.filter(name -> name != null && !name.isEmpty())
|
||||
.limit(5)
|
||||
.forEach(tags::add);
|
||||
}
|
||||
|
||||
if (issue.getCharacterCredits() != null) {
|
||||
issue.getCharacterCredits().stream()
|
||||
.map(Comic.CharacterCredit::getName)
|
||||
.filter(name -> name != null && !name.isEmpty())
|
||||
.limit(10)
|
||||
.forEach(tags::add);
|
||||
}
|
||||
|
||||
if (issue.getTeamCredits() != null) {
|
||||
issue.getTeamCredits().stream()
|
||||
.map(Comic.TeamCredit::getName)
|
||||
.filter(name -> name != null && !name.isEmpty())
|
||||
.limit(5)
|
||||
.forEach(tags::add);
|
||||
}
|
||||
|
||||
return tags;
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
private String formatTitle(String seriesName, String issueNumber, String issueName) {
|
||||
@@ -759,7 +751,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
String normalizedIssue = normalizeIssueNumber(issueNumber);
|
||||
String title = seriesName + " #" + (normalizedIssue != null ? normalizedIssue : "");
|
||||
|
||||
// Append issue title if it exists and isn't just "Issue #X" or identical to series
|
||||
if (issueName != null && !issueName.isBlank()) {
|
||||
String lowerName = issueName.toLowerCase();
|
||||
boolean isGeneric = ISSUE_NUMBER_PATTERN.matcher(lowerName).matches() || lowerName.equals(seriesName.toLowerCase());
|
||||
@@ -775,10 +766,8 @@ public class ComicvineBookParser implements BookParser {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
|
||||
// Primary writer roles
|
||||
Set<String> writerRoles = Set.of("writer", "script", "story", "plotter", "plot");
|
||||
|
||||
// Collect writers
|
||||
Set<String> authors = personCredits.stream()
|
||||
.filter(pc -> {
|
||||
if (pc.getRole() == null) return false;
|
||||
@@ -789,9 +778,7 @@ public class ComicvineBookParser implements BookParser {
|
||||
.filter(name -> name != null && !name.isEmpty())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// Fallback: If no writers found, check for "creator" role
|
||||
if (authors.isEmpty()) {
|
||||
// DEBUG: Log all roles if we missed them
|
||||
List<String> allRoles = personCredits.stream()
|
||||
.map(pc -> pc.getName() + " (" + pc.getRole() + ")")
|
||||
.toList();
|
||||
@@ -811,9 +798,7 @@ public class ComicvineBookParser implements BookParser {
|
||||
if (request.getTitle() != null && !request.getTitle().isEmpty()) {
|
||||
return request.getTitle();
|
||||
} else if (book.getPrimaryFile() != null && book.getPrimaryFile().getFileName() != null && !book.getPrimaryFile().getFileName().isEmpty()) {
|
||||
// Use filename but preserve parentheses for year extraction
|
||||
String name = book.getPrimaryFile().getFileName();
|
||||
// Remove extension only
|
||||
int dotIndex = name.lastIndexOf('.');
|
||||
if (dotIndex > 0) {
|
||||
name = name.substring(0, dotIndex);
|
||||
@@ -824,7 +809,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
}
|
||||
|
||||
private SeriesAndIssue extractSeriesAndIssue(String term) {
|
||||
// 1. Extract Year
|
||||
Integer year = null;
|
||||
Matcher yearMatcher = YEAR_PATTERN.matcher(term);
|
||||
String yearString = null;
|
||||
@@ -838,7 +822,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
|
||||
// 2. Remove ONLY the found year and noise from the term
|
||||
String cleaned = term;
|
||||
if (yearString != null) {
|
||||
cleaned = cleaned.replace(yearString, "");
|
||||
@@ -851,28 +834,23 @@ public class ComicvineBookParser implements BookParser {
|
||||
|
||||
log.debug("Cleaned filename: '{}'", cleaned);
|
||||
|
||||
// 3. Check for special issue keywords BEFORE extracting issue number
|
||||
String lowerCleaned = cleaned.toLowerCase();
|
||||
if (lowerCleaned.contains("annual") ||
|
||||
lowerCleaned.contains("special") ||
|
||||
lowerCleaned.contains("one-shot") ||
|
||||
lowerCleaned.contains("one shot")) {
|
||||
|
||||
// Try to extract number from "Annual 2" or "Special 3"
|
||||
Matcher specialMatcher = SPECIAL_ISSUE_PATTERN.matcher(cleaned);
|
||||
if (specialMatcher.find()) {
|
||||
String type = specialMatcher.group(1);
|
||||
String num = specialMatcher.group(2);
|
||||
// Return series name before the special keyword
|
||||
String series = cleaned.substring(0, specialMatcher.start()).trim();
|
||||
return new SeriesAndIssue(series, num, year, type, null);
|
||||
} else {
|
||||
// "Batman Annual" without number - search for all annuals
|
||||
return new SeriesAndIssue(cleaned, null, year, "annual", null);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Standard issue extraction with IMPROVED PATTERN
|
||||
Matcher matcher = SERIES_ISSUE_PATTERN.matcher(cleaned);
|
||||
if (matcher.find()) {
|
||||
String series = matcher.group(1).trim();
|
||||
@@ -892,14 +870,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
return new SeriesAndIssue(cleaned, null, year, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lenient comparison of issue numbers.
|
||||
* Handles edge cases like:
|
||||
* - "1" vs "01" vs "1.0" (should match)
|
||||
* - "1.5" vs "1.50" (should match)
|
||||
* - "1AU" vs "1AU" (exact match for non-numeric)
|
||||
* - null handling
|
||||
*/
|
||||
private boolean issueNumbersMatch(String requested, String returned) {
|
||||
if (requested == null || returned == null) {
|
||||
return requested == null && returned == null;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.booklore.service.metadata.parser;
|
||||
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
|
||||
public interface DetailedMetadataProvider {
|
||||
BookMetadata fetchDetailedMetadata(String providerItemId);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ import java.util.regex.Pattern;
|
||||
@Slf4j
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
public class GoodReadsParser implements BookParser {
|
||||
public class GoodReadsParser implements BookParser, DetailedMetadataProvider {
|
||||
|
||||
private static final String BASE_SEARCH_URL = "https://www.goodreads.com/search?q=";
|
||||
private static final String BASE_BOOK_URL = "https://www.goodreads.com/book/show/";
|
||||
@@ -111,10 +111,9 @@ public class GoodReadsParser implements BookParser {
|
||||
}
|
||||
}
|
||||
|
||||
List<BookMetadata> previews = fetchMetadataPreviews(book, fetchMetadataRequest).stream()
|
||||
return fetchMetadataPreviews(book, fetchMetadataRequest).stream()
|
||||
.limit(COUNT_DETAILED_METADATA_TO_GET)
|
||||
.toList();
|
||||
return fetchMetadataUsingPreviews(previews);
|
||||
}
|
||||
|
||||
private List<BookMetadata> fetchMetadataUsingPreviews(List<BookMetadata> previews) {
|
||||
@@ -481,6 +480,8 @@ public class GoodReadsParser implements BookParser {
|
||||
.goodreadsId(String.valueOf(extractGoodReadsIdPreview(previewBook)))
|
||||
.title(extractTitlePreview(previewBook))
|
||||
.authors(authors)
|
||||
.provider(MetadataProvider.GoodReads)
|
||||
.thumbnailUrl(extractThumbnailPreview(previewBook))
|
||||
.build();
|
||||
metadataPreviews.add(previewMetadata);
|
||||
}
|
||||
@@ -547,6 +548,33 @@ public class GoodReadsParser implements BookParser {
|
||||
}
|
||||
}
|
||||
|
||||
private String extractThumbnailPreview(Element book) {
|
||||
try {
|
||||
Element img = book.selectFirst("img");
|
||||
if (img != null) {
|
||||
String src = img.attr("src");
|
||||
if (!src.isBlank()) {
|
||||
return src;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Error extracting thumbnail: {}", e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BookMetadata fetchDetailedMetadata(String goodreadsId) {
|
||||
log.info("GoodReads: Fetching detailed metadata for ID: {}", goodreadsId);
|
||||
try {
|
||||
Document document = fetchDoc(BASE_BOOK_URL + goodreadsId);
|
||||
return parseBookDetails(document, goodreadsId);
|
||||
} catch (Exception e) {
|
||||
log.error("Error fetching detailed metadata for GoodReads ID: {}", goodreadsId, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Document fetchDoc(String url) {
|
||||
try {
|
||||
Connection.Response response = Jsoup.connect(url)
|
||||
|
||||
@@ -249,6 +249,9 @@ public class MetadataChangeDetector {
|
||||
if (hasComicMetadataChanges(newMeta, existingMeta)) {
|
||||
return true;
|
||||
}
|
||||
if (hasComicLockChanges(newMeta, existingMeta)) {
|
||||
return true;
|
||||
}
|
||||
return differsLock(newMeta.getCoverLocked(), existingMeta.getCoverLocked()) || differsLock(newMeta.getAudiobookCoverLocked(), existingMeta.getAudiobookCoverLocked());
|
||||
}
|
||||
|
||||
@@ -354,6 +357,14 @@ public class MetadataChangeDetector {
|
||||
return value;
|
||||
}
|
||||
|
||||
private static boolean differsValue(Object newVal, Object oldVal) {
|
||||
Object normNew = normalize(newVal);
|
||||
Object normOld = normalize(oldVal);
|
||||
if (normOld == null && isEffectivelyEmpty(normNew)) return false;
|
||||
if (normNew == null && isEffectivelyEmpty(normOld)) return false;
|
||||
return !Objects.equals(normNew, normOld);
|
||||
}
|
||||
|
||||
private static Set<String> toNameSet(Set<?> entities) {
|
||||
if (entities == null) {
|
||||
return Collections.emptySet();
|
||||
@@ -378,32 +389,106 @@ public class MetadataChangeDetector {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Comic metadata in DTO but not in entity - this is a change
|
||||
// Comic metadata in DTO but not in entity - only a value change if DTO has actual data
|
||||
if (comicEntity == null) {
|
||||
return true;
|
||||
return hasNonEmptyComicValue(comicDto);
|
||||
}
|
||||
|
||||
// Compare individual fields
|
||||
return !Objects.equals(normalize(comicDto.getIssueNumber()), normalize(comicEntity.getIssueNumber()))
|
||||
|| !Objects.equals(normalize(comicDto.getVolumeName()), normalize(comicEntity.getVolumeName()))
|
||||
|| !Objects.equals(comicDto.getVolumeNumber(), comicEntity.getVolumeNumber())
|
||||
|| !Objects.equals(normalize(comicDto.getStoryArc()), normalize(comicEntity.getStoryArc()))
|
||||
|| !Objects.equals(comicDto.getStoryArcNumber(), comicEntity.getStoryArcNumber())
|
||||
|| !Objects.equals(normalize(comicDto.getAlternateSeries()), normalize(comicEntity.getAlternateSeries()))
|
||||
|| !Objects.equals(normalize(comicDto.getAlternateIssue()), normalize(comicEntity.getAlternateIssue()))
|
||||
|| !Objects.equals(normalize(comicDto.getImprint()), normalize(comicEntity.getImprint()))
|
||||
|| !Objects.equals(normalize(comicDto.getFormat()), normalize(comicEntity.getFormat()))
|
||||
|| !Objects.equals(comicDto.getBlackAndWhite(), comicEntity.getBlackAndWhite())
|
||||
|| !Objects.equals(comicDto.getManga(), comicEntity.getManga())
|
||||
|| !Objects.equals(normalize(comicDto.getReadingDirection()), normalize(comicEntity.getReadingDirection()))
|
||||
|| !Objects.equals(normalize(comicDto.getWebLink()), normalize(comicEntity.getWebLink()))
|
||||
|| !Objects.equals(normalize(comicDto.getNotes()), normalize(comicEntity.getNotes()))
|
||||
// Compare individual fields (using differsValue to treat null and empty as equivalent)
|
||||
return differsValue(comicDto.getIssueNumber(), comicEntity.getIssueNumber())
|
||||
|| differsValue(comicDto.getVolumeName(), comicEntity.getVolumeName())
|
||||
|| differsValue(comicDto.getVolumeNumber(), comicEntity.getVolumeNumber())
|
||||
|| differsValue(comicDto.getStoryArc(), comicEntity.getStoryArc())
|
||||
|| differsValue(comicDto.getStoryArcNumber(), comicEntity.getStoryArcNumber())
|
||||
|| differsValue(comicDto.getAlternateSeries(), comicEntity.getAlternateSeries())
|
||||
|| differsValue(comicDto.getAlternateIssue(), comicEntity.getAlternateIssue())
|
||||
|| differsValue(comicDto.getImprint(), comicEntity.getImprint())
|
||||
|| differsValue(comicDto.getFormat(), comicEntity.getFormat())
|
||||
|| differsValue(comicDto.getBlackAndWhite(), comicEntity.getBlackAndWhite())
|
||||
|| differsValue(comicDto.getManga(), comicEntity.getManga())
|
||||
|| differsValue(comicDto.getReadingDirection(), comicEntity.getReadingDirection())
|
||||
|| differsValue(comicDto.getWebLink(), comicEntity.getWebLink())
|
||||
|| differsValue(comicDto.getNotes(), comicEntity.getNotes())
|
||||
|| !stringSetsEqual(comicDto.getCharacters(), extractCharacterNames(comicEntity.getCharacters()))
|
||||
|| !stringSetsEqual(comicDto.getTeams(), extractTeamNames(comicEntity.getTeams()))
|
||||
|| !stringSetsEqual(comicDto.getLocations(), extractLocationNames(comicEntity.getLocations()))
|
||||
|| hasCreatorChanges(comicDto, comicEntity);
|
||||
}
|
||||
|
||||
private static boolean hasComicLockChanges(BookMetadata newMeta, BookMetadataEntity existingMeta) {
|
||||
ComicMetadata comicDto = newMeta.getComicMetadata();
|
||||
ComicMetadataEntity comicEntity = existingMeta.getComicMetadata();
|
||||
if (comicDto == null) return false;
|
||||
if (comicEntity == null) return false;
|
||||
return differsLock(comicDto.getIssueNumberLocked(), comicEntity.getIssueNumberLocked())
|
||||
|| differsLock(comicDto.getVolumeNameLocked(), comicEntity.getVolumeNameLocked())
|
||||
|| differsLock(comicDto.getVolumeNumberLocked(), comicEntity.getVolumeNumberLocked())
|
||||
|| differsLock(comicDto.getStoryArcLocked(), comicEntity.getStoryArcLocked())
|
||||
|| differsLock(comicDto.getStoryArcNumberLocked(), comicEntity.getStoryArcNumberLocked())
|
||||
|| differsLock(comicDto.getAlternateSeriesLocked(), comicEntity.getAlternateSeriesLocked())
|
||||
|| differsLock(comicDto.getAlternateIssueLocked(), comicEntity.getAlternateIssueLocked())
|
||||
|| differsLock(comicDto.getImprintLocked(), comicEntity.getImprintLocked())
|
||||
|| differsLock(comicDto.getFormatLocked(), comicEntity.getFormatLocked())
|
||||
|| differsLock(comicDto.getBlackAndWhiteLocked(), comicEntity.getBlackAndWhiteLocked())
|
||||
|| differsLock(comicDto.getMangaLocked(), comicEntity.getMangaLocked())
|
||||
|| differsLock(comicDto.getReadingDirectionLocked(), comicEntity.getReadingDirectionLocked())
|
||||
|| differsLock(comicDto.getWebLinkLocked(), comicEntity.getWebLinkLocked())
|
||||
|| differsLock(comicDto.getNotesLocked(), comicEntity.getNotesLocked())
|
||||
|| differsLock(comicDto.getCreatorsLocked(), comicEntity.getCreatorsLocked())
|
||||
|| differsLock(comicDto.getPencillersLocked(), comicEntity.getPencillersLocked())
|
||||
|| differsLock(comicDto.getInkersLocked(), comicEntity.getInkersLocked())
|
||||
|| differsLock(comicDto.getColoristsLocked(), comicEntity.getColoristsLocked())
|
||||
|| differsLock(comicDto.getLetterersLocked(), comicEntity.getLetterersLocked())
|
||||
|| differsLock(comicDto.getCoverArtistsLocked(), comicEntity.getCoverArtistsLocked())
|
||||
|| differsLock(comicDto.getEditorsLocked(), comicEntity.getEditorsLocked())
|
||||
|| differsLock(comicDto.getCharactersLocked(), comicEntity.getCharactersLocked())
|
||||
|| differsLock(comicDto.getTeamsLocked(), comicEntity.getTeamsLocked())
|
||||
|| differsLock(comicDto.getLocationsLocked(), comicEntity.getLocationsLocked());
|
||||
}
|
||||
|
||||
public static boolean hasLockChanges(BookMetadata newMeta, BookMetadataEntity existingMeta) {
|
||||
for (FieldDescriptor<?> field : SIMPLE_FIELDS) {
|
||||
if (differsLock(field.getNewLock(newMeta), field.getOldLock(existingMeta))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (CollectionFieldDescriptor field : COLLECTION_FIELDS) {
|
||||
if (differsLock(field.getNewLock(newMeta), field.getOldLock(existingMeta))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (differsLock(newMeta.getCoverLocked(), existingMeta.getCoverLocked())) return true;
|
||||
if (differsLock(newMeta.getAudiobookCoverLocked(), existingMeta.getAudiobookCoverLocked())) return true;
|
||||
if (differsLock(newMeta.getReviewsLocked(), existingMeta.getReviewsLocked())) return true;
|
||||
return hasComicLockChanges(newMeta, existingMeta);
|
||||
}
|
||||
|
||||
private static boolean hasNonEmptyComicValue(ComicMetadata dto) {
|
||||
return !isEffectivelyEmpty(dto.getIssueNumber())
|
||||
|| !isEffectivelyEmpty(dto.getVolumeName())
|
||||
|| dto.getVolumeNumber() != null
|
||||
|| !isEffectivelyEmpty(dto.getStoryArc())
|
||||
|| dto.getStoryArcNumber() != null
|
||||
|| !isEffectivelyEmpty(dto.getAlternateSeries())
|
||||
|| !isEffectivelyEmpty(dto.getAlternateIssue())
|
||||
|| !isEffectivelyEmpty(dto.getImprint())
|
||||
|| !isEffectivelyEmpty(dto.getFormat())
|
||||
|| dto.getBlackAndWhite() != null
|
||||
|| dto.getManga() != null
|
||||
|| !isEffectivelyEmpty(dto.getReadingDirection())
|
||||
|| !isEffectivelyEmpty(dto.getWebLink())
|
||||
|| !isEffectivelyEmpty(dto.getNotes())
|
||||
|| (dto.getCharacters() != null && !dto.getCharacters().isEmpty())
|
||||
|| (dto.getTeams() != null && !dto.getTeams().isEmpty())
|
||||
|| (dto.getLocations() != null && !dto.getLocations().isEmpty())
|
||||
|| (dto.getPencillers() != null && !dto.getPencillers().isEmpty())
|
||||
|| (dto.getInkers() != null && !dto.getInkers().isEmpty())
|
||||
|| (dto.getColorists() != null && !dto.getColorists().isEmpty())
|
||||
|| (dto.getLetterers() != null && !dto.getLetterers().isEmpty())
|
||||
|| (dto.getCoverArtists() != null && !dto.getCoverArtists().isEmpty())
|
||||
|| (dto.getEditors() != null && !dto.getEditors().isEmpty());
|
||||
}
|
||||
|
||||
private static boolean stringSetsEqual(Set<String> set1, Set<String> set2) {
|
||||
if (set1 == null && (set2 == null || set2.isEmpty())) return true;
|
||||
if (set1 == null || set2 == null) return false;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Add per-field lock columns to comic_metadata (previously these shared grouped locks)
|
||||
|
||||
-- Fields previously grouped under issue_number_locked
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS imprint_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS format_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS black_and_white_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS manga_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS reading_direction_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS web_link_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS notes_locked BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Fields previously grouped under story_arc_locked
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS story_arc_number_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS alternate_series_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS alternate_issue_locked BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Fields previously grouped under creators_locked (per-role locks)
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS pencillers_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS inkers_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS colorists_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS letterers_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS cover_artists_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS editors_locked BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Initialize new columns from their previous group lock values for existing rows
|
||||
UPDATE comic_metadata SET imprint_locked = COALESCE(issue_number_locked, FALSE) WHERE imprint_locked = FALSE AND COALESCE(issue_number_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET format_locked = COALESCE(issue_number_locked, FALSE) WHERE format_locked = FALSE AND COALESCE(issue_number_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET black_and_white_locked = COALESCE(issue_number_locked, FALSE) WHERE black_and_white_locked = FALSE AND COALESCE(issue_number_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET manga_locked = COALESCE(issue_number_locked, FALSE) WHERE manga_locked = FALSE AND COALESCE(issue_number_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET reading_direction_locked = COALESCE(issue_number_locked, FALSE) WHERE reading_direction_locked = FALSE AND COALESCE(issue_number_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET web_link_locked = COALESCE(issue_number_locked, FALSE) WHERE web_link_locked = FALSE AND COALESCE(issue_number_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET notes_locked = COALESCE(issue_number_locked, FALSE) WHERE notes_locked = FALSE AND COALESCE(issue_number_locked, FALSE) = TRUE;
|
||||
|
||||
UPDATE comic_metadata SET story_arc_number_locked = COALESCE(story_arc_locked, FALSE) WHERE story_arc_number_locked = FALSE AND COALESCE(story_arc_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET alternate_series_locked = COALESCE(story_arc_locked, FALSE) WHERE alternate_series_locked = FALSE AND COALESCE(story_arc_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET alternate_issue_locked = COALESCE(story_arc_locked, FALSE) WHERE alternate_issue_locked = FALSE AND COALESCE(story_arc_locked, FALSE) = TRUE;
|
||||
|
||||
UPDATE comic_metadata SET pencillers_locked = COALESCE(creators_locked, FALSE) WHERE pencillers_locked = FALSE AND COALESCE(creators_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET inkers_locked = COALESCE(creators_locked, FALSE) WHERE inkers_locked = FALSE AND COALESCE(creators_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET colorists_locked = COALESCE(creators_locked, FALSE) WHERE colorists_locked = FALSE AND COALESCE(creators_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET letterers_locked = COALESCE(creators_locked, FALSE) WHERE letterers_locked = FALSE AND COALESCE(creators_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET cover_artists_locked = COALESCE(creators_locked, FALSE) WHERE cover_artists_locked = FALSE AND COALESCE(creators_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET editors_locked = COALESCE(creators_locked, FALSE) WHERE editors_locked = FALSE AND COALESCE(creators_locked, FALSE) = TRUE;
|
||||
@@ -21,7 +21,6 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.UrlResource;
|
||||
@@ -318,7 +317,7 @@ class BookServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void getBookContent_returnsByteArrayResource() throws Exception {
|
||||
void getBookContent_returnsResource() throws Exception {
|
||||
BookEntity entity = new BookEntity();
|
||||
entity.setId(10L);
|
||||
when(bookRepository.findById(10L)).thenReturn(Optional.of(entity));
|
||||
@@ -326,9 +325,9 @@ class BookServiceTest {
|
||||
Files.write(path, "hello".getBytes());
|
||||
try (MockedStatic<FileUtils> fileUtilsMock = mockStatic(FileUtils.class)) {
|
||||
fileUtilsMock.when(() -> FileUtils.getBookFullPath(entity)).thenReturn(path.toString());
|
||||
ResponseEntity<ByteArrayResource> response = bookService.getBookContent(10L);
|
||||
ResponseEntity<Resource> response = bookService.getBookContent(10L);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertArrayEquals("hello".getBytes(), response.getBody().getByteArray());
|
||||
assertArrayEquals("hello".getBytes(), response.getBody().getInputStream().readAllBytes());
|
||||
} finally {
|
||||
Files.deleteIfExists(path);
|
||||
}
|
||||
@@ -341,13 +340,13 @@ class BookServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void getBookContent_fileIoError_throwsIOException() throws Exception {
|
||||
void getBookContent_fileNotFound_throwsException() {
|
||||
BookEntity entity = new BookEntity();
|
||||
entity.setId(12L);
|
||||
when(bookRepository.findById(12L)).thenReturn(Optional.of(entity));
|
||||
try (MockedStatic<FileUtils> fileUtilsMock = mockStatic(FileUtils.class)) {
|
||||
fileUtilsMock.when(() -> FileUtils.getBookFullPath(entity)).thenReturn("/tmp/nonexistentfile.txt");
|
||||
assertThrows(java.io.FileNotFoundException.class, () -> bookService.getBookContent(12L));
|
||||
assertThrows(APIException.class, () -> bookService.getBookContent(12L));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -260,7 +260,7 @@
|
||||
[(ngModel)]="editingLibraryIds"
|
||||
placeholder="Select libraries new users can access"
|
||||
appendTo="body"
|
||||
styleClass="libraries-select">
|
||||
class="libraries-select">
|
||||
</p-multiSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
[text]="true"
|
||||
severity="secondary"
|
||||
(click)="closeDialog()"
|
||||
styleClass="close-button">
|
||||
class="close-button">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Select file type"
|
||||
styleClass="full-width"
|
||||
class="full-width"
|
||||
[disabled]="isUploading"
|
||||
></p-select>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
[(ngModel)]="description"
|
||||
rows="3"
|
||||
placeholder="Add a description for this file..."
|
||||
styleClass="full-width"
|
||||
class="full-width"
|
||||
[disabled]="isUploading"
|
||||
></p-textarea>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
optionValue="value"
|
||||
class="filter-mode-select"
|
||||
allowEmpty="false"
|
||||
styleClass="h-8"></p-selectButton>
|
||||
class="h-8"></p-selectButton>
|
||||
</div>
|
||||
|
||||
<div class="filter-content">
|
||||
|
||||
@@ -20,7 +20,8 @@ export type FilterType =
|
||||
| 'personalRating' | 'publisher' | 'matchScore' | 'library' | 'shelf'
|
||||
| 'shelfStatus' | 'tag' | 'publishedDate' | 'fileSize' | 'amazonRating'
|
||||
| 'goodreadsRating' | 'hardcoverRating' | 'language' | 'pageCount' | 'mood'
|
||||
| 'ageRating' | 'contentRating';
|
||||
| 'ageRating' | 'contentRating'
|
||||
| 'comicCharacter' | 'comicTeam' | 'comicLocation' | 'comicCreator';
|
||||
|
||||
export type SortMode = 'count' | 'sortIndex';
|
||||
|
||||
@@ -156,7 +157,11 @@ export const FILTER_LABELS: Readonly<Record<FilterType, string>> = {
|
||||
pageCount: 'Page Count',
|
||||
mood: 'Mood',
|
||||
ageRating: 'Age Rating',
|
||||
contentRating: 'Content Rating'
|
||||
contentRating: 'Content Rating',
|
||||
comicCharacter: 'Comic Character',
|
||||
comicTeam: 'Comic Team',
|
||||
comicLocation: 'Comic Location',
|
||||
comicCreator: 'Comic Creator'
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
@@ -230,6 +235,34 @@ export const FILTER_EXTRACTORS: Readonly<Record<Exclude<FilterType, 'library'>,
|
||||
if (!rating) return [];
|
||||
const label = CONTENT_RATING_LABELS[rating] ?? rating;
|
||||
return [{id: rating, name: label}];
|
||||
},
|
||||
comicCharacter: (book) => extractStringsAsFilters(book.metadata?.comicMetadata?.characters),
|
||||
comicTeam: (book) => extractStringsAsFilters(book.metadata?.comicMetadata?.teams),
|
||||
comicLocation: (book) => extractStringsAsFilters(book.metadata?.comicMetadata?.locations),
|
||||
comicCreator: (book) => {
|
||||
const comic = book.metadata?.comicMetadata;
|
||||
if (!comic) return [];
|
||||
const creators: FilterValue[] = [];
|
||||
const roleLabels: Record<string, string> = {
|
||||
penciller: 'Penciller', inker: 'Inker', colorist: 'Colorist',
|
||||
letterer: 'Letterer', coverArtist: 'Cover Artist', editor: 'Editor'
|
||||
};
|
||||
const roles: [string[] | undefined, string][] = [
|
||||
[comic.pencillers, 'penciller'],
|
||||
[comic.inkers, 'inker'],
|
||||
[comic.colorists, 'colorist'],
|
||||
[comic.letterers, 'letterer'],
|
||||
[comic.coverArtists, 'coverArtist'],
|
||||
[comic.editors, 'editor']
|
||||
];
|
||||
for (const [names, role] of roles) {
|
||||
if (names) {
|
||||
for (const name of names) {
|
||||
creators.push({id: `${name}:${role}`, name: `${name} (${roleLabels[role]})`});
|
||||
}
|
||||
}
|
||||
}
|
||||
return creators;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -254,5 +287,9 @@ export const FILTER_CONFIGS: Readonly<Record<Exclude<FilterType, 'library'>, Omi
|
||||
pageCount: {label: 'Page Count', sortMode: 'sortIndex', isNumericId: true},
|
||||
mood: {label: 'Mood', sortMode: 'count'},
|
||||
ageRating: {label: 'Age Rating', sortMode: 'sortIndex', isNumericId: true},
|
||||
contentRating: {label: 'Content Rating', sortMode: 'count'}
|
||||
contentRating: {label: 'Content Rating', sortMode: 'count'},
|
||||
comicCharacter: {label: 'Comic Character', sortMode: 'count'},
|
||||
comicTeam: {label: 'Comic Team', sortMode: 'count'},
|
||||
comicLocation: {label: 'Comic Location', sortMode: 'count'},
|
||||
comicCreator: {label: 'Comic Creator', sortMode: 'count'}
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ import {BookRuleEvaluatorService} from '../../../../magic-shelf/service/book-rul
|
||||
import {GroupRule} from '../../../../magic-shelf/component/magic-shelf-component';
|
||||
import {EntityType} from '../book-browser.component';
|
||||
import {Filter, FILTER_CONFIGS, FILTER_EXTRACTORS, FilterType, FilterValue, NUMERIC_ID_FILTER_TYPES, SortMode} from './book-filter.config';
|
||||
import {filterBooksByFilters} from '../filters/SidebarFilter';
|
||||
import {filterBooksByFilters} from '../filters/sidebar-filter';
|
||||
import {BookFilterMode} from '../../../../settings/user-management/user.service';
|
||||
|
||||
const MAX_FILTER_ITEMS = 100;
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import {combineLatest, Observable} from 'rxjs';
|
||||
import {map} from 'rxjs/operators';
|
||||
import {BookFilter} from './BookFilter';
|
||||
import {BookState} from '../../../model/state/book-state.model';
|
||||
import {fileSizeRanges, matchScoreRanges, pageCountRanges, ratingRanges} from '../book-filter/book-filter.config';
|
||||
import {Book, ReadStatus} from '../../../model/book.model';
|
||||
import {BookFilterMode} from '../../../../settings/user-management/user.service';
|
||||
|
||||
export function isRatingInRange(rating: number | undefined | null, rangeId: string | number): boolean {
|
||||
if (rating == null) return false;
|
||||
const numericId = typeof rangeId === 'string' ? Number(rangeId) : rangeId;
|
||||
const range = ratingRanges.find(r => r.id === numericId);
|
||||
if (!range) return false;
|
||||
return rating >= range.min && rating < range.max;
|
||||
}
|
||||
|
||||
export function isRatingInRange10(rating: number | undefined | null, rangeId: string | number): boolean {
|
||||
if (rating == null) return false;
|
||||
const numericId = typeof rangeId === 'string' ? Number(rangeId) : rangeId;
|
||||
return Math.round(rating) === numericId;
|
||||
}
|
||||
|
||||
export function isFileSizeInRange(fileSizeKb: number | undefined, rangeId: string | number): boolean {
|
||||
if (fileSizeKb == null) return false;
|
||||
const numericId = typeof rangeId === 'string' ? Number(rangeId) : rangeId;
|
||||
const range = fileSizeRanges.find(r => r.id === numericId);
|
||||
if (!range) return false;
|
||||
return fileSizeKb >= range.min && fileSizeKb < range.max;
|
||||
}
|
||||
|
||||
export function isPageCountInRange(pageCount: number | undefined, rangeId: string | number): boolean {
|
||||
if (pageCount == null) return false;
|
||||
const numericId = typeof rangeId === 'string' ? Number(rangeId) : rangeId;
|
||||
const range = pageCountRanges.find(r => r.id === numericId);
|
||||
if (!range) return false;
|
||||
return pageCount >= range.min && pageCount < range.max;
|
||||
}
|
||||
|
||||
export function isMatchScoreInRange(score: number | undefined | null, rangeId: string | number): boolean {
|
||||
if (score == null) return false;
|
||||
const normalizedScore = score > 1 ? score / 100 : score;
|
||||
const numericId = typeof rangeId === 'string' ? Number(rangeId) : rangeId;
|
||||
const range = matchScoreRanges.find(r => r.id === numericId);
|
||||
if (!range) return false;
|
||||
return normalizedScore >= range.min && normalizedScore < range.max;
|
||||
}
|
||||
|
||||
export function doesBookMatchReadStatus(book: Book, selected: unknown[]): boolean {
|
||||
const status = book.readStatus ?? ReadStatus.UNSET;
|
||||
return selected.includes(status);
|
||||
}
|
||||
|
||||
export function doesBookMatchFilter(
|
||||
book: Book,
|
||||
filterType: string,
|
||||
filterValues: unknown[],
|
||||
mode: BookFilterMode
|
||||
): boolean {
|
||||
if (!Array.isArray(filterValues) || filterValues.length === 0) {
|
||||
return mode === 'or';
|
||||
}
|
||||
|
||||
switch (filterType) {
|
||||
case 'author':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.authors?.includes(val as string))
|
||||
: filterValues.every(val => book.metadata?.authors?.includes(val as string));
|
||||
case 'category':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.categories?.includes(val as string))
|
||||
: filterValues.every(val => book.metadata?.categories?.includes(val as string));
|
||||
case 'series':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.seriesName === val)
|
||||
: filterValues.every(val => book.metadata?.seriesName === val);
|
||||
case 'bookType':
|
||||
return filterValues.includes(book.primaryFile?.bookType);
|
||||
case 'readStatus':
|
||||
return doesBookMatchReadStatus(book, filterValues);
|
||||
case 'personalRating':
|
||||
return filterValues.some(range => isRatingInRange10(book.personalRating, range as string | number));
|
||||
case 'publisher':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.publisher === val)
|
||||
: filterValues.every(val => book.metadata?.publisher === val);
|
||||
case 'matchScore':
|
||||
return filterValues.some(range => isMatchScoreInRange(book.metadataMatchScore, range as string | number));
|
||||
case 'library':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => val == book.libraryId)
|
||||
: filterValues.every(val => val == book.libraryId);
|
||||
case 'shelf':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.shelves?.some(s => s.id == val))
|
||||
: filterValues.every(val => book.shelves?.some(s => s.id == val));
|
||||
case 'shelfStatus':
|
||||
const shelved = book.shelves && book.shelves.length > 0 ? 'shelved' : 'unshelved';
|
||||
return filterValues.includes(shelved);
|
||||
case 'tag':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.tags?.includes(val as string))
|
||||
: filterValues.every(val => book.metadata?.tags?.includes(val as string));
|
||||
case 'publishedDate':
|
||||
const bookYear = book.metadata?.publishedDate
|
||||
? new Date(book.metadata.publishedDate).getFullYear()
|
||||
: null;
|
||||
return bookYear ? filterValues.some(val => val == bookYear || val == bookYear.toString()) : false;
|
||||
case 'fileSize':
|
||||
return filterValues.some(range => isFileSizeInRange(book.fileSizeKb, range as string | number));
|
||||
case 'amazonRating':
|
||||
return filterValues.some(range => isRatingInRange(book.metadata?.amazonRating, range as string | number));
|
||||
case 'goodreadsRating':
|
||||
return filterValues.some(range => isRatingInRange(book.metadata?.goodreadsRating, range as string | number));
|
||||
case 'hardcoverRating':
|
||||
return filterValues.some(range => isRatingInRange(book.metadata?.hardcoverRating, range as string | number));
|
||||
case 'language':
|
||||
return filterValues.includes(book.metadata?.language);
|
||||
case 'pageCount':
|
||||
return filterValues.some(range => isPageCountInRange(book.metadata?.pageCount!, range as string | number));
|
||||
case 'mood':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.moods?.includes(val as string))
|
||||
: filterValues.every(val => book.metadata?.moods?.includes(val as string));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function filterBooksByFilters(
|
||||
books: Book[],
|
||||
activeFilters: Record<string, unknown[]> | null,
|
||||
mode: BookFilterMode,
|
||||
excludeFilterType?: string
|
||||
): Book[] {
|
||||
if (!activeFilters) return books;
|
||||
|
||||
const filterEntries = Object.entries(activeFilters)
|
||||
.filter(([type]) => type !== excludeFilterType);
|
||||
|
||||
if (filterEntries.length === 0) return books;
|
||||
|
||||
return books.filter(book => {
|
||||
const matches = filterEntries.map(([filterType, filterValues]) =>
|
||||
doesBookMatchFilter(book, filterType, filterValues, mode)
|
||||
);
|
||||
return mode === 'or' ? matches.some(m => m) : matches.every(m => m);
|
||||
});
|
||||
}
|
||||
|
||||
export class SideBarFilter implements BookFilter {
|
||||
|
||||
constructor(private selectedFilter$: Observable<unknown>, private selectedFilterMode$: Observable<BookFilterMode>) {
|
||||
}
|
||||
|
||||
filter(bookState: BookState): Observable<BookState> {
|
||||
return combineLatest([this.selectedFilter$, this.selectedFilterMode$]).pipe(
|
||||
map(([activeFilters, mode]) => {
|
||||
if (bookState.books == null) return bookState;
|
||||
if (!activeFilters) return bookState;
|
||||
const filteredBooks = filterBooksByFilters(
|
||||
bookState.books || [],
|
||||
activeFilters as Record<string, unknown[]>,
|
||||
mode
|
||||
);
|
||||
return {...bookState, books: filteredBooks};
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import {combineLatest, Observable} from 'rxjs';
|
||||
import {map} from 'rxjs/operators';
|
||||
import {BookFilter} from './BookFilter';
|
||||
import {BookState} from '../../../model/state/book-state.model';
|
||||
import {AGE_RATING_OPTIONS, fileSizeRanges, matchScoreRanges, pageCountRanges, ratingRanges, RangeConfig} from '../book-filter/book-filter.config';
|
||||
import {fileSizeRanges, matchScoreRanges, pageCountRanges, ratingRanges} from '../book-filter/book-filter.config';
|
||||
import {Book, ReadStatus} from '../../../model/book.model';
|
||||
import {BookFilterMode} from '../../../../settings/user-management/user.service';
|
||||
|
||||
@@ -45,41 +45,30 @@ export function isMatchScoreInRange(score: number | undefined | null, rangeId: s
|
||||
return normalizedScore >= range.min && normalizedScore < range.max;
|
||||
}
|
||||
|
||||
export function doesBookMatchReadStatus(book: Book, selected: string[]): boolean {
|
||||
export function doesBookMatchReadStatus(book: Book, selected: unknown[]): boolean {
|
||||
const status = book.readStatus ?? ReadStatus.UNSET;
|
||||
return selected.includes(status);
|
||||
}
|
||||
|
||||
export function isAgeRatingMatch(ageRating: number | undefined | null, rangeId: string | number): boolean {
|
||||
if (ageRating == null) return false;
|
||||
const numericId = typeof rangeId === 'string' ? Number(rangeId) : rangeId;
|
||||
return ageRating === numericId;
|
||||
}
|
||||
|
||||
export class SideBarFilter implements BookFilter {
|
||||
|
||||
constructor(private selectedFilter$: Observable<unknown>, private selectedFilterMode$: Observable<BookFilterMode>) {
|
||||
}
|
||||
|
||||
filter(bookState: BookState): Observable<BookState> {
|
||||
return combineLatest([this.selectedFilter$, this.selectedFilterMode$]).pipe(
|
||||
map(([activeFilters, mode]) => {
|
||||
if (bookState.books == null) return bookState;
|
||||
if (!activeFilters) return bookState;
|
||||
const filteredBooks = (bookState.books || []).filter(book => {
|
||||
const matches = Object.entries(activeFilters).map(([filterType, filterValues]) => {
|
||||
export function doesBookMatchFilter(
|
||||
book: Book,
|
||||
filterType: string,
|
||||
filterValues: unknown[],
|
||||
mode: BookFilterMode
|
||||
): boolean {
|
||||
if (!Array.isArray(filterValues) || filterValues.length === 0) {
|
||||
return mode === 'or';
|
||||
}
|
||||
|
||||
switch (filterType) {
|
||||
case 'author':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.authors?.includes(val))
|
||||
: filterValues.every(val => book.metadata?.authors?.includes(val));
|
||||
? filterValues.some(val => book.metadata?.authors?.includes(val as string))
|
||||
: filterValues.every(val => book.metadata?.authors?.includes(val as string));
|
||||
case 'category':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.categories?.includes(val))
|
||||
: filterValues.every(val => book.metadata?.categories?.includes(val));
|
||||
? filterValues.some(val => book.metadata?.categories?.includes(val as string))
|
||||
: filterValues.every(val => book.metadata?.categories?.includes(val as string));
|
||||
case 'series':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.seriesName === val)
|
||||
@@ -89,13 +78,13 @@ export class SideBarFilter implements BookFilter {
|
||||
case 'readStatus':
|
||||
return doesBookMatchReadStatus(book, filterValues);
|
||||
case 'personalRating':
|
||||
return filterValues.some(range => isRatingInRange10(book.personalRating, range));
|
||||
return filterValues.some(range => isRatingInRange10(book.personalRating, range as string | number));
|
||||
case 'publisher':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.publisher === val)
|
||||
: filterValues.every(val => book.metadata?.publisher === val);
|
||||
case 'matchScore':
|
||||
return filterValues.some(range => isMatchScoreInRange(book.metadataMatchScore, range));
|
||||
return filterValues.some(range => isMatchScoreInRange(book.metadataMatchScore, range as string | number));
|
||||
case 'library':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => val == book.libraryId)
|
||||
@@ -109,39 +98,112 @@ export class SideBarFilter implements BookFilter {
|
||||
return filterValues.includes(shelved);
|
||||
case 'tag':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.tags?.includes(val))
|
||||
: filterValues.every(val => book.metadata?.tags?.includes(val));
|
||||
? filterValues.some(val => book.metadata?.tags?.includes(val as string))
|
||||
: filterValues.every(val => book.metadata?.tags?.includes(val as string));
|
||||
case 'publishedDate':
|
||||
const bookYear = book.metadata?.publishedDate
|
||||
? new Date(book.metadata.publishedDate).getFullYear()
|
||||
: null;
|
||||
return bookYear ? filterValues.some(val => val == bookYear || val == bookYear.toString()) : false;
|
||||
case 'fileSize':
|
||||
return filterValues.some(range => isFileSizeInRange(book.fileSizeKb, range));
|
||||
return filterValues.some(range => isFileSizeInRange(book.fileSizeKb, range as string | number));
|
||||
case 'amazonRating':
|
||||
return filterValues.some(range => isRatingInRange(book.metadata?.amazonRating, range));
|
||||
return filterValues.some(range => isRatingInRange(book.metadata?.amazonRating, range as string | number));
|
||||
case 'goodreadsRating':
|
||||
return filterValues.some(range => isRatingInRange(book.metadata?.goodreadsRating, range));
|
||||
return filterValues.some(range => isRatingInRange(book.metadata?.goodreadsRating, range as string | number));
|
||||
case 'hardcoverRating':
|
||||
return filterValues.some(range => isRatingInRange(book.metadata?.hardcoverRating, range));
|
||||
return filterValues.some(range => isRatingInRange(book.metadata?.hardcoverRating, range as string | number));
|
||||
case 'language':
|
||||
return filterValues.includes(book.metadata?.language);
|
||||
case 'pageCount':
|
||||
return filterValues.some(range => isPageCountInRange(book.metadata?.pageCount!, range));
|
||||
return filterValues.some(range => isPageCountInRange(book.metadata?.pageCount!, range as string | number));
|
||||
case 'mood':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.moods?.includes(val))
|
||||
: filterValues.every(val => book.metadata?.moods?.includes(val));
|
||||
? filterValues.some(val => book.metadata?.moods?.includes(val as string))
|
||||
: filterValues.every(val => book.metadata?.moods?.includes(val as string));
|
||||
case 'ageRating':
|
||||
return filterValues.some(val => isAgeRatingMatch(book.metadata?.ageRating, val));
|
||||
return filterValues.some(val => {
|
||||
const numVal = typeof val === 'string' ? Number(val) : val;
|
||||
return book.metadata?.ageRating === numVal;
|
||||
});
|
||||
case 'contentRating':
|
||||
return filterValues.includes(book.metadata?.contentRating);
|
||||
case 'comicCharacter':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.comicMetadata?.characters?.includes(val as string))
|
||||
: filterValues.every(val => book.metadata?.comicMetadata?.characters?.includes(val as string));
|
||||
case 'comicTeam':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.comicMetadata?.teams?.includes(val as string))
|
||||
: filterValues.every(val => book.metadata?.comicMetadata?.teams?.includes(val as string));
|
||||
case 'comicLocation':
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => book.metadata?.comicMetadata?.locations?.includes(val as string))
|
||||
: filterValues.every(val => book.metadata?.comicMetadata?.locations?.includes(val as string));
|
||||
case 'comicCreator': {
|
||||
const comic = book.metadata?.comicMetadata;
|
||||
if (!comic) return false;
|
||||
const allCreators: string[] = [];
|
||||
const roles: [string[] | undefined, string][] = [
|
||||
[comic.pencillers, 'penciller'],
|
||||
[comic.inkers, 'inker'],
|
||||
[comic.colorists, 'colorist'],
|
||||
[comic.letterers, 'letterer'],
|
||||
[comic.coverArtists, 'coverArtist'],
|
||||
[comic.editors, 'editor']
|
||||
];
|
||||
for (const [names, role] of roles) {
|
||||
if (names) {
|
||||
for (const name of names) {
|
||||
allCreators.push(`${name}:${role}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return mode === 'or'
|
||||
? filterValues.some(val => allCreators.includes(val as string))
|
||||
: filterValues.every(val => allCreators.includes(val as string));
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function filterBooksByFilters(
|
||||
books: Book[],
|
||||
activeFilters: Record<string, unknown[]> | null,
|
||||
mode: BookFilterMode,
|
||||
excludeFilterType?: string
|
||||
): Book[] {
|
||||
if (!activeFilters) return books;
|
||||
|
||||
const filterEntries = Object.entries(activeFilters)
|
||||
.filter(([type]) => type !== excludeFilterType);
|
||||
|
||||
if (filterEntries.length === 0) return books;
|
||||
|
||||
return books.filter(book => {
|
||||
const matches = filterEntries.map(([filterType, filterValues]) =>
|
||||
doesBookMatchFilter(book, filterType, filterValues, mode)
|
||||
);
|
||||
return mode === 'or' ? matches.some(m => m) : matches.every(m => m);
|
||||
});
|
||||
}
|
||||
|
||||
export class SideBarFilter implements BookFilter {
|
||||
|
||||
constructor(private selectedFilter$: Observable<unknown>, private selectedFilterMode$: Observable<BookFilterMode>) {
|
||||
}
|
||||
|
||||
filter(bookState: BookState): Observable<BookState> {
|
||||
return combineLatest([this.selectedFilter$, this.selectedFilterMode$]).pipe(
|
||||
map(([activeFilters, mode]) => {
|
||||
if (bookState.books == null) return bookState;
|
||||
if (!activeFilters) return bookState;
|
||||
const filteredBooks = filterBooksByFilters(
|
||||
bookState.books || [],
|
||||
activeFilters as Record<string, unknown[]>,
|
||||
mode
|
||||
);
|
||||
return {...bookState, books: filteredBooks};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
[text]="true"
|
||||
severity="secondary"
|
||||
(click)="dialogRef.close()"
|
||||
styleClass="close-button">
|
||||
class="close-button">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
[text]="true"
|
||||
severity="secondary"
|
||||
(click)="closeDialog()"
|
||||
styleClass="close-button">
|
||||
class="close-button">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
[dropdown]="true"
|
||||
[showClear]="true"
|
||||
placeholder="Search for a book in the same library..."
|
||||
styleClass="full-width"
|
||||
class="full-width"
|
||||
[disabled]="isAttaching">
|
||||
<ng-template let-book pTemplate="item">
|
||||
<div class="book-suggestion">
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
[text]="true"
|
||||
severity="secondary"
|
||||
(click)="dynamicDialogRef.close()"
|
||||
styleClass="close-button">
|
||||
class="close-button">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
placeholder="Select Email Provider"
|
||||
[(ngModel)]="selectedProvider"
|
||||
appendTo="body"
|
||||
styleClass="full-width">
|
||||
class="full-width">
|
||||
</p-select>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
[(ngModel)]="selectedRecipient"
|
||||
[disabled]="!selectedProvider"
|
||||
appendTo="body"
|
||||
styleClass="full-width">
|
||||
class="full-width">
|
||||
</p-select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
[rounded]="true"
|
||||
severity="secondary"
|
||||
(onClick)="closeDialog()"
|
||||
styleClass="close-button">
|
||||
class="close-button">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
[rounded]="true"
|
||||
severity="secondary"
|
||||
(onClick)="closeDialog()"
|
||||
styleClass="close-button">
|
||||
class="close-button">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -193,6 +193,7 @@ export interface BookMetadata {
|
||||
tags?: string[];
|
||||
provider?: string;
|
||||
providerBookId?: string;
|
||||
externalUrl?: string;
|
||||
thumbnailUrl?: string | null;
|
||||
reviews?: BookReview[];
|
||||
titleLocked?: boolean;
|
||||
|
||||
@@ -5,12 +5,13 @@ import {FetchMetadataRequest} from '../../metadata/model/request/fetch-metadata-
|
||||
import {BookMetadata} from '../model/book.model';
|
||||
import {AuthService} from '../../../shared/service/auth.service';
|
||||
import {SseClient} from 'ngx-sse-client';
|
||||
import {HttpHeaders} from '@angular/common/http';
|
||||
import {HttpClient, HttpHeaders} from '@angular/common/http';
|
||||
import {map} from 'rxjs/operators';
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class BookMetadataService {
|
||||
private readonly url = `${API_CONFIG.BASE_URL}/api/v1/books`;
|
||||
private http = inject(HttpClient);
|
||||
private authService = inject(AuthService);
|
||||
private sseClient = inject(SseClient);
|
||||
|
||||
@@ -52,4 +53,8 @@ export class BookMetadataService {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fetchMetadataDetail(provider: string, providerItemId: string): Observable<BookMetadata> {
|
||||
return this.http.get<BookMetadata>(`${this.url}/metadata/detail/${provider}/${providerItemId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
[text]="true"
|
||||
severity="secondary"
|
||||
(click)="ref.close()"
|
||||
styleClass="close-button">
|
||||
class="close-button">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
[label]="getPlaceholderLabel(placeholder.name)"
|
||||
[pTooltip]="getPlaceholderTooltip(placeholder)"
|
||||
tooltipPosition="top"
|
||||
styleClass="placeholder-chip"
|
||||
class="placeholder-chip"
|
||||
(click)="insertPlaceholder(placeholder.name)">
|
||||
</p-chip>
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<p-button
|
||||
label="Create Your Library"
|
||||
icon="pi pi-plus"
|
||||
styleClass="p-button-rounded p-button-outlined"
|
||||
class="p-button-rounded p-button-outlined"
|
||||
(click)="createNewLibrary()">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
[rounded]="true"
|
||||
severity="secondary"
|
||||
(onClick)="cancel()"
|
||||
styleClass="close-button">
|
||||
class="close-button">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
[text]="true"
|
||||
severity="secondary"
|
||||
(click)="ref?.close()"
|
||||
styleClass="close-button">
|
||||
class="close-button">
|
||||
</p-button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -161,6 +161,7 @@
|
||||
<p-button pTooltip="Unlock Cover" tooltipPosition="top" size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('audiobookCover')" severity="warn"></p-button>
|
||||
}
|
||||
</div>
|
||||
`
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -305,7 +306,7 @@
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field-group full-width">
|
||||
<div class="field-group half-width">
|
||||
<label for="moods">Moods</label>
|
||||
<div class="field-with-lock">
|
||||
<div class="autocomplete-wrapper">
|
||||
@@ -329,9 +330,7 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field-group full-width">
|
||||
<div class="field-group half-width">
|
||||
<label for="tags">Tags</label>
|
||||
<div class="field-with-lock">
|
||||
<div class="autocomplete-wrapper">
|
||||
@@ -406,20 +405,45 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group small-field">
|
||||
<label for="pageCount">Pages</label>
|
||||
<label>Public Reviews</label>
|
||||
<div class="field-with-lock">
|
||||
<input pInputText id="pageCount" formControlName="pageCount"/>
|
||||
@if (!book.metadata!['pageCountLocked']) {
|
||||
<p-button icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('pageCount')" severity="success"></p-button>
|
||||
<input pSize="small" pInputText [disabled]="true" value="No Value"/>
|
||||
@if (!book.metadata!['reviewsLocked']) {
|
||||
<p-button icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('reviews')" severity="success"></p-button>
|
||||
}
|
||||
@if (book.metadata!['pageCountLocked']) {
|
||||
<p-button icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('pageCount')" severity="warn"></p-button>
|
||||
@if (book.metadata!['reviewsLocked']) {
|
||||
<p-button icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('reviews')" severity="warn"></p-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field-group small-field">
|
||||
<label for="isbn10">ISBN 10</label>
|
||||
<div class="field-with-lock">
|
||||
<input pInputText id="isbn10" formControlName="isbn10"/>
|
||||
@if (!book.metadata!['isbn10Locked']) {
|
||||
<p-button icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('isbn10')" severity="success"></p-button>
|
||||
}
|
||||
@if (book.metadata!['isbn10Locked']) {
|
||||
<p-button icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('isbn10')" severity="warn"></p-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-group small-field">
|
||||
<label for="isbn13">ISBN 13</label>
|
||||
<div class="field-with-lock">
|
||||
<input pInputText id="isbn13" formControlName="isbn13"/>
|
||||
@if (!book.metadata!['isbn13Locked']) {
|
||||
<p-button icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('isbn13')" severity="success"></p-button>
|
||||
}
|
||||
@if (book.metadata!['isbn13Locked']) {
|
||||
<p-button icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('isbn13')" severity="warn"></p-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-group small-field">
|
||||
<label for="ageRating">Age Rating</label>
|
||||
<div class="field-with-lock">
|
||||
@@ -441,7 +465,6 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group small-field">
|
||||
<label for="contentRating">Content Rating</label>
|
||||
<div class="field-with-lock">
|
||||
@@ -463,47 +486,151 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-group small-field">
|
||||
<label for="pageCount">Pages</label>
|
||||
<div class="field-with-lock">
|
||||
<input pInputText id="pageCount" formControlName="pageCount"/>
|
||||
@if (!book.metadata!['pageCountLocked']) {
|
||||
<p-button icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('pageCount')" severity="success"></p-button>
|
||||
}
|
||||
@if (book.metadata!['pageCountLocked']) {
|
||||
<p-button icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('pageCount')" severity="warn"></p-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audiobook Metadata Section -->
|
||||
@if (isAudiobook(book)) {
|
||||
<div class="collapsible-section" [class.expanded]="audiobookSectionExpanded">
|
||||
<div class="collapsible-header" (click)="audiobookSectionExpanded = !audiobookSectionExpanded">
|
||||
<i class="pi pi-headphones"></i>
|
||||
<span>Audiobook Details</span>
|
||||
<i [class]="audiobookSectionExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'" class="section-toggle"></i>
|
||||
</div>
|
||||
@if (audiobookSectionExpanded) {
|
||||
<div class="collapsible-content">
|
||||
<div class="field-row metadata-ids-row">
|
||||
@for (field of audiobookMetadataFields; track field) {
|
||||
<div class="field-group metadata-id-field">
|
||||
<label for="reviews">Public Reviews</label>
|
||||
<label>{{ field.label }}</label>
|
||||
<div class="field-with-lock">
|
||||
<input pSize="small" pInputText [disabled]="true" value="No Value"/>
|
||||
@if (!book.metadata!['reviewsLocked']) {
|
||||
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('reviews')" severity="success"></p-button>
|
||||
@if (field.type === 'boolean') {
|
||||
<p-select [formControlName]="field.controlName" [options]="booleanOptions" optionLabel="label" optionValue="value" appendTo="body"></p-select>
|
||||
} @else {
|
||||
<input pInputText [formControlName]="field.controlName"/>
|
||||
}
|
||||
@if (book.metadata!['reviewsLocked']) {
|
||||
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('reviews')" severity="warn"></p-button>
|
||||
@if (!metadataForm.get(field.lockedKey)?.value) {
|
||||
<p-button icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock(field.controlName)" severity="success"></p-button>
|
||||
}
|
||||
@if (metadataForm.get(field.lockedKey)?.value) {
|
||||
<p-button icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock(field.controlName)" severity="warn"></p-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-group metadata-id-field">
|
||||
<label for="isbn10">ISBN 10</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Comic Book Metadata Section -->
|
||||
@if (isCBX(book)) {
|
||||
<div class="collapsible-section" [class.expanded]="comicSectionExpanded">
|
||||
<div class="collapsible-header" (click)="comicSectionExpanded = !comicSectionExpanded">
|
||||
<i class="pi pi-bolt"></i>
|
||||
<span>Comic Book Details</span>
|
||||
<i [class]="comicSectionExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'" class="section-toggle"></i>
|
||||
</div>
|
||||
@if (comicSectionExpanded) {
|
||||
<div class="collapsible-content">
|
||||
<!-- Text/number/boolean fields in grid -->
|
||||
<div class="comic-text-grid">
|
||||
@for (field of comicTextFields; track field) {
|
||||
<div class="field-group">
|
||||
<label>{{ field.label }}</label>
|
||||
<div class="field-with-lock">
|
||||
<input pSize="small" pInputText id="isbn10" formControlName="isbn10"/>
|
||||
@if (!book.metadata!['isbn10Locked']) {
|
||||
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('isbn10')" severity="success"></p-button>
|
||||
@if (field.type === 'boolean') {
|
||||
<p-select [formControlName]="field.controlName" [options]="booleanOptions" optionLabel="label" optionValue="value" size="small" appendTo="body"></p-select>
|
||||
} @else {
|
||||
<input pSize="small" pInputText [formControlName]="field.controlName"/>
|
||||
}
|
||||
@if (book.metadata!['isbn10Locked']) {
|
||||
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('isbn10')" severity="warn"></p-button>
|
||||
@if (!metadataForm.get(field.lockedKey)?.value) {
|
||||
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock(field.controlName)" severity="success"></p-button>
|
||||
}
|
||||
@if (metadataForm.get(field.lockedKey)?.value) {
|
||||
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock(field.controlName)" severity="warn"></p-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-group metadata-id-field">
|
||||
<label for="isbn13">ISBN 13</label>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Array fields (creators, characters, teams, locations) -->
|
||||
<div class="comic-array-grid">
|
||||
@for (field of comicArrayFields; track field) {
|
||||
<div class="field-group">
|
||||
<label>{{ field.label }}</label>
|
||||
<div class="field-with-lock">
|
||||
<input pSize="small" pInputText id="isbn13" formControlName="isbn13"/>
|
||||
@if (!book.metadata!['isbn13Locked']) {
|
||||
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('isbn13')" severity="success"></p-button>
|
||||
<div class="autocomplete-wrapper">
|
||||
<p-autoComplete
|
||||
[formControlName]="field.controlName"
|
||||
[multiple]="true"
|
||||
[dropdown]="false"
|
||||
[suggestions]="[]"
|
||||
[forceSelection]="false"
|
||||
[showClear]="true"
|
||||
(onKeyUp)="onAutoCompleteKeyUp(field.controlName, $event)"
|
||||
(onSelect)="onAutoCompleteSelect(field.controlName, $event)">
|
||||
</p-autoComplete>
|
||||
</div>
|
||||
@if (!metadataForm.get(field.lockedKey)?.value) {
|
||||
<p-button icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock(field.controlName)" severity="success"></p-button>
|
||||
}
|
||||
@if (book.metadata!['isbn13Locked']) {
|
||||
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('isbn13')" severity="warn"></p-button>
|
||||
@if (metadataForm.get(field.lockedKey)?.value) {
|
||||
<p-button icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock(field.controlName)" severity="warn"></p-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Textarea fields (notes) -->
|
||||
@for (field of comicTextareaFields; track field) {
|
||||
<div class="field-row comic-array-row">
|
||||
<div class="field-group full-width">
|
||||
<label>{{ field.label }}</label>
|
||||
<div class="field-with-lock description-with-lock">
|
||||
<textarea pTextarea [formControlName]="field.controlName" rows="4"></textarea>
|
||||
@if (!metadataForm.get(field.lockedKey)?.value) {
|
||||
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock(field.controlName)" severity="success"></p-button>
|
||||
}
|
||||
@if (metadataForm.get(field.lockedKey)?.value) {
|
||||
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock(field.controlName)" severity="warn"></p-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Provider Metadata Section (collapsible) -->
|
||||
<div class="collapsible-section" [class.expanded]="providerSectionExpanded">
|
||||
<div class="collapsible-header" (click)="providerSectionExpanded = !providerSectionExpanded">
|
||||
<i class="pi pi-database"></i>
|
||||
<span>Provider Metadata</span>
|
||||
<i [class]="providerSectionExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'" class="section-toggle"></i>
|
||||
</div>
|
||||
@if (providerSectionExpanded) {
|
||||
<div class="collapsible-content">
|
||||
<div class="field-row metadata-ids-row">
|
||||
@if (isFieldVisible('asin')) {
|
||||
<div class="field-group metadata-id-field">
|
||||
<label for="asin">Amazon ASIN</label>
|
||||
@@ -771,6 +898,9 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="description-section">
|
||||
<div class="field-group description-field">
|
||||
|
||||
@@ -239,7 +239,7 @@ label {
|
||||
|
||||
.language-field {
|
||||
@media (min-width: 768px) {
|
||||
width: 9rem;
|
||||
width: 11rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,21 +271,31 @@ label {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.half-width {
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.series-name-field {
|
||||
@media (min-width: 768px) {
|
||||
flex-basis: 75%;
|
||||
flex: 4.75;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.small-field {
|
||||
@media (min-width: 768px) {
|
||||
width: 9rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.metadata-ids-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
|
||||
@@ -300,6 +310,76 @@ label {
|
||||
}
|
||||
}
|
||||
|
||||
.collapsible-section {
|
||||
margin-top: 1rem;
|
||||
width: 100%;
|
||||
background: var(--card-background);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.75rem;
|
||||
|
||||
.collapsible-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
min-height: 2.5rem;
|
||||
background: var(--surface-ground);
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
i:first-child {
|
||||
font-size: 0.9rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.section-toggle {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.expanded .collapsible-header {
|
||||
border-radius: 0.75rem 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.collapsible-content {
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.comic-text-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.comic-array-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.description-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -335,7 +415,8 @@ label {
|
||||
|
||||
input,
|
||||
p-autocomplete,
|
||||
p-datepicker {
|
||||
p-datepicker,
|
||||
p-select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ import {FormControl, FormGroup, FormsModule, ReactiveFormsModule,} from "@angula
|
||||
import {Observable, sample} from "rxjs";
|
||||
import {AsyncPipe} from "@angular/common";
|
||||
import {MessageService} from "primeng/api";
|
||||
import {Book, BookMetadata, MetadataClearFlags, MetadataUpdateWrapper,} from "../../../../book/model/book.model";
|
||||
import {Book, BookMetadata, ComicMetadata, MetadataClearFlags, MetadataUpdateWrapper,} from "../../../../book/model/book.model";
|
||||
import {UrlHelperService} from "../../../../../shared/service/url-helper.service";
|
||||
import {ALL_COMIC_METADATA_FIELDS, AUDIOBOOK_METADATA_FIELDS, COMIC_FORM_TO_MODEL_LOCK, COMIC_TEXT_METADATA_FIELDS, COMIC_ARRAY_METADATA_FIELDS, COMIC_TEXTAREA_METADATA_FIELDS, MetadataFieldConfig} from '../../../../../shared/metadata';
|
||||
import {FileUpload, FileUploadErrorEvent, FileUploadEvent,} from "primeng/fileupload";
|
||||
import {HttpResponse} from "@angular/common/http";
|
||||
import {BookService} from "../../../../book/service/book.service";
|
||||
@@ -106,6 +107,20 @@ export class MetadataEditorComponent implements OnInit {
|
||||
|
||||
contentRatingOptions: {label: string, value: string}[] = [];
|
||||
ageRatingOptions: {label: string, value: number}[] = [];
|
||||
booleanOptions: {label: string, value: boolean | null}[] = [
|
||||
{label: 'Unknown', value: null},
|
||||
{label: 'Yes', value: true},
|
||||
{label: 'No', value: false},
|
||||
];
|
||||
|
||||
comicSectionExpanded = true;
|
||||
audiobookSectionExpanded = true;
|
||||
providerSectionExpanded = true;
|
||||
|
||||
comicTextFields = COMIC_TEXT_METADATA_FIELDS;
|
||||
comicArrayFields = COMIC_ARRAY_METADATA_FIELDS;
|
||||
comicTextareaFields = COMIC_TEXTAREA_METADATA_FIELDS;
|
||||
audiobookMetadataFields = AUDIOBOOK_METADATA_FIELDS;
|
||||
|
||||
providerSpecificFields: MetadataProviderSpecificFields = {
|
||||
asin: true,
|
||||
@@ -274,6 +289,20 @@ export class MetadataEditorComponent implements OnInit {
|
||||
{label: '18+', value: 18},
|
||||
{label: '21+', value: 21}
|
||||
];
|
||||
|
||||
// Add audiobook metadata form controls
|
||||
for (const field of AUDIOBOOK_METADATA_FIELDS) {
|
||||
const defaultValue = field.type === 'boolean' ? null : '';
|
||||
this.metadataForm.addControl(field.controlName, new FormControl(defaultValue));
|
||||
this.metadataForm.addControl(field.lockedKey, new FormControl(false));
|
||||
}
|
||||
|
||||
// Add comic metadata form controls
|
||||
for (const field of ALL_COMIC_METADATA_FIELDS) {
|
||||
const defaultValue = field.type === 'array' ? [] : (field.type === 'number' || field.type === 'boolean') ? null : '';
|
||||
this.metadataForm.addControl(field.controlName, new FormControl(defaultValue));
|
||||
this.metadataForm.addControl(field.lockedKey, new FormControl(false));
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -434,6 +463,33 @@ export class MetadataEditorComponent implements OnInit {
|
||||
contentRatingLocked: metadata.contentRatingLocked ?? false,
|
||||
});
|
||||
|
||||
// Patch audiobook metadata
|
||||
const audiobookPatch: Record<string, unknown> = {};
|
||||
for (const field of AUDIOBOOK_METADATA_FIELDS) {
|
||||
const key = field.controlName as keyof BookMetadata;
|
||||
const lockedKey = field.lockedKey as keyof BookMetadata;
|
||||
audiobookPatch[field.controlName] = metadata[key] ?? (field.type === 'boolean' ? null : '');
|
||||
audiobookPatch[field.lockedKey] = metadata[lockedKey] ?? false;
|
||||
}
|
||||
this.metadataForm.patchValue(audiobookPatch);
|
||||
|
||||
// Patch comic metadata
|
||||
const comicMeta = metadata.comicMetadata;
|
||||
const comicPatch: Record<string, unknown> = {};
|
||||
for (const field of ALL_COMIC_METADATA_FIELDS) {
|
||||
const value = comicMeta?.[field.fetchedKey as keyof ComicMetadata];
|
||||
if (field.type === 'array') {
|
||||
comicPatch[field.controlName] = [...(value as string[] ?? [])].sort();
|
||||
} else if (field.type === 'boolean' || field.type === 'number') {
|
||||
comicPatch[field.controlName] = value ?? null;
|
||||
} else {
|
||||
comicPatch[field.controlName] = value ?? '';
|
||||
}
|
||||
const modelLockKey = COMIC_FORM_TO_MODEL_LOCK[field.lockedKey];
|
||||
comicPatch[field.lockedKey] = comicMeta?.[modelLockKey as keyof ComicMetadata] ?? false;
|
||||
}
|
||||
this.metadataForm.patchValue(comicPatch);
|
||||
|
||||
const lockableFields: { key: keyof BookMetadata; control: string }[] = [
|
||||
{key: "titleLocked", control: "title"},
|
||||
{key: "subtitleLocked", control: "subtitle"},
|
||||
@@ -485,6 +541,24 @@ export class MetadataEditorComponent implements OnInit {
|
||||
isLocked ? formControl.disable() : formControl.enable();
|
||||
}
|
||||
}
|
||||
|
||||
// Apply audiobook lock states
|
||||
for (const field of AUDIOBOOK_METADATA_FIELDS) {
|
||||
const isLocked = this.metadataForm.get(field.lockedKey)?.value === true;
|
||||
const formControl = this.metadataForm.get(field.controlName);
|
||||
if (formControl) {
|
||||
isLocked ? formControl.disable() : formControl.enable();
|
||||
}
|
||||
}
|
||||
|
||||
// Apply comic lock states
|
||||
for (const field of ALL_COMIC_METADATA_FIELDS) {
|
||||
const isLocked = this.metadataForm.get(field.lockedKey)?.value === true;
|
||||
const formControl = this.metadataForm.get(field.controlName);
|
||||
if (formControl) {
|
||||
isLocked ? formControl.disable() : formControl.enable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onAutoCompleteSelect(fieldName: string, event: AutoCompleteSelectEvent) {
|
||||
@@ -628,6 +702,12 @@ export class MetadataEditorComponent implements OnInit {
|
||||
thumbnailUrl: form.get("thumbnailUrl")?.value,
|
||||
audiobookCover: form.get("audiobookCover")?.value,
|
||||
|
||||
// Audiobook metadata
|
||||
narrator: form.get("narrator")?.value,
|
||||
abridged: form.get("abridged")?.value,
|
||||
narratorLocked: form.get("narratorLocked")?.value,
|
||||
abridgedLocked: form.get("abridgedLocked")?.value,
|
||||
|
||||
// Locks
|
||||
titleLocked: form.get("titleLocked")?.value,
|
||||
subtitleLocked: form.get("subtitleLocked")?.value,
|
||||
@@ -677,6 +757,35 @@ export class MetadataEditorComponent implements OnInit {
|
||||
}),
|
||||
};
|
||||
|
||||
// Build comic metadata from form controls
|
||||
const comicMetadata: Record<string, unknown> = {};
|
||||
for (const field of ALL_COMIC_METADATA_FIELDS) {
|
||||
const value = form.get(field.controlName)?.value;
|
||||
if (field.type === 'array') {
|
||||
comicMetadata[field.fetchedKey] = Array.isArray(value) ? value : [];
|
||||
} else if (field.type === 'number') {
|
||||
comicMetadata[field.fetchedKey] = value !== '' && value !== null ? Number(value) : null;
|
||||
} else if (field.type === 'boolean') {
|
||||
comicMetadata[field.fetchedKey] = value ?? null;
|
||||
} else {
|
||||
comicMetadata[field.fetchedKey] = value ?? '';
|
||||
}
|
||||
}
|
||||
// Consolidate comic lock states to model lock keys
|
||||
const lockGroups: Record<string, boolean> = {};
|
||||
for (const [formKey, modelKey] of Object.entries(COMIC_FORM_TO_MODEL_LOCK)) {
|
||||
const value = form.get(formKey)?.value ?? false;
|
||||
if (value) {
|
||||
lockGroups[modelKey] = true;
|
||||
} else if (!(modelKey in lockGroups)) {
|
||||
lockGroups[modelKey] = false;
|
||||
}
|
||||
}
|
||||
for (const [modelKey, value] of Object.entries(lockGroups)) {
|
||||
comicMetadata[modelKey] = value;
|
||||
}
|
||||
metadata.comicMetadata = comicMetadata as ComicMetadata;
|
||||
|
||||
const original = this.originalMetadata;
|
||||
|
||||
const wasCleared = (key: keyof BookMetadata): boolean => {
|
||||
@@ -725,6 +834,8 @@ export class MetadataEditorComponent implements OnInit {
|
||||
seriesName: wasCleared("seriesName"),
|
||||
seriesNumber: wasCleared("seriesNumber"),
|
||||
seriesTotal: wasCleared("seriesTotal"),
|
||||
narrator: wasCleared("narrator"),
|
||||
abridged: wasCleared("abridged"),
|
||||
audiobookCover: wasCleared("audiobookCover"),
|
||||
cover: false,
|
||||
ageRating: wasCleared("ageRating"),
|
||||
@@ -1051,6 +1162,14 @@ export class MetadataEditorComponent implements OnInit {
|
||||
return this.bookService.supportsDualCovers(book);
|
||||
}
|
||||
|
||||
isCBX(book: Book): boolean {
|
||||
return book.primaryFile?.bookType === 'CBX';
|
||||
}
|
||||
|
||||
isAudiobook(book: Book): boolean {
|
||||
return book.primaryFile?.bookType === 'AUDIOBOOK';
|
||||
}
|
||||
|
||||
getUploadAudiobookCoverUrl(): string {
|
||||
return this.bookService.getUploadAudiobookCoverUrl(this.currentBookId);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
@if (book$ | async; as book) {
|
||||
<form [formGroup]="metadataForm" (ngSubmit)="onSave()" class="metadata-picker">
|
||||
<div class="dialog-body">
|
||||
@if (detailLoading) {
|
||||
<div class="detail-loading-banner">
|
||||
<i class="pi pi-spin pi-spinner"></i>
|
||||
<span>Loading complete metadata...</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Column Headers (Desktop) -->
|
||||
<div class="column-headers desktop-only">
|
||||
<div class="column-label current">
|
||||
@@ -434,16 +441,22 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Provider Metadata Section -->
|
||||
@if (metadataProviderFields.length > 0) {
|
||||
<!-- Comic Book Details Section -->
|
||||
@if (shouldShowComicSection()) {
|
||||
<section class="form-section">
|
||||
<div class="section-header">
|
||||
<i class="pi pi-cloud"></i>
|
||||
<span>Provider Metadata</span>
|
||||
<div class="section-header collapsible-header" (click)="comicSectionExpanded = !comicSectionExpanded">
|
||||
<i class="pi pi-bolt"></i>
|
||||
<span>Comic Book Details</span>
|
||||
@if (!hasAnyFetchedComicData() && !hasAnyCurrentComicData()) {
|
||||
<span class="section-empty-badge">none present</span>
|
||||
}
|
||||
<i [class]="comicSectionExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'" class="section-toggle"></i>
|
||||
</div>
|
||||
|
||||
@if (comicSectionExpanded) {
|
||||
<div class="section-content">
|
||||
@for (field of metadataProviderFields; track field) {
|
||||
<!-- Comic text/number/boolean fields -->
|
||||
@for (field of comicTextFields; track field) {
|
||||
<div class="field-row">
|
||||
<label class="field-label">{{ field.label }}</label>
|
||||
<div class="field-content">
|
||||
@@ -456,7 +469,15 @@
|
||||
>
|
||||
<i [class]="metadataForm.get(field.lockedKey)?.value ? 'pi pi-lock' : 'pi pi-lock-open'"></i>
|
||||
</button>
|
||||
@if (field.type === 'boolean') {
|
||||
<p-checkbox
|
||||
formControlName="{{field.controlName}}"
|
||||
[binary]="true"
|
||||
class="field-checkbox"
|
||||
/>
|
||||
} @else {
|
||||
<input pInputText formControlName="{{field.controlName}}" class="field-input"/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -473,12 +494,117 @@
|
||||
</button>
|
||||
|
||||
<div class="field-side fetched">
|
||||
<input pInputText [value]="fetchedMetadata[field.fetchedKey] ?? ''" class="field-input" readonly/>
|
||||
@if (field.type === 'boolean') {
|
||||
<p-checkbox
|
||||
[ngModel]="getFetchedComicValue(field.fetchedKey)"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[binary]="true"
|
||||
[disabled]="true"
|
||||
class="field-checkbox"
|
||||
/>
|
||||
} @else {
|
||||
<input pInputText [value]="getFetchedComicValue(field.fetchedKey) ?? ''" class="field-input" readonly/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Comic array fields (creators, characters, teams, locations) -->
|
||||
@for (field of comicArrayFields; track field) {
|
||||
<div class="field-row">
|
||||
<label class="field-label">{{ field.label }}</label>
|
||||
<div class="field-content">
|
||||
<div class="field-side current">
|
||||
<button
|
||||
type="button"
|
||||
class="lock-btn"
|
||||
[class.locked]="metadataForm.get(field.lockedKey)?.value"
|
||||
(click)="toggleLock(field.controlName)"
|
||||
>
|
||||
<i [class]="metadataForm.get(field.lockedKey)?.value ? 'pi pi-lock' : 'pi pi-lock-open'"></i>
|
||||
</button>
|
||||
<p-autoComplete
|
||||
class="chips-input"
|
||||
formControlName="{{field.controlName}}"
|
||||
[multiple]="true"
|
||||
[dropdown]="false"
|
||||
[forceSelection]="false"
|
||||
[showClear]="true"
|
||||
[suggestions]="getFiltered(field.controlName)"
|
||||
(completeMethod)="filterItems($event, field.controlName)"
|
||||
(onKeyUp)="onAutoCompleteKeyUp(field.controlName, $event)"
|
||||
(onSelect)="onAutoCompleteSelect(field.controlName, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="transfer-btn"
|
||||
[class.saved]="isValueSaved(field.controlName)"
|
||||
[class.copied]="isValueCopied(field.controlName) && !hoveredFields[field.controlName]"
|
||||
[class.reset]="isValueCopied(field.controlName) && hoveredFields[field.controlName]"
|
||||
(click)="hoveredFields[field.controlName] && isValueCopied(field.controlName) ? resetField(field.controlName) : copyFetchedToCurrent(field.controlName)"
|
||||
(mouseenter)="onMouseEnter(field.controlName)"
|
||||
(mouseleave)="onMouseLeave(field.controlName)"
|
||||
>
|
||||
<i [class]="isValueSaved(field.controlName) ? 'pi pi-check' : (hoveredFields[field.controlName] && isValueCopied(field.controlName) ? 'pi pi-times' : 'pi pi-arrow-left')"></i>
|
||||
</button>
|
||||
|
||||
<div class="field-side fetched">
|
||||
<p-autoComplete
|
||||
[ngModel]="getFetchedComicValue(field.fetchedKey) ?? []"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[disabled]="true"
|
||||
[multiple]="true"
|
||||
[typeahead]="false"
|
||||
[dropdown]="false"
|
||||
[forceSelection]="false"
|
||||
class="chips-input readonly"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Comic textarea fields (notes) -->
|
||||
@for (field of comicTextareaFields; track field) {
|
||||
<div class="field-row textarea-row">
|
||||
<label class="field-label">{{ field.label }}</label>
|
||||
<div class="field-content">
|
||||
<div class="field-side current">
|
||||
<button
|
||||
type="button"
|
||||
class="lock-btn"
|
||||
[class.locked]="metadataForm.get(field.lockedKey)?.value"
|
||||
(click)="toggleLock(field.controlName)"
|
||||
>
|
||||
<i [class]="metadataForm.get(field.lockedKey)?.value ? 'pi pi-lock' : 'pi pi-lock-open'"></i>
|
||||
</button>
|
||||
<textarea pTextarea formControlName="{{field.controlName}}" class="field-textarea" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="transfer-btn"
|
||||
[class.saved]="isValueSaved(field.controlName)"
|
||||
[class.copied]="isValueCopied(field.controlName) && !hoveredFields[field.controlName]"
|
||||
[class.reset]="isValueCopied(field.controlName) && hoveredFields[field.controlName]"
|
||||
(click)="hoveredFields[field.controlName] && isValueCopied(field.controlName) ? resetField(field.controlName) : copyFetchedToCurrent(field.controlName)"
|
||||
(mouseenter)="onMouseEnter(field.controlName)"
|
||||
(mouseleave)="onMouseLeave(field.controlName)"
|
||||
>
|
||||
<i [class]="isValueSaved(field.controlName) ? 'pi pi-check' : (hoveredFields[field.controlName] && isValueCopied(field.controlName) ? 'pi pi-times' : 'pi pi-arrow-left')"></i>
|
||||
</button>
|
||||
|
||||
<div class="field-side fetched">
|
||||
<textarea pTextarea [value]="getFetchedComicValue(field.fetchedKey) ?? ''" class="field-textarea" rows="3" readonly></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -547,6 +673,54 @@
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Provider Metadata Section -->
|
||||
@if (metadataProviderFields.length > 0) {
|
||||
<section class="form-section">
|
||||
<div class="section-header">
|
||||
<i class="pi pi-cloud"></i>
|
||||
<span>Provider Metadata</span>
|
||||
</div>
|
||||
|
||||
<div class="section-content">
|
||||
@for (field of metadataProviderFields; track field) {
|
||||
<div class="field-row">
|
||||
<label class="field-label">{{ field.label }}</label>
|
||||
<div class="field-content">
|
||||
<div class="field-side current">
|
||||
<button
|
||||
type="button"
|
||||
class="lock-btn"
|
||||
[class.locked]="metadataForm.get(field.lockedKey)?.value"
|
||||
(click)="toggleLock(field.controlName)"
|
||||
>
|
||||
<i [class]="metadataForm.get(field.lockedKey)?.value ? 'pi pi-lock' : 'pi pi-lock-open'"></i>
|
||||
</button>
|
||||
<input pInputText formControlName="{{field.controlName}}" class="field-input"/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="transfer-btn"
|
||||
[class.saved]="isValueSaved(field.controlName)"
|
||||
[class.copied]="isValueCopied(field.controlName) && !hoveredFields[field.controlName]"
|
||||
[class.reset]="isValueCopied(field.controlName) && hoveredFields[field.controlName]"
|
||||
(click)="hoveredFields[field.controlName] && isValueCopied(field.controlName) ? resetField(field.controlName) : copyFetchedToCurrent(field.controlName)"
|
||||
(mouseenter)="onMouseEnter(field.controlName)"
|
||||
(mouseleave)="onMouseLeave(field.controlName)"
|
||||
>
|
||||
<i [class]="isValueSaved(field.controlName) ? 'pi pi-check' : (hoveredFields[field.controlName] && isValueCopied(field.controlName) ? 'pi pi-times' : 'pi pi-arrow-left')"></i>
|
||||
</button>
|
||||
|
||||
<div class="field-side fetched">
|
||||
<input pInputText [value]="fetchedMetadata[field.fetchedKey] ?? ''" class="field-input" readonly/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!reviewMode) {
|
||||
|
||||
@@ -25,6 +25,26 @@
|
||||
}
|
||||
|
||||
|
||||
// Detail loading banner
|
||||
.detail-loading-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
border-radius: 8px;
|
||||
color: var(--p-green-400);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
|
||||
i {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Body
|
||||
.dialog-body {
|
||||
flex: 1;
|
||||
@@ -128,6 +148,35 @@
|
||||
font-size: 0.9rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
&.collapsible-header {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.section-toggle {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.section-empty-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
color: var(--text-secondary-color);
|
||||
background: var(--surface-ground);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 0.125rem 0.5rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.section-content {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Component, DestroyRef, EventEmitter, inject, Input, OnInit, Output} from '@angular/core';
|
||||
import {Book, BookMetadata, MetadataClearFlags, MetadataUpdateWrapper} from '../../../../book/model/book.model';
|
||||
import {Book, BookMetadata, ComicMetadata, MetadataClearFlags, MetadataUpdateWrapper} from '../../../../book/model/book.model';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {Button} from 'primeng/button';
|
||||
import {FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms';
|
||||
@@ -18,7 +18,7 @@ import {Checkbox} from 'primeng/checkbox';
|
||||
import {LazyLoadImageModule} from 'ng-lazyload-image';
|
||||
import {AppSettingsService} from '../../../../../shared/service/app-settings.service';
|
||||
import {MetadataProviderSpecificFields} from '../../../../../shared/model/app-settings.model';
|
||||
import {ALL_METADATA_FIELDS, AUDIOBOOK_METADATA_FIELDS, getArrayFields, getBookDetailsFields, getBottomFields, getProviderFields, getSeriesFields, getTextareaFields, getTopFields, MetadataFieldConfig, MetadataFormBuilder, MetadataUtilsService} from '../../../../../shared/metadata';
|
||||
import {ALL_COMIC_METADATA_FIELDS, ALL_METADATA_FIELDS, AUDIOBOOK_METADATA_FIELDS, COMIC_ARRAY_METADATA_FIELDS, COMIC_FORM_TO_MODEL_LOCK, COMIC_TEXT_METADATA_FIELDS, COMIC_TEXTAREA_METADATA_FIELDS, getArrayFields, getBookDetailsFields, getBottomFields, getProviderFields, getSeriesFields, getTextareaFields, getTopFields, MetadataFieldConfig, MetadataFormBuilder, MetadataUtilsService} from '../../../../../shared/metadata';
|
||||
|
||||
@Component({
|
||||
selector: 'app-metadata-picker',
|
||||
@@ -50,10 +50,15 @@ export class MetadataPickerComponent implements OnInit {
|
||||
metadataProviderFields: MetadataFieldConfig[] = [];
|
||||
metadataFieldsBottom: MetadataFieldConfig[] = [];
|
||||
audiobookMetadataFields: MetadataFieldConfig[] = [];
|
||||
comicTextFields: MetadataFieldConfig[] = [];
|
||||
comicArrayFields: MetadataFieldConfig[] = [];
|
||||
comicTextareaFields: MetadataFieldConfig[] = [];
|
||||
comicSectionExpanded = false;
|
||||
|
||||
@Input() reviewMode!: boolean;
|
||||
@Input() fetchedMetadata!: BookMetadata;
|
||||
@Input() book$!: Observable<Book | null>;
|
||||
@Input() detailLoading = false;
|
||||
@Output() goBack = new EventEmitter<boolean>();
|
||||
|
||||
currentBook: Book | null = null;
|
||||
@@ -93,6 +98,9 @@ export class MetadataPickerComponent implements OnInit {
|
||||
this.updateProviderFields();
|
||||
this.updateBottomFields();
|
||||
this.audiobookMetadataFields = AUDIOBOOK_METADATA_FIELDS;
|
||||
this.comicTextFields = COMIC_TEXT_METADATA_FIELDS;
|
||||
this.comicArrayFields = COMIC_ARRAY_METADATA_FIELDS;
|
||||
this.comicTextareaFields = COMIC_TEXTAREA_METADATA_FIELDS;
|
||||
}
|
||||
|
||||
private updateProviderFields(): void {
|
||||
@@ -209,8 +217,26 @@ export class MetadataPickerComponent implements OnInit {
|
||||
patchData[field.lockedKey] = topLevelLocked ?? false;
|
||||
}
|
||||
|
||||
// Handle comic book metadata fields (nested under comicMetadata)
|
||||
const comicMeta = metadata.comicMetadata;
|
||||
for (const field of ALL_COMIC_METADATA_FIELDS) {
|
||||
const value = comicMeta?.[field.fetchedKey as keyof ComicMetadata];
|
||||
if (field.type === 'array') {
|
||||
patchData[field.controlName] = [...(value as string[] ?? [])].sort();
|
||||
} else if (field.type === 'boolean') {
|
||||
patchData[field.controlName] = value ?? null;
|
||||
} else if (field.type === 'number') {
|
||||
patchData[field.controlName] = value ?? null;
|
||||
} else {
|
||||
patchData[field.controlName] = value ?? '';
|
||||
}
|
||||
const modelLockKey = COMIC_FORM_TO_MODEL_LOCK[field.lockedKey];
|
||||
patchData[field.lockedKey] = comicMeta?.[modelLockKey as keyof ComicMetadata] ?? false;
|
||||
}
|
||||
|
||||
this.metadataForm.patchValue(patchData);
|
||||
this.applyLockStates(metadata);
|
||||
this.comicSectionExpanded = this.hasAnyFetchedComicData() || this.hasAnyCurrentComicData();
|
||||
}
|
||||
|
||||
private applyLockStates(metadata: BookMetadata): void {
|
||||
@@ -222,6 +248,12 @@ export class MetadataPickerComponent implements OnInit {
|
||||
for (const field of AUDIOBOOK_METADATA_FIELDS) {
|
||||
lockedFields[field.lockedKey] = !!metadata[field.lockedKey as keyof BookMetadata];
|
||||
}
|
||||
// Handle comic book metadata lock states (nested under comicMetadata)
|
||||
const comicMeta = metadata.comicMetadata;
|
||||
for (const field of ALL_COMIC_METADATA_FIELDS) {
|
||||
const modelLockKey = COMIC_FORM_TO_MODEL_LOCK[field.lockedKey];
|
||||
lockedFields[field.lockedKey] = !!comicMeta?.[modelLockKey as keyof ComicMetadata];
|
||||
}
|
||||
this.formBuilder.applyLockStates(this.metadataForm, lockedFields);
|
||||
}
|
||||
|
||||
@@ -326,6 +358,35 @@ export class MetadataPickerComponent implements OnInit {
|
||||
metadata[field.lockedKey] = this.metadataForm.get(field.lockedKey)?.value ?? false;
|
||||
}
|
||||
|
||||
// Build comic metadata from form controls
|
||||
const comicMetadata: Record<string, unknown> = {};
|
||||
for (const field of ALL_COMIC_METADATA_FIELDS) {
|
||||
if (field.type === 'array') {
|
||||
comicMetadata[field.fetchedKey] = this.getArrayValue(field.controlName);
|
||||
} else if (field.type === 'number') {
|
||||
comicMetadata[field.fetchedKey] = this.getNumberValue(field.controlName);
|
||||
} else if (field.type === 'boolean') {
|
||||
comicMetadata[field.fetchedKey] = this.getBooleanValue(field.controlName);
|
||||
} else {
|
||||
comicMetadata[field.fetchedKey] = this.getStringValue(field.controlName);
|
||||
}
|
||||
}
|
||||
// Consolidate lock states back to model lock keys using the form-to-model mapping.
|
||||
// If ANY field in a group is locked, the backend group lock is set to true.
|
||||
const lockGroups: Record<string, boolean> = {};
|
||||
for (const [formKey, modelKey] of Object.entries(COMIC_FORM_TO_MODEL_LOCK)) {
|
||||
const value = this.metadataForm.get(formKey)?.value ?? false;
|
||||
if (value) {
|
||||
lockGroups[modelKey] = true;
|
||||
} else if (!(modelKey in lockGroups)) {
|
||||
lockGroups[modelKey] = false;
|
||||
}
|
||||
}
|
||||
for (const [modelKey, value] of Object.entries(lockGroups)) {
|
||||
comicMetadata[modelKey] = value;
|
||||
}
|
||||
metadata['comicMetadata'] = comicMetadata;
|
||||
|
||||
return metadata as BookMetadata;
|
||||
}
|
||||
|
||||
@@ -411,6 +472,27 @@ export class MetadataPickerComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle comic metadata clear flags
|
||||
const currComic = current.comicMetadata;
|
||||
const origComic = original.comicMetadata;
|
||||
if (origComic) {
|
||||
for (const field of ALL_COMIC_METADATA_FIELDS) {
|
||||
const key = field.fetchedKey as keyof ComicMetadata;
|
||||
const curr = currComic?.[key];
|
||||
const orig = origComic[key];
|
||||
|
||||
if (field.type === 'array') {
|
||||
flags[`comic_${key}`] = !(curr as string[])?.length && !!(orig as string[])?.length;
|
||||
} else if (field.type === 'boolean') {
|
||||
flags[`comic_${key}`] = curr === null && orig !== null;
|
||||
} else if (field.type === 'number') {
|
||||
flags[`comic_${key}`] = curr === null && orig !== null;
|
||||
} else {
|
||||
flags[`comic_${key}`] = !curr && !!orig;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return flags as MetadataClearFlags;
|
||||
}
|
||||
|
||||
@@ -478,6 +560,21 @@ export class MetadataPickerComponent implements OnInit {
|
||||
this.copiedFields,
|
||||
(field) => this.copyFetchedToCurrent(field)
|
||||
);
|
||||
// Also copy missing comic fields
|
||||
if (this.fetchedMetadata?.comicMetadata) {
|
||||
for (const field of ALL_COMIC_METADATA_FIELDS) {
|
||||
const isLocked = this.metadataForm.get(field.lockedKey)?.value;
|
||||
const currentValue = this.metadataForm.get(field.controlName)?.value;
|
||||
const fetchedValue = this.fetchedMetadata.comicMetadata[field.fetchedKey as keyof ComicMetadata];
|
||||
const isEmpty = Array.isArray(currentValue)
|
||||
? currentValue.length === 0
|
||||
: (currentValue === null || currentValue === undefined || currentValue === '');
|
||||
const hasFetchedValue = fetchedValue !== null && fetchedValue !== undefined && fetchedValue !== '';
|
||||
if (!isLocked && isEmpty && hasFetchedValue) {
|
||||
this.copyFetchedToCurrent(field.controlName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
copyAll(): void {
|
||||
@@ -486,9 +583,40 @@ export class MetadataPickerComponent implements OnInit {
|
||||
this.metadataForm,
|
||||
(field) => this.copyFetchedToCurrent(field)
|
||||
);
|
||||
// Also copy all comic fields
|
||||
if (this.fetchedMetadata?.comicMetadata) {
|
||||
for (const field of ALL_COMIC_METADATA_FIELDS) {
|
||||
const isLocked = this.metadataForm.get(field.lockedKey)?.value;
|
||||
const fetchedValue = this.fetchedMetadata.comicMetadata[field.fetchedKey as keyof ComicMetadata];
|
||||
if (!isLocked && fetchedValue != null && fetchedValue !== '') {
|
||||
this.copyFetchedToCurrent(field.controlName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
copyFetchedToCurrent(field: string): void {
|
||||
// Handle comic fields (nested under comicMetadata)
|
||||
const comicConfig = this.getComicFieldConfig(field);
|
||||
if (comicConfig) {
|
||||
const isLocked = this.metadataForm.get(comicConfig.lockedKey)?.value;
|
||||
if (isLocked) {
|
||||
this.messageService.add({
|
||||
severity: 'warn',
|
||||
summary: 'Action Blocked',
|
||||
detail: `${comicConfig.label} is locked and cannot be updated.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
const value = this.fetchedMetadata?.comicMetadata?.[comicConfig.fetchedKey as keyof ComicMetadata];
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
this.metadataForm.get(field)?.setValue(value);
|
||||
this.copiedFields[field] = true;
|
||||
this.highlightCopiedInput(field);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let lockField = field;
|
||||
if (field === 'thumbnailUrl') {
|
||||
lockField = 'cover';
|
||||
@@ -552,6 +680,14 @@ export class MetadataPickerComponent implements OnInit {
|
||||
}
|
||||
|
||||
resetField(field: string): void {
|
||||
const comicConfig = this.getComicFieldConfig(field);
|
||||
if (comicConfig) {
|
||||
const value = this.originalMetadata?.comicMetadata?.[comicConfig.fetchedKey as keyof ComicMetadata];
|
||||
this.metadataForm.get(field)?.setValue(value ?? (comicConfig.type === 'array' ? [] : comicConfig.type === 'boolean' ? null : ''));
|
||||
this.copiedFields[field] = false;
|
||||
this.hoveredFields[field] = false;
|
||||
return;
|
||||
}
|
||||
this.metadataUtils.resetField(field, this.metadataForm, this.originalMetadata, this.copiedFields, this.hoveredFields);
|
||||
}
|
||||
|
||||
@@ -580,4 +716,41 @@ export class MetadataPickerComponent implements OnInit {
|
||||
supportsDualCovers(book: Book): boolean {
|
||||
return this.hasEbookFormat(book) && this.hasAudiobookFormat(book);
|
||||
}
|
||||
|
||||
// Comic metadata helpers
|
||||
hasAnyFetchedComicData(): boolean {
|
||||
const comic = this.fetchedMetadata?.comicMetadata;
|
||||
if (!comic) return false;
|
||||
return ALL_COMIC_METADATA_FIELDS.some(field => {
|
||||
const value = comic[field.fetchedKey as keyof ComicMetadata];
|
||||
if (value === null || value === undefined || value === '' || value === false) return false;
|
||||
if (Array.isArray(value) && value.length === 0) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
hasAnyCurrentComicData(): boolean {
|
||||
const comic = this.currentBook?.metadata?.comicMetadata;
|
||||
if (!comic) return false;
|
||||
return ALL_COMIC_METADATA_FIELDS.some(field => {
|
||||
const value = comic[field.fetchedKey as keyof ComicMetadata];
|
||||
if (value === null || value === undefined || value === '' || value === false) return false;
|
||||
if (Array.isArray(value) && value.length === 0) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
shouldShowComicSection(): boolean {
|
||||
return this.hasAnyFetchedComicData() || this.hasAnyCurrentComicData() ||
|
||||
this.currentBook?.primaryFile?.bookType === 'CBX' ||
|
||||
this.fetchedMetadata?.provider?.toLowerCase() === 'comicvine';
|
||||
}
|
||||
|
||||
getFetchedComicValue(fetchedKey: string): unknown {
|
||||
return this.fetchedMetadata?.comicMetadata?.[fetchedKey as keyof ComicMetadata];
|
||||
}
|
||||
|
||||
private getComicFieldConfig(controlName: string): MetadataFieldConfig | undefined {
|
||||
return ALL_COMIC_METADATA_FIELDS.find(f => f.controlName === controlName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<app-metadata-picker
|
||||
[fetchedMetadata]="selectedFetchedMetadata"
|
||||
[book$]="book$"
|
||||
[detailLoading]="detailLoading"
|
||||
(goBack)="onGoBack()">
|
||||
</app-metadata-picker>
|
||||
</div>
|
||||
|
||||
@@ -44,6 +44,7 @@ export class MetadataSearcherComponent implements OnInit, OnDestroy {
|
||||
@Input() book$!: Observable<Book | null>;
|
||||
|
||||
selectedFetchedMetadata$ = new BehaviorSubject<BookMetadata | null>(null);
|
||||
detailLoading = false;
|
||||
|
||||
private formBuilder = inject(FormBuilder);
|
||||
private bookMetadataService = inject(BookMetadataService);
|
||||
@@ -267,7 +268,7 @@ export class MetadataSearcherComponent implements OnInit, OnDestroy {
|
||||
if (metadata.comicvineId) return 'comicvine';
|
||||
if (metadata.ranobedbId) return 'ranobedb';
|
||||
if (metadata.audibleId) return 'audible';
|
||||
return null;
|
||||
return metadata.provider?.toLowerCase() || null;
|
||||
}
|
||||
|
||||
getProviderClass(metadata: BookMetadata): string {
|
||||
@@ -341,9 +342,64 @@ export class MetadataSearcherComponent implements OnInit, OnDestroy {
|
||||
|
||||
onBookClick(fetchedMetadata: BookMetadata) {
|
||||
this.selectedFetchedMetadata$.next(fetchedMetadata);
|
||||
|
||||
const enrichment = this.getDetailEnrichmentInfo(fetchedMetadata);
|
||||
|
||||
if (enrichment) {
|
||||
this.detailLoading = true;
|
||||
this.bookMetadataService.fetchMetadataDetail(enrichment.provider, enrichment.id)
|
||||
.pipe(takeUntil(this.cancelRequest$))
|
||||
.subscribe({
|
||||
next: (enriched) => {
|
||||
const current = this.selectedFetchedMetadata$.value;
|
||||
const currentId = current && this.getProviderItemId(current, enrichment.provider);
|
||||
if (currentId === enrichment.id) {
|
||||
this.selectedFetchedMetadata$.next(enriched);
|
||||
}
|
||||
this.detailLoading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error fetching detailed metadata:', err);
|
||||
this.detailLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private getDetailEnrichmentInfo(metadata: BookMetadata): { provider: string; id: string } | null {
|
||||
if (metadata.comicvineId && (!metadata.comicMetadata
|
||||
|| (!metadata.comicMetadata.pencillers?.length
|
||||
&& !metadata.comicMetadata.inkers?.length
|
||||
&& !metadata.comicMetadata.colorists?.length
|
||||
&& !metadata.comicMetadata.letterers?.length
|
||||
&& !metadata.comicMetadata.editors?.length
|
||||
&& !metadata.comicMetadata.characters?.length))) {
|
||||
return {provider: 'Comicvine', id: metadata.comicvineId};
|
||||
}
|
||||
if (metadata.goodreadsId && !metadata.description) {
|
||||
return {provider: 'GoodReads', id: metadata.goodreadsId};
|
||||
}
|
||||
if (metadata.asin && !metadata.description) {
|
||||
return {provider: 'Amazon', id: metadata.asin};
|
||||
}
|
||||
if (metadata.audibleId && !metadata.description) {
|
||||
return {provider: 'Audible', id: metadata.audibleId};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private getProviderItemId(metadata: BookMetadata, provider: string): string | undefined {
|
||||
switch (provider) {
|
||||
case 'Comicvine': return metadata.comicvineId;
|
||||
case 'GoodReads': return metadata.goodreadsId;
|
||||
case 'Amazon': return metadata.asin;
|
||||
case 'Audible': return metadata.audibleId;
|
||||
default: return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
onGoBack() {
|
||||
this.detailLoading = false;
|
||||
this.selectedFetchedMetadata$.next(null);
|
||||
}
|
||||
|
||||
@@ -371,16 +427,17 @@ export class MetadataSearcherComponent implements OnInit, OnDestroy {
|
||||
} else if (metadata['lubimyczytacId']) {
|
||||
return `<a href="https://lubimyczytac.pl/ksiazka/${metadata['lubimyczytacId']}/ksiazka" target="_blank">Lubimyczytac</a>`;
|
||||
} else if (metadata.comicvineId) {
|
||||
if (metadata.comicvineId.startsWith('4000')) {
|
||||
const name = metadata.seriesName ? metadata.seriesName.replace(/ /g, '-').toLowerCase() + "-" + metadata.seriesNumber : '';
|
||||
return `<a href="https://comicvine.gamespot.com/${name}/${metadata.comicvineId}" target="_blank">Comicvine</a>`;
|
||||
if (metadata.externalUrl) {
|
||||
return `<a href="${metadata.externalUrl}" target="_blank">Comicvine</a>`;
|
||||
}
|
||||
const name = metadata.seriesName;
|
||||
return `<a href="https://comicvine.gamespot.com/volume/${metadata.comicvineId}" target="_blank">Comicvine</a>`;
|
||||
return `<a href="https://comicvine.gamespot.com/4050-${metadata.comicvineId}/" target="_blank">Comicvine</a>`;
|
||||
} else if (metadata.ranobedbId) {
|
||||
return `<a href="https://ranobedb.org/book/${metadata.ranobedbId}" target="_blank">RanobeDB</a>`;
|
||||
} else if (metadata.audibleId) {
|
||||
return `<a href="https://www.audible.com/pd/${metadata.audibleId}" target="_blank">Audible</a>`;
|
||||
} else if (metadata.externalUrl) {
|
||||
const providerName = metadata.provider || 'Link';
|
||||
return `<a href="${metadata.externalUrl}" target="_blank">${providerName}</a>`;
|
||||
}
|
||||
throw new Error("No provider ID found in metadata.");
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
width="200"
|
||||
appendTo="body"
|
||||
[preview]="true"
|
||||
styleClass="square-cover">
|
||||
class="square-cover">
|
||||
</p-image>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -254,10 +254,10 @@
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (book?.metadata?.comicvineId) {
|
||||
@if (book?.metadata?.comicMetadata?.webLink) {
|
||||
<a
|
||||
class="rating-link"
|
||||
[href]="'https://comicvine.gamespot.com/' + book.metadata!.comicvineId"
|
||||
[href]="book.metadata!.comicMetadata!.webLink"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<p-tag
|
||||
[value]="getSyncStatusLabel()"
|
||||
[severity]="getSyncStatusSeverity()"
|
||||
styleClass="sync-status-tag"
|
||||
class="sync-status-tag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
[text]="true"
|
||||
severity="secondary"
|
||||
(click)="ref.close()"
|
||||
styleClass="close-button">
|
||||
class="close-button">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
[text]="true"
|
||||
severity="secondary"
|
||||
(click)="dynamicDialogRef.close()"
|
||||
styleClass="close-button">
|
||||
class="close-button">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
header="Merge/Split {{ currentMergeType | titlecase }}"
|
||||
[(visible)]="showMergeDialog"
|
||||
[modal]="true"
|
||||
styleClass="user-dialog"
|
||||
class="user-dialog"
|
||||
[style]="{width: '50rem'}">
|
||||
<div class="dialog-form">
|
||||
<p>You are merging/splitting {{ getSelectedItems(currentMergeType).length }} {{ currentMergeType }}:</p>
|
||||
@@ -238,7 +238,7 @@
|
||||
header="Rename/Split {{ currentMergeType | titlecase }}"
|
||||
[(visible)]="showRenameDialog"
|
||||
[modal]="true"
|
||||
styleClass="user-dialog"
|
||||
class="user-dialog"
|
||||
[style]="{width: '40rem'}">
|
||||
<div class="dialog-form">
|
||||
<p>You are renaming/splitting:</p>
|
||||
@@ -308,7 +308,7 @@
|
||||
header="Delete {{ currentMergeType | titlecase }}"
|
||||
[(visible)]="showDeleteDialog"
|
||||
[modal]="true"
|
||||
styleClass="user-dialog"
|
||||
class="user-dialog"
|
||||
[style]="{width: '40rem'}">
|
||||
<div class="dialog-form">
|
||||
<div class="warning-message">
|
||||
|
||||
@@ -36,28 +36,28 @@
|
||||
<p-select [options]="providersWithClear" [(ngModel)]="bulkP1"
|
||||
(ngModelChange)="setBulkProvider('p1', $event)"
|
||||
placeholder="Set all P1" appendTo="body"
|
||||
styleClass="select-full-width" size="small">
|
||||
class="select-full-width" size="small">
|
||||
</p-select>
|
||||
</td>
|
||||
<td class="table-cell">
|
||||
<p-select [options]="providersWithClear" [(ngModel)]="bulkP2"
|
||||
(ngModelChange)="setBulkProvider('p2', $event)"
|
||||
placeholder="Set all P2" appendTo="body"
|
||||
styleClass="select-full-width" size="small">
|
||||
class="select-full-width" size="small">
|
||||
</p-select>
|
||||
</td>
|
||||
<td class="table-cell">
|
||||
<p-select [options]="providersWithClear" [(ngModel)]="bulkP3"
|
||||
(ngModelChange)="setBulkProvider('p3', $event)"
|
||||
placeholder="Set all P3" appendTo="body"
|
||||
styleClass="select-full-width" size="small">
|
||||
class="select-full-width" size="small">
|
||||
</p-select>
|
||||
</td>
|
||||
<td class="table-cell">
|
||||
<p-select [options]="providersWithClear" [(ngModel)]="bulkP4"
|
||||
(ngModelChange)="setBulkProvider('p4', $event)"
|
||||
placeholder="Set all P4" appendTo="body"
|
||||
styleClass="select-full-width" size="small">
|
||||
class="select-full-width" size="small">
|
||||
</p-select>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -75,28 +75,28 @@
|
||||
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p1"
|
||||
[disabled]="!enabledFields[field]"
|
||||
placeholder="Unset" appendTo="body"
|
||||
styleClass="select-full-width" size="small">
|
||||
class="select-full-width" size="small">
|
||||
</p-select>
|
||||
</td>
|
||||
<td class="table-cell-sm">
|
||||
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p2"
|
||||
[disabled]="!enabledFields[field]"
|
||||
placeholder="Unset" appendTo="body"
|
||||
styleClass="select-full-width" size="small">
|
||||
class="select-full-width" size="small">
|
||||
</p-select>
|
||||
</td>
|
||||
<td class="table-cell-sm">
|
||||
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p3"
|
||||
[disabled]="!enabledFields[field]"
|
||||
placeholder="Unset" appendTo="body"
|
||||
styleClass="select-full-width" size="small">
|
||||
class="select-full-width" size="small">
|
||||
</p-select>
|
||||
</td>
|
||||
<td class="table-cell-sm">
|
||||
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p4"
|
||||
[disabled]="!enabledFields[field]"
|
||||
placeholder="Unset" appendTo="body"
|
||||
styleClass="select-full-width" size="small">
|
||||
class="select-full-width" size="small">
|
||||
</p-select>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -125,7 +125,7 @@
|
||||
<div class="option-item">
|
||||
<p pTooltip="Controls how fetched metadata replaces existing values. 'Replace Missing Only' only fills empty fields, 'Replace All Fields' overwrites existing values." tooltipPosition="top">Replace Mode:</p>
|
||||
<p-select [options]="replaceModeOptions" [(ngModel)]="replaceMode" optionLabel="label" optionValue="value"
|
||||
appendTo="body" size="small" styleClass="select-mode">
|
||||
appendTo="body" size="small" class="select-mode">
|
||||
</p-select>
|
||||
</div>
|
||||
<div class="option-item">
|
||||
|
||||
@@ -109,14 +109,21 @@
|
||||
.options-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 2.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1rem 2.5rem;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
gap: 0.5rem;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
[text]="true"
|
||||
severity="secondary"
|
||||
(click)="close()"
|
||||
styleClass="close-button">
|
||||
class="close-button">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
[text]="true"
|
||||
severity="secondary"
|
||||
(click)="ref.close()"
|
||||
styleClass="close-button">
|
||||
class="close-button">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
[text]="true"
|
||||
severity="secondary"
|
||||
(click)="dialogRef.close()"
|
||||
styleClass="close-button">
|
||||
class="close-button">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
[max]="duration || 100"
|
||||
[step]="1"
|
||||
(onChange)="seek($event)"
|
||||
styleClass="progress-slider"
|
||||
class="progress-slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,7 +154,7 @@
|
||||
severity="contrast"
|
||||
(onClick)="togglePlay()"
|
||||
[disabled]="audioLoading"
|
||||
styleClass="play-button"
|
||||
class="play-button"
|
||||
size="large"
|
||||
/>
|
||||
|
||||
@@ -214,7 +214,7 @@
|
||||
[max]="1"
|
||||
[step]="0.01"
|
||||
(onChange)="setVolume($event)"
|
||||
styleClass="volume-slider"
|
||||
class="volume-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -252,7 +252,7 @@
|
||||
[rounded]="true"
|
||||
[text]="true"
|
||||
severity="secondary"
|
||||
[styleClass]="sleepTimerActive ? 'sleep-timer-active' : ''"
|
||||
[class]="sleepTimerActive ? 'sleep-timer-active' : ''"
|
||||
(onClick)="sleepTimerMenu.toggle($event)"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
@@ -64,11 +64,11 @@
|
||||
</label>
|
||||
@if (!fontsLoadedInBrowser) {
|
||||
<div class="font-preview-skeleton">
|
||||
<p-skeleton width="100%" height="1rem" styleClass="mb-3"></p-skeleton>
|
||||
<p-skeleton width="100%" height="1rem" styleClass="mb-3"></p-skeleton>
|
||||
<p-skeleton width="85%" height="1rem" styleClass="mb-3"></p-skeleton>
|
||||
<p-skeleton width="60%" height="1rem" styleClass="mb-3"></p-skeleton>
|
||||
<p-skeleton width="85%" height="1rem" styleClass="mb-3"></p-skeleton>
|
||||
<p-skeleton width="100%" height="1rem" class="mb-3"></p-skeleton>
|
||||
<p-skeleton width="100%" height="1rem" class="mb-3"></p-skeleton>
|
||||
<p-skeleton width="85%" height="1rem" class="mb-3"></p-skeleton>
|
||||
<p-skeleton width="60%" height="1rem" class="mb-3"></p-skeleton>
|
||||
<p-skeleton width="85%" height="1rem" class="mb-3"></p-skeleton>
|
||||
<p-skeleton width="95%" height="0.875rem"></p-skeleton>
|
||||
</div>
|
||||
} @else {
|
||||
|
||||
@@ -39,11 +39,13 @@ export class LibraryMetadataSettingsComponent implements OnInit {
|
||||
activePanel: number | null = null;
|
||||
sidecarExporting: Record<number, boolean> = {};
|
||||
sidecarImporting: Record<number, boolean> = {};
|
||||
private cachedDefaultOptions: Record<number, MetadataRefreshOptions> = {};
|
||||
|
||||
ngOnInit() {
|
||||
this.appSettingsService.appSettings$.subscribe(appSettings => {
|
||||
if (appSettings) {
|
||||
this.defaultMetadataOptions = appSettings.defaultMetadataRefreshOptions;
|
||||
this.cachedDefaultOptions = {};
|
||||
this.initializeLibraryOptions(appSettings);
|
||||
this.updateLibraryOptionsFromSettings(appSettings);
|
||||
}
|
||||
@@ -80,7 +82,13 @@ export class LibraryMetadataSettingsComponent implements OnInit {
|
||||
}
|
||||
|
||||
getLibraryOptions(libraryId: number): MetadataRefreshOptions {
|
||||
return this.libraryMetadataOptions[libraryId] || {...this.defaultMetadataOptions, libraryId};
|
||||
if (this.libraryMetadataOptions[libraryId]) {
|
||||
return this.libraryMetadataOptions[libraryId];
|
||||
}
|
||||
if (!this.cachedDefaultOptions[libraryId]) {
|
||||
this.cachedDefaultOptions[libraryId] = {...this.defaultMetadataOptions, libraryId};
|
||||
}
|
||||
return this.cachedDefaultOptions[libraryId];
|
||||
}
|
||||
|
||||
trackByLibrary(index: number, library: Library): number | undefined {
|
||||
|
||||
@@ -291,7 +291,7 @@
|
||||
header="Create OPDS User"
|
||||
[(visible)]="showCreateUserDialog"
|
||||
[modal]="true"
|
||||
styleClass="user-dialog"
|
||||
class="user-dialog"
|
||||
[style]="{width: '400px'}">
|
||||
<div class="dialog-form">
|
||||
<div class="form-field">
|
||||
|
||||
@@ -424,7 +424,7 @@
|
||||
header="Change Password"
|
||||
[(visible)]="isPasswordDialogVisible"
|
||||
[modal]="true"
|
||||
styleClass="user-dialog"
|
||||
class="user-dialog"
|
||||
[style]="{width: '400px'}">
|
||||
<div class="dialog-form">
|
||||
<div class="form-field">
|
||||
|
||||
@@ -193,7 +193,8 @@ export type VisibleFilterType =
|
||||
| 'personalRating' | 'publisher' | 'matchScore' | 'library' | 'shelf'
|
||||
| 'shelfStatus' | 'tag' | 'publishedDate' | 'fileSize' | 'amazonRating'
|
||||
| 'goodreadsRating' | 'hardcoverRating' | 'language' | 'pageCount' | 'mood'
|
||||
| 'ageRating' | 'contentRating';
|
||||
| 'ageRating' | 'contentRating'
|
||||
| 'comicCharacter' | 'comicTeam' | 'comicLocation' | 'comicCreator';
|
||||
|
||||
export const DEFAULT_VISIBLE_FILTERS: VisibleFilterType[] = [
|
||||
'author', 'category', 'series', 'bookType', 'readStatus',
|
||||
@@ -223,7 +224,11 @@ export const ALL_FILTER_OPTIONS: { label: string; value: VisibleFilterType }[] =
|
||||
{label: 'Mood', value: 'mood'},
|
||||
{label: 'Amazon Rating', value: 'amazonRating'},
|
||||
{label: 'Goodreads Rating', value: 'goodreadsRating'},
|
||||
{label: 'Hardcover Rating', value: 'hardcoverRating'}
|
||||
{label: 'Hardcover Rating', value: 'hardcoverRating'},
|
||||
{label: 'Comic Character', value: 'comicCharacter'},
|
||||
{label: 'Comic Team', value: 'comicTeam'},
|
||||
{label: 'Comic Location', value: 'comicLocation'},
|
||||
{label: 'Comic Creator', value: 'comicCreator'}
|
||||
];
|
||||
|
||||
export interface UserSettings {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
[rounded]="true"
|
||||
severity="secondary"
|
||||
(onClick)="closeDialog()"
|
||||
styleClass="close-button">
|
||||
class="close-button">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
id="confirmNewPassword"
|
||||
formControlName="confirmNewPassword"
|
||||
[feedback]="false"
|
||||
styleClass="field-input"
|
||||
class="field-input"
|
||||
[inputStyle]="{'width': '100%'}">
|
||||
</p-password>
|
||||
@if (changePasswordForm.get('confirmNewPassword')?.invalid &&
|
||||
|
||||
@@ -31,26 +31,58 @@
|
||||
<label class="setting-label">Visible Filters</label>
|
||||
</div>
|
||||
<p class="setting-description">
|
||||
Select which filters appear in the filter sidebar. Choose between {{ minFilters }} and {{ maxFilters }} filters.
|
||||
Choose which filters appear in the sidebar and drag to reorder. Between {{ minFilters }} and {{ maxFilters }} filters.
|
||||
<span class="filter-count">({{ selectionCountText }})</span>
|
||||
</p>
|
||||
<div class="setting-options">
|
||||
<p-multiselect
|
||||
[options]="allFilterOptions"
|
||||
[(ngModel)]="selectedVisibleFilters"
|
||||
(ngModelChange)="onVisibleFiltersChange()"
|
||||
<div class="filter-order-editor">
|
||||
<div
|
||||
#filterList
|
||||
class="filter-order-list"
|
||||
cdkDropList
|
||||
[cdkDropListData]="selectedVisibleFilters"
|
||||
(cdkDropListDropped)="onDrop($event)">
|
||||
@for (f of selectedVisibleFilters; track f; let i = $index) {
|
||||
<div class="filter-order-row" cdkDrag>
|
||||
<div class="filter-drag-handle" cdkDragHandle>
|
||||
<i class="pi pi-bars"></i>
|
||||
</div>
|
||||
<span class="filter-rank">{{ i + 1 }}.</span>
|
||||
<span class="filter-label">{{ getFilterLabel(f) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="remove-filter-btn"
|
||||
(click)="removeFilter(i)"
|
||||
[disabled]="selectedVisibleFilters.length <= minFilters"
|
||||
pTooltip="Remove"
|
||||
tooltipPosition="top">
|
||||
<i class="pi pi-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
@if (availableFilters.length > 0 && selectedVisibleFilters.length < maxFilters) {
|
||||
<p-select
|
||||
size="small"
|
||||
[options]="availableFilters"
|
||||
[(ngModel)]="selectedAddFilter"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
[showClear]="false"
|
||||
[filter]="true"
|
||||
filterPlaceHolder="Search filters..."
|
||||
placeholder="Select filters"
|
||||
display="chip"
|
||||
placeholder="Add filter..."
|
||||
[style]="{ width: '200px' }"
|
||||
appendTo="body"
|
||||
[maxSelectedLabels]="5"
|
||||
selectedItemsLabel="{0} filters selected"
|
||||
styleClass="visible-filters-select">
|
||||
</p-multiselect>
|
||||
(onChange)="addFilter()">
|
||||
</p-select>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="reset-btn"
|
||||
(click)="resetToDefaults()"
|
||||
pTooltip="Reset to defaults"
|
||||
tooltipPosition="top">
|
||||
<i class="pi pi-refresh"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -47,19 +47,150 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
p-multiselect {
|
||||
min-width: 300px;
|
||||
max-width: 100%;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
min-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
font-weight: 500;
|
||||
color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
// Filter order editor styles
|
||||
.filter-order-editor {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.filter-order-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-height: 40px;
|
||||
max-height: 320px;
|
||||
max-width: 350px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.filter-order-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: var(--overlay-background);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: move;
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--ground-background);
|
||||
}
|
||||
}
|
||||
|
||||
.cdk-drag-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
background-color: var(--card-background);
|
||||
border: 1px solid var(--primary-color);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.cdk-drag-placeholder {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.cdk-drag-animating {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.filter-order-list.cdk-drop-list-dragging .filter-order-row:not(.cdk-drag-placeholder) {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.filter-drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary-color);
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-rank {
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
min-width: 1.75rem;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.remove-filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
color: var(--p-red-500);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
i {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--ground-background);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-actions-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary-color);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
i {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--ground-background);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
|
||||
import {Component, ElementRef, inject, OnDestroy, OnInit, viewChild} from '@angular/core';
|
||||
import {Select} from 'primeng/select';
|
||||
import {
|
||||
ALL_FILTER_OPTIONS,
|
||||
BookFilterMode,
|
||||
DEFAULT_VISIBLE_FILTERS,
|
||||
User,
|
||||
UserService,
|
||||
UserSettings,
|
||||
UserState,
|
||||
VisibleFilterType
|
||||
} from '../../user-management/user.service';
|
||||
import {ALL_FILTER_OPTIONS, BookFilterMode, DEFAULT_VISIBLE_FILTERS, User, UserService, UserSettings, UserState, VisibleFilterType} from '../../user-management/user.service';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {Observable, Subject} from 'rxjs';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {filter, takeUntil} from 'rxjs/operators';
|
||||
import {MultiSelect} from 'primeng/multiselect';
|
||||
import {CdkDrag, CdkDragDrop, CdkDragHandle, CdkDropList, moveItemInArray} from '@angular/cdk/drag-drop';
|
||||
import {Tooltip} from 'primeng/tooltip';
|
||||
|
||||
const MIN_VISIBLE_FILTERS = 5;
|
||||
const MAX_VISIBLE_FILTERS = 15;
|
||||
const MAX_VISIBLE_FILTERS = 20;
|
||||
|
||||
@Component({
|
||||
selector: 'app-filter-preferences',
|
||||
imports: [
|
||||
Select,
|
||||
FormsModule,
|
||||
MultiSelect
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
CdkDragHandle,
|
||||
Tooltip
|
||||
],
|
||||
templateUrl: './filter-preferences.component.html',
|
||||
styleUrl: './filter-preferences.component.scss'
|
||||
@@ -44,6 +39,8 @@ export class FilterPreferencesComponent implements OnInit, OnDestroy {
|
||||
selectedFilterMode: BookFilterMode = 'and';
|
||||
selectedVisibleFilters: VisibleFilterType[] = [...DEFAULT_VISIBLE_FILTERS];
|
||||
|
||||
private readonly filterList = viewChild<ElementRef<HTMLElement>>('filterList');
|
||||
|
||||
private readonly userService = inject(UserService);
|
||||
private readonly messageService = inject(MessageService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
@@ -95,25 +92,43 @@ export class FilterPreferencesComponent implements OnInit, OnDestroy {
|
||||
this.updatePreference(['filterMode'], this.selectedFilterMode);
|
||||
}
|
||||
|
||||
onVisibleFiltersChange(): void {
|
||||
if (this.selectedVisibleFilters.length < MIN_VISIBLE_FILTERS) {
|
||||
this.messageService.add({
|
||||
severity: 'warn',
|
||||
summary: 'Minimum Filters Required',
|
||||
detail: `Please select at least ${MIN_VISIBLE_FILTERS} filters.`,
|
||||
life: 2000
|
||||
});
|
||||
return;
|
||||
selectedAddFilter: string | null = null;
|
||||
|
||||
get availableFilters(): {label: string; value: string}[] {
|
||||
const used = new Set(this.selectedVisibleFilters);
|
||||
return this.allFilterOptions.filter(opt => !used.has(opt.value));
|
||||
}
|
||||
if (this.selectedVisibleFilters.length > MAX_VISIBLE_FILTERS) {
|
||||
this.messageService.add({
|
||||
severity: 'warn',
|
||||
summary: 'Maximum Filters Exceeded',
|
||||
detail: `Please select at most ${MAX_VISIBLE_FILTERS} filters.`,
|
||||
life: 2000
|
||||
});
|
||||
return;
|
||||
|
||||
getFilterLabel(value: string): string {
|
||||
return this.allFilterOptions.find(opt => opt.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
onDrop(event: CdkDragDrop<VisibleFilterType[]>): void {
|
||||
moveItemInArray(this.selectedVisibleFilters, event.previousIndex, event.currentIndex);
|
||||
this.updatePreference(['visibleFilters'], this.selectedVisibleFilters);
|
||||
}
|
||||
|
||||
addFilter(): void {
|
||||
if (this.selectedAddFilter) {
|
||||
this.selectedVisibleFilters.push(this.selectedAddFilter as VisibleFilterType);
|
||||
this.selectedAddFilter = null;
|
||||
this.updatePreference(['visibleFilters'], this.selectedVisibleFilters);
|
||||
requestAnimationFrame(() => {
|
||||
const el = this.filterList()?.nativeElement;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
removeFilter(index: number): void {
|
||||
if (this.selectedVisibleFilters.length > MIN_VISIBLE_FILTERS) {
|
||||
this.selectedVisibleFilters.splice(index, 1);
|
||||
this.updatePreference(['visibleFilters'], this.selectedVisibleFilters);
|
||||
}
|
||||
}
|
||||
|
||||
resetToDefaults(): void {
|
||||
this.selectedVisibleFilters = [...DEFAULT_VISIBLE_FILTERS];
|
||||
this.updatePreference(['visibleFilters'], this.selectedVisibleFilters);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
[draggable]="false"
|
||||
[resizable]="false"
|
||||
[style]="{width: '600px', maxWidth: '90vw'}"
|
||||
styleClass="config-dialog"
|
||||
class="config-dialog"
|
||||
header="Chart Configuration">
|
||||
|
||||
<div class="config-dialog-content">
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
<p-progressbar
|
||||
[value]="getOverallProgress()"
|
||||
[showValue]="false"
|
||||
styleClass="overall-progress-bar"/>
|
||||
class="overall-progress-bar"/>
|
||||
</div>
|
||||
}
|
||||
<div class="files-list">
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
[text]="true"
|
||||
severity="secondary"
|
||||
(click)="ref.close()"
|
||||
styleClass="close-button">
|
||||
class="close-button">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
optionValue="id"
|
||||
placeholder="Select library"
|
||||
(onChange)="onDefaultLibraryChange()"
|
||||
styleClass="select-library"
|
||||
class="select-library"
|
||||
size="small"
|
||||
appendTo="body"
|
||||
></p-select>
|
||||
@@ -116,7 +116,7 @@
|
||||
optionValue="id"
|
||||
placeholder="Select path"
|
||||
(onChange)="onDefaultLibraryPathChange()"
|
||||
styleClass="select-path"
|
||||
class="select-path"
|
||||
size="small"
|
||||
appendTo="body"
|
||||
></p-select>
|
||||
@@ -175,7 +175,7 @@
|
||||
placeholder="Select"
|
||||
(onChange)="onLibraryChange(preview)"
|
||||
[disabled]="preview.isMoved"
|
||||
styleClass="select-full-width"
|
||||
class="select-full-width"
|
||||
appendTo="body"
|
||||
size="small"
|
||||
></p-select>
|
||||
@@ -190,7 +190,7 @@
|
||||
placeholder="Select"
|
||||
(onChange)="onLibraryPathChange(preview)"
|
||||
[disabled]="preview.isMoved"
|
||||
styleClass="select-full-width"
|
||||
class="select-full-width"
|
||||
appendTo="body"
|
||||
size="small"
|
||||
></p-select>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
[text]="true"
|
||||
severity="secondary"
|
||||
(click)="close()"
|
||||
styleClass="close-button">
|
||||
class="close-button">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
icon="pi pi-github"
|
||||
outlined
|
||||
severity="success"
|
||||
styleClass="action-button">
|
||||
class="action-button">
|
||||
</p-button>
|
||||
</a>
|
||||
<a href="https://opencollective.com/booklore" target="_blank" rel="noopener noreferrer">
|
||||
@@ -46,7 +46,7 @@
|
||||
icon="pi pi-heart"
|
||||
severity="info"
|
||||
outlined
|
||||
styleClass="action-button">
|
||||
class="action-button">
|
||||
</p-button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -12,19 +12,19 @@
|
||||
<p-tag
|
||||
value="Review"
|
||||
severity="warn"
|
||||
styleClass="task-tag">
|
||||
class="task-tag">
|
||||
</p-tag>
|
||||
} @else {
|
||||
<p-tag
|
||||
value="Auto"
|
||||
severity="success"
|
||||
styleClass="task-tag">
|
||||
class="task-tag">
|
||||
</p-tag>
|
||||
}
|
||||
<p-tag
|
||||
[value]="getStatusLabel(task.value.status)"
|
||||
[severity]="getTagSeverity(task.value.status)"
|
||||
styleClass="task-tag"
|
||||
class="task-tag"
|
||||
></p-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
[text]="true"
|
||||
severity="secondary"
|
||||
(click)="dialogRef.close()"
|
||||
styleClass="close-button">
|
||||
class="close-button">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -55,6 +55,72 @@ export const AUDIOBOOK_METADATA_FIELDS: MetadataFieldConfig[] = [
|
||||
{label: 'Abridged', controlName: 'abridged', lockedKey: 'abridgedLocked', fetchedKey: 'abridged', type: 'boolean'}
|
||||
];
|
||||
|
||||
// Comic book metadata fields - stored nested under BookMetadata.comicMetadata
|
||||
export const COMIC_TEXT_METADATA_FIELDS: MetadataFieldConfig[] = [
|
||||
{label: 'Issue #', controlName: 'comicIssueNumber', lockedKey: 'comicIssueNumberLocked', fetchedKey: 'issueNumber', type: 'string'},
|
||||
{label: 'Volume', controlName: 'comicVolumeName', lockedKey: 'comicVolumeNameLocked', fetchedKey: 'volumeName', type: 'string'},
|
||||
{label: 'Volume #', controlName: 'comicVolumeNumber', lockedKey: 'comicVolumeNumberLocked', fetchedKey: 'volumeNumber', type: 'number'},
|
||||
{label: 'Story Arc', controlName: 'comicStoryArc', lockedKey: 'comicStoryArcLocked', fetchedKey: 'storyArc', type: 'string'},
|
||||
{label: 'Arc #', controlName: 'comicStoryArcNumber', lockedKey: 'comicStoryArcNumberLocked', fetchedKey: 'storyArcNumber', type: 'number'},
|
||||
{label: 'Alt. Series', controlName: 'comicAlternateSeries', lockedKey: 'comicAlternateSeriesLocked', fetchedKey: 'alternateSeries', type: 'string'},
|
||||
{label: 'Alt. Issue', controlName: 'comicAlternateIssue', lockedKey: 'comicAlternateIssueLocked', fetchedKey: 'alternateIssue', type: 'string'},
|
||||
{label: 'Imprint', controlName: 'comicImprint', lockedKey: 'comicImprintLocked', fetchedKey: 'imprint', type: 'string'},
|
||||
{label: 'Format', controlName: 'comicFormat', lockedKey: 'comicFormatLocked', fetchedKey: 'format', type: 'string'},
|
||||
{label: 'Reading Dir.', controlName: 'comicReadingDirection', lockedKey: 'comicReadingDirectionLocked', fetchedKey: 'readingDirection', type: 'string'},
|
||||
{label: 'Web Link', controlName: 'comicWebLink', lockedKey: 'comicWebLinkLocked', fetchedKey: 'webLink', type: 'string'},
|
||||
{label: 'B&W', controlName: 'comicBlackAndWhite', lockedKey: 'comicBlackAndWhiteLocked', fetchedKey: 'blackAndWhite', type: 'boolean'},
|
||||
{label: 'Manga', controlName: 'comicManga', lockedKey: 'comicMangaLocked', fetchedKey: 'manga', type: 'boolean'},
|
||||
];
|
||||
|
||||
export const COMIC_ARRAY_METADATA_FIELDS: MetadataFieldConfig[] = [
|
||||
{label: 'Pencillers', controlName: 'comicPencillers', lockedKey: 'comicPencillersLocked', fetchedKey: 'pencillers', type: 'array'},
|
||||
{label: 'Inkers', controlName: 'comicInkers', lockedKey: 'comicInkersLocked', fetchedKey: 'inkers', type: 'array'},
|
||||
{label: 'Colorists', controlName: 'comicColorists', lockedKey: 'comicColoristsLocked', fetchedKey: 'colorists', type: 'array'},
|
||||
{label: 'Letterers', controlName: 'comicLetterers', lockedKey: 'comicLetterersLocked', fetchedKey: 'letterers', type: 'array'},
|
||||
{label: 'Cover Artists', controlName: 'comicCoverArtists', lockedKey: 'comicCoverArtistsLocked', fetchedKey: 'coverArtists', type: 'array'},
|
||||
{label: 'Editors', controlName: 'comicEditors', lockedKey: 'comicEditorsLocked', fetchedKey: 'editors', type: 'array'},
|
||||
{label: 'Characters', controlName: 'comicCharacters', lockedKey: 'comicCharactersLocked', fetchedKey: 'characters', type: 'array'},
|
||||
{label: 'Teams', controlName: 'comicTeams', lockedKey: 'comicTeamsLocked', fetchedKey: 'teams', type: 'array'},
|
||||
{label: 'Locations', controlName: 'comicLocations', lockedKey: 'comicLocationsLocked', fetchedKey: 'locations', type: 'array'},
|
||||
];
|
||||
|
||||
export const COMIC_TEXTAREA_METADATA_FIELDS: MetadataFieldConfig[] = [
|
||||
{label: 'Notes', controlName: 'comicNotes', lockedKey: 'comicNotesLocked', fetchedKey: 'notes', type: 'textarea'},
|
||||
];
|
||||
|
||||
export const ALL_COMIC_METADATA_FIELDS: MetadataFieldConfig[] = [
|
||||
...COMIC_TEXT_METADATA_FIELDS,
|
||||
...COMIC_ARRAY_METADATA_FIELDS,
|
||||
...COMIC_TEXTAREA_METADATA_FIELDS,
|
||||
];
|
||||
|
||||
// Maps form lockedKey → ComicMetadata lock property name (1:1 per-field locks).
|
||||
export const COMIC_FORM_TO_MODEL_LOCK: Record<string, string> = {
|
||||
'comicIssueNumberLocked': 'issueNumberLocked',
|
||||
'comicVolumeNameLocked': 'volumeNameLocked',
|
||||
'comicVolumeNumberLocked': 'volumeNumberLocked',
|
||||
'comicStoryArcLocked': 'storyArcLocked',
|
||||
'comicStoryArcNumberLocked': 'storyArcNumberLocked',
|
||||
'comicAlternateSeriesLocked': 'alternateSeriesLocked',
|
||||
'comicAlternateIssueLocked': 'alternateIssueLocked',
|
||||
'comicImprintLocked': 'imprintLocked',
|
||||
'comicFormatLocked': 'formatLocked',
|
||||
'comicBlackAndWhiteLocked': 'blackAndWhiteLocked',
|
||||
'comicMangaLocked': 'mangaLocked',
|
||||
'comicReadingDirectionLocked': 'readingDirectionLocked',
|
||||
'comicWebLinkLocked': 'webLinkLocked',
|
||||
'comicNotesLocked': 'notesLocked',
|
||||
'comicPencillersLocked': 'pencillersLocked',
|
||||
'comicInkersLocked': 'inkersLocked',
|
||||
'comicColoristsLocked': 'coloristsLocked',
|
||||
'comicLetterersLocked': 'letterersLocked',
|
||||
'comicCoverArtistsLocked': 'coverArtistsLocked',
|
||||
'comicEditorsLocked': 'editorsLocked',
|
||||
'comicCharactersLocked': 'charactersLocked',
|
||||
'comicTeamsLocked': 'teamsLocked',
|
||||
'comicLocationsLocked': 'locationsLocked',
|
||||
};
|
||||
|
||||
export const TOP_FIELD_NAMES = ['title', 'subtitle', 'publisher', 'publishedDate'];
|
||||
export const ARRAY_FIELD_NAMES = ['authors', 'categories', 'moods', 'tags'];
|
||||
export const TEXTAREA_FIELD_NAMES = ['description'];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {FormControl, FormGroup} from '@angular/forms';
|
||||
import {ALL_METADATA_FIELDS, AUDIOBOOK_METADATA_FIELDS, MetadataFieldConfig} from './metadata-field.config';
|
||||
import {ALL_COMIC_METADATA_FIELDS, ALL_METADATA_FIELDS, AUDIOBOOK_METADATA_FIELDS, MetadataFieldConfig} from './metadata-field.config';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -31,6 +31,15 @@ export class MetadataFormBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// Add comic book metadata fields
|
||||
for (const field of ALL_COMIC_METADATA_FIELDS) {
|
||||
const defaultValue = this.getDefaultValue(field.type);
|
||||
controls[field.controlName] = new FormControl(defaultValue);
|
||||
if (includeLockedControls) {
|
||||
controls[field.lockedKey] = new FormControl(false);
|
||||
}
|
||||
}
|
||||
|
||||
controls['thumbnailUrl'] = new FormControl('');
|
||||
controls['audiobookThumbnailUrl'] = new FormControl('');
|
||||
if (includeLockedControls) {
|
||||
@@ -78,6 +87,13 @@ export class MetadataFormBuilder {
|
||||
metadataForm.get(field.controlName)?.disable({emitEvent: false});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply locks for comic book metadata fields
|
||||
for (const field of ALL_COMIC_METADATA_FIELDS) {
|
||||
if (lockedFields[field.lockedKey]) {
|
||||
metadataForm.get(field.controlName)?.disable({emitEvent: false});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAllFieldsLocked(metadataForm: FormGroup, locked: boolean): void {
|
||||
|
||||
Reference in New Issue
Block a user