From fa3005369fd4c1b786daa07d29a55c9a419c3a13 Mon Sep 17 00:00:00 2001 From: ACX <8075870+acx10@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:26:41 -0700 Subject: [PATCH] Revamp metadata search UI to display live results (#2195) Co-authored-by: acx10 --- booklore-api/build.gradle | 3 + .../config/security/SecurityConfig.java | 9 +- .../controller/MetadataController.java | 13 +- .../service/metadata/BookMetadataService.java | 38 +- booklore-ui/package-lock.json | 177 +++-- booklore-ui/package.json | 1 + .../book/service/book-metadata.service.ts | 55 ++ .../book/service/book.service.spec.ts | 13 - .../app/features/book/service/book.service.ts | 4 - .../metadata-searcher.component.html | 269 ++++--- .../metadata-searcher.component.scss | 739 +++++++++++++++++- .../metadata-searcher.component.ts | 208 ++++- 12 files changed, 1288 insertions(+), 241 deletions(-) create mode 100644 booklore-ui/src/app/features/book/service/book-metadata.service.ts diff --git a/booklore-api/build.gradle b/booklore-api/build.gradle index a54edd655..69fa19505 100644 --- a/booklore-api/build.gradle +++ b/booklore-api/build.gradle @@ -37,6 +37,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + // --- Reactive Streams --- + implementation 'io.projectreactor:reactor-core' + // --- Database & Migration --- implementation 'org.mariadb.jdbc:mariadb-java-client:3.5.6' implementation 'org.flywaydb:flyway-mysql:11.19.0' diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java index 6789c5db5..716eaac9b 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java @@ -1,12 +1,9 @@ package com.adityachandel.booklore.config.security; import com.adityachandel.booklore.config.AppProperties; -import com.adityachandel.booklore.config.security.filter.CoverJwtFilter; -import com.adityachandel.booklore.config.security.filter.CustomFontJwtFilter; -import com.adityachandel.booklore.config.security.filter.DualJwtAuthenticationFilter; -import com.adityachandel.booklore.config.security.filter.KoboAuthFilter; -import com.adityachandel.booklore.config.security.filter.KoreaderAuthFilter; +import com.adityachandel.booklore.config.security.filter.*; import com.adityachandel.booklore.config.security.service.OpdsUserDetailsService; +import jakarta.servlet.DispatcherType; import jakarta.servlet.http.HttpServletResponse; import lombok.AllArgsConstructor; import org.springframework.context.annotation.Bean; @@ -159,6 +156,7 @@ public class SecurityConfig { .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth + .dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll() .requestMatchers(publicEndpoints.toArray(new String[0])).permitAll() .anyRequest().authenticated() ) @@ -168,7 +166,6 @@ public class SecurityConfig { @Bean public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception { - // Configure the shared AuthenticationManagerBuilder with the UserDetailsService and PasswordEncoder AuthenticationManagerBuilder auth = http.getSharedObject(AuthenticationManagerBuilder.class); auth.userDetailsService(opdsUserDetailsService).passwordEncoder(passwordEncoder()); return auth.build(); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/MetadataController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/MetadataController.java index 074a89449..ad3bd6aef 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/MetadataController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/MetadataController.java @@ -17,11 +17,13 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.AllArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import reactor.core.publisher.Flux; import java.util.List; import java.util.Map; @@ -42,13 +44,13 @@ public class MetadataController { @Operation(summary = "Get prospective metadata for a book", description = "Fetch prospective metadata for a book by its ID. Requires metadata edit permission or admin.") @ApiResponse(responseCode = "200", description = "Prospective metadata returned successfully") - @PostMapping("/{bookId}/metadata/prospective") + @PostMapping(value = "/{bookId}/metadata/prospective", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()") @CheckBookAccess(bookIdParam = "bookId") - public ResponseEntity> getMetadataList( + public Flux getMetadataList( @Parameter(description = "Fetch metadata request") @RequestBody(required = false) FetchMetadataRequest fetchMetadataRequest, @Parameter(description = "ID of the book") @PathVariable Long bookId) { - return ResponseEntity.ok(bookMetadataService.getProspectiveMetadataListForBookId(bookId, fetchMetadataRequest)); + return bookMetadataService.getProspectiveMetadataListForBookId(bookId, fetchMetadataRequest); } @Operation(summary = "Update book metadata", description = "Update metadata for a book. Requires metadata edit permission or admin.") @@ -180,7 +182,7 @@ public class MetadataController { @PreAuthorize("@securityUtil.canBulkEditMetadata() or @securityUtil.isAdmin()") public ResponseEntity bulkUploadCover( @Parameter(description = "Cover image file") @RequestParam("file") MultipartFile file, - @Parameter(description = "Comma-separated book IDs") @RequestParam("bookIds") @jakarta.validation.constraints.NotEmpty java.util.Set bookIds) { + @Parameter(description = "Comma-separated book IDs") @RequestParam("bookIds") @RequestBody java.util.Set bookIds) { bookMetadataService.updateCoverImageFromFileForBooks(bookIds, file); return ResponseEntity.noContent().build(); } @@ -221,4 +223,5 @@ public class MetadataController { metadataManagementService.deleteMetadata(request.getMetadataType(), request.getValuesToDelete()); return ResponseEntity.noContent().build(); } -} \ No newline at end of file +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java index 0b85d4b1f..3e24cafe2 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java @@ -30,6 +30,9 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; import java.io.File; import java.lang.reflect.Method; @@ -58,33 +61,20 @@ public class BookMetadataService { bookCoverService.generateCustomCover(bookId); } - public List getProspectiveMetadataListForBookId(long bookId, FetchMetadataRequest request) { + public Flux getProspectiveMetadataListForBookId(long bookId, FetchMetadataRequest request) { BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); Book book = bookMapper.toBook(bookEntity); - List> allMetadata = request.getProviders().stream() - .map(provider -> CompletableFuture.supplyAsync(() -> fetchMetadataListFromAProvider(provider, book, request)) - .exceptionally(e -> { - log.error("Error fetching metadata from provider: {}", provider, e); - return List.of(); - })) - .toList() - .stream() - .map(CompletableFuture::join) - .filter(Objects::nonNull) - .toList(); - List interleavedMetadata = new ArrayList<>(); - int maxSize = allMetadata.stream().mapToInt(List::size).max().orElse(0); - - for (int i = 0; i < maxSize; i++) { - for (List metadataList : allMetadata) { - if (i < metadataList.size()) { - interleavedMetadata.add(metadataList.get(i)); - } - } - } - - return interleavedMetadata; + return Flux.fromIterable(request.getProviders()) + .flatMap(provider -> + Mono.fromCallable(() -> fetchMetadataListFromAProvider(provider, book, request)) + .subscribeOn(Schedulers.boundedElastic()) + .flatMapMany(Flux::fromIterable) + .onErrorResume(e -> { + log.error("Error fetching metadata from provider: {}", provider, e); + return Flux.empty(); + }) + ); } public List fetchMetadataListFromAProvider(MetadataProvider provider, Book book, FetchMetadataRequest request) { diff --git a/booklore-ui/package-lock.json b/booklore-ui/package-lock.json index 794484b3e..e9d8a6b84 100644 --- a/booklore-ui/package-lock.json +++ b/booklore-ui/package-lock.json @@ -33,6 +33,7 @@ "ng2-charts": "^8.0.0", "ngx-extended-pdf-viewer": "^25.6.4", "ngx-infinite-scroll": "^21.0.0", + "ngx-sse-client": "^20.0.1", "primeicons": "^7.0.0", "primeng": "^21.0.2", "quill": "^2.0.3", @@ -453,7 +454,6 @@ "integrity": "sha512-PYVgNbjNtuD5/QOuS6cHR8A7bRqsVqxtUUXGqdv76FYMAajQcAvyfR0QxOkqf3NmYxgNgO3hlUHWq0ILjVbcow==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-eslint/bundled-angular-compiler": "21.1.0", "eslint-scope": "^9.0.0" @@ -483,7 +483,6 @@ "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.0.5.tgz", "integrity": "sha512-7Lr60wLlYcGG+VDnnOY9xpn8Zz3yyJcWGSjNEbXPEGaaD0nTZLNZ1nIXRhTeYZwosK5GvPDFxq68kdLxczskHA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -599,7 +598,6 @@ "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.0.3.tgz", "integrity": "sha512-abfckeZfFvovdpxuQHRE4gS1VLNa05Dx0ZSKLGVL9DsQsi4pgn6wWg1y9TkXMlmtpG/EhLmCBxUc6LOHfdeWQA==", "license": "MIT", - "peer": true, "dependencies": { "parse5": "^8.0.0", "tslib": "^2.3.0" @@ -616,7 +614,6 @@ "integrity": "sha512-3lMR3J231JhLgAt37yEULSHFte3zPeta9VYpIIf92JiBsTnWrvKnaK8RXhfdiSQrvhqQ9FMQdl5AG62r1c4dbA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-devkit/architect": "0.2100.3", "@angular-devkit/core": "21.0.3", @@ -652,7 +649,6 @@ "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.0.5.tgz", "integrity": "sha512-/ZI11F6Wxr8TZRVO4O7pmhBJ9YxDg9mvA76e0PiivmqZggM02HY0y3XPMP3hAOe4K+PfaVBgMAu3P9t32klzfA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -669,7 +665,6 @@ "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.0.5.tgz", "integrity": "sha512-92sv9pVm9o/8KfPM7T8j5VQmTaSOqmIajrJF8evXE2dNJcwkBpVtzZUqDzr23AV3vg94C7eYU64i8qrsmJ+cYQ==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -683,7 +678,6 @@ "integrity": "sha512-45sFKqt+badXl6Ab2XsxuOsdi0BbIZgcc9TdwmFPdXMNfcSUYDcPiOA0l1iPwDIZiu4VyqzepMfnHB9IwCatgA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "7.28.4", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -716,7 +710,6 @@ "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.0.5.tgz", "integrity": "sha512-HFXfO5YsBVM+IEaU8h3DZSxO98yDZM2v49NlSVNDzFD3fhnkpTmcgT2NKz9ulIiuV9N376itt+x+NG12sg/+Fw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -742,7 +735,6 @@ "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.0.5.tgz", "integrity": "sha512-RcmXs/LgKyc7D70xVT+3aK/H2SCFEyuebAiw72Iz1te1Gbql2GDFF6hgEOaNwOUglDg8ogN5MdVif2DbRLD3Hw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -762,7 +754,6 @@ "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.0.5.tgz", "integrity": "sha512-UVCrqOxFmX6kAG3Y6jqjCWvLoTP7fxeY96AsxTMp1fkBdqbQbEPleWQpwngNimsuUPvf+rA6XOxsqiDmRex5mA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -803,7 +794,6 @@ "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.0.5.tgz", "integrity": "sha512-IFmf0Wd7jSOoZ8TI+4RXMsYmnIfHQG+kGxeMQVKrefTdr3uEHW/TEsNzbW5bkCpVJHRm4EhkH4hSu8D8tUQffQ==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -903,7 +893,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1200,6 +1189,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.1.90" } @@ -1292,7 +1282,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1336,7 +1325,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2348,7 +2336,6 @@ "integrity": "sha512-X7/+dG9SLpSzRkwgG5/xiIzW0oMrV3C0HOa7YHG1WnrLK+vCQHfte4k/T80059YBdei29RBC3s+pSMvPJDU9/A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/checkbox": "^4.3.0", "@inquirer/confirm": "^5.1.19", @@ -4563,14 +4550,14 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@stomp/rx-stomp": { "version": "2.3.0", @@ -4587,8 +4574,7 @@ "version": "7.2.1", "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.2.1.tgz", "integrity": "sha512-DLd/WeicnHS5SsWWSk3x6/pcivqchNaEvg9UEGVqAcfYEBVmS9D6980ckXjTtfpXLjdLDsd96M7IuX4w7nzq5g==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@tailwindcss/typography": { "version": "0.5.19", @@ -4631,8 +4617,7 @@ "version": "25.0.0", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", @@ -4663,6 +4648,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/node": "*" } @@ -4757,7 +4743,6 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -4865,7 +4850,6 @@ "integrity": "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -4908,7 +4892,6 @@ "integrity": "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.50.0", @@ -5181,7 +5164,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5496,6 +5478,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^4.5.0 || >= 5.9" } @@ -5626,7 +5609,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5848,7 +5830,6 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -6069,6 +6050,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "debug": "2.6.9", "finalhandler": "1.1.2", @@ -6086,6 +6068,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -6097,6 +6080,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 0.8" } @@ -6108,6 +6092,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", @@ -6127,7 +6112,8 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/connect/node_modules/on-finished": { "version": "2.3.0", @@ -6136,6 +6122,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "ee-first": "1.1.1" }, @@ -6150,6 +6137,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 0.6" } @@ -6339,7 +6327,8 @@ "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/d": { "version": "1.0.2", @@ -6385,6 +6374,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=4.0" } @@ -6438,6 +6428,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" @@ -6460,7 +6451,8 @@ "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/didyoumean": { "version": "1.2.2", @@ -6481,6 +6473,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "custom-event": "~1.0.0", "ent": "~2.2.0", @@ -6625,6 +6618,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", @@ -6647,6 +6641,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=10.0.0" } @@ -6658,6 +6653,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" @@ -6673,6 +6669,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -6692,6 +6689,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 0.6" } @@ -6703,6 +6701,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -6717,6 +6716,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 0.6" } @@ -6728,6 +6728,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -6751,6 +6752,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", @@ -6979,7 +6981,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7280,7 +7281,8 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/eventsource": { "version": "3.0.7", @@ -7328,7 +7330,6 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -7398,7 +7399,8 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/fast-deep-equal": { "version": "3.1.3", @@ -7596,6 +7598,7 @@ ], "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=4.0" }, @@ -7646,6 +7649,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", @@ -7674,7 +7678,8 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true, "license": "ISC", - "optional": true + "optional": true, + "peer": true }, "node_modules/fsevents": { "version": "2.3.3", @@ -7779,6 +7784,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -7820,6 +7826,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -7832,6 +7839,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -7902,6 +7910,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "has-symbols": "^1.0.3" }, @@ -8035,6 +8044,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "eventemitter3": "^4.0.0", "follow-redirects": "^1.0.0", @@ -8176,6 +8186,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -8324,6 +8335,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", @@ -8363,6 +8375,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 8.0.0" }, @@ -8491,7 +8504,6 @@ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -8597,6 +8609,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -8639,6 +8652,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -8679,6 +8693,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=8" } @@ -8690,6 +8705,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", @@ -8716,6 +8732,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -8728,6 +8745,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -8754,6 +8772,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", @@ -8767,6 +8786,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8777,7 +8797,8 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/karma/node_modules/glob-parent": { "version": "5.1.2", @@ -8786,6 +8807,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -8800,6 +8822,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -8814,6 +8837,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=8" } @@ -8825,6 +8849,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 0.6" } @@ -8836,6 +8861,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 0.6" } @@ -8847,6 +8873,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -8861,6 +8888,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -8874,7 +8902,8 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/karma/node_modules/picomatch": { "version": "2.3.1", @@ -8883,6 +8912,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=8.6" }, @@ -8897,6 +8927,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", @@ -8914,6 +8945,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -8928,6 +8960,7 @@ "dev": true, "license": "BSD-3-Clause", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8939,6 +8972,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -8955,6 +8989,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -8969,6 +9004,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" @@ -8984,6 +9020,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -9003,6 +9040,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", @@ -9023,6 +9061,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "engines": { "node": ">=10" } @@ -9084,7 +9123,6 @@ "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", @@ -9304,6 +9342,7 @@ "dev": true, "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "date-format": "^4.0.14", "debug": "^4.3.4", @@ -9496,6 +9535,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "bin": { "mime": "cli.js" }, @@ -9566,6 +9606,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -9730,6 +9771,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -9908,6 +9950,19 @@ "@angular/core": ">=21.0.0 <22.0.0" } }, + "node_modules/ngx-sse-client": { + "version": "20.0.1", + "resolved": "https://registry.npmjs.org/ngx-sse-client/-/ngx-sse-client-20.0.1.tgz", + "integrity": "sha512-OSFRirL5beveGj4An3lOzWwg/JZWJG4Q1TdbyW7lqSDacfwINpIjSHdWlpiQwIghKU7BtLAc6TonUGlU4MzGTQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + }, + "peerDependencies": { + "@angular/common": ">=20.0.0", + "@angular/core": ">=20.0.0" + } + }, "node_modules/node-addon-api": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", @@ -10525,6 +10580,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10674,7 +10730,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10921,7 +10976,8 @@ "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/qjobs": { "version": "1.2.0", @@ -10930,6 +10986,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.9" } @@ -11083,6 +11140,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11103,7 +11161,8 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/resolve": { "version": "1.22.11", @@ -11187,6 +11246,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -11317,7 +11377,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -11335,6 +11394,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -11662,6 +11722,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", @@ -11682,6 +11743,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "debug": "~4.3.4", "ws": "~8.17.1" @@ -11694,6 +11756,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -11713,6 +11776,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -11736,6 +11800,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1" @@ -11751,6 +11816,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -11770,6 +11836,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" @@ -11785,6 +11852,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -11804,6 +11872,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 0.6" } @@ -11815,6 +11884,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -11829,6 +11899,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 0.6" } @@ -11996,6 +12067,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "date-format": "^4.0.14", "debug": "^4.3.4", @@ -12129,7 +12201,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -12371,6 +12442,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=14.14" } @@ -12456,8 +12528,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tuf-js": { "version": "4.0.0", @@ -12514,7 +12585,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12529,7 +12599,6 @@ "integrity": "sha512-Q1/6yNUmCpH94fbgMUMg2/BSAr/6U7GBk61kZTv1/asghQOWOjTlp9K8mixS5NcJmm2creY+UFfGeW/+OcA64A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/eslint-plugin": "8.50.0", "@typescript-eslint/parser": "8.50.0", @@ -12569,6 +12638,7 @@ ], "license": "MIT", "optional": true, + "peer": true, "bin": { "ua-parser-js": "script/cli.js" }, @@ -12626,6 +12696,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -12704,6 +12775,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 0.4.0" } @@ -12759,7 +12831,6 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -13319,7 +13390,6 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -13409,6 +13479,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13730,7 +13801,6 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -13749,8 +13819,7 @@ "version": "0.16.0", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.0.tgz", "integrity": "sha512-LqLPpIQANebrlxY6jKcYKdgN5DTXyyHAKnnWWjE5pPfEQ4n7j5zn7mOEEpwNZVKGqx3kKKmvplEmoBrvpgROTA==", - "license": "MIT", - "peer": true + "license": "MIT" } } } diff --git a/booklore-ui/package.json b/booklore-ui/package.json index 69be34c35..fa5c7888b 100644 --- a/booklore-ui/package.json +++ b/booklore-ui/package.json @@ -37,6 +37,7 @@ "ng2-charts": "^8.0.0", "ngx-extended-pdf-viewer": "^25.6.4", "ngx-infinite-scroll": "^21.0.0", + "ngx-sse-client": "^20.0.1", "primeicons": "^7.0.0", "primeng": "^21.0.2", "quill": "^2.0.3", diff --git a/booklore-ui/src/app/features/book/service/book-metadata.service.ts b/booklore-ui/src/app/features/book/service/book-metadata.service.ts new file mode 100644 index 000000000..e24f725d0 --- /dev/null +++ b/booklore-ui/src/app/features/book/service/book-metadata.service.ts @@ -0,0 +1,55 @@ +import {inject, Injectable} from '@angular/core'; +import {Observable} from 'rxjs'; +import {API_CONFIG} from '../../../core/config/api-config'; +import {FetchMetadataRequest} from '../../metadata/model/request/fetch-metadata-request.model'; +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 {map} from 'rxjs/operators'; + +@Injectable({providedIn: 'root'}) +export class BookMetadataService { + private readonly url = `${API_CONFIG.BASE_URL}/api/v1/books`; + private authService = inject(AuthService); + private sseClient = inject(SseClient); + + fetchBookMetadata(bookId: number, request: FetchMetadataRequest): Observable { + const token = + this.authService.getOidcAccessToken() || + this.authService.getInternalAccessToken(); + + if (!token) { + throw new Error('No authentication token available'); + } + + const headers = new HttpHeaders() + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${token}`); + + return this.sseClient.stream( + `${this.url}/${bookId}/metadata/prospective`, + { + keepAlive: false, + reconnectionDelay: 1000, + responseType: 'event' + }, + { + headers, + body: request, + withCredentials: true + }, + 'POST' + ).pipe( + map((event) => { + if (event.type === 'error') { + const errorEvent = event as ErrorEvent; + throw new Error(errorEvent.message); + } else { + const messageEvent = event as MessageEvent; + return JSON.parse(messageEvent.data) as BookMetadata; + } + }) + ); + } +} diff --git a/booklore-ui/src/app/features/book/service/book.service.spec.ts b/booklore-ui/src/app/features/book/service/book.service.spec.ts index 7c83de699..68dff8057 100644 --- a/booklore-ui/src/app/features/book/service/book.service.spec.ts +++ b/booklore-ui/src/app/features/book/service/book.service.spec.ts @@ -12,7 +12,6 @@ import {AuthService} from '../../../shared/service/auth.service'; import {FileDownloadService} from '../../../shared/service/file-download.service'; import {Router} from '@angular/router'; import {AdditionalFileType, Book, BookMetadata, ReadStatus} from '../model/book.model'; -import {FetchMetadataRequest} from '../../metadata/model/request/fetch-metadata-request.model'; describe('BookService', () => { let service: BookService; @@ -376,18 +375,6 @@ describe('BookService', () => { }); describe('Metadata Operations', () => { - it('should fetch book metadata', async () => { - httpMock.post.mockReturnValue(of([{bookId: 1}])); - const req: FetchMetadataRequest = { - bookId: 1, - providers: [], - title: '', - author: '', - isbn: '' - }; - const result = await firstValueFrom(service.fetchBookMetadata(1, req)); - expect(result).toEqual([{bookId: 1}]); - }); it('should update book metadata', async () => { httpMock.put.mockReturnValue(of({bookId: 1})); diff --git a/booklore-ui/src/app/features/book/service/book.service.ts b/booklore-ui/src/app/features/book/service/book.service.ts index 09562bf0d..d8029e2f9 100644 --- a/booklore-ui/src/app/features/book/service/book.service.ts +++ b/booklore-ui/src/app/features/book/service/book.service.ts @@ -5,7 +5,6 @@ import {catchError, distinctUntilChanged, filter, finalize, map, shareReplay, ta import {AdditionalFile, AdditionalFileType, Book, BookDeletionResponse, BookMetadata, BookRecommendation, BookSetting, BulkMetadataUpdateRequest, MetadataUpdateWrapper, ReadStatus} from '../model/book.model'; import {BookState} from '../model/state/book-state.model'; import {API_CONFIG} from '../../../core/config/api-config'; -import {FetchMetadataRequest} from '../../metadata/model/request/fetch-metadata-request.model'; import {MessageService} from 'primeng/api'; import {ResetProgressType} from '../../../shared/constants/reset-progress-type'; import {AuthService} from '../../../shared/service/auth.service'; @@ -426,9 +425,6 @@ export class BookService { /*------------------ Metadata Operations ------------------*/ - fetchBookMetadata(bookId: number, request: FetchMetadataRequest): Observable { - return this.http.post(`${this.url}/${bookId}/metadata/prospective`, request); - } updateBookMetadata(bookId: number | undefined, wrapper: MetadataUpdateWrapper, mergeCategories: boolean): Observable { const params = new HttpParams().set('mergeCategories', mergeCategories.toString()); diff --git a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-searcher/metadata-searcher.component.html b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-searcher/metadata-searcher.component.html index 437b5bede..59038b2ce 100644 --- a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-searcher/metadata-searcher.component.html +++ b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-searcher/metadata-searcher.component.html @@ -1,5 +1,5 @@ @if (selectedFetchedMetadata$ | async; as selectedFetchedMetadata) { -
+ } @else { -
- -
-
-
- +
+ +
+
+ + placeholder="Select providers" + [styleClass]="'custom-multiselect'">
-
- - +
+ +
-
- - +
+ +
-
- - +
+ +
-
- -
- - +
+ + +
+
+ + + @if (loading || searchTriggered) { +
+
+
+

+ + Search Results +

+ {{ filteredMetadata.length }} books found
+ @if (loading) { + + + Fetching... + + }
-
-
- - - When an ISBN is provided, the search will use only the ISBN. If no ISBN is given, the search will fall back to Title and Author. - -
-
- - @if (loading) { -
- -

- Fetching metadata from your selected sources.
- This may take a few moments... -

-
- } - - @if (!loading) { - @if (searchTriggered && allFetchedMetadata.length === 0) { -
-

No metadata results found.
Try adjusting your search criteria.

-
- } - @if (!searchTriggered && allFetchedMetadata.length === 0) { -
-

Start by searching for metadata using the search bar above.

-
- } - @if (allFetchedMetadata.length > 0) { -
- @for (metadata of allFetchedMetadata; track metadata) { -
-
- Image -
-
-

{{ truncateText(metadata['title']!, 90) }}

-
- @if (metadata['isbn10']) { -

ISBN10: {{ metadata['isbn10']}}

- - } - @if (metadata['isbn13']) { -

ISBN13: {{ metadata['isbn13']}}

- - } -

Published: {{ metadata['publishedDate'] }}

-
-
-
- @if (metadata['authors'] && metadata['authors'].length > 0) { -

by {{ truncateText(metadata['authors'].join(', '), 70) }}

- - } -

Source:

-
-

- {{ sanitizeHtml(metadata['description']) }} -

-
+
+ @for (tab of getProviderTabs(); track tab.provider) { +
+
+ +
+
+ {{ tab.provider }} + @if (isProviderLoading(tab.provider)) { + + } @else { + {{ tab.count }} + }
-
}
- } +
} - + + @if (!searchTriggered && allFetchedMetadata.length === 0) { +
+
+ +
+

Ready to Search

+

Enter your search criteria above and click Search to find metadata

+
+ } + + @if (searchTriggered && allFetchedMetadata.length === 0 && !loading) { +
+
+ +
+

No Results Found

+

Try adjusting your search criteria or selecting different providers

+
+ } + + @if (allFetchedMetadata.length > 0) { +
+
+ @for (metadata of filteredMetadata; track trackByMetadata($index, metadata)) { + + } +
+
+ } +
} diff --git a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-searcher/metadata-searcher.component.scss b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-searcher/metadata-searcher.component.scss index 93112b5f1..1de005bb5 100644 --- a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-searcher/metadata-searcher.component.scss +++ b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-searcher/metadata-searcher.component.scss @@ -1,36 +1,725 @@ -.truncate-text { +$provider-colors: ( + 'amazon': #FF9902, + 'goodreads': #bc957a, + 'google': #0DB65D, + 'hardcover': #6366F1, + 'douban': #1474E9, + 'lubimyczytac': #FF5501, + 'comicvine': #F9E04F +); + +.metadata-container { + height: 100%; + width: 100%; + flex: auto; +} + +.searcher-container { + max-width: 1600px; + margin: 0 auto; + animation: fadeIn 0.3s ease-in; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.search-header { + margin-bottom: 1.5rem; + + .header-content { + text-align: center; + } + + .header-title { + font-size: 1.5rem; + font-weight: 700; + margin: 0 0 0.375rem; + color: var(--text-color); + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + + i { + color: var(--primary-color); + font-size: 1.5rem; + } + } + + .header-subtitle { + font-size: 0.875rem; + color: var(--text-color-secondary); + margin: 0; + } +} + +.search-card { + background: var(--p-surface-900); + border-radius: 0.75rem; + padding: 1.25rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + border: 1px solid var(--border-color); + margin-bottom: 1.25rem; + transition: all 0.3s ease; + + &:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + } +} + +.search-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; + margin-bottom: 1rem; + + @media (min-width: 640px) { + grid-template-columns: repeat(2, 1fr); + } + + @media (min-width: 1024px) { + grid-template-columns: 2fr 1fr 1.5fr 1.5fr auto; + align-items: end; + } +} + +.search-field { + display: flex; + flex-direction: column; + gap: 0.375rem; + + label { + font-weight: 600; + font-size: 0.875rem; + color: var(--text-color); + display: flex; + align-items: center; + gap: 0.375rem; + + i { + color: var(--primary-color); + font-size: 0.8125rem; + } + } + + &.search-field-provider { + @media (min-width: 640px) { + grid-column: span 2; + } + + @media (min-width: 1024px) { + grid-column: auto; + } + } + + &.search-button-field { + @media (min-width: 640px) { + grid-column: span 2; + } + + @media (min-width: 1024px) { + grid-column: auto; + } + } +} + +::ng-deep { + .custom-search-button { + width: 100%; + height: 2.5rem; + font-weight: 600; + font-size: 0.9375rem; + border-radius: 0.5rem; + + @media (min-width: 1024px) { + width: auto; + min-width: 8rem; + } + } +} + +.search-info { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + background: var(--p-surface-900); + border-radius: 0.5rem; + font-size: 0.875rem; + color: var(--text-color-secondary); + border-left: 3px solid var(--primary-color); + + i { + color: var(--primary-color); + font-size: 0.9375rem; + flex-shrink: 0; + } +} + +.provider-status { + background: var(--p-surface-900); + border-radius: 0.75rem; + padding: 1rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + border: 1px solid var(--border-color); + margin-bottom: 1.25rem; + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(-20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes slideInFromTop { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.status-header { + display: flex; + justify-content: flex-start; + align-items: center; + margin-bottom: 1rem; + flex-wrap: wrap; + gap: 0.75rem; + + .results-info { + display: flex; + align-items: center; + gap: 0.75rem; + } + + h3 { + margin: 0; + font-size: 1.0625rem; + font-weight: 600; + color: var(--text-color); + display: flex; + align-items: center; + gap: 0.375rem; + + i { + color: var(--primary-color); + font-size: 0.9375rem; + } + } + + .results-count { + padding: 0.25rem 0.75rem; + background: rgba(var(--primary-color-rgb, 99, 102, 241), 0.2); + color: var(--primary-color); + border-radius: 2rem; + font-size: 0.875rem; + font-weight: 700; + border: 1px solid var(--primary-color); + } +} + +.fetching-badge { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.85rem; + background: rgba(34, 197, 94, 0.8); + color: white; + border-radius: 2rem; + font-size: 0.85rem; + font-weight: 700; + box-shadow: 0 2px 8px rgba(34, 197, 94, 0.35); +} + +.provider-pills { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 0.75rem; +} + +.provider-pill { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: var(--p-surface-900); + border: 1px solid var(--p-surface-600); + border-radius: 0.5rem; + transition: all 0.3s ease; + user-select: none; + + &.has-results { + background: var(--p-surface-900); + border-color: var(--primary-color); + cursor: pointer; + + .pill-icon { + background: rgba(var(--primary-color-rgb, 99, 102, 241), 0.12); + color: var(--primary-color); + } + + .pill-count { + background: rgba(var(--primary-color-rgb, 99, 102, 241), 0.18); + color: #fff; + font-weight: 700; + } + } + + &.active { + background: rgba(var(--primary-color-rgb, 99, 102, 241), 0.10); + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(var(--primary-color-rgb, 99, 102, 241), 0.10), + 0 2px 6px rgba(var(--primary-color-rgb, 99, 102, 241), 0.10); + transform: translateY(-1px); + + .pill-icon { + background: rgba(var(--primary-color-rgb, 99, 102, 241), 0.18); + color: var(--primary-color); + } + + .pill-name { + color: var(--primary-color); + font-weight: 700; + } + + .pill-count { + background: rgba(var(--primary-color-rgb, 99, 102, 241), 0.22); + color: #fff; + font-weight: 800; + } + } + + @each $provider, $color in $provider-colors { + &.provider-#{$provider} { + &.has-results { + border-color: $color; + + .pill-icon { + background: rgba($color, 0.12); + color: $color; + } + + .pill-count { + background: rgba($color, 0.18); + color: #fff; + font-weight: 700; + } + + .pill-name { + color: $color; + } + } + + &.active { + background: rgba($color, 0.2); + border-color: $color; + box-shadow: 0 0 0 2px rgba($color, 0.10), 0 2px 6px rgba($color, 0.10); + + .pill-name { + color: $color; + } + + .pill-icon { + background: rgba($color, 0.18); + color: $color; + } + + .pill-count { + background: rgba($color, 0.22); + color: #fff; + font-weight: 800; + } + } + } + } + + &:hover { + transform: translateY(-2px); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); + } + + &.active:hover { + transform: translateY(-3px); + } +} + +.pill-icon { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + background: var(--p-surface-600); + border-radius: 0.375rem; + color: var(--text-color-secondary); + flex-shrink: 0; + transition: all 0.3s ease; + + i { + font-size: 0.9375rem; + } +} + +.pill-content { + flex: 1; + display: flex; + justify-content: space-between; + align-items: center; + + .pill-name { + font-weight: 700; + font-size: 0.95rem; + color: var(--text-color); + } + + .pill-count { + display: flex; + align-items: center; + justify-content: center; + min-width: 1.75rem; + height: 1.75rem; + padding: 0 0.375rem; + background: var(--p-surface-600); + color: #fff; + border-radius: 2rem; + font-weight: 800; + font-size: 1rem; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); + } +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 1.5rem; + text-align: center; + + .empty-icon { + width: 4rem; + height: 4rem; + display: flex; + align-items: center; + justify-content: center; + background: var(--p-surface-900); + border-radius: 50%; + margin-bottom: 1rem; + + i { + font-size: 2rem; + color: var(--primary-color); + } + + &.no-results { + background: var(--p-surface-900); + + i { + color: var(--text-color-secondary); + } + } + } + + h3 { + margin: 0 0 0.375rem; + font-size: 1.25rem; + font-weight: 600; + color: var(--text-color); + } + + p { + margin: 0; + font-size: 0.875rem; + color: var(--text-color-secondary); + max-width: 500px; + } +} + +.results-section { + animation: fadeIn 0.3s ease-in; +} + +.results-toolbar { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1rem; + padding: 1rem; + background: var(--p-surface-900); + border-radius: 0.75rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + border: 1px solid var(--border-color); + + @media (min-width: 768px) { + flex-direction: row; + justify-content: space-between; + align-items: center; + } +} + +.results-info { + display: flex; + flex-direction: column; + gap: 0.375rem; + + @media (min-width: 768px) { + flex-direction: row; + align-items: center; + gap: 0.75rem; + } + + h3 { + margin: 0; + font-size: 1.0625rem; + font-weight: 600; + color: var(--text-color); + display: flex; + align-items: center; + gap: 0.375rem; + + i { + color: var(--primary-color); + font-size: 0.9375rem; + } + } + + .results-count { + padding: 0.1875rem 0.625rem; + background: var(--p-surface-900); + color: var(--primary-color); + border-radius: 2rem; + font-size: 0.8125rem; + font-weight: 600; + } +} + +.results-filter { + display: flex; + align-items: center; + gap: 0.5rem; + + label { + font-weight: 600; + font-size: 0.875rem; + color: var(--text-color); + display: flex; + align-items: center; + gap: 0.375rem; + white-space: nowrap; + + i { + color: var(--primary-color); + font-size: 0.8125rem; + } + } + + p-select { + min-width: 180px; + } +} + +.results-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; + + @media (min-width: 768px) { + grid-template-columns: repeat(2, 1fr); + } + + @media (min-width: 1200px) { + grid-template-columns: repeat(3, 1fr); + } +} + +.metadata-card { + background: var(--p-surface-900); + border-radius: 0.75rem; + overflow: hidden; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); + border: 1px solid var(--border-color); + cursor: pointer; + transition: all 0.3s ease; + display: flex; + flex-direction: row; + height: 200px; + animation: slideInFromTop 0.4s ease-out; + + &:hover { + transform: translateY(-4px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); + border-color: var(--primary-color); + + .card-overlay { + opacity: 1; + } + + .card-image img { + transform: scale(1.05); + } + } +} + +.card-image { + position: relative; + width: 140px; + min-width: 140px; + height: 200px; + overflow: hidden; + background: var(--p-surface-900); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + img { + width: 140px; + height: 200px; + object-fit: cover; + transition: transform 0.3s ease; + } + + .card-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.75); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.375rem; + opacity: 0; + transition: opacity 0.3s ease; + color: white; + + i { + font-size: 1.5rem; + } + + span { + font-weight: 600; + font-size: 0.875rem; + } + } +} + +.card-content { + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.625rem; + flex: 1; + min-width: 0; + overflow: hidden; +} + +.card-header { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.card-title { + margin: 0; + font-size: 1rem; + font-weight: 700; + color: var(--text-color); + line-height: 1.3; display: -webkit-box; - -webkit-line-clamp: 4; + -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; +} + +.card-provider { + font-size: 0.9rem; + + ::ng-deep a { + text-decoration: none; + font-weight: 700; + + &:hover { + text-decoration: underline; + } + } + + @each $provider, $color in $provider-colors { + &.provider-#{$provider} ::ng-deep a { + color: $color; + } + } +} + +.card-authors { + margin: 0; + font-size: 0.9rem; + color: var(--text-color-secondary); + display: flex; + align-items: center; + gap: 0.375rem; + white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; + font-weight: 700; + + i { + color: var(--primary-color); + font-size: 0.8125rem; + flex-shrink: 0; + } } -.img-fixed-dimensions { - height: 150px; - width: 100px; - object-fit: fill; +.card-meta { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; } -.book-item { - position: relative; - cursor: pointer; +.meta-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.5rem 0.5rem 0.5rem 0; + background: var(--p-surface-900); + border-radius: 0.25rem; + font-size: 0.8rem; + color: var(--text-color-secondary); + font-weight: 500; + white-space: nowrap; + + i { + padding-right: 0.25rem; + font-size: 0.6875rem; + color: var(--primary-color); + flex-shrink: 0; + } } -.book-item-content { - position: relative; -} - -.book-item:hover .book-item-content::after { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(255, 255, 255, 0.075); -} - -.book-item:hover .book-item-content { - box-shadow: 0 0 0 rgba(255, 255, 255, 0.9); +.card-description { + margin: 0; + font-size: 0.875rem; + color: var(--text-color-secondary); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 6; + -webkit-box-orient: vertical; + overflow: hidden; } diff --git a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-searcher/metadata-searcher.component.ts b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-searcher/metadata-searcher.component.ts index 6f5c1a683..2fe5a2192 100644 --- a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-searcher/metadata-searcher.component.ts +++ b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-searcher/metadata-searcher.component.ts @@ -1,14 +1,11 @@ import {Component, inject, Input, OnDestroy, OnInit} from '@angular/core'; -import {FormBuilder, FormGroup, ReactiveFormsModule} from '@angular/forms'; +import {FormBuilder, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms'; import {Button} from 'primeng/button'; import {InputText} from 'primeng/inputtext'; -import {Divider} from 'primeng/divider'; import {MultiSelect} from 'primeng/multiselect'; -import {ProgressSpinner} from 'primeng/progressspinner'; import {FetchMetadataRequest} from '../../../model/request/fetch-metadata-request.model'; import {Book, BookMetadata} from '../../../../book/model/book.model'; -import {BookService} from '../../../../book/service/book.service'; import {AppSettings} from '../../../../../shared/model/app-settings.model'; import {AppSettingsService} from '../../../../../shared/service/app-settings.service'; @@ -17,6 +14,8 @@ import {distinctUntilChanged, filter, switchMap} from 'rxjs/operators'; import {ActivatedRoute} from '@angular/router'; import {AsyncPipe} from '@angular/common'; import {MetadataPickerComponent} from '../metadata-picker/metadata-picker.component'; +import {BookMetadataService} from '../../../../book/service/book-metadata.service'; +import {Tooltip} from 'primeng/tooltip'; @Component({ selector: 'app-metadata-searcher', @@ -24,13 +23,13 @@ import {MetadataPickerComponent} from '../metadata-picker/metadata-picker.compon styleUrls: ['./metadata-searcher.component.scss'], imports: [ ReactiveFormsModule, + FormsModule, Button, InputText, - Divider, - ProgressSpinner, MetadataPickerComponent, MultiSelect, - AsyncPipe + AsyncPipe, + Tooltip ], standalone: true }) @@ -47,7 +46,7 @@ export class MetadataSearcherComponent implements OnInit, OnDestroy { selectedFetchedMetadata$ = new BehaviorSubject(null); private formBuilder = inject(FormBuilder); - private bookService = inject(BookService); + private bookMetadataService = inject(BookMetadataService); private appSettingsService = inject(AppSettingsService); private route = inject(ActivatedRoute); @@ -56,6 +55,15 @@ export class MetadataSearcherComponent implements OnInit, OnDestroy { appSettings$: Observable = this.appSettingsService.appSettings$; + providerCounts: Map = new Map(); + providerLoading: Map = new Map(); + selectedProviderFilters: Set = new Set(['all']); + filteredMetadata: BookMetadata[] = []; + providerFilterOptions: Array<{ label: string; value: string }> = []; + + private metadataByProvider: Map = new Map(); + private providerCompletionStatus: Map = new Map(); + constructor() { this.form = this.formBuilder.group({ provider: null, @@ -76,6 +84,8 @@ export class MetadataSearcherComponent implements OnInit, OnDestroy { this.cancelRequest$.next(); this.loading = false; this.allFetchedMetadata = []; + this.filteredMetadata = []; + this.providerCounts.clear(); this.selectedFetchedMetadata$.next(null); } return combineLatest([this.book$, this.appSettings$]); @@ -101,6 +111,10 @@ export class MetadataSearcherComponent implements OnInit, OnDestroy { private resetFormFromBook(book: Book): void { this.selectedFetchedMetadata$.next(null); this.allFetchedMetadata = []; + this.filteredMetadata = []; + this.providerCounts.clear(); + this.metadataByProvider.clear(); + this.selectedProviderFilters = new Set(['all']); this.bookId = book.id; this.form.patchValue({ @@ -139,20 +153,68 @@ export class MetadataSearcherComponent implements OnInit, OnDestroy { }; this.loading = true; + this.allFetchedMetadata = []; + this.filteredMetadata = []; + this.providerCounts.clear(); + this.providerLoading.clear(); + this.providerCompletionStatus.clear(); + this.metadataByProvider.clear(); + this.selectedProviderFilters = new Set(['all']); this.cancelRequest$.next(); - this.bookService.fetchBookMetadata(fetchRequest.bookId, fetchRequest) + providerKeys.forEach((provider: string) => { + const providerLower = provider.toLowerCase(); + this.providerCounts.set(providerLower, 0); + this.providerLoading.set(providerLower, true); + this.providerCompletionStatus.set(providerLower, false); + this.metadataByProvider.set(providerLower, []); + }); + + this.updateProviderFilterOptions(); + + const activeProviders = new Set(providerKeys.map((p: string) => p.toLowerCase())); + + this.bookMetadataService.fetchBookMetadata(fetchRequest.bookId, fetchRequest) .pipe(takeUntil(this.cancelRequest$)) .subscribe({ - next: (fetchedMetadata) => { - this.loading = false; - this.allFetchedMetadata = fetchedMetadata.map(m => ({ - ...m, - thumbnailUrl: m.thumbnailUrl - })); + next: (metadata) => { + const metadataWithThumbnail = { + ...metadata, + thumbnailUrl: metadata.thumbnailUrl + }; + + const provider = this.getProviderFromMetadata(metadata); + if (provider) { + const providerList = this.metadataByProvider.get(provider) || []; + providerList.push(metadataWithThumbnail); + this.metadataByProvider.set(provider, providerList); + + this.providerCounts.set(provider, providerList.length); + + if (!this.providerCompletionStatus.get(provider)) { + this.providerLoading.set(provider, false); + this.providerCompletionStatus.set(provider, true); + } + } + + this.allFetchedMetadata = this.interleaveResults(); + + this.applyFilter(); + this.updateProviderFilterOptions(); }, - error: () => { + error: (error) => { + console.error('Error fetching metadata:', error); this.loading = false; + this.providerLoading.clear(); + }, + complete: () => { + this.loading = false; + activeProviders.forEach((provider: string) => { + if (!this.providerCompletionStatus.get(provider)) { + this.providerLoading.set(provider, false); + this.providerCompletionStatus.set(provider, true); + } + }); } }); } else { @@ -160,6 +222,108 @@ export class MetadataSearcherComponent implements OnInit, OnDestroy { } } + private interleaveResults(): BookMetadata[] { + const interleaved: BookMetadata[] = []; + const providers = Array.from(this.metadataByProvider.keys()); + + if (providers.length === 0) return []; + + const maxLength = Math.max( + ...Array.from(this.metadataByProvider.values()).map(list => list.length) + ); + + for (let i = 0; i < maxLength; i++) { + for (const provider of providers) { + const providerList = this.metadataByProvider.get(provider); + if (providerList && i < providerList.length) { + interleaved.push(providerList[i]); + } + } + } + + return interleaved; + } + + private getProviderFromMetadata(metadata: BookMetadata): string | null { + if (metadata.asin) return 'amazon'; + if (metadata.goodreadsId) return 'goodreads'; + if (metadata.googleId) return 'google'; + if (metadata.hardcoverId) return 'hardcover'; + if (metadata['doubanId']) return 'douban'; + if (metadata['lubimyczytacId']) return 'lubimyczytac'; + if (metadata.comicvineId) return 'comicvine'; + return null; + } + + getProviderClass(metadata: BookMetadata): string { + return this.getProviderFromMetadata(metadata) || 'unknown'; + } + + private updateProviderFilterOptions(): void { + this.providerFilterOptions = [ + {label: `All (${this.allFetchedMetadata.length})`, value: 'all'}, + ...Array.from(this.providerCounts.entries()) + .filter(([_, count]) => count > 0) + .map(([provider, count]) => ({ + label: `${provider.charAt(0).toUpperCase() + provider.slice(1)} (${count})`, + value: provider + })) + ]; + } + + onProviderPillClick(provider: string, event: MouseEvent): void { + const providerLower = provider.toLowerCase(); + + if (event.ctrlKey || event.metaKey) { + if (this.selectedProviderFilters.has(providerLower)) { + this.selectedProviderFilters.delete(providerLower); + } else { + this.selectedProviderFilters.add(providerLower); + this.selectedProviderFilters.delete('all'); + } + + if (this.selectedProviderFilters.size === 0) { + this.selectedProviderFilters.add('all'); + } + } else { + if (this.selectedProviderFilters.has(providerLower) && this.selectedProviderFilters.size === 1) { + this.selectedProviderFilters.clear(); + this.selectedProviderFilters.add('all'); + } else { + this.selectedProviderFilters.clear(); + this.selectedProviderFilters.add(providerLower); + } + } + + this.applyFilter(); + } + + isProviderPillActive(provider: string): boolean { + return this.selectedProviderFilters.has(provider.toLowerCase()); + } + + isProviderLoading(provider: string): boolean { + return this.providerLoading.get(provider.toLowerCase()) ?? false; + } + + private applyFilter(): void { + if (this.selectedProviderFilters.has('all')) { + this.filteredMetadata = [...this.allFetchedMetadata]; + } else { + this.filteredMetadata = this.allFetchedMetadata.filter(metadata => { + const provider = this.getProviderFromMetadata(metadata); + return provider && this.selectedProviderFilters.has(provider); + }); + } + } + + getProviderTabs(): Array<{ provider: string; count: number }> { + return Array.from(this.providerCounts.entries()).map(([provider, count]) => ({ + provider: provider.charAt(0).toUpperCase() + provider.slice(1), + count + })); + } + onBookClick(fetchedMetadata: BookMetadata) { this.selectedFetchedMetadata$.next(fetchedMetadata); } @@ -201,4 +365,16 @@ export class MetadataSearcherComponent implements OnInit, OnDestroy { } throw new Error("No provider ID found in metadata."); } + + trackByMetadata(index: number, metadata: BookMetadata): string { + return metadata.googleId || metadata.goodreadsId || metadata.asin || + metadata.hardcoverId || metadata.comicvineId || index.toString(); + } + + onProviderClick(event: MouseEvent) { + const target = event.target as HTMLElement; + if (target.tagName === 'A' || target.closest('a')) { + event.stopPropagation(); + } + } }