diff --git a/booklore-api/build.gradle b/booklore-api/build.gradle index 9292ad48..a0ff8e5f 100644 --- a/booklore-api/build.gradle +++ b/booklore-api/build.gradle @@ -42,6 +42,8 @@ dependencies { implementation 'com.github.jai-imageio:jai-imageio-jpeg2000:1.3.0' 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' + } hibernate { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/WebSocketConfig.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/WebSocketConfig.java new file mode 100644 index 00000000..80b1c1c1 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/WebSocketConfig.java @@ -0,0 +1,24 @@ +package com.adityachandel.booklore.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + config.enableSimpleBroker("/topic"); + config.setApplicationDestinationPrefixes("/app"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws").setAllowedOrigins("*"); + System.out.println("WebSocket endpoint registered at /ws"); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookController.java index f20edc33..078bca65 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookController.java @@ -30,15 +30,11 @@ public class BookController { } @GetMapping - public ResponseEntity> getBooks( - @RequestParam(defaultValue = "0") @Min(0) int page, - @RequestParam(defaultValue = "25") @Min(1) @Max(100) int size, - @RequestParam(defaultValue = "lastReadTime") String sortBy, - @RequestParam(defaultValue = "desc") String sortDir) { + public ResponseEntity> getBooks(@RequestParam(defaultValue = "lastReadTime") String sortBy, @RequestParam(defaultValue = "desc") String sortDir) { if (!sortBy.equals("lastReadTime") && !sortBy.equals("addedOn")) { return ResponseEntity.badRequest().body(null); } - Page books = booksService.getBooks(page, size, sortBy, sortDir); + List books = booksService.getBooks(sortBy, sortDir); return ResponseEntity.ok(books); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/LibraryController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/LibraryController.java index 429a0ecf..e7293e52 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/LibraryController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/LibraryController.java @@ -14,6 +14,9 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import java.io.IOException; +import java.util.List; + @RestController @RequestMapping("/v1/library") @@ -38,11 +41,6 @@ public class LibraryController { return ResponseEntity.ok(libraryService.createLibrary(request)); } - @GetMapping(path = "/{libraryId}/parse") - public SseEmitter parseLibrary(@RequestParam(required = false, defaultValue = "false") boolean force, @PathVariable long libraryId) { - return libraryService.parseLibraryBooks(libraryId, force); - } - @DeleteMapping("/{libraryId}") public ResponseEntity deleteLibrary(@PathVariable long libraryId) { libraryService.deleteLibrary(libraryId); @@ -60,11 +58,8 @@ public class LibraryController { } @GetMapping("/{libraryId}/book") - public ResponseEntity> getBooks( - @PathVariable long libraryId, - @RequestParam(defaultValue = "0") @Min(0) int page, - @RequestParam(defaultValue = "25") @Min(1) @Max(100) int size) { - Page books = libraryService.getBooks(libraryId, page, size); + public ResponseEntity> getBooks(@PathVariable long libraryId) { + List books = libraryService.getBooks(libraryId); return ResponseEntity.ok(books); } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/NotificationController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/NotificationController.java new file mode 100644 index 00000000..6494c7d3 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/NotificationController.java @@ -0,0 +1,34 @@ +package com.adityachandel.booklore.controller; + +import com.adityachandel.booklore.service.NotificationService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.stereotype.Controller; + +@Controller +public class NotificationController { + + /*@MessageMapping("/send") + @SendTo("/topic/messages") + public String sendMessage(String message) { + System.out.println("Received message: " + message); + return "Hello"; + }*/ + + private final NotificationService notificationService; + + @Autowired + public NotificationController(NotificationService notificationService) { + this.notificationService = notificationService; + } + + @MessageMapping("/send") + public void handleIncomingMessage(Object message) { + System.out.println("Received message: " + message); + } + + public void sendMessageToTopic(String topic, Object message) { + notificationService.sendMessage(topic, message); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java index 7af50db5..e428312d 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookRepository.java @@ -17,7 +17,7 @@ public interface BookRepository extends JpaRepository, JpaSpecificat Optional findByFileName(String fileName); - Page findBooksByLibraryId(Long libraryId, Pageable pageable); + List findBooksByLibraryId(Long libraryId); Optional findBookByIdAndLibraryId(long id, long libraryId); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/BooksService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/BooksService.java index 11e841f9..72329610 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/BooksService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/BooksService.java @@ -49,6 +49,9 @@ public class BooksService { private final AuthorRepository authorRepository; private final CategoryRepository categoryRepository; private final LibraryRepository libraryRepository; + private final NotificationService notificationService; + + public BookDTO getBook(long bookId) { @@ -56,19 +59,20 @@ public class BooksService { return BookTransformer.convertToBookDTO(book); } - public Page getBooks(int page, int size, String sortBy, String sortDir) { - Sort sort = Sort.by(Sort.Direction.fromString(sortDir), sortBy); - PageRequest pageRequest = PageRequest.of(page, size, sort); - Page bookPage = Page.empty(); + public List getBooks(String sortBy, String sortDir) { + int size = 25; + PageRequest pageRequest = PageRequest.of(0, size, Sort.by(Sort.Direction.fromString(sortDir), sortBy)); + Page bookPage; if (sortBy.equals("addedOn")) { bookPage = bookRepository.findByAddedOnIsNotNull(pageRequest); } else if (sortBy.equals("lastReadTime")) { bookPage = bookRepository.findByLastReadTimeIsNotNull(pageRequest); + } else { + throw new IllegalArgumentException("Invalid sortBy parameter"); } - List bookDTOs = bookPage.getContent().stream() + return bookPage.getContent().stream() .map(BookTransformer::convertToBookDTO) .collect(Collectors.toList()); - return new PageImpl<>(bookDTOs, pageRequest, bookPage.getTotalElements()); } public void saveBookViewerSetting(long bookId, BookViewerSettingDTO bookViewerSettingDTO) { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/FileProcessor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/FileProcessor.java index 0adf4f75..c4043c58 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/FileProcessor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/FileProcessor.java @@ -2,7 +2,8 @@ package com.adityachandel.booklore.service; import com.adityachandel.booklore.model.FileProcessResult; import com.adityachandel.booklore.model.LibraryFile; +import com.adityachandel.booklore.model.dto.BookDTO; public interface FileProcessor { - FileProcessResult processFile(LibraryFile libraryFile, boolean forceProcess); + BookDTO processFile(LibraryFile libraryFile, boolean forceProcess); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/LibraryProcessingService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/LibraryProcessingService.java new file mode 100644 index 00000000..ac29b6b3 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/LibraryProcessingService.java @@ -0,0 +1,77 @@ +package com.adityachandel.booklore.service; + +import com.adityachandel.booklore.exception.ApiError; +import com.adityachandel.booklore.model.LibraryFile; +import com.adityachandel.booklore.model.dto.BookDTO; +import com.adityachandel.booklore.model.entity.Library; +import com.adityachandel.booklore.repository.LibraryRepository; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +@Service +@AllArgsConstructor +@Slf4j +public class LibraryProcessingService { + + private final LibraryRepository libraryRepository; + private final NotificationService notificationService; + private final PdfFileProcessor pdfFileProcessor; + + + @Transactional + public void parseLibraryBooks(long libraryId) throws IOException { + Library library = libraryRepository.findById(libraryId).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId)); + List libraryFiles = getLibraryFiles(library); + for (LibraryFile libraryFile : libraryFiles) { + log.info("Processing file: {}", libraryFile.getFilePath()); + BookDTO bookDTO = processLibraryFile(libraryFile); + if(bookDTO != null) { + notificationService.sendMessage("/topic/books", bookDTO); + } + } + } + + @Transactional + protected BookDTO processLibraryFile(LibraryFile libraryFile) { + if (libraryFile.getFileType().equalsIgnoreCase("pdf")) { + return pdfFileProcessor.processFile(libraryFile, false); + } + return null; + } + + private List getLibraryFiles(Library library) throws IOException { + List libraryFiles = new ArrayList<>(); + for (String libraryPath : library.getPaths()) { + libraryFiles.addAll(findLibraryFiles(libraryPath, library)); + } + return libraryFiles; + } + + private List findLibraryFiles(String directoryPath, Library library) throws IOException { + List libraryFiles = new ArrayList<>(); + try (var stream = Files.walk(Path.of(directoryPath))) { + stream.filter(Files::isRegularFile) + .filter(file -> { + String fileName = file.getFileName().toString().toLowerCase(); + return fileName.endsWith(".pdf") || fileName.endsWith(".epub"); + }) + .forEach(file -> { + String fileType = file.getFileName().toString().toLowerCase().endsWith(".pdf") ? "PDF" : "EPUB"; + libraryFiles.add(new LibraryFile(library, file.toAbsolutePath().toString(), fileType)); + }); + } + return libraryFiles; + } + + +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/LibraryService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/LibraryService.java index 795d0bad..9eff95f5 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/LibraryService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/LibraryService.java @@ -1,37 +1,24 @@ package com.adityachandel.booklore.service; -import com.adityachandel.booklore.model.FileProcessResult; -import com.adityachandel.booklore.model.LibraryFile; -import com.adityachandel.booklore.model.ParseLibraryEvent; -import com.adityachandel.booklore.model.enums.ParsingStatus; -import com.adityachandel.booklore.model.dto.*; +import com.adityachandel.booklore.exception.ApiError; +import com.adityachandel.booklore.model.dto.BookDTO; +import com.adityachandel.booklore.model.dto.LibraryDTO; import com.adityachandel.booklore.model.dto.request.CreateLibraryRequest; import com.adityachandel.booklore.model.entity.Book; import com.adityachandel.booklore.model.entity.Library; -import com.adityachandel.booklore.exception.ApiError; -import com.adityachandel.booklore.repository.*; +import com.adityachandel.booklore.repository.BookRepository; +import com.adityachandel.booklore.repository.LibraryRepository; import com.adityachandel.booklore.transformer.BookTransformer; import com.adityachandel.booklore.transformer.LibraryTransformer; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.catalina.connector.ClientAbortException; +import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; -import org.springframework.web.context.request.async.AsyncRequestNotUsableException; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.locks.ReentrantLock; @Slf4j @Service @@ -40,16 +27,29 @@ public class LibraryService { private LibraryRepository libraryRepository; private BookRepository bookRepository; - private PdfFileProcessor pdfFileProcessor; - private final Map libraryLocks = new ConcurrentHashMap<>(); - + private final LibraryProcessingService libraryProcessingService; public LibraryDTO createLibrary(CreateLibraryRequest request) { Library library = Library.builder() .name(request.getName()) .paths(request.getPaths()) .build(); - return LibraryTransformer.convertToLibraryDTO(libraryRepository.save(library)); + library = libraryRepository.save(library); + Long libraryId = library.getId(); + + Thread.startVirtualThread(() -> { + log.info("Running in a virtual thread: {}", Thread.currentThread()); + try { + libraryProcessingService.parseLibraryBooks(libraryId); + } catch (InvalidDataAccessApiUsageException e) { + log.warn("InvalidDataAccessApiUsageException - Library id: {}", libraryId); + } catch (IOException e) { + log.error("Error while parsing library books", e); + } + log.info("Parsing task completed!"); + }); + + return LibraryTransformer.convertToLibraryDTO(library); } public LibraryDTO getLibrary(long libraryId) { @@ -74,127 +74,9 @@ public class LibraryService { return BookTransformer.convertToBookDTO(book); } - public Page getBooks(long libraryId, int page, int size) { + public List getBooks(long libraryId) { libraryRepository.findById(libraryId).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId)); - PageRequest pageRequest = PageRequest.of(page, size); - Page bookPage = bookRepository.findBooksByLibraryId(libraryId, pageRequest); - return bookPage.map(BookTransformer::convertToBookDTO); - } - - - public SseEmitter parseLibraryBooks(long libraryId, boolean force) { - ReentrantLock lock = libraryLocks.computeIfAbsent(libraryId, k -> new ReentrantLock()); - if (!lock.tryLock()) { - SseEmitter emitter = new SseEmitter(); - emitter.completeWithError(new IllegalStateException("Library is already being processed")); - return emitter; - } - Library library = libraryRepository.findById(libraryId).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId)); - if(library.isInitialProcessed()) { - SseEmitter emitter = new SseEmitter(); - emitter.complete(); - return emitter; - } - SseEmitter emitter = new SseEmitter(); - ExecutorService sseMvcExecutor = Executors.newSingleThreadExecutor(); - final AtomicBoolean isCompleted = new AtomicBoolean(false); - emitter.onTimeout(() -> { - log.info("Emitter timeout reached. Stopping further processing."); - emitter.complete(); - isCompleted.set(true); - }); - emitter.onCompletion(() -> { - log.info("Emitter completed. Shutting down executor."); - isCompleted.set(true); - sseMvcExecutor.shutdown(); - }); - sseMvcExecutor.execute(() -> { - try { - List libraryFiles = getLibraryFiles(library); - for (LibraryFile libraryFile : libraryFiles) { - try { - log.info("Processing file: {}", libraryFile.getFilePath()); - FileProcessResult fileProcessResult = processLibraryFile(libraryFile); - ParseLibraryEvent event = createParseLibraryEvent(libraryId, fileProcessResult); - if (!isCompleted.get()) { - emitter.send(event); - } - Thread.sleep(500); - } catch (AsyncRequestNotUsableException | IllegalStateException e) { - log.warn("Client disconnected! Continue processing."); - } catch (IOException e) { - log.warn("Client disconnected or failed to send response: {}", libraryFile.getFilePath(), e); - emitter.completeWithError(e); - sseMvcExecutor.shutdown(); - return; - } catch (Exception e) { - log.error("Error processing file: {}", libraryFile.getFilePath(), e); - emitter.completeWithError(e); - sseMvcExecutor.shutdown(); - return; - } - } - library.setInitialProcessed(true); - libraryRepository.save(library); - emitter.complete(); - sseMvcExecutor.shutdown(); - } catch (Exception ex) { - log.error("Error during file processing: ", ex); - if (!isCompleted.get()) { - emitter.completeWithError(ex); - sseMvcExecutor.shutdown(); - } - } finally { - log.info("File processing complete. Executor shutting down."); - lock.unlock(); - emitter.complete(); - sseMvcExecutor.shutdown(); - } - }); - return emitter; - } - - private ParseLibraryEvent createParseLibraryEvent(long libraryId, FileProcessResult result) { - return ParseLibraryEvent.builder() - .libraryId(libraryId) - .file(result.getLibraryFile().getFilePath()) - .parsingStatus(result.getParsingStatus()) - .book(result.getBookDTO()) - .build(); - } - - private FileProcessResult processLibraryFile(LibraryFile libraryFile) { - if (libraryFile.getFileType().equalsIgnoreCase("pdf")) { - return pdfFileProcessor.processFile(libraryFile, false); - } else if (libraryFile.getFileType().equalsIgnoreCase("epub")) { - // TODO:: To implement - return FileProcessResult.builder().parsingStatus(ParsingStatus.FAILED_TO_PARSE_BOOK).libraryFile(libraryFile).build(); - } - // TODO:: To handle - return FileProcessResult.builder().parsingStatus(ParsingStatus.FAILED_TO_PARSE_BOOK).libraryFile(libraryFile).build(); - } - - private List getLibraryFiles(Library library) throws IOException { - List libraryFiles = new ArrayList<>(); - for (String libraryPath : library.getPaths()) { - libraryFiles.addAll(findLibraryFiles(libraryPath, library)); - } - return libraryFiles; - } - - private List findLibraryFiles(String directoryPath, Library library) throws IOException { - List libraryFiles = new ArrayList<>(); - try (var stream = Files.walk(Path.of(directoryPath))) { - stream.filter(Files::isRegularFile) - .filter(file -> { - String fileName = file.getFileName().toString().toLowerCase(); - return fileName.endsWith(".pdf") || fileName.endsWith(".epub"); - }) - .forEach(file -> { - String fileType = file.getFileName().toString().toLowerCase().endsWith(".pdf") ? "PDF" : "EPUB"; - libraryFiles.add(new LibraryFile(library, file.toAbsolutePath().toString(), fileType)); - }); - } - return libraryFiles; + List books = bookRepository.findBooksByLibraryId(libraryId); + return books.stream().map(BookTransformer::convertToBookDTO).toList(); } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/NotificationService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/NotificationService.java new file mode 100644 index 00000000..4a847c93 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/NotificationService.java @@ -0,0 +1,18 @@ +package com.adityachandel.booklore.service; + +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +@Service +public class NotificationService { + + private final SimpMessagingTemplate messagingTemplate; + + public NotificationService(SimpMessagingTemplate messagingTemplate) { + this.messagingTemplate = messagingTemplate; + } + + public void sendMessage(String topic, Object message) { + messagingTemplate.convertAndSend(topic, message); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/PdfFileProcessor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/PdfFileProcessor.java index bc6e6670..575c32ed 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/PdfFileProcessor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/PdfFileProcessor.java @@ -1,13 +1,14 @@ package com.adityachandel.booklore.service; import com.adityachandel.booklore.config.AppProperties; -import com.adityachandel.booklore.model.enums.ParsingStatus; -import com.adityachandel.booklore.model.FileProcessResult; import com.adityachandel.booklore.model.LibraryFile; +import com.adityachandel.booklore.model.dto.BookDTO; import com.adityachandel.booklore.model.entity.Book; import com.adityachandel.booklore.model.entity.BookMetadata; -import com.adityachandel.booklore.repository.*; +import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.transformer.BookTransformer; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.pdfbox.Loader; @@ -15,6 +16,7 @@ import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.rendering.ImageType; import org.apache.pdfbox.rendering.PDFRenderer; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import javax.imageio.ImageIO; @@ -22,7 +24,10 @@ import java.awt.*; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; -import java.util.*; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; @Slf4j @@ -34,32 +39,24 @@ public class PdfFileProcessor implements FileProcessor { private BookCreatorService bookCreatorService; private AppProperties appProperties; - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) @Override - public FileProcessResult processFile(LibraryFile libraryFile, boolean forceProcess) { + public BookDTO processFile(LibraryFile libraryFile, boolean forceProcess) { File bookFile = new File(libraryFile.getFilePath()); String fileName = bookFile.getName(); if (!forceProcess) { Optional bookOptional = bookRepository.findBookByFileNameAndLibraryId(fileName, libraryFile.getLibrary().getId()); - if (bookOptional.isPresent()) { - return FileProcessResult.builder() - .libraryFile(libraryFile) - .bookDTO(BookTransformer.convertToBookDTO(bookOptional.get())) - .parsingStatus(ParsingStatus.EXISTING_BOOK_NO_FORCED_UPDATE) - .build(); - } else { - return processNewFile(libraryFile); - } + return bookOptional.map(BookTransformer::convertToBookDTO).orElseGet(() -> processNewFile(libraryFile)); } else { return processNewFile(libraryFile); } } - private FileProcessResult processNewFile(LibraryFile libraryFile) { + @Transactional(propagation = Propagation.REQUIRES_NEW) + protected BookDTO processNewFile(LibraryFile libraryFile) { File bookFile = new File(libraryFile.getFilePath()); Book book = bookCreatorService.createShellBook(libraryFile); BookMetadata bookMetadata = book.getMetadata(); - FileProcessResult fileProcessResult; try (PDDocument document = Loader.loadPDF(bookFile)) { if (document.getDocumentInformation() == null) { log.warn("No document information found"); @@ -74,19 +71,12 @@ public class PdfFileProcessor implements FileProcessor { } generateCoverImage(bookFile, new File(appProperties.getPathConfig() + "/thumbs"), document); } catch (Exception e) { - log.error("Error while processing file {}", libraryFile.getFilePath(), e); - return FileProcessResult.builder() - .libraryFile(libraryFile) - .parsingStatus(ParsingStatus.FAILED_TO_PARSE_BOOK) - .build(); + log.error("Error while processing file {}, error: {}", libraryFile.getFilePath(), e.getMessage()); } bookCreatorService.saveConnections(book); - fileProcessResult = FileProcessResult.builder() - .bookDTO(BookTransformer.convertToBookDTO(book)) - .parsingStatus(ParsingStatus.PARSED_NEW_BOOK) - .libraryFile(libraryFile) - .build(); - return fileProcessResult; + bookRepository.save(book); + bookRepository.flush(); + return BookTransformer.convertToBookDTO(book); } private Set getAuthors(PDDocument document) { diff --git a/booklore-ui/package-lock.json b/booklore-ui/package-lock.json index df03ea64..ec57557b 100644 --- a/booklore-ui/package-lock.json +++ b/booklore-ui/package-lock.json @@ -1,11 +1,11 @@ { - "name": "my-library-v3", + "name": "booklore", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "my-library-v3", + "name": "booklore", "version": "0.0.0", "dependencies": { "@angular/animations": "^19.0.0", @@ -16,6 +16,10 @@ "@angular/platform-browser": "^19.0.0", "@angular/platform-browser-dynamic": "^19.0.0", "@angular/router": "^19.0.0", + "@iharbeck/ngx-virtual-scroller": "^19.0.1", + "@stomp/rx-stomp": "^2.0.0", + "@stomp/stompjs": "^7.0.0", + "ng-lazyload-image": "^9.1.3", "ngx-extended-pdf-viewer": "^22.0.0", "ngx-infinite-scroll": "^19.0.0", "primeflex": "^3.3.1", @@ -23,6 +27,7 @@ "primeng": "^17.18.12", "rxjs": "~7.8.0", "tslib": "^2.3.0", + "ws": "^8.18.0", "zone.js": "~0.15.0" }, "devDependencies": { @@ -487,33 +492,6 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, - "node_modules/@angular/localize": { - "version": "19.0.1", - "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-19.0.1.tgz", - "integrity": "sha512-tCfTOkdHj6VhskudgsNKF0SS/e0Le+9kv4tPdSsjo9bFcg806lG5/010+UYhy4MDJZ+vDUGTACrMRj2TcFmHRA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/core": "7.26.0", - "@types/babel__core": "7.20.5", - "fast-glob": "3.3.2", - "yargs": "^17.2.1" - }, - "bin": { - "localize-extract": "tools/bundles/src/extract/cli.js", - "localize-migrate": "tools/bundles/src/migrate/cli.js", - "localize-translate": "tools/bundles/src/translate/cli.js" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" - }, - "peerDependencies": { - "@angular/compiler": "19.0.1", - "@angular/compiler-cli": "19.0.1" - } - }, "node_modules/@angular/platform-browser": { "version": "19.0.1", "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.0.1.tgz", @@ -2628,6 +2606,18 @@ "node": ">=18" } }, + "node_modules/@iharbeck/ngx-virtual-scroller": { + "version": "19.0.1", + "resolved": "https://registry.npmjs.org/@iharbeck/ngx-virtual-scroller/-/ngx-virtual-scroller-19.0.1.tgz", + "integrity": "sha512-dtn4CpbEY92H9nd1A48WNhsyUgtFBjC83xcsc9VzlSQT/KN2fEx0oBs0Obnn6ZdPanDP/IQdlBgmANmlds/wHA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@tweenjs/tween.js": "^25.0.0" + } + }, "node_modules/@inquirer/checkbox": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.0.2.tgz", @@ -4841,6 +4831,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@stomp/rx-stomp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@stomp/rx-stomp/-/rx-stomp-2.0.0.tgz", + "integrity": "sha512-3UxTxAA3NWGnwFfIvN8AigJ7BxGXG0u5IK8K12mQ9cCMuaT/MM7xlyZnuV8sDbHiqqLlbwA1wk1fDfUyOTIeug==", + "license": "Apache-2.0", + "peerDependencies": { + "@stomp/stompjs": "^7.0.0", + "rxjs": "^7.2.0", + "uuid": "^9.0.0" + } + }, + "node_modules/@stomp/stompjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.0.0.tgz", + "integrity": "sha512-fGdq4wPDnSV/KyOsjq4P+zLc8MFWC3lMmP5FBgLWKPJTYcuCbAIrnRGjB7q2jHZdYCOD5vxLuFoKIYLy5/u8Pw==", + "license": "Apache-2.0" + }, "node_modules/@tufjs/canonical-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", @@ -4891,58 +4898,12 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, + "node_modules/@tweenjs/tween.js": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", - "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", - "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/types": "^7.20.7" - } + "peer": true }, "node_modules/@types/body-parser": { "version": "1.19.5", @@ -7219,6 +7180,28 @@ "node": ">=10.0.0" } }, + "node_modules/engine.io/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/enhanced-resolve": { "version": "5.17.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", @@ -10633,6 +10616,20 @@ "dev": true, "license": "MIT" }, + "node_modules/ng-lazyload-image": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/ng-lazyload-image/-/ng-lazyload-image-9.1.3.tgz", + "integrity": "sha512-GlajmzbKhQCvg9pcrASq4fe/MNv9KoifGe6N+xRbseaBrNj2uwU4Vwic041NlmAQFEkpDM1H2EJCAjjmJeF7Hg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": ">=11.0.0", + "@angular/core": ">=11.0.0", + "rxjs": ">=6.0.0" + } + }, "node_modules/ngx-extended-pdf-viewer": { "version": "22.0.0", "resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-22.0.0.tgz", @@ -12921,6 +12918,28 @@ "ws": "~8.17.1" } }, + "node_modules/socket.io-adapter/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", @@ -12947,6 +12966,16 @@ "websocket-driver": "^0.7.4" } }, + "node_modules/sockjs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/socks": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", @@ -14019,11 +14048,15 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", + "peer": true, "bin": { "uuid": "dist/bin/uuid" } @@ -14825,28 +14858,6 @@ "node": ">=8.10.0" } }, - "node_modules/webpack-dev-server/node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/webpack-merge": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", @@ -15105,10 +15116,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "dev": true, + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/booklore-ui/package.json b/booklore-ui/package.json index c6affa0b..a7e5e070 100644 --- a/booklore-ui/package.json +++ b/booklore-ui/package.json @@ -18,6 +18,10 @@ "@angular/platform-browser": "^19.0.0", "@angular/platform-browser-dynamic": "^19.0.0", "@angular/router": "^19.0.0", + "@iharbeck/ngx-virtual-scroller": "^19.0.1", + "@stomp/rx-stomp": "^2.0.0", + "@stomp/stompjs": "^7.0.0", + "ng-lazyload-image": "^9.1.3", "ngx-extended-pdf-viewer": "^22.0.0", "ngx-infinite-scroll": "^19.0.0", "primeflex": "^3.3.1", @@ -25,6 +29,7 @@ "primeng": "^17.18.12", "rxjs": "~7.8.0", "tslib": "^2.3.0", + "ws": "^8.18.0", "zone.js": "~0.15.0" }, "devDependencies": { diff --git a/booklore-ui/src/app/app.component.ts b/booklore-ui/src/app/app.component.ts index 9a14203a..7266758e 100644 --- a/booklore-ui/src/app/app.component.ts +++ b/booklore-ui/src/app/app.component.ts @@ -1,5 +1,9 @@ import {Component, OnInit} from '@angular/core'; import {LibraryService} from './book/service/library.service'; +import {RxStompService} from './rx-stomp.service'; +import {Message} from '@stomp/stompjs'; +import {BookService} from './book/service/book.service'; +import {Book} from './book/model/book.model'; @Component({ selector: 'app-root', @@ -9,9 +13,22 @@ import {LibraryService} from './book/service/library.service'; }) export class AppComponent implements OnInit { - constructor(private libraryService: LibraryService) {} + constructor(private libraryService: LibraryService, private bookService: BookService, private rxStompService: RxStompService) { + } ngOnInit(): void { this.libraryService.initializeLibraries(); + this.bookService.loadBooksSignal(1); + + + this.rxStompService.watch('/topic/books').subscribe((message: Message) => { + const book: Book = JSON.parse(message.body); // Parse the incoming message to book DTO + this.bookService.appendBookToLibrary(book); // Call method to append to libraryBooks + }); + } + + sendMessage() { + const message = `Message generated at ${new Date()}`; + this.rxStompService.publish({destination: '/app/send', body: message}); } } diff --git a/booklore-ui/src/app/app.module.ts b/booklore-ui/src/app/app.module.ts index d60ee71a..31b8543d 100644 --- a/booklore-ui/src/app/app.module.ts +++ b/booklore-ui/src/app/app.module.ts @@ -22,6 +22,10 @@ import {SearchComponent} from './book/component/search/search.component'; import {MessageService} from 'primeng/api'; import {FileUploadComponent} from './book/component/file-upload/file-upload.component'; import {DropdownModule} from 'primeng/dropdown'; +import {rxStompServiceFactory} from './rx-stomp-service-factory'; +import {RxStompService} from './rx-stomp.service'; +import {VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller'; +import {LazyLoadImageModule} from 'ng-lazyload-image'; @NgModule({ declarations: [ @@ -46,8 +50,18 @@ import {DropdownModule} from 'primeng/dropdown'; InfiniteScrollDirective, SearchComponent, DropdownModule, + VirtualScrollerModule, + LazyLoadImageModule + ], + providers: [ + DialogService, + MessageService, + { + provide: RxStompService, + useFactory: rxStompServiceFactory, + }, ], - providers: [DialogService, MessageService], bootstrap: [AppComponent] }) -export class AppModule { } +export class AppModule { +} diff --git a/booklore-ui/src/app/book/component/library-browser/library-browser.component.html b/booklore-ui/src/app/book/component/library-browser/library-browser.component.html index c440604c..005fad15 100644 --- a/booklore-ui/src/app/book/component/library-browser/library-browser.component.html +++ b/booklore-ui/src/app/book/component/library-browser/library-browser.component.html @@ -1,32 +1,38 @@
-
-

{{ libraryNameSignal() }}

-
- -
- - +
+

{{ libraryNameSignal() }}

+
+ +
+ + +
-
-
-
- Cover of {{ book.metadata.title }} -
-

{{ book.metadata.title }}

-

{{ getAuthorNames(book) }}

- - -
+ + +
+
+ +
+
+ + Cover of {{ book.metadata.title }} +
+

{{ book.metadata.title }}

+ + + +
+
+
+
+ +
diff --git a/booklore-ui/src/app/book/component/library-browser/library-browser.component.scss b/booklore-ui/src/app/book/component/library-browser/library-browser.component.scss index 7e23dff9..681e9f58 100644 --- a/booklore-ui/src/app/book/component/library-browser/library-browser.component.scss +++ b/booklore-ui/src/app/book/component/library-browser/library-browser.component.scss @@ -23,62 +23,49 @@ align-items: center; } -.book-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(135px, 1fr)); - gap: 20px; - transition: all 0.3s ease-in-out; +.virtual-scroller { + width: 100%; + height: calc(100vh - 200px); + padding: 1rem; + box-sizing: border-box; + overflow-y: auto; } -.small { - grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); -} - -.medium { - grid-template-columns: repeat(auto-fill, minmax(135px, 1fr)); -} - -.large { - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); -} - -.extra-large { - grid-template-columns: repeat(auto-fill, minmax(165px, 1fr)); -} - -.book-item { +.virtual-scroller-item { background-color: var(--surface-card); + height: 240px; + width: 150px; border-radius: 8px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); - display: flex; - flex-direction: column; - justify-content: space-between; position: relative; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 1.3rem; + padding: 1rem; + width: 100%; overflow: hidden; + align-items: start; } .book-cover { + width: 100%; + height: auto; + max-height: 100%; object-fit: cover; - transition: filter 0.3s ease-in-out; -} - -.book-item:hover .book-cover { - filter: blur(2px); -} - -.book-info { - padding: 7px 7px 5px; - text-align: center; + border-radius: 8px 8px 0 0; } .book-title { - font-size: 0.9rem; + font-size: 0.85rem; color: var(--text-color); margin-bottom: 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: center; + padding: 6px 6px 5px; } .book-authors { @@ -89,15 +76,7 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} - -.authors { - font-weight: bold; -} - -.no-authors { - color: #888; - font-style: italic; + text-align: center; } .view-btn, .read-btn { @@ -110,15 +89,19 @@ transition: opacity 0.2s ease-in-out; } -.book-item:hover .view-btn, -.book-item:hover .read-btn { +.virtual-scroller-item:hover .view-btn, +.virtual-scroller-item:hover .read-btn { display: block; } -.book-item:hover .read-btn { +.virtual-scroller-item:hover .read-btn { top: 70%; } -.infinite-scroll { - margin-top: 20px; +.virtual-scroller-item:hover .book-cover { + filter: blur(2px); } + + + + diff --git a/booklore-ui/src/app/book/component/library-browser/library-browser.component.ts b/booklore-ui/src/app/book/component/library-browser/library-browser.component.ts index b26489ef..28bd6330 100644 --- a/booklore-ui/src/app/book/component/library-browser/library-browser.component.ts +++ b/booklore-ui/src/app/book/component/library-browser/library-browser.component.ts @@ -1,33 +1,30 @@ -import { Component, NgZone, OnDestroy, OnInit, signal, computed } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { BookProgressService } from '../../service/book-progress-service'; -import { BookService } from '../../service/book.service'; -import { Book, BookUpdateEvent } from '../../model/book.model'; -import { combineLatest, Subscription } from 'rxjs'; -import { InfiniteScrollDirective } from 'ngx-infinite-scroll'; -import { Button } from 'primeng/button'; -import { NgClass, NgForOf } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { DropdownModule } from 'primeng/dropdown'; -import { LibraryService } from '../../service/library.service'; +import {Component, computed, OnInit, signal} from '@angular/core'; +import {ActivatedRoute, Router} from '@angular/router'; +import {BookService} from '../../service/book.service'; +import {Book} from '../../model/book.model'; +import {combineLatest} from 'rxjs'; +import {Button} from 'primeng/button'; +import {NgForOf} from '@angular/common'; +import {FormsModule} from '@angular/forms'; +import {DropdownModule} from 'primeng/dropdown'; +import {LibraryService} from '../../service/library.service'; +import {LazyLoadImageModule} from 'ng-lazyload-image'; +import {VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller'; @Component({ selector: 'app-library-browser-v2', templateUrl: './library-browser.component.html', styleUrls: ['./library-browser.component.scss'], imports: [ - InfiniteScrollDirective, Button, NgForOf, FormsModule, DropdownModule, - NgClass + LazyLoadImageModule, + VirtualScrollerModule ] }) -export class LibraryBrowserComponent implements OnInit, OnDestroy { - books: Book[] = []; - private currentPage: number = 0; - private progressSubscription?: Subscription; +export class LibraryBrowserComponent implements OnInit { coverSizeClass = 'medium'; coverSizeClasses = ['small', 'medium', 'large', 'extra-large']; @@ -41,81 +38,25 @@ export class LibraryBrowserComponent implements OnInit, OnDestroy { selectedCity: any; cities: any[] | undefined; + booksSignal = computed(() => { + return this.bookService.libraryBooks(); + }); + constructor( private bookService: BookService, private activatedRoute: ActivatedRoute, private router: Router, - private bookProgressService: BookProgressService, - private ngZone: NgZone, - private libraryService: LibraryService - ) {} + private libraryService: LibraryService) { + } ngOnInit(): void { combineLatest([this.activatedRoute.paramMap, this.activatedRoute.queryParamMap]) .subscribe(([params, queryParams]) => { const libraryId = params.get('libraryId'); const watch = queryParams.get('watch'); - if (libraryId) { - const libraryIdNum = +libraryId; - if (this.libraryIdSignal() !== libraryIdNum) { - this.libraryIdSignal.set(libraryIdNum); - this.resetState(); - this.loadBooks(); - } else if (this.books.length === 0) { - this.loadBooks(); - } - } - if (watch && !this.progressSubscription) { - this.startListeningForProgress(); - } else if (!watch && this.progressSubscription) { - this.stopListeningForProgress(); - } }); } - startListeningForProgress(): void { - this.progressSubscription = this.bookProgressService - .connect(this.libraryIdSignal()) - .subscribe({ - next: (event: BookUpdateEvent) => this.addBookToCollection(event), - error: (error) => console.error('Error receiving progress updates:', error), - }); - } - - stopListeningForProgress(): void { - if (this.progressSubscription) { - this.progressSubscription.unsubscribe(); - this.progressSubscription = undefined; - } - } - - addBookToCollection(event: BookUpdateEvent): void { - if (event.parsingStatus === 'PARSED_NEW_BOOK') { - const newBook: Book = { - id: event.book.id, - libraryId: event.book.libraryId, - metadata: event.book.metadata, - }; - this.ngZone.run(() => { - if (!this.books.find((book) => book.id === newBook.id)) { - this.books = [newBook, ...this.books]; - } - }); - } else { - console.warn('Status other than: PARSED_NEW_BOOK'); - } - } - - loadBooks(): void { - this.bookService.loadBooks(this.libraryIdSignal(), this.currentPage).subscribe({ - next: (response) => { - this.books = [...this.books, ...response.content]; - this.currentPage++; - }, - error: (err) => console.error('Error loading books:', err), - }); - } - coverImageSrc(bookId: number): string { return this.bookService.getBookCoverUrl(bookId); } @@ -159,14 +100,4 @@ export class LibraryBrowserComponent implements OnInit, OnDestroy { return this.coverSizeClass === this.coverSizeClasses[0]; } - resetState(): void { - this.books = []; - this.currentPage = 0; - this.stopListeningForProgress(); - } - - ngOnDestroy(): void { - this.stopListeningForProgress(); - } - } diff --git a/booklore-ui/src/app/book/model/book.model.ts b/booklore-ui/src/app/book/model/book.model.ts index f3002e76..4a903426 100644 --- a/booklore-ui/src/app/book/model/book.model.ts +++ b/booklore-ui/src/app/book/model/book.model.ts @@ -4,16 +4,6 @@ export interface Book { metadata: BookMetadata } -export interface PaginatedBooksResponse { - content: Book[]; - totalElements: number; - totalPages: number; - size: number; - number: number; - first: boolean; - last: boolean; -} - export interface BookMetadata { thumbnail: string; title: string; diff --git a/booklore-ui/src/app/book/service/book.service.ts b/booklore-ui/src/app/book/service/book.service.ts index e890d891..d7bd241c 100644 --- a/booklore-ui/src/app/book/service/book.service.ts +++ b/booklore-ui/src/app/book/service/book.service.ts @@ -1,15 +1,13 @@ import {Observable, of} from 'rxjs'; -import {Book, BookMetadata, BookSetting, BookWithNeighborsDTO, PaginatedBooksResponse} from '../model/book.model'; +import {Book, BookMetadata, BookSetting, BookWithNeighborsDTO} from '../model/book.model'; import {computed, Injectable, signal} from '@angular/core'; import {HttpClient} from '@angular/common/http'; import {catchError, map} from 'rxjs/operators'; -import {LibraryApiResponse} from '../model/library.model'; @Injectable({ providedIn: 'root', }) export class BookService { - private readonly pageSize = 50; private readonly libraryUrl = 'http://localhost:8080/v1/library'; private readonly bookUrl = 'http://localhost:8080/v1/book'; @@ -21,10 +19,40 @@ export class BookService { latestAddedBooks = computed(this.#latestAddedBooks); latestAddedBooksLoaded: boolean = false; + #libraryBooks = signal([]); + libraryBooks = computed(this.#libraryBooks); + lastLibraryBooksLoaded: boolean = false; + constructor(private http: HttpClient) { } + appendBookToLibrary(book: Book) { + const newBook: Book = this.convertBookDTOToBook(book); + this.#libraryBooks.set([newBook, ...this.#libraryBooks()]); + } + + private convertBookDTOToBook(book: Book): Book { + return { + id: book.id, + libraryId: book.libraryId, + metadata: { + thumbnail: book.metadata.thumbnail, + title: book.metadata.title, + subtitle: book.metadata.subtitle, + authors: book.metadata.authors, + categories: book.metadata.categories, + publisher: book.metadata.publisher, + publishedDate: book.metadata.publishedDate, + isbn10: book.metadata.isbn10, + description: book.metadata.description, + pageCount: book.metadata.pageCount, + language: book.metadata.language, + googleBookId: book.metadata.googleBookId, + } + }; + } + getBook(bookId: number): Observable { return this.http.get(`${this.bookUrl}/${bookId}`); } @@ -33,15 +61,31 @@ export class BookService { return this.http.get(`${this.libraryUrl}/${libraryId}/book/${bookId}/withNeighbors`); } - loadBooks(libraryId: number, page: number): Observable { - return this.http.get( - `${this.libraryUrl}/${libraryId}/book?page=${page}&size=${this.pageSize}` + loadBooksSignal(libraryId: number) { + this.http.get(`${this.libraryUrl}/${libraryId}/book`).pipe( + map(response => response), + catchError(error => { + console.error('Error loading library books:', error); + return of([]); + }) + ).subscribe( + (books) => { + this.#libraryBooks.set([...this.#libraryBooks(), ...books]); + this.lastReadBooksLoaded = true; + console.log("Loaded library books") + } + ); + } + + loadBooks(libraryId: number): Observable { + return this.http.get( + `${this.libraryUrl}/${libraryId}/book` ); } getLastReadBooks() { - this.http.get(`${this.bookUrl}?page=0&size=25&sortBy=lastReadTime&sortDir=desc`).pipe( - map(response => response.content), + this.http.get(`${this.bookUrl}?sortBy=lastReadTime&sortDir=desc`).pipe( + map(response => response), catchError(error => { console.error('Error loading last read books:', error); return of([]); @@ -56,8 +100,8 @@ export class BookService { } getLatestAddedBooks() { - this.http.get(`${this.bookUrl}?page=0&size=25&sortBy=addedOn&sortDir=desc`).pipe( - map(response => response.content), + this.http.get(`${this.bookUrl}?sortBy=addedOn&sortDir=desc`).pipe( + map(response => response), catchError(error => { console.error('Error loading latest added books:', error); return of([]); diff --git a/booklore-ui/src/app/my-rx-stomp.config.ts b/booklore-ui/src/app/my-rx-stomp.config.ts new file mode 100644 index 00000000..08e4a565 --- /dev/null +++ b/booklore-ui/src/app/my-rx-stomp.config.ts @@ -0,0 +1,30 @@ +import { RxStompConfig } from '@stomp/rx-stomp'; + +export const myRxStompConfig: RxStompConfig = { + // Which server? + brokerURL: 'ws://localhost:8080/ws', + + // Headers + // Typical keys: login, passcode, host + connectHeaders: { + login: 'guest', + passcode: 'guest', + }, + + // How often to heartbeat? + // Interval in milliseconds, set to 0 to disable + heartbeatIncoming: 0, // Typical value 0 - disabled + heartbeatOutgoing: 20000, // Typical value 20000 - every 20 seconds + + // Wait in milliseconds before attempting auto reconnect + // Set to 0 to disable + // Typical value 500 (500 milli seconds) + reconnectDelay: 200, + + // Will log diagnostics on console + // It can be quite verbose, not recommended in production + // Skip this key to stop logging to console + debug: (msg: string): void => { + //console.log(new Date(), msg); + }, +}; diff --git a/booklore-ui/src/app/rx-stomp-service-factory.ts b/booklore-ui/src/app/rx-stomp-service-factory.ts new file mode 100644 index 00000000..01176a81 --- /dev/null +++ b/booklore-ui/src/app/rx-stomp-service-factory.ts @@ -0,0 +1,9 @@ +import { RxStompService } from './rx-stomp.service'; +import { myRxStompConfig } from './my-rx-stomp.config'; + +export function rxStompServiceFactory() { + const rxStomp = new RxStompService(); + rxStomp.configure(myRxStompConfig); + rxStomp.activate(); + return rxStomp; +} diff --git a/booklore-ui/src/app/rx-stomp.service.ts b/booklore-ui/src/app/rx-stomp.service.ts new file mode 100644 index 00000000..58c4cbda --- /dev/null +++ b/booklore-ui/src/app/rx-stomp.service.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@angular/core'; +import { RxStomp } from '@stomp/rx-stomp'; + +@Injectable({ + providedIn: 'root', +}) +export class RxStompService extends RxStomp { + constructor() { + super(); + } +}