WIP: New metadata engine

This commit is contained in:
aditya.chandel
2025-01-01 01:12:27 -07:00
parent 1d06f83de8
commit ea2ac58f03
30 changed files with 1145 additions and 83 deletions

View File

@@ -43,7 +43,7 @@ dependencies {
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.3'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.2'
implementation 'org.springframework.boot:spring-boot-starter-websocket:3.4.0'
implementation 'org.jsoup:jsoup:1.18.3'
}
hibernate {

View File

@@ -1,13 +1,17 @@
package com.adityachandel.booklore.controller;
import com.adityachandel.booklore.model.dto.BookDTO;
import com.adityachandel.booklore.model.dto.BookMetadataDTO;
import com.adityachandel.booklore.model.dto.BookViewerSettingDTO;
import com.adityachandel.booklore.model.dto.request.SetMetadataRequest;
import com.adityachandel.booklore.model.dto.request.ShelvesAssignmentRequest;
import com.adityachandel.booklore.model.dto.response.GoogleBooksMetadata;
import com.adityachandel.booklore.service.BooksService;
import com.adityachandel.booklore.service.metadata.parser.AmazonParser;
import com.adityachandel.booklore.service.metadata.parser.model.QueryData;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import lombok.extern.java.Log;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -21,6 +25,7 @@ import java.util.List;
public class BookController {
private BooksService booksService;
private AmazonParser amazonParser;
@GetMapping("/{bookId}")
public ResponseEntity<BookDTO> getBook(@PathVariable long bookId) {
@@ -69,6 +74,11 @@ public class BookController {
return ResponseEntity.ok(booksService.fetchProspectiveMetadataListByBookId(bookId));
}
@PostMapping("/{bookId}/query-for-books")
public ResponseEntity<BookMetadataDTO> getBookMetadata(@RequestBody(required = false) QueryData queryData, @PathVariable Long bookId) {
return ResponseEntity.ok(amazonParser.queryForBookMetadata(bookId, queryData));
}
@GetMapping("/fetch-metadata")
public ResponseEntity<List<GoogleBooksMetadata>> fetchMedataByTerm(@RequestParam String term) {
return ResponseEntity.ok(booksService.fetchProspectiveMetadataListBySearchTerm(term));
@@ -83,4 +93,10 @@ public class BookController {
public ResponseEntity<List<BookDTO>> addBookToShelf(@RequestBody @Valid ShelvesAssignmentRequest request) {
return ResponseEntity.ok(booksService.assignShelvesToBooks(request.getBookIds(), request.getShelvesToAssign(), request.getShelvesToUnassign()));
}
@PutMapping("/{bookId}/metadata")
public ResponseEntity<BookMetadataDTO> updateBookMetadata(@RequestBody BookMetadataDTO metadataDTO, @PathVariable long bookId) throws IOException {
return ResponseEntity.ok(booksService.setMetadata(bookId, metadataDTO));
}
}

View File

@@ -1,16 +1,20 @@
package com.adityachandel.booklore.controller;
import com.adityachandel.booklore.model.dto.BookDTO;
import com.adityachandel.booklore.model.dto.BookMetadataDTO;
import com.adityachandel.booklore.model.dto.BookWithNeighborsDTO;
import com.adityachandel.booklore.model.dto.LibraryDTO;
import com.adityachandel.booklore.model.dto.request.CreateLibraryRequest;
import com.adityachandel.booklore.model.entity.Sort;
import com.adityachandel.booklore.service.BooksService;
import com.adityachandel.booklore.service.LibraryService;
import com.adityachandel.booklore.service.metadata.parser.AmazonParser;
import com.adityachandel.booklore.service.metadata.parser.model.QueryData;
import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List;
@@ -21,6 +25,7 @@ public class LibraryController {
private LibraryService libraryService;
private BooksService booksService;
private AmazonParser amazonParser;
@GetMapping("/{libraryId}")
public ResponseEntity<LibraryDTO> getLibrary(@PathVariable long libraryId) {

View File

@@ -1,17 +1,16 @@
package com.adityachandel.booklore.model.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Builder
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
@Builder
public class BookMetadataDTO {
private Long bookId;
private String googleBookId;
private String amazonBookId;
private String title;
private String subtitle;
private String publisher;
@@ -24,4 +23,7 @@ public class BookMetadataDTO {
private String language;
private List<AuthorDTO> authors;
private List<CategoryDTO> categories;
private String rating;
private String reviewCount;
private String printLength;
}

View File

@@ -19,9 +19,6 @@ public class BookMetadata {
@Column(name = "book_id")
private Long bookId;
@Column(name = "google_book_id", nullable = false, unique = true)
private String googleBookId;
@Column(name = "title", nullable = false)
private String title;

View File

@@ -1,20 +1,20 @@
package com.adityachandel.booklore.service;
import com.adityachandel.booklore.config.AppProperties;
import com.adityachandel.booklore.model.dto.BookDTO;
import com.adityachandel.booklore.model.dto.BookWithNeighborsDTO;
import com.adityachandel.booklore.model.dto.response.GoogleBooksMetadata;
import com.adityachandel.booklore.model.dto.BookViewerSettingDTO;
import com.adityachandel.booklore.model.dto.request.SetMetadataRequest;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.model.dto.*;
import com.adityachandel.booklore.model.dto.request.SetMetadataRequest;
import com.adityachandel.booklore.model.dto.response.GoogleBooksMetadata;
import com.adityachandel.booklore.model.entity.*;
import com.adityachandel.booklore.repository.*;
import com.adityachandel.booklore.transformer.BookMetadataTransformer;
import com.adityachandel.booklore.transformer.BookSettingTransformer;
import com.adityachandel.booklore.transformer.BookTransformer;
import com.adityachandel.booklore.util.BookUtils;
import com.adityachandel.booklore.util.DateUtils;
import com.adityachandel.booklore.util.FileService;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
@@ -23,13 +23,13 @@ import org.springframework.transaction.annotation.Transactional;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.*;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Slf4j
@@ -37,7 +37,6 @@ import java.util.stream.Collectors;
@Service
public class BooksService {
private final AppProperties appProperties;
private final BookRepository bookRepository;
private final BookViewerSettingRepository bookViewerSettingRepository;
private final GoogleBookMetadataService googleBookMetadataService;
@@ -47,6 +46,7 @@ public class BooksService {
private final LibraryRepository libraryRepository;
private final NotificationService notificationService;
private final ShelfRepository shelfRepository;
private final FileService fileService;
public BookDTO getBook(long bookId) {
@@ -69,22 +69,6 @@ public class BooksService {
bookViewerSettingRepository.save(bookViewerSetting);
}
public Resource getBookCover(long bookId) {
Book book = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
String thumbPath = appProperties.getPathConfig() + "/thumbs/" + getFileNameWithoutExtension(book.getFileName()) + ".jpg";
Path filePath = Paths.get(thumbPath);
try {
Resource resource = new UrlResource(filePath.toUri());
if (resource.exists() && resource.isReadable()) {
return resource;
} else {
throw ApiError.IMAGE_NOT_FOUND.createException(thumbPath);
}
} catch (IOException e) {
throw ApiError.IMAGE_NOT_FOUND.createException(thumbPath);
}
}
public ResponseEntity<byte[]> getBookData(long bookId) throws IOException {
Book book = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
byte[] pdfBytes = Files.readAllBytes(new File(book.getPath()).toPath());
@@ -116,7 +100,7 @@ public class BooksService {
searchString.append(book.getMetadata().getTitle());
}
if (searchString.isEmpty()) {
searchString.append(cleanFileName(book.getFileName()));
searchString.append(BookUtils.cleanFileName(book.getFileName()));
}
if (book.getMetadata().getAuthors() != null && !book.getMetadata().getAuthors().isEmpty()) {
if (!searchString.isEmpty()) {
@@ -129,23 +113,57 @@ public class BooksService {
return googleBookMetadataService.queryByTerm(searchString.toString());
}
private char[] cleanFileName(String fileName) {
if (fileName == null) {
return null;
}
String cleanedFileName = fileName.replace("(Z-Library)", "").trim();
return cleanedFileName.toCharArray();
}
public List<GoogleBooksMetadata> fetchProspectiveMetadataListBySearchTerm(String searchTerm) {
return googleBookMetadataService.queryByTerm(searchTerm);
}
public BookMetadataDTO setMetadata(long bookId, BookMetadataDTO newMetadata) throws IOException {
Book book = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
BookMetadata metadata = book.getMetadata();
metadata.setTitle(newMetadata.getTitle());
metadata.setSubtitle(newMetadata.getSubtitle());
metadata.setPublisher(newMetadata.getPublisher());
metadata.setPublishedDate(DateUtils.parseDateToInstant(newMetadata.getPublishedDate()));
metadata.setLanguage(newMetadata.getLanguage());
metadata.setIsbn10(newMetadata.getIsbn10());
metadata.setIsbn13(newMetadata.getIsbn13());
metadata.setDescription(newMetadata.getDescription());
metadata.setPageCount(newMetadata.getPageCount());
if (newMetadata.getAuthors() != null && !newMetadata.getAuthors().isEmpty()) {
List<Author> authors = newMetadata.getAuthors().stream()
.map(authorDTO -> authorRepository.findByName(authorDTO.getName())
.orElseGet(() -> authorRepository.save(Author.builder().name(authorDTO.getName()).build())))
.collect(Collectors.toList());
metadata.setAuthors(authors);
}
if (newMetadata.getCategories() != null && !newMetadata.getCategories().isEmpty()) {
List<Category> categories = newMetadata
.getCategories()
.stream()
.map(CategoryDTO::getName)
.collect(Collectors.toSet())
.stream()
.map(categoryName -> categoryRepository.findByName(categoryName)
.orElseGet(() -> categoryRepository.save(Category.builder().name(categoryName).build())))
.collect(Collectors.toList());
metadata.setCategories(categories);
}
if(newMetadata.getThumbnail() != null && !newMetadata.getThumbnail().isEmpty()) {
String thumbnailPath = fileService.createThumbnail(bookId, newMetadata.getThumbnail(), "amz");
metadata.setThumbnail(thumbnailPath);
}
authorRepository.saveAll(metadata.getAuthors());
categoryRepository.saveAll(metadata.getCategories());
metadataRepository.save(metadata);
return BookMetadataTransformer.convertToBookDTO(metadata);
}
public BookDTO setMetadata(SetMetadataRequest setMetadataRequest, long bookId) {
Book book = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
GoogleBooksMetadata gMetadata = googleBookMetadataService.getByGoogleBookId(setMetadataRequest.getGoogleBookId());
BookMetadata metadata = book.getMetadata();
metadata.setGoogleBookId(gMetadata.getGoogleBookId());
metadata.setDescription(gMetadata.getDescription());
metadata.setTitle(gMetadata.getTitle());
metadata.setLanguage(gMetadata.getLanguage());
@@ -229,11 +247,9 @@ public class BooksService {
List<Book> books = bookRepository.findAllById(bookIds);
List<Shelf> shelvesToAssign = shelfRepository.findAllById(shelfIdsToAssign);
List<Shelf> shelvesToUnassign = shelfRepository.findAllById(shelfIdsToUnassign);
for (Book book : books) {
book.getShelves().removeIf(shelf -> shelfIdsToUnassign.contains(shelf.getId()));
shelvesToUnassign.forEach(shelf -> shelf.getBooks().remove(book));
shelvesToAssign.forEach(shelf -> {
if (!book.getShelves().contains(shelf)) {
book.getShelves().add(shelf);
@@ -242,12 +258,14 @@ public class BooksService {
shelf.getBooks().add(book);
}
});
bookRepository.save(book);
shelfRepository.saveAll(shelvesToAssign);
}
return books.stream().map(BookTransformer::convertToBookDTO).collect(Collectors.toList());
}
}
public Resource getBookCover(long bookId) {
Book book = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
return fileService.getBookCover(book.getMetadata().getThumbnail());
}
}

View File

@@ -7,6 +7,7 @@ import com.adityachandel.booklore.model.entity.Book;
import com.adityachandel.booklore.model.entity.BookMetadata;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.transformer.BookTransformer;
import com.adityachandel.booklore.util.FileService;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import lombok.AllArgsConstructor;
@@ -69,13 +70,16 @@ public class PdfFileProcessor implements FileProcessor {
bookCreatorService.addAuthorsToBook(authors, book);
}
}
generateCoverImage(bookFile, new File(appProperties.getPathConfig() + "/thumbs"), document);
bookCreatorService.saveConnections(book);
Book saved = bookRepository.save(book);
boolean success = generateCoverImage(saved.getId(), new File(appProperties.getPathConfig() + "/thumbs"), document);
if (success) {
bookMetadata.setThumbnail(appProperties.getPathConfig() + "/thumbs/" + book.getId() + "/f.jpg");
}
bookRepository.flush();
} catch (Exception e) {
log.error("Error while processing file {}, error: {}", libraryFile.getFilePath(), e.getMessage());
}
bookCreatorService.saveConnections(book);
bookRepository.save(book);
bookRepository.flush();
return BookTransformer.convertToBookDTO(book);
}
@@ -92,13 +96,19 @@ public class PdfFileProcessor implements FileProcessor {
return authorNames.stream().map(String::trim).collect(Collectors.toSet());
}
private void generateCoverImage(File bookFile, File coverDirectory, PDDocument document) throws IOException {
private boolean generateCoverImage(Long bookId, File coverDirectory, PDDocument document) throws IOException {
PDFRenderer renderer = new PDFRenderer(document);
BufferedImage coverImage = renderer.renderImageWithDPI(0, 300, ImageType.RGB);
BufferedImage resizedImage = resizeImage(coverImage, 250, 350);
String coverImageName = getFileNameWithoutExtension(bookFile.getName()) + ".jpg";
File coverImageFile = new File(coverDirectory, coverImageName);
ImageIO.write(resizedImage, "JPEG", coverImageFile);
File bookDirectory = new File(coverDirectory, bookId.toString());
if (!bookDirectory.exists()) {
if (!bookDirectory.mkdirs()) {
throw new IOException("Failed to create directory: " + bookDirectory.getAbsolutePath());
}
}
String coverImageName = "f.jpg";
File coverImageFile = new File(bookDirectory, coverImageName);
return ImageIO.write(resizedImage, "JPEG", coverImageFile);
}
public static BufferedImage resizeImage(BufferedImage originalImage, int width, int height) {
@@ -109,13 +119,4 @@ public class PdfFileProcessor implements FileProcessor {
g2d.dispose();
return resizedImage;
}
public static String getFileNameWithoutExtension(String fileName) {
int dotIndex = fileName.lastIndexOf('.');
if (dotIndex == -1) {
return fileName;
} else {
return fileName.substring(0, dotIndex);
}
}
}

View File

@@ -0,0 +1,358 @@
package com.adityachandel.booklore.service.metadata.parser;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.model.dto.AuthorDTO;
import com.adityachandel.booklore.model.dto.BookMetadataDTO;
import com.adityachandel.booklore.model.dto.CategoryDTO;
import com.adityachandel.booklore.model.entity.Book;
import com.adityachandel.booklore.model.entity.Library;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.repository.LibraryRepository;
import com.adityachandel.booklore.service.metadata.parser.model.QueryData;
import com.adityachandel.booklore.util.BookUtils;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Connection;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Slf4j
@Service
@AllArgsConstructor
public class AmazonParser {
private BookRepository bookRepository;
public BookMetadataDTO queryForBookMetadata(Long bookId, QueryData queryData) {
Book book = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
if (queryData == null || (queryData.getBookTitle() == null && queryData.getAuthor() == null && queryData.getIsbn() == null)) {
String title = book.getMetadata().getTitle();
if (title == null || title.isEmpty()) {
String cleanFileName = BookUtils.cleanFileName(book.getFileName());
queryData = QueryData.builder().bookTitle(cleanFileName).build();
} else {
queryData = QueryData.builder().bookTitle(title).build();
}
}
String amazonBookId = getAmazonBookId(queryData);
if (amazonBookId == null) {
return null;
}
return getBookMetadata(amazonBookId);
}
public String getAmazonBookId(QueryData queryData) {
String queryUrl = buildQueryUrl(queryData);
if (queryUrl == null) {
return null;
}
try {
Document doc = fetchDoc(queryUrl);
Element searchResults = doc.select("span[data-component-type=s-search-results]").first();
Element item = searchResults.select("div[role=listitem][data-index=2]").first();
return item.attr("data-asin");
} catch (Exception e) {
log.error("Failed to get asin: {}", e.getMessage());
return null;
}
}
public BookMetadataDTO getBookMetadata(String amazonBookId) {
Document doc = fetchDoc("https://www.amazon.com/dp/" + amazonBookId);
return BookMetadataDTO.builder()
.amazonBookId(amazonBookId)
.title(getTitle(doc))
.subtitle(getSubtitle(doc))
.authors(getAuthors(doc).stream()
.map(name -> AuthorDTO.builder().name(name).build())
.collect(Collectors.toList()))
.categories(getBestSellerCategories(doc).stream()
.map(category -> CategoryDTO.builder().name(category).build())
.collect(Collectors.toList()))
.description(getDescription(doc))
.isbn13(getIsbn13(doc))
.isbn10(getIsbn10(doc))
.publisher(getPublisher(doc))
.publishedDate(getPublicationDate(doc))
.language(getLanguage(doc))
.pageCount(getPageCount(doc))
.thumbnail(getThumbnail(doc))
.rating(getRating(doc))
.reviewCount(getReviewCount(doc))
.printLength(getPrintLength(doc))
.build();
}
private String buildQueryUrl(QueryData queryData) {
StringBuilder queryBuilder = new StringBuilder("https://www.amazon.com/s/?search-alias=stripbooks&unfiltered=1&sort=relevanceexprank");
if (queryData.getIsbn() != null && !queryData.getIsbn().isEmpty()) {
queryBuilder.append("&field-isbn=").append(queryData.getIsbn());
}
if (queryData.getBookTitle() != null && !queryData.getBookTitle().isEmpty()) {
queryBuilder.append("&field-title=").append(queryData.getBookTitle().replace(" ", "%20"));
}
if (queryData.getAuthor() != null && !queryData.getAuthor().isEmpty()) {
queryBuilder.append("&field-author=").append(queryData.getAuthor().replace(" ", "%20"));
}
if (queryData.getIsbn() == null && queryData.getBookTitle() == null && queryData.getAuthor() != null) {
return null;
}
return queryBuilder.toString();
}
private String getTitle(Document doc) {
Element titleElement = doc.getElementById("productTitle");
if (titleElement != null) {
return titleElement.text();
}
log.error("Error fetching title: Element not found.");
return null;
}
private String getSubtitle(Document doc) {
Element subtitleElement = doc.getElementById("productSubtitle");
if (subtitleElement != null) {
return subtitleElement.text();
}
log.error("Error fetching subtitle: Element not found.");
return null;
}
private Set<String> getAuthors(Document doc) {
try {
Element bylineDiv = doc.select("#bylineInfo_feature_div").first();
if (bylineDiv != null) {
return bylineDiv
.select(".author a")
.stream()
.map(Element::text)
.collect(Collectors.toSet());
}
log.error("Error fetching authors: Byline element not found.");
} catch (Exception e) {
log.error("Error fetching authors: {}", e.getMessage());
}
return Set.of();
}
private String getDescription(Document doc) {
try {
Elements descriptionElements = doc.select("[data-a-expander-name=book_description_expander] .a-expander-content");
if (!descriptionElements.isEmpty()) {
String html = descriptionElements.getFirst().html();
html = html.replace("\n", "<br>");
return html;
}
return null;
} catch (Exception e) {
log.error("Error extracting description from the document", e);
}
return null;
}
private String getIsbn10(Document doc) {
try {
Element isbn10Element = doc.select("#rpi-attribute-book_details-isbn10 .rpi-attribute-value span").first();
if (isbn10Element != null) {
return isbn10Element.text();
}
log.error("Error fetching ISBN-10: Element not found.");
} catch (Exception e) {
log.error("Error fetching ISBN-10: {}", e.getMessage());
}
return null;
}
private String getIsbn13(Document doc) {
try {
Element isbn13Element = doc.select("#rpi-attribute-book_details-isbn13 .rpi-attribute-value span").first();
if (isbn13Element != null) {
return isbn13Element.text();
}
log.error("Error fetching ISBN-13: Element not found.");
} catch (Exception e) {
log.error("Error fetching ISBN-13: {}", e.getMessage());
}
return null;
}
private String getPublisher(Document doc) {
try {
Element publisherElement = doc.select("#rpi-attribute-book_details-publisher .rpi-attribute-value span").first();
if (publisherElement != null) {
return publisherElement.text();
}
log.error("Error fetching publisher: Element not found.");
} catch (Exception e) {
log.error("Error fetching publisher: {}", e.getMessage());
}
return null;
}
private String getPublicationDate(Document doc) {
try {
Element publicationDateElement = doc.select("#rpi-attribute-book_details-publication_date .rpi-attribute-value span").first();
if (publicationDateElement != null) {
return publicationDateElement.text();
}
log.error("Error fetching publication date: Element not found.");
} catch (Exception e) {
log.error("Error fetching publication date: {}", e.getMessage());
}
return null;
}
private String getLanguage(Document doc) {
try {
Element languageElement = doc.select("#rpi-attribute-language .rpi-attribute-value span").first();
if (languageElement != null) {
return languageElement.text();
}
log.error("Error fetching language: Element not found.");
} catch (Exception e) {
log.error("Error fetching language: {}", e.getMessage());
}
return null;
}
private String getPrintLength(Document doc) {
try {
Element printLengthElement = doc.select("#rpi-attribute-book_details-fiona_pages .rpi-attribute-value span").first();
if (printLengthElement != null) {
return printLengthElement.text();
}
log.error("Error fetching print length: Element not found.");
} catch (Exception e) {
log.error("Error fetching print length: {}", e.getMessage());
}
return null;
}
private Set<String> getBestSellerCategories(Document doc) {
try {
Element bestSellerCategoriesElement = doc.select("#detailBullets_feature_div").first();
if (bestSellerCategoriesElement != null) {
return bestSellerCategoriesElement
.select(".zg_hrsr .a-list-item a")
.stream()
.map(Element::text)
.map(c -> c.replace("(Books)", "").trim())
.collect(Collectors.toSet());
}
log.error("Error fetching best seller categories: Element not found.");
} catch (Exception e) {
log.error("Error fetching best seller categories: {}", e.getMessage());
}
return Set.of();
}
private String getRating(Document doc) {
try {
Element reviewDiv = doc.select("div#averageCustomerReviews_feature_div").first();
if (reviewDiv != null) {
Elements ratingElements = reviewDiv.select("span#acrPopover span.a-size-base.a-color-base");
if (!ratingElements.isEmpty()) {
return Objects.requireNonNull(ratingElements.first()).text();
}
}
} catch (Exception e) {
log.error("Error fetching rating", e);
}
return null;
}
private String getReviewCount(Document doc) {
try {
Element reviewDiv = doc.select("div#averageCustomerReviews_feature_div").first();
if (reviewDiv != null) {
Element reviewCountElement = reviewDiv.getElementById("acrCustomerReviewText");
if (reviewCountElement != null) {
return Objects.requireNonNull(reviewCountElement).text().split(" ")[0];
}
}
} catch (Exception e) {
log.error("Error fetching review count", e);
}
return null;
}
private String getThumbnail(Document doc) {
try {
Element imageElement = doc.select("#landingImage").first();
if (imageElement != null) {
return imageElement.attr("src");
}
log.error("Error fetching image URL: Image element not found.");
} catch (Exception e) {
log.error("Error fetching image URL: {}", e.getMessage());
}
return null;
}
private Integer getPageCount(Document doc) {
Elements pageCountElements = doc.select("#rpi-attribute-book_details-fiona_pages .rpi-attribute-value span");
if (!pageCountElements.isEmpty()) {
String pageCountText = pageCountElements.first().text();
if (!pageCountText.isEmpty()) {
try {
String cleanedPageCount = pageCountText.replaceAll("[^\\d]", "");
return Integer.parseInt(cleanedPageCount);
} catch (NumberFormatException e) {
log.error("Error parsing page count: {}", pageCountText, e);
}
}
}
return null;
}
private Document fetchDoc(String url) {
try {
Connection.Response response = Jsoup.connect(url)
.header("accept", "text/html, application/json")
.header("accept-language", "en-US,en;q=0.9")
.header("content-type", "application/json")
.header("device-memory", "8")
.header("downlink", "10")
.header("dpr", "2")
.header("ect", "4g")
.header("origin", "https://www.amazon.com")
.header("priority", "u=1, i")
.header("rtt", "50")
.header("sec-ch-device-memory", "8")
.header("sec-ch-dpr", "2")
.header("sec-ch-ua", "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"")
.header("sec-ch-ua-mobile", "?0")
.header("sec-ch-ua-platform", "\"macOS\"")
.header("sec-ch-viewport-width", "1170")
.header("sec-fetch-dest", "empty")
.header("sec-fetch-mode", "cors")
.header("sec-fetch-site", "same-origin")
.header("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
.header("viewport-width", "1170")
.header("x-amz-amabot-click-attributes", "disable")
.header("x-requested-with", "XMLHttpRequest")
.method(Connection.Method.GET)
.execute();
return response.parse();
} catch (IOException e) {
log.error("Error parsing url: {}", url, e);
throw new RuntimeException(e);
}
}
}

View File

@@ -0,0 +1,12 @@
package com.adityachandel.booklore.service.metadata.parser.model;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class QueryData {
private String isbn;
private String bookTitle;
private String author;
}

View File

@@ -1,8 +1,16 @@
package com.adityachandel.booklore.transformer;
import com.adityachandel.booklore.model.dto.BookMetadataDTO;
import com.adityachandel.booklore.model.entity.Author;
import com.adityachandel.booklore.model.entity.BookMetadata;
import com.adityachandel.booklore.model.entity.Category;
import com.adityachandel.booklore.util.FileService;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.annotations.Comment;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
public class BookMetadataTransformer {
@@ -10,7 +18,6 @@ public class BookMetadataTransformer {
public static BookMetadataDTO convertToBookDTO(BookMetadata bookMetadata) {
return BookMetadataDTO.builder()
.bookId(bookMetadata.getBookId())
.googleBookId(bookMetadata.getGoogleBookId())
.title(bookMetadata.getTitle())
.description(bookMetadata.getDescription())
.isbn10(bookMetadata.getIsbn10())
@@ -18,7 +25,6 @@ public class BookMetadataTransformer {
.publisher(bookMetadata.getPublisher())
.subtitle(bookMetadata.getSubtitle())
.language(bookMetadata.getLanguage())
.thumbnail(bookMetadata.getThumbnail())
.pageCount(bookMetadata.getPageCount())
.publishedDate(bookMetadata.getPublishedDate())
.authors(bookMetadata.getAuthors() == null ? null : bookMetadata.getAuthors().stream().map(AuthorTransformer::toAuthorDTO).collect(Collectors.toList()))

View File

@@ -0,0 +1,22 @@
package com.adityachandel.booklore.util;
public class BookUtils {
public static String cleanFileName(String fileName) {
if (fileName == null) {
return null;
}
fileName = fileName.replace("(Z-Library)", "").trim();
// Remove the author name inside parentheses (e.g. (Jon Yablonski))
fileName = fileName.replaceAll("\\s?\\(.*?\\)", "").trim();
// Remove the file extension (e.g., .pdf, .docx)
int dotIndex = fileName.lastIndexOf('.');
if (dotIndex > 0) {
fileName = fileName.substring(0, dotIndex).trim();
}
return fileName;
}
}

View File

@@ -0,0 +1,29 @@
package com.adityachandel.booklore.util;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Locale;
import java.util.regex.Pattern;
public class DateUtils {
private static final String DATE_FORMAT_REGEX = "^[A-Za-z]+\\s[0-9]{1,2},\\s[0-9]{4}$";
public static String parseDateToInstant(String dateString) {
if (dateString == null || dateString.trim().isEmpty() || !Pattern.matches(DATE_FORMAT_REGEX, dateString)) {
return dateString;
}
try {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMMM d, yyyy", Locale.ENGLISH);
LocalDate localDate = LocalDate.parse(dateString, formatter);
Instant instant = localDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
LocalDate finalDate = instant.atZone(ZoneId.systemDefault()).toLocalDate();
return finalDate.toString();
} catch (DateTimeParseException e) {
return null;
}
}
}

View File

@@ -0,0 +1,116 @@
package com.adityachandel.booklore.util;
import com.adityachandel.booklore.config.AppProperties;
import com.adityachandel.booklore.exception.ApiError;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.nio.file.Path;
import java.nio.file.Paths;
@Slf4j
@RequiredArgsConstructor
@Service
public class FileService {
private final AppProperties appProperties;
public Resource getBookCover(String thumbnailPath) {
Path filePath = Paths.get(thumbnailPath);
try {
Resource resource = new UrlResource(filePath.toUri());
if (resource.exists() && resource.isReadable()) {
return resource;
} else {
throw ApiError.IMAGE_NOT_FOUND.createException(filePath);
}
} catch (IOException e) {
throw ApiError.IMAGE_NOT_FOUND.createException(filePath);
}
}
/*private String getCurrentThumbnail(String bookThumbFolder) {
File directory = new File(bookThumbFolder);
if (!directory.isDirectory()) {
log.error("Invalid directory path: {}", bookThumbFolder);
return null;
}
File[] files = directory.listFiles((dir, name) -> name.endsWith("-current.jpg"));
if (files == null || files.length == 0) {
log.info("No file ending with '-current.jpg' found in directory: {}", bookThumbFolder);
return null;
}
return files[0].getPath();
}*/
public String createThumbnail(long bookId, String thumbnailUrl, String suffix) throws IOException {
String newFilename = suffix + ".jpg";
resizeAndSaveImage(thumbnailUrl, new File(getThumbnailPath(bookId)), newFilename);
return getThumbnailPath(bookId) + newFilename;
//removeOldCurrent(bookId, prefix);
}
/*public void removeOldCurrent(long bookId, String except) {
String thumbnailPath = getThumbnailPath(bookId);
File directory = new File(thumbnailPath);
if (!directory.isDirectory()) {
log.error("Invalid directory path: {}", thumbnailPath);
return;
}
File[] files = directory.listFiles((dir, name) ->
name.endsWith(".jpg") && !name.equals(except + "-current.jpg") && name.contains("-current"));
if (files == null || files.length == 0) {
log.info("No matching files to process in directory: {}", thumbnailPath);
return;
}
for (File file : files) {
File newFile = new File(file.getParent(), file.getName().replace("-current", ""));
if (file.renameTo(newFile)) {
log.info("Renamed: {} -> {}", file.getName(), newFile.getName());
} else {
log.error("Failed to rename: {}", file.getName());
}
}
}*/
private void resizeAndSaveImage(String imageUrl, File outputFolder, String outputFileName) throws IOException {
BufferedImage originalImage;
try (InputStream inputStream = new URL(imageUrl).openStream()) {
originalImage = ImageIO.read(inputStream);
}
if (originalImage == null) {
throw new IOException("Failed to read image from URL: " + imageUrl);
}
BufferedImage resizedImage = resizeImage(originalImage);
if (!outputFolder.exists() && !outputFolder.mkdirs()) {
throw new IOException("Failed to create output directory: " + outputFolder.getAbsolutePath());
}
File outputFile = new File(outputFolder, outputFileName);
ImageIO.write(resizedImage, "JPEG", outputFile);
log.info("Image saved to: {}", outputFile.getAbsolutePath());
}
private BufferedImage resizeImage(BufferedImage originalImage) {
BufferedImage resizedImage = new BufferedImage(250, 350, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = resizedImage.createGraphics();
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g2d.drawImage(originalImage, 0, 0, 250, 350, null);
g2d.dispose();
return resizedImage;
}
public String getThumbnailPath(long bookId) {
return appProperties.getPathConfig() + "/thumbs/" + bookId + "/";
}
}

View File

@@ -24,14 +24,13 @@ CREATE TABLE IF NOT EXISTS book
CREATE TABLE IF NOT EXISTS book_metadata
(
book_id BIGINT NOT NULL PRIMARY KEY,
google_book_id VARCHAR(255) UNIQUE,
title VARCHAR(255),
subtitle VARCHAR(255),
publisher VARCHAR(255),
published_date DATE,
description TEXT,
isbn_13 VARCHAR(13),
isbn_10 VARCHAR(10),
isbn_13 VARCHAR(20),
isbn_10 VARCHAR(20),
page_count INT,
thumbnail VARCHAR(1000),
language VARCHAR(10),

View File

@@ -1,3 +1,4 @@
<p-confirmDialog />
<p-toast></p-toast>
<app-loading-overlay></app-loading-overlay>
<router-outlet></router-outlet>

View File

@@ -41,6 +41,9 @@ import {ProgressSpinnerModule} from 'primeng/progressspinner';
import {provideAnimationsAsync} from '@angular/platform-browser/animations/async';
import {providePrimeNG} from 'primeng/config';
import Aura from '@primeng/themes/aura';
import { MetadataSearcherComponent } from './metadata-searcher/metadata-searcher.component';
import {IftaLabel} from 'primeng/iftalabel';
import { LoadingOverlayComponent } from './loading-overlay/loading-overlay.component';
@NgModule({
declarations: [
@@ -48,7 +51,8 @@ import Aura from '@primeng/themes/aura';
DirectoryPickerComponent,
LibraryCreatorComponent,
ShelfAssignerComponent,
BookBrowserComponent
BookBrowserComponent,
MetadataSearcherComponent
],
imports: [
BrowserModule,
@@ -78,6 +82,8 @@ import Aura from '@primeng/themes/aura';
MenuModule,
IconPickerComponent,
ProgressSpinnerModule,
IftaLabel,
LoadingOverlayComponent,
],
providers: [
DialogService,

View File

@@ -74,7 +74,9 @@ export class BookCardComponent implements OnInit {
{
label: 'View metadata',
icon: 'pi pi-info-circle',
command: () => this.openBookInfo(this.book),
command: () => {
this.openBookInfo(this.book)
},
},
],
},

View File

@@ -20,7 +20,7 @@
<div *ngFor="let metadata of bookMetadataList" class="book-metadata">
<div class="metadata-header">
<div class="book-image-container">
<img class="book-image" [src]="metadata.thumbnail || 'assets/book-cover-metadata.png'" alt="Book Cover"/>
<!--<img class="book-image" [src]="metadata.thumbnail || 'assets/book-cover-metadata.png'" alt="Book Cover"/>-->
<p-button class="select-button" (click)="selectMetadata(metadata)">Select</p-button>
</div>
<div>

View File

@@ -37,7 +37,7 @@
{{ book?.metadata?.title }}<span *ngIf="book?.metadata?.subtitle">: {{ book?.metadata?.subtitle }}</span>
</h1>
<div class="button-group">
<p-button [rounded]="true" [raised]="true" [outlined]="true" (onClick)="openEditDialog(book?.id, book?.libraryId)" icon="pi pi-pencil"></p-button>
<p-button [rounded]="true" [raised]="true" [outlined]="true" (onClick)="openEditDialog(book?.id!, book?.libraryId!)" icon="pi pi-pencil"></p-button>
<p-button [rounded]="true" [raised]="true" [outlined]="true" (onClick)="readBook(book!)" icon="pi pi-eye"></p-button>
<p-button [rounded]="true" [raised]="true" [outlined]="true" icon="pi pi-download"></p-button>
</div>

View File

@@ -9,6 +9,9 @@ import {Subscription} from 'rxjs';
import {TagModule} from 'primeng/tag';
import {BookService} from '../../service/book.service';
import {LibraryService} from '../../service/library.service';
import {MetadataSearcherComponent} from '../../../metadata-searcher/metadata-searcher.component';
import {ProgressSpinner} from 'primeng/progressspinner';
import {LoadingService} from '../../../loading.service';
@Component({
selector: 'app-book-metadata',
@@ -27,18 +30,18 @@ export class BookMetadataComponent implements OnInit, OnDestroy {
previousBookId: number | null = null;
private routeSubscription!: Subscription;
private dialogRef: DynamicDialogRef | undefined;
private dialogRef!: DynamicDialogRef;
private dialogSubscription?: Subscription;
constructor(
private bookService: BookService, private activatedRoute: ActivatedRoute,
private dialogService: DialogService, private router: Router,
private libraryService: LibraryService) {
private libraryService: LibraryService, private loadingService: LoadingService,) {
}
ngOnInit(): void {
this.routeSubscription = this.activatedRoute.paramMap.subscribe((paramMap) => {
const bookId = +paramMap.get('bookId')!;
const bookId = +paramMap.get('bookId')!;
const libraryId = +paramMap.get('libraryId')!;
if (bookId && libraryId) {
this.loadBookWithNeighbors(bookId, libraryId);
@@ -75,7 +78,47 @@ export class BookMetadataComponent implements OnInit, OnDestroy {
return 'No authors available';
}
openEditDialog(bookId: number | undefined, libraryId: number | undefined) {
openEditDialog(bookId: number, libraryId: number) {
this.loadingService.show();
this.bookService.getFetchBookMetadata(bookId).subscribe({
next: (fetchedMetadata) => {
this.loadingService.hide();
this.dialogRef = this.dialogService.open(MetadataSearcherComponent, {
header: 'Update Book Metadata',
modal: true,
closable: true,
width: '1350px',
height: '1000px',
contentStyle: {
'overflow-y': 'auto',
'max-height': 'calc(100vh - 150px)',
'padding': '1.25rem 1.25rem 0',
},
data: {
currentMetadata: this.book?.metadata,
fetchedMetadata: fetchedMetadata,
book: this.book
}
});
if (this.dialogRef) {
this.dialogSubscription = this.dialogRef.onClose.subscribe(() => {
if (this.book?.id && this.book?.libraryId) {
this.loadBookWithNeighbors(this.book.id, this.book.libraryId);
}
});
} else {
console.error('DialogRef is undefined or null');
}
},
error: (error) => {
this.loadingService.hide();
console.error('Error fetching metadata:', error);
}
});
}
/*openEditDialog1(bookId: number | undefined, libraryId: number | undefined) {
this.dialogRef = this.dialogService.open(BookMetadataDialogComponent, {
header: 'Metadata: Google Books',
modal: true,
@@ -94,7 +137,7 @@ export class BookMetadataComponent implements OnInit, OnDestroy {
this.loadBookWithNeighbors(this.book.id, this.book.libraryId);
}
});
}
}*/
coverImageSrc(bookId: number | undefined): string {
if (bookId === null) {

View File

@@ -10,7 +10,6 @@ export interface Book {
}
export interface BookMetadata {
thumbnail: string;
title: string;
subtitle?: string;
authors: Author[];
@@ -22,6 +21,39 @@ export interface BookMetadata {
pageCount: number;
language: string;
googleBookId: string;
[key: string]: any;
}
export interface FetchedMetadata {
title: string;
subtitle?: string;
authors: Author[];
categories: Category[];
publisher: string;
publishedDate: string;
isbn10: string;
description: string;
pageCount: number;
language: string;
googleBookId: string;
thumbnail: string;
[key: string]: any;
}
export interface UpdateMedata {
title?: string;
subtitle?: string;
authors?: Author[];
categories?: Category[];
publisher?: string;
publishedDate?: string;
isbn10?: string;
description?: string;
pageCount?: number;
language?: string;
googleBookId?: string;
thumbnail?: string;
[key: string]: any;
}
export interface Author {

View File

@@ -2,7 +2,7 @@ import {Injectable} from '@angular/core';
import {BehaviorSubject, Observable, of} from 'rxjs';
import {HttpClient} from '@angular/common/http';
import {catchError, map, switchMap, tap} from 'rxjs/operators';
import {Book, BookMetadata, BookSetting} from '../model/book.model';
import {Book, BookMetadata, BookSetting, FetchedMetadata, UpdateMedata} from '../model/book.model';
import {BookState} from '../model/state/book-state.model';
@Injectable({
@@ -186,4 +186,29 @@ export class BookService {
const filteredBooks = (currentState.books || []).filter(book => !removedBookIds.has(book.id));
this.bookStateSubject.next({...currentState, books: filteredBooks});
}
getFetchBookMetadata(bookId: number): Observable<FetchedMetadata> {
return this.http.post<FetchedMetadata>(`${this.url}/${bookId}/query-for-books`, null);
}
updateMetadata(bookId: number, updateMedata: UpdateMedata): Observable<BookMetadata> {
return this.http.put<BookMetadata>(`${this.url}/${bookId}/metadata`, updateMedata).pipe(
map((updatedMetadata) => {
const currentState = this.bookStateSubject.value;
const updatedBooks = currentState.books?.map((book) => {
if (book.id === bookId) {
return { ...book, metadata: updatedMetadata };
}
return book;
}) || [];
this.bookStateSubject.next({ ...currentState, books: updatedBooks });
return updatedMetadata;
}),
catchError((error) => {
const currentState = this.bookStateSubject.value;
this.bookStateSubject.next({ ...currentState, error: error.message });
throw error;
})
);
}
}

View File

@@ -4,7 +4,7 @@ import { HttpClient } from '@angular/common/http';
import { catchError, map } from 'rxjs/operators';
import { Library } from '../model/library.model';
import { BookService } from './book.service';
import { BookWithNeighborsDTO } from '../model/book.model';
import {BookMetadata, BookWithNeighborsDTO} from '../model/book.model';
import { SortOption } from '../model/sort.model';
import { LibraryState } from '../model/state/library-state.model';
@@ -113,4 +113,5 @@ export class LibraryService {
map(state => (state.books || []).filter(book => book.libraryId === libraryId).length)
);
}
}

View File

@@ -0,0 +1,3 @@
<div *ngIf="loading" class="loading-overlay">
<p-progressSpinner></p-progressSpinner>
</div>

View File

@@ -0,0 +1,12 @@
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5); /* Dimmed background */
display: flex;
justify-content: center;
align-items: center;
z-index: 9999; /* Ensure it stays on top of all content */
}

View File

@@ -0,0 +1,33 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import {LoadingService} from '../loading.service';
import {ProgressSpinner} from 'primeng/progressspinner';
import {NgIf} from '@angular/common';
@Component({
selector: 'app-loading-overlay',
templateUrl: './loading-overlay.component.html',
imports: [
ProgressSpinner,
NgIf
],
styleUrls: ['./loading-overlay.component.scss']
})
export class LoadingOverlayComponent implements OnInit, OnDestroy {
loading: boolean = false;
private loadingSubscription: Subscription | undefined;
constructor(private loadingService: LoadingService) {}
ngOnInit(): void {
this.loadingSubscription = this.loadingService.loading$.subscribe(
(loading) => {
this.loading = loading;
}
);
}
ngOnDestroy(): void {
this.loadingSubscription?.unsubscribe();
}
}

View File

@@ -0,0 +1,19 @@
// loading.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class LoadingService {
private loadingSubject = new BehaviorSubject<boolean>(false);
loading$ = this.loadingSubject.asObservable();
show(): void {
this.loadingSubject.next(true);
}
hide(): void {
this.loadingSubject.next(false);
}
}

View File

@@ -0,0 +1,95 @@
<div class="form-row">
<label for="title" class="label">Title</label>
<div class="input-container">
<input pInputText id="title" [(ngModel)]="toUpdateMetadata.title" class="input"/>
<p-button icon="pi pi-arrow-left" [outlined]="true" class="arrow-button" (click)="copyFetchedToCurrent('title')"/>
<input pInputText id="titleFetched" [(ngModel)]="fetchedMetadata.title" class="input"/>
</div>
</div>
<div class="form-row">
<label for="authors" class="label">Authors</label>
<div class="input-container">
<input pInputText id="authors" [(ngModel)]="currentAuthorsString" class="input"/>
<p-button icon="pi pi-arrow-left" [outlined]="true" class="arrow-button" (click)="copyFetchedToCurrent('authors')"/>
<input pInputText id="authorsFetched" [(ngModel)]="fetchedAuthorsString" class="input"/>
</div>
</div>
<div class="form-row">
<label for="categories" class="label">Categories</label>
<div class="input-container">
<input pInputText id="categories" [(ngModel)]="currentCategoriesString" class="input"/>
<p-button icon="pi pi-arrow-left" [outlined]="true" class="arrow-button" (click)="copyFetchedToCurrent('categories')"/>
<input pInputText id="categoriesFetched" [(ngModel)]="fetchedCategoriesString" class="input"/>
</div>
</div>
<div class="form-row">
<label for="publisher" class="label">Publisher</label>
<div class="input-container">
<input pInputText id="publisher" [(ngModel)]="toUpdateMetadata.publisher" class="input"/>
<p-button icon="pi pi-arrow-left" [outlined]="true" class="arrow-button" (click)="copyFetchedToCurrent('publisher')"/>
<input pInputText id="publisherFetched" [(ngModel)]="fetchedMetadata.publisher" class="input"/>
</div>
</div>
<div class="form-row">
<label for="publishedDate" class="label">Published Date</label>
<div class="input-container">
<input pInputText id="publishedDate" [(ngModel)]="toUpdateMetadata.publishedDate" class="input"/>
<p-button icon="pi pi-arrow-left" [outlined]="true" class="arrow-button" (click)="copyFetchedToCurrent('publishedDate')"/>
<input pInputText id="publishedDateFetched" [(ngModel)]="fetchedMetadata.publishedDate" class="input"/>
</div>
</div>
<div class="form-row">
<label for="isbn10" class="label">ISBN-10</label>
<div class="input-container">
<input pInputText id="isbn10" [(ngModel)]="toUpdateMetadata.isbn10" class="input"/>
<p-button icon="pi pi-arrow-left" [outlined]="true" class="arrow-button" (click)="copyFetchedToCurrent('isbn10')"/>
<input pInputText id="isbn10Fetched" [(ngModel)]="fetchedMetadata.isbn10" class="input"/>
</div>
</div>
<div class="form-row">
<label for="language" class="label">Language</label>
<div class="input-container">
<input pInputText id="language" [(ngModel)]="toUpdateMetadata.language" class="input"/>
<p-button icon="pi pi-arrow-left" [outlined]="true" class="arrow-button" (click)="copyFetchedToCurrent('language')"/>
<input pInputText id="languageFetched" [(ngModel)]="fetchedMetadata.language" class="input"/>
</div>
</div>
<div class="form-row">
<label for="language" class="label">Page Count</label>
<div class="input-container">
<input pInputText id="pageCount" [(ngModel)]="toUpdateMetadata.pageCount" class="input"/>
<p-button icon="pi pi-arrow-left" [outlined]="true" class="arrow-button" (click)="copyFetchedToCurrent('pageCount')"/>
<input pInputText id="pageCountFetched" [(ngModel)]="fetchedMetadata.pageCount" class="input"/>
</div>
</div>
<div class="form-row">
<label for="description" class="label">Description</label>
<div class="input-container">
<textarea id="description" [(ngModel)]="toUpdateMetadata.description" class="input"></textarea>
<p-button icon="pi pi-arrow-left" [outlined]="true" class="arrow-button" (click)="copyFetchedToCurrent('description')"/>
<textarea id="descriptionFetched" [(ngModel)]="fetchedMetadata.description" class="input"></textarea>
</div>
</div>
<div class="form-row">
<label class="label"></label>
<div class="input-container">
<img *ngIf="!updateThumbnail" [src]="coverImageSrc(book)" alt="Book Thumbnail" class="thumbnail"/>
<img *ngIf="updateThumbnail" [src]="fetchedMetadata.thumbnail" alt="Fetched Thumbnail" class="thumbnail"/>
<p-button icon="pi pi-arrow-left" [outlined]="true" class="arrow-button" (click)="shouldUpdateThumbnail()"/>
<img [src]="fetchedMetadata.thumbnail" alt="Fetched Thumbnail" class="thumbnail"/>
</div>
</div>
<div class="button-container">
<p-button label="Cancel" icon="pi pi-times" severity="secondary" class="cancel-button" (onClick)="closeDialog()"></p-button>
<p-button label="Save" icon="pi pi-check" severity="primary" class="save-button" [disabled]="loading" (onClick)="saveMetadata()"></p-button>
</div>

View File

@@ -0,0 +1,56 @@
.button-container {
display: flex;
justify-content: flex-end;
width: 100%;
padding: 1.25rem;
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 10;
}
.cancel-button {
margin-right: 1rem;
}
.form-row {
display: flex;
align-items: center;
margin-bottom: 1rem;
width: 100%;
justify-content: flex-start;
}
.label {
width: 8rem;
text-align: left;
}
.input-container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.input {
width: 50%;
padding: 0.5rem;
border-radius: 0.25rem;
margin: 0 0.5rem;
}
.thumbnail {
width: 15.625rem;
height: 21.875rem;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-left: 2.5rem;
margin-right: 2.5rem;
}
.arrow-button {
padding-left: 1rem;
padding-right: 1rem;
}

View File

@@ -0,0 +1,153 @@
import {Component} from '@angular/core';
import {Book, FetchedMetadata, UpdateMedata} from '../book/model/book.model';
import {DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog';
import {BookService} from '../book/service/book.service';
import {MessageService} from 'primeng/api';
@Component({
selector: 'app-metadata-searcher',
standalone: false,
templateUrl: './metadata-searcher.component.html',
styleUrls: ['./metadata-searcher.component.scss']
})
export class MetadataSearcherComponent {
fetchedMetadata: FetchedMetadata;
toUpdateMetadata: UpdateMedata;
book: Book;
loading: boolean = false;
updateThumbnail: boolean = false;
constructor(public dynamicDialogConfig: DynamicDialogConfig, private dynamicDialogRef: DynamicDialogRef,
private bookService: BookService, private messageService: MessageService) {
this.fetchedMetadata = this.dynamicDialogConfig.data.fetchedMetadata;
this.toUpdateMetadata = JSON.parse(JSON.stringify(this.dynamicDialogConfig.data.currentMetadata));
this.book = this.dynamicDialogConfig.data.book;
}
copyFetchedToCurrent(field: keyof UpdateMedata) {
if (this.fetchedMetadata && this.toUpdateMetadata) {
this.toUpdateMetadata[field] = this.fetchedMetadata[field];
}
}
get currentAuthorsString(): string {
return this.toUpdateMetadata.authors ? this.toUpdateMetadata.authors.map(author => author.name).join(', ') : '';
}
set currentAuthorsString(value: string) {
this.toUpdateMetadata.authors = value.split(',').map(name => ({
id: this.toUpdateMetadata.authors?.find(author => author.name.trim() === name.trim())?.id || 0,
name: name.trim()
}));
}
get fetchedAuthorsString(): string {
return this.fetchedMetadata.authors ? this.fetchedMetadata.authors.map(author => author.name).join(', ') : '';
}
set fetchedAuthorsString(value: string) {
this.fetchedMetadata.authors = value.split(',').map(name => ({
id: this.fetchedMetadata.authors?.find(author => author.name.trim() === name.trim())?.id || 0,
name: name.trim()
}));
}
get currentCategoriesString(): string {
return this.toUpdateMetadata.categories ? this.toUpdateMetadata.categories.map(category => category.name).join(', ') : '';
}
set currentCategoriesString(value: string) {
this.toUpdateMetadata.categories = value.split(',').map(name => ({
id: this.toUpdateMetadata.categories?.find(category => category.name.trim() === name.trim())?.id || 0,
name: name.trim()
}));
}
get fetchedCategoriesString(): string {
return this.fetchedMetadata.categories ? this.fetchedMetadata.categories.map(category => category.name).join(', ') : '';
}
set fetchedCategoriesString(value: string) {
this.fetchedMetadata.categories = value.split(',').map(name => ({
id: this.fetchedMetadata.categories?.find(category => category.name.trim() === name.trim())?.id || 0,
name: name.trim()
}));
}
closeDialog() {
this.dynamicDialogRef.close();
}
coverImageSrc(book: Book): string {
return this.bookService.getBookCoverUrl(book.id);
}
saveMetadata() {
this.loading = true;
this.updateFields();
this.bookService.updateMetadata(this.book.id, this.toUpdateMetadata).subscribe({
next: () => {
this.messageService.add({severity: 'info', summary: 'Success', detail: 'Book metadata updated'});
this.loading = false;
this.dynamicDialogRef.close();
},
error: (error) => {
this.loading = false;
this.messageService.add({severity: 'error', summary: 'Error', detail: 'Failed to update book metadata'});
}
});
}
updateFields() {
if (!this.toUpdateMetadata.title && this.fetchedMetadata.title) {
this.toUpdateMetadata.title = this.fetchedMetadata.title;
}
if (!this.toUpdateMetadata.subtitle && this.fetchedMetadata.subtitle) {
this.toUpdateMetadata.subtitle = this.fetchedMetadata.subtitle;
}
if (!this.toUpdateMetadata.publisher && this.fetchedMetadata.publisher) {
this.toUpdateMetadata.publisher = this.fetchedMetadata.publisher;
}
if (!this.toUpdateMetadata.publishedDate && this.fetchedMetadata.publishedDate) {
this.toUpdateMetadata.publishedDate = this.fetchedMetadata.publishedDate;
}
if (!this.toUpdateMetadata.description && this.fetchedMetadata.description) {
this.toUpdateMetadata.description = this.fetchedMetadata.description;
}
if (!this.toUpdateMetadata.isbn10 && this.fetchedMetadata.isbn10) {
this.toUpdateMetadata.isbn10 = this.fetchedMetadata.isbn10;
}
if (!this.toUpdateMetadata.language && this.fetchedMetadata.language) {
this.toUpdateMetadata.language = this.fetchedMetadata.language;
}
if (!this.toUpdateMetadata.pageCount && this.fetchedMetadata.pageCount) {
this.toUpdateMetadata.pageCount = this.fetchedMetadata.pageCount;
}
if (!this.toUpdateMetadata.authors || this.toUpdateMetadata.authors.length === 0) {
this.toUpdateMetadata.authors = this.fetchedMetadata.authors;
}
if (!this.toUpdateMetadata.categories || this.toUpdateMetadata.categories.length === 0) {
this.toUpdateMetadata.categories = this.fetchedMetadata.categories;
}
if(this.updateThumbnail) {
this.toUpdateMetadata.thumbnail = this.fetchedMetadata.thumbnail;
}
}
shouldUpdateThumbnail() {
this.updateThumbnail = true;
}
}