From 2e537bb2a1b079c8d03560d825a8726dad8b8f5b Mon Sep 17 00:00:00 2001 From: Ruben GM <2044827+rubengarciam@users.noreply.github.com> Date: Wed, 10 Sep 2025 00:16:29 +1000 Subject: [PATCH 01/10] feat: ComicInfo.xml support for CBX files (#1073) * write, read ComicInfo.xml for CBZ files * updated with recommendations from previous PR https://github.com/booklore-app/booklore/pull/1069 * read ComicInfo.xml on CBR, extract first image for CBZ/CBR files, save CBR metadata in ComicInfo.xml as CBZ * Delete CBR file after CBZ conversion. Update DB * Backs up file before updating. Restores back up if errored * Test classes * Update booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/writer/CbxMetadataWriter.java Updating RAR binary availability check Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/CbxMetadataExtractor.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/CbxMetadataExtractor.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Code updates from PR suggestions * Support to extract ComicInfo.xml for .cb7 files * Writer extension for .cb7 files * Adding com.github.junrar to build.gradle (forgot in previous commit) * Settings toggle to control CBR/CB7 to CBZ conversion * indentation complains * removed duplicated junrar inport * Restore comicinfo.xml metadata in edit view * retrieve ComicInfo.xml metadata for new files in library scan * private class definition was missed after merge * Delete  --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- booklore-api/build.gradle | 4 +- .../booklore/controller/BookController.java | 7 + .../settings/MetadataPersistenceSettings.java | 1 + .../appsettings/SettingPersistenceHelper.java | 1 + .../service/fileprocessor/CbxProcessor.java | 45 +- .../service/metadata/BookMetadataService.java | 22 +- .../service/metadata/BookMetadataUpdater.java | 30 +- .../backuprestore/BookMetadataRestorer.java | 8 +- .../extractor/CbxMetadataExtractor.java | 686 ++++++++++++++++-- .../metadata/writer/CbxMetadataWriter.java | 454 ++++++++++++ .../extractor/CbxMetadataExtractorTest.java | 153 ++++ .../writer/CbxMetadataWriterTest.java | 190 +++++ .../src/app/book/service/book.service.ts | 4 + .../src/app/core/model/app-settings.model.ts | 1 + .../metadata-editor.component.html | 16 + .../metadata-editor.component.ts | 622 +++++++++------- ...tadata-persistence-settings-component.html | 17 + ...metadata-persistence-settings-component.ts | 2 + 18 files changed, 1951 insertions(+), 312 deletions(-) create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/writer/CbxMetadataWriter.java create mode 100644 booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/extractor/CbxMetadataExtractorTest.java create mode 100644 booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/writer/CbxMetadataWriterTest.java diff --git a/booklore-api/build.gradle b/booklore-api/build.gradle index 2b8c9e1c0..fe290f70d 100644 --- a/booklore-api/build.gradle +++ b/booklore-api/build.gradle @@ -60,6 +60,9 @@ dependencies { implementation 'com.github.jai-imageio:jai-imageio-jpeg2000:1.4.0' implementation 'io.documentnode:epub4j-core:4.2.2' + // --- UNRAR Support --- + implementation 'com.github.junrar:junrar:7.5.5' + // --- JSON & Web Scraping --- implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.19.2' implementation 'org.jsoup:jsoup:1.21.1' @@ -71,7 +74,6 @@ dependencies { // --- API Documentation --- implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' implementation 'org.apache.commons:commons-compress:1.28.0' - implementation 'com.github.junrar:junrar:7.5.5' implementation 'org.apache.commons:commons-text:1.14.0' // --- Test Dependencies --- 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 54fa57209..ef0e0096f 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 @@ -36,6 +36,7 @@ public class BookController { private final BookService bookService; private final BookRecommendationService bookRecommendationService; + private final BookMetadataService bookMetadataService; @GetMapping public ResponseEntity> getBooks(@RequestParam(required = false, defaultValue = "false") boolean withDescription) { @@ -59,6 +60,12 @@ public class BookController { return ResponseEntity.ok(bookService.getBooksByIds(ids, withDescription)); } + @GetMapping("/{bookId}/cbx/metadata/comicinfo") + public ResponseEntity getComicInfoMetadata(@PathVariable long bookId) { + return ResponseEntity.ok(bookMetadataService.getComicInfoMetadata(bookId)); + } + + @GetMapping("/{bookId}/content") @CheckBookAccess(bookIdParam = "bookId") public ResponseEntity getBookContent(@PathVariable long bookId) throws IOException { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/MetadataPersistenceSettings.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/MetadataPersistenceSettings.java index b04dc2155..b1dcae25a 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/MetadataPersistenceSettings.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/MetadataPersistenceSettings.java @@ -11,6 +11,7 @@ import lombok.NoArgsConstructor; @AllArgsConstructor public class MetadataPersistenceSettings { private boolean saveToOriginalFile; + private boolean convertCbrCb7ToCbz; private boolean backupMetadata; private boolean backupCover; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java index ef98938a1..c47664e05 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java @@ -183,6 +183,7 @@ public class SettingPersistenceHelper { public MetadataPersistenceSettings getDefaultMetadataPersistenceSettings() { return MetadataPersistenceSettings.builder() .saveToOriginalFile(false) + .convertCbrCb7ToCbz(false) .backupMetadata(false) .backupCover(false) .build(); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessor.java index a5c21a42f..9812f374f 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/CbxProcessor.java @@ -1,13 +1,16 @@ package com.adityachandel.booklore.service.fileprocessor; import com.adityachandel.booklore.mapper.BookMapper; +import com.adityachandel.booklore.model.dto.BookMetadata; import com.adityachandel.booklore.model.dto.settings.LibraryFile; import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookMetadataEntity; import com.adityachandel.booklore.model.enums.BookFileType; import com.adityachandel.booklore.repository.BookAdditionalFileRepository; import com.adityachandel.booklore.repository.BookMetadataRepository; import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.service.BookCreatorService; +import com.adityachandel.booklore.service.metadata.extractor.CbxMetadataExtractor; import com.adityachandel.booklore.service.metadata.MetadataMatchService; import com.adityachandel.booklore.util.FileService; import com.adityachandel.booklore.util.FileUtils; @@ -28,11 +31,13 @@ import java.util.*; import static com.adityachandel.booklore.util.FileService.truncate; + @Slf4j @Service public class CbxProcessor extends AbstractFileProcessor implements BookFileProcessor { private final BookMetadataRepository bookMetadataRepository; + private final CbxMetadataExtractor cbxMetadataExtractor; public CbxProcessor(BookRepository bookRepository, BookAdditionalFileRepository bookAdditionalFileRepository, @@ -40,9 +45,11 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce BookMapper bookMapper, FileService fileService, BookMetadataRepository bookMetadataRepository, - MetadataMatchService metadataMatchService) { + MetadataMatchService metadataMatchService, + CbxMetadataExtractor cbxMetadataExtractor) { super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService); this.bookMetadataRepository = bookMetadataRepository; + this.cbxMetadataExtractor = cbxMetadataExtractor; } @Override @@ -51,7 +58,8 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce if (generateCover(bookEntity)) { fileService.setBookCoverPath(bookEntity.getMetadata()); } - setMetadata(bookEntity); + + extractAndSetMetadata(bookEntity); return bookEntity; } @@ -167,6 +175,39 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce return Optional.empty(); } + private void extractAndSetMetadata(BookEntity bookEntity) { + try { + BookMetadata extracted = cbxMetadataExtractor.extractMetadata(new File(FileUtils.getBookFullPath(bookEntity))); + if (extracted == null) { + // Fallback to filename-derived title + setMetadata(bookEntity); + return; + } + + BookMetadataEntity metadata = bookEntity.getMetadata(); + metadata.setTitle(truncate(extracted.getTitle(), 1000)); + metadata.setDescription(truncate(extracted.getDescription(), 5000)); + metadata.setPublisher(truncate(extracted.getPublisher(), 1000)); + metadata.setPublishedDate(extracted.getPublishedDate()); + metadata.setSeriesName(truncate(extracted.getSeriesName(), 1000)); + metadata.setSeriesNumber(extracted.getSeriesNumber()); + metadata.setSeriesTotal(extracted.getSeriesTotal()); + metadata.setPageCount(extracted.getPageCount()); + metadata.setLanguage(truncate(extracted.getLanguage(), 1000)); + + if (extracted.getAuthors() != null) { + bookCreatorService.addAuthorsToBook(extracted.getAuthors(), bookEntity); + } + if (extracted.getCategories() != null) { + bookCreatorService.addCategoriesToBook(extracted.getCategories(), bookEntity); + } + } catch (Exception e) { + log.warn("Failed to extract ComicInfo metadata for '{}': {}", bookEntity.getFileName(), e.getMessage()); + // Fallback to filename-derived title + setMetadata(bookEntity); + } + } + private void setMetadata(BookEntity bookEntity) { String baseName = new File(bookEntity.getFileName()).getName(); String title = baseName diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java index e10d742e8..86b838515 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataService.java @@ -11,8 +11,10 @@ import com.adityachandel.booklore.model.dto.BookMetadata; import com.adityachandel.booklore.model.dto.request.BulkMetadataUpdateRequest; import com.adityachandel.booklore.model.dto.request.FetchMetadataRequest; import com.adityachandel.booklore.model.dto.request.ToggleAllLockRequest; +import com.adityachandel.booklore.model.dto.settings.MetadataPersistenceSettings; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.entity.BookMetadataEntity; +import com.adityachandel.booklore.model.enums.BookFileType; import com.adityachandel.booklore.model.enums.Lock; import com.adityachandel.booklore.model.enums.MetadataProvider; import com.adityachandel.booklore.model.websocket.Topic; @@ -27,9 +29,11 @@ import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor; import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry; import com.adityachandel.booklore.service.metadata.backuprestore.MetadataBackupRestore; import com.adityachandel.booklore.service.metadata.backuprestore.MetadataBackupRestoreFactory; +import com.adityachandel.booklore.service.metadata.extractor.CbxMetadataExtractor; import com.adityachandel.booklore.service.metadata.parser.BookParser; import com.adityachandel.booklore.service.metadata.writer.MetadataWriterFactory; import com.adityachandel.booklore.util.FileService; +import com.adityachandel.booklore.util.FileUtils; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.Resource; @@ -37,6 +41,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import java.io.File; import java.io.IOException; import java.lang.reflect.Method; import java.time.Instant; @@ -67,6 +72,7 @@ public class BookMetadataService { private final BookQueryService bookQueryService; private final Map parserMap; private final MetadataBackupRestoreFactory metadataBackupRestoreFactory; + private final CbxMetadataExtractor cbxMetadataExtractor; private final MetadataWriterFactory metadataWriterFactory; private final MetadataClearFlagsMapper metadataClearFlagsMapper; @@ -163,8 +169,10 @@ public class BookMetadataService { private BookMetadata updateCover(Long bookId, BiConsumer writerAction) { BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); - bookEntity.getMetadata().setCoverUpdatedOn(Instant.now()); - if (appSettingService.getAppSettings().getMetadataPersistenceSettings().isSaveToOriginalFile()) { + MetadataPersistenceSettings settings = appSettingService.getAppSettings().getMetadataPersistenceSettings(); + boolean saveToOriginalFile = settings.isSaveToOriginalFile(); + boolean convertCbrCb7ToCbz = settings.isConvertCbrCb7ToCbz(); + if (saveToOriginalFile && (bookEntity.getBookType() != BookFileType.CBX || convertCbrCb7ToCbz)) { metadataWriterFactory.getWriter(bookEntity.getBookType()) .ifPresent(writer -> writerAction.accept(writer, bookEntity)); } @@ -219,6 +227,16 @@ public class BookMetadataService { log.info("{}Successfully regenerated cover for book ID {} ({})", progress, book.getId(), title); } + public BookMetadata getComicInfoMetadata(long bookId) { + log.info("Extracting ComicInfo metadata for book ID: {}", bookId); + BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); + if (bookEntity.getBookType() != BookFileType.CBX) { + log.info("Unsupported operation for file type: {}", bookEntity.getBookType().name()); + return null; + } + return cbxMetadataExtractor.extractMetadata(new File(FileUtils.getBookFullPath(bookEntity))); + } + public BookMetadata restoreMetadataFromBackup(Long bookId) throws IOException { BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); metadataBackupRestoreFactory.getService(bookEntity.getBookType()).restoreEmbeddedMetadata(bookEntity); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java index 5355618c1..5484c24bb 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java @@ -74,11 +74,12 @@ public class BookMetadataUpdater { MetadataPersistenceSettings settings = appSettingService.getAppSettings().getMetadataPersistenceSettings(); boolean writeToFile = settings.isSaveToOriginalFile(); + boolean convertCbrCb7ToCbz = settings.isConvertCbrCb7ToCbz(); boolean backupEnabled = settings.isBackupMetadata(); boolean backupCover = settings.isBackupCover(); BookFileType bookType = bookEntity.getBookType(); - if (writeToFile && backupEnabled) { + if (writeToFile && backupEnabled && (bookType != BookFileType.CBX || convertCbrCb7ToCbz)) { try { MetadataBackupRestore service = metadataBackupRestoreFactory.getService(bookType); if (service != null) { @@ -104,7 +105,10 @@ public class BookMetadataUpdater { } if ((writeToFile && hasValueChanges) || thumbnailRequiresUpdate) { - metadataWriterFactory.getWriter(bookType).ifPresent(writer -> { + if (bookType == BookFileType.CBX && !convertCbrCb7ToCbz) { + log.info("CBX metadata writing disabled for book ID {}", bookId); + } else { + metadataWriterFactory.getWriter(bookType).ifPresent(writer -> { try { String thumbnailUrl = setThumbnail ? newMetadata.getThumbnailUrl() : null; @@ -115,12 +119,32 @@ public class BookMetadataUpdater { File file = new File(bookEntity.getFullFilePath().toUri()); writer.writeMetadataToFile(file, metadata, thumbnailUrl, false, clearFlags); - String newHash = FileFingerprint.generateHash(bookEntity.getFullFilePath()); + + String newHash = ""; + + // Special handling: If original file was .cbr or .cb7 and now .cbz exists, update to .cbz + File resultingFile = file; + if (!file.exists()) { + // Replace last extension .cbr or .cb7 (case-insensitive) with .cbz + String cbzName = file.getName().replaceFirst("(?i)\\.(cbr|cb7)$", ".cbz"); + File cbzFile = new File(file.getParentFile(), cbzName); + if (cbzFile.exists()) { + bookEntity.setFileName(cbzName); + resultingFile = cbzFile; + } + bookEntity.setFileSizeKb(resultingFile.length() / 1024); + log.info("Converted to CBZ: {} -> {}", file.getAbsolutePath(), resultingFile.getAbsolutePath()); + newHash = FileFingerprint.generateHash(resultingFile.toPath()); + } else { + newHash = FileFingerprint.generateHash(bookEntity.getFullFilePath()); + } + bookEntity.setCurrentHash(newHash); } catch (Exception e) { log.warn("Failed to write metadata for book ID {}: {}", bookId, e.getMessage()); } }); + } } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/BookMetadataRestorer.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/BookMetadataRestorer.java index 2b4eb3aad..f70c257ec 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/BookMetadataRestorer.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/backuprestore/BookMetadataRestorer.java @@ -2,6 +2,8 @@ package com.adityachandel.booklore.service.metadata.backuprestore; import com.adityachandel.booklore.model.MetadataClearFlags; import com.adityachandel.booklore.model.dto.BookMetadata; +import com.adityachandel.booklore.model.dto.settings.MetadataPersistenceSettings; +import com.adityachandel.booklore.model.enums.BookFileType; import com.adityachandel.booklore.model.entity.AuthorEntity; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.entity.BookMetadataEntity; @@ -103,8 +105,10 @@ public class BookMetadataRestorer { } try { - boolean saveToOriginal = appSettingService.getAppSettings().getMetadataPersistenceSettings().isSaveToOriginalFile(); - if (saveToOriginal) { + MetadataPersistenceSettings settings = appSettingService.getAppSettings().getMetadataPersistenceSettings(); + boolean saveToOriginal = settings.isSaveToOriginalFile(); + boolean convertCbrCb7ToCbz = settings.isConvertCbrCb7ToCbz(); + if (saveToOriginal && (bookEntity.getBookType() != BookFileType.CBX || convertCbrCb7ToCbz)) { metadataWriterFactory.getWriter(bookEntity.getBookType()).ifPresent(writer -> { try { File file = new File(bookEntity.getFullFilePath().toUri()); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/CbxMetadataExtractor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/CbxMetadataExtractor.java index 917c36c85..eacc555b9 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/CbxMetadataExtractor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/CbxMetadataExtractor.java @@ -1,58 +1,660 @@ package com.adityachandel.booklore.service.metadata.extractor; import com.adityachandel.booklore.model.dto.BookMetadata; +import com.github.junrar.Archive; +import com.github.junrar.rarfile.FileHeader; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.List; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Collections; +import java.util.stream.Collectors; +import org.apache.commons.compress.archivers.sevenz.SevenZFile; +import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry; +import javax.imageio.ImageIO; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.FilenameUtils; import org.springframework.stereotype.Component; - -import javax.imageio.ImageIO; -import java.awt.*; -import java.awt.image.BufferedImage; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.IOException; +import org.w3c.dom.Document; +import org.w3c.dom.NodeList; @Slf4j @Component public class CbxMetadataExtractor implements FileMetadataExtractor { - @Override - public BookMetadata extractMetadata(File file) { - String baseName = FilenameUtils.getBaseName(file.getName()); - return BookMetadata.builder() - .title(baseName) - .build(); + @Override + public BookMetadata extractMetadata(File file) { + String baseName = FilenameUtils.getBaseName(file.getName()); + String lowerName = file.getName().toLowerCase(); + + // Non-archive (fallback) + if (!lowerName.endsWith(".cbz") && !lowerName.endsWith(".cbr") && !lowerName.endsWith(".cb7")) { + return BookMetadata.builder().title(baseName).build(); } - @Override - public byte[] extractCover(File file) { - return generatePlaceholderCover(250, 350); - } - - private byte[] generatePlaceholderCover(int width, int height) { - BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); - Graphics2D g = image.createGraphics(); - - g.setColor(Color.LIGHT_GRAY); - g.fillRect(0, 0, width, height); - - g.setColor(Color.DARK_GRAY); - g.setFont(new Font("SansSerif", Font.BOLD, width / 10)); - FontMetrics fm = g.getFontMetrics(); - String text = "Preview Unavailable"; - - int textWidth = fm.stringWidth(text); - int textHeight = fm.getAscent(); - g.drawString(text, (width - textWidth) / 2, (height + textHeight) / 2); - - g.dispose(); - - try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { - ImageIO.write(image, "jpg", baos); - return baos.toByteArray(); - } catch (IOException e) { - log.warn("Failed to generate placeholder image", e); - return null; + // CBZ path (ZIP) + if (lowerName.endsWith(".cbz")) { + try (ZipFile zipFile = new ZipFile(file)) { + ZipEntry entry = findComicInfoEntry(zipFile); + if (entry == null) { + return BookMetadata.builder().title(baseName).build(); } + try (InputStream is = zipFile.getInputStream(entry)) { + Document document = buildSecureDocument(is); + return mapDocumentToMetadata(document, baseName); + } + } catch (Exception e) { + log.warn("Failed to extract metadata from CBZ", e); + return BookMetadata.builder().title(baseName).build(); + } } + + // CB7 path (7z) + if (lowerName.endsWith(".cb7")) { + try (SevenZFile sevenZ = new SevenZFile(file)) { + SevenZArchiveEntry entry = findSevenZComicInfoEntry(sevenZ); + if (entry == null) { + return BookMetadata.builder().title(baseName).build(); + } + byte[] xmlBytes = readSevenZEntryBytes(sevenZ, entry); + if (xmlBytes == null) { + return BookMetadata.builder().title(baseName).build(); + } + try (InputStream is = new ByteArrayInputStream(xmlBytes)) { + Document document = buildSecureDocument(is); + return mapDocumentToMetadata(document, baseName); + } + } catch (Exception e) { + log.warn("Failed to extract metadata from CB7", e); + return BookMetadata.builder().title(baseName).build(); + } + } + + // CBR path (RAR) + Archive archive = null; + try { + archive = new Archive(file); + FileHeader header = findComicInfoHeader(archive); + if (header == null) { + return BookMetadata.builder().title(baseName).build(); + } + byte[] xmlBytes = readRarEntryBytes(archive, header); + if (xmlBytes == null) { + return BookMetadata.builder().title(baseName).build(); + } + try (InputStream is = new ByteArrayInputStream(xmlBytes)) { + Document document = buildSecureDocument(is); + return mapDocumentToMetadata(document, baseName); + } + } catch (Exception e) { + log.warn("Failed to extract metadata from CBR", e); + return BookMetadata.builder().title(baseName).build(); + } finally { + try { if (archive != null) archive.close(); } catch (Exception ignore) {} + } + } + + private ZipEntry findComicInfoEntry(ZipFile zipFile) { + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + String name = entry.getName(); + if ("comicinfo.xml".equalsIgnoreCase(name)) { + return entry; + } + } + return null; + } + + private Document buildSecureDocument(InputStream is) throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + factory.setExpandEntityReferences(false); + DocumentBuilder builder = factory.newDocumentBuilder(); + return builder.parse(is); + } + + private BookMetadata mapDocumentToMetadata( + Document document, + String fallbackTitle + ) { + BookMetadata.BookMetadataBuilder builder = BookMetadata.builder(); + + String title = getTextContent(document, "Title"); + builder.title(title == null || title.isBlank() ? fallbackTitle : title); + + builder.description( + coalesce( + getTextContent(document, "Summary"), + getTextContent(document, "Description") + ) + ); + builder.publisher(getTextContent(document, "Publisher")); + builder.seriesName(getTextContent(document, "Series")); + builder.seriesNumber(parseFloat(getTextContent(document, "Number"))); + builder.seriesTotal(parseInteger(getTextContent(document, "Count"))); + builder.publishedDate( + parseDate( + getTextContent(document, "Year"), + getTextContent(document, "Month"), + getTextContent(document, "Day") + ) + ); + builder.pageCount( + parseInteger( + coalesce( + getTextContent(document, "PageCount"), + getTextContent(document, "Pages") + ) + ) + ); + builder.language(getTextContent(document, "LanguageISO")); + + Set authors = new HashSet<>(); + authors.addAll(splitValues(getTextContent(document, "Writer"))); + authors.addAll(splitValues(getTextContent(document, "Penciller"))); + authors.addAll(splitValues(getTextContent(document, "Inker"))); + authors.addAll(splitValues(getTextContent(document, "Colorist"))); + authors.addAll(splitValues(getTextContent(document, "Letterer"))); + authors.addAll(splitValues(getTextContent(document, "CoverArtist"))); + if (!authors.isEmpty()) { + builder.authors(authors); + } + + Set categories = new HashSet<>(); + categories.addAll(splitValues(getTextContent(document, "Genre"))); + categories.addAll(splitValues(getTextContent(document, "Tags"))); + if (!categories.isEmpty()) { + builder.categories(categories); + } + + return builder.build(); + } + + private String getTextContent(Document document, String tag) { + NodeList nodes = document.getElementsByTagName(tag); + if (nodes.getLength() == 0) { + return null; + } + return nodes.item(0).getTextContent().trim(); + } + + private String coalesce(String a, String b) { + return (a != null && !a.isBlank()) + ? a + : (b != null && !b.isBlank() ? b : null); + } + + private Set splitValues(String value) { + if (value == null) { + return new HashSet<>(); + } + return Arrays.stream(value.split("[,;]")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + } + + private Integer parseInteger(String value) { + try { + return (value == null || value.isBlank()) + ? null + : Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + private Float parseFloat(String value) { + try { + return (value == null || value.isBlank()) + ? null + : Float.parseFloat(value.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + private LocalDate parseDate(String year, String month, String day) { + Integer y = parseInteger(year); + Integer m = parseInteger(month); + Integer d = parseInteger(day); + if (y == null) { + return null; + } + if (m == null) { + m = 1; + } + if (d == null) { + d = 1; + } + try { + return LocalDate.of(y, m, d); + } catch (Exception e) { + return null; + } + } + + public BookMetadata extractFromComicInfoXml(File xmlFile) { + try (InputStream is = new FileInputStream(xmlFile)) { + Document document = buildSecureDocument(is); + String fallbackTitle = xmlFile.getParentFile() != null + ? xmlFile.getParentFile().getName() + : xmlFile.getName(); + return mapDocumentToMetadata(document, fallbackTitle); + } catch (Exception e) { + log.warn("Failed to parse ComicInfo.xml: {}", e.getMessage()); + String fallbackTitle = xmlFile.getParentFile() != null + ? xmlFile.getParentFile().getName() + : xmlFile.getName(); + return BookMetadata.builder().title(fallbackTitle).build(); + } + } + + @Override + public byte[] extractCover(File file) { + String lowerName = file.getName().toLowerCase(); + + // Non-archive fallback + if (!lowerName.endsWith(".cbz") && !lowerName.endsWith(".cbr") && !lowerName.endsWith(".cb7")) { + return generatePlaceholderCover(250, 350); + } + + // CBZ path + if (lowerName.endsWith(".cbz")) { + try (ZipFile zipFile = new ZipFile(file)) { + ZipEntry coverEntry = findFrontCoverEntry(zipFile); + if (coverEntry != null) { + try (InputStream is = zipFile.getInputStream(coverEntry)) { + return is.readAllBytes(); + } + } else { + // Fallback: first image after sorting alphabetically + ZipEntry firstImage = findFirstAlphabeticalImageEntry(zipFile); + if (firstImage != null) { + try (InputStream is2 = zipFile.getInputStream(firstImage)) { + return is2.readAllBytes(); + } + } + } + } catch (Exception e) { + log.warn("Failed to extract cover image from CBZ", e); + return generatePlaceholderCover(250, 350); + } + } + + // CB7 path + if (lowerName.endsWith(".cb7")) { + try (SevenZFile sevenZ = new SevenZFile(file)) { + // Try via ComicInfo.xml first + SevenZArchiveEntry ci = findSevenZComicInfoEntry(sevenZ); + if (ci != null) { + byte[] xmlBytes = readSevenZEntryBytes(sevenZ, ci); + if (xmlBytes != null) { + try (InputStream is = new ByteArrayInputStream(xmlBytes)) { + Document document = buildSecureDocument(is); + String imageName = findFrontCoverImageName(document); + if (imageName != null) { + SevenZArchiveEntry byName = findSevenZEntryByName(sevenZ, imageName); + if (byName != null) { + return readSevenZEntryBytes(sevenZ, byName); + } + try { + int index = Integer.parseInt(imageName); + SevenZArchiveEntry byIndex = findSevenZImageEntryByIndex(sevenZ, index); + if (byIndex != null) { + return readSevenZEntryBytes(sevenZ, byIndex); + } + } catch (NumberFormatException ignore) { + // continue to fallback + } + } + } + } + } + + // Fallback: first image alphabetically + SevenZArchiveEntry first = findFirstAlphabeticalSevenZImageEntry(sevenZ); + if (first != null) { + return readSevenZEntryBytes(sevenZ, first); + } + } catch (Exception e) { + log.warn("Failed to extract cover image from CB7", e); + return generatePlaceholderCover(250, 350); + } + } + + // CBR path + Archive archive = null; + try { + archive = new Archive(file); + + // Try via ComicInfo.xml first + FileHeader comicInfo = findComicInfoHeader(archive); + if (comicInfo != null) { + byte[] xmlBytes = readRarEntryBytes(archive, comicInfo); + if (xmlBytes != null) { + try (InputStream is = new ByteArrayInputStream(xmlBytes)) { + Document document = buildSecureDocument(is); + String imageName = findFrontCoverImageName(document); + if (imageName != null) { + FileHeader byName = findRarHeaderByName(archive, imageName); + if (byName != null) { + return readRarEntryBytes(archive, byName); + } + try { + int index = Integer.parseInt(imageName); + FileHeader byIndex = findRarImageHeaderByIndex(archive, index); + if (byIndex != null) { + return readRarEntryBytes(archive, byIndex); + } + } catch (NumberFormatException ignore) { + // ignore and continue fallback + } + } + } + } + } + + // Fallback: first image in alphabetical order + FileHeader firstImage = findFirstAlphabeticalImageHeader(archive); + if (firstImage != null) { + return readRarEntryBytes(archive, firstImage); + } + } catch (Exception e) { + log.warn("Failed to extract cover image from CBR", e); + return generatePlaceholderCover(250, 350); + } finally { + try { if (archive != null) archive.close(); } catch (Exception ignore) {} + } + + return generatePlaceholderCover(250, 350); + } + + private ZipEntry findFrontCoverEntry(ZipFile zipFile) { + ZipEntry comicInfoEntry = findComicInfoEntry(zipFile); + if (comicInfoEntry != null) { + try (InputStream is = zipFile.getInputStream(comicInfoEntry)) { + Document document = buildSecureDocument(is); + String imageName = findFrontCoverImageName(document); + if (imageName != null) { + ZipEntry byName = zipFile.getEntry(imageName); + if (byName != null) { + return byName; + } + try { + int index = Integer.parseInt(imageName); + ZipEntry byIndex = findImageEntryByIndex(zipFile, index); + if (byIndex != null) { + return byIndex; + } + } catch (NumberFormatException ignore) { + // ignore + } + } + } catch (Exception e) { + log.warn("Failed to parse ComicInfo.xml for cover", e); + } + } + ZipEntry firstImage = findFirstAlphabeticalImageEntry(zipFile); + return firstImage; + } + + private ZipEntry findImageEntryByIndex(ZipFile zipFile, int index) { + Enumeration entries = zipFile.entries(); + int count = 0; + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (!entry.isDirectory() && isImageEntry(entry.getName())) { + if (count == index) { + return entry; + } + count++; + } + } + return null; + } + + private String findFrontCoverImageName(Document document) { + NodeList pages = document.getElementsByTagName("Page"); + for (int i = 0; i < pages.getLength(); i++) { + org.w3c.dom.Node node = pages.item(i); + if (node instanceof org.w3c.dom.Element) { + org.w3c.dom.Element page = (org.w3c.dom.Element) node; + String type = page.getAttribute("Type"); + if (type != null && type.equalsIgnoreCase("FrontCover")) { + String imageFile = page.getAttribute("ImageFile"); + if (imageFile != null && !imageFile.isBlank()) { + return imageFile.trim(); + } + String image = page.getAttribute("Image"); + if (image != null && !image.isBlank()) { + return image.trim(); + } + } + } + } + return null; + } + + private boolean isImageEntry(String name) { + String lower = name.toLowerCase(); + return ( + lower.endsWith(".jpg") || + lower.endsWith(".jpeg") || + lower.endsWith(".png") || + lower.endsWith(".gif") || + lower.endsWith(".bmp") || + lower.endsWith(".webp") + ); + } + + private byte[] generatePlaceholderCover(int width, int height) { + BufferedImage image = new BufferedImage( + width, + height, + BufferedImage.TYPE_INT_RGB + ); + Graphics2D g = image.createGraphics(); + + g.setColor(Color.LIGHT_GRAY); + g.fillRect(0, 0, width, height); + + g.setColor(Color.DARK_GRAY); + g.setFont(new Font("SansSerif", Font.BOLD, width / 10)); + FontMetrics fm = g.getFontMetrics(); + String text = "Preview Unavailable"; + + int textWidth = fm.stringWidth(text); + int textHeight = fm.getAscent(); + g.drawString(text, (width - textWidth) / 2, (height + textHeight) / 2); + + g.dispose(); + + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + ImageIO.write(image, "jpg", baos); + return baos.toByteArray(); + } catch (IOException e) { + log.warn("Failed to generate placeholder image", e); + return null; + } + } + + + // ==== RAR (.cbr) helpers ==== + private FileHeader findComicInfoHeader(Archive archive) { + if (archive == null) return null; + for (FileHeader fh : archive.getFileHeaders()) { + String name = fh.getFileName(); + if (name == null) continue; + String base = baseName(name); + if ("comicinfo.xml".equalsIgnoreCase(base)) { + return fh; + } + } + return null; + } + + private FileHeader findRarHeaderByName(Archive archive, String imageName) { + if (archive == null || imageName == null) return null; + for (FileHeader fh : archive.getFileHeaders()) { + String name = fh.getFileName(); + if (name == null) continue; + if (name.equalsIgnoreCase(imageName)) return fh; + // also try base-name match to be lenient + if (baseName(name).equalsIgnoreCase(baseName(imageName))) return fh; + } + return null; + } + + private FileHeader findRarImageHeaderByIndex(Archive archive, int index) { + int count = 0; + for (FileHeader fh : archive.getFileHeaders()) { + if (!fh.isDirectory() && isImageEntry(fh.getFileName())) { + if (count == index) return fh; + count++; + } + } + return null; + } + + private FileHeader findFirstImageHeader(Archive archive) { + for (FileHeader fh : archive.getFileHeaders()) { + if (!fh.isDirectory() && isImageEntry(fh.getFileName())) { + return fh; + } + } + return null; + } + + private byte[] readRarEntryBytes(Archive archive, FileHeader header) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + archive.extractFile(header, baos); + return baos.toByteArray(); + } catch (Exception e) { + log.warn("Failed to read RAR entry bytes for {}", header != null ? header.getFileName() : "", e); + return null; + } + } + + private String baseName(String path) { + if (path == null) return null; + int slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + return slash >= 0 ? path.substring(slash + 1) : path; + } + + private FileHeader findFirstAlphabeticalImageHeader(Archive archive) { + if (archive == null) return null; + List images = new ArrayList<>(); + for (FileHeader fh : archive.getFileHeaders()) { + if (fh == null || fh.isDirectory()) continue; + String name = fh.getFileName(); + if (name == null) continue; + if (isImageEntry(name)) { + images.add(fh); + } + } + if (images.isEmpty()) return null; + images.sort(Comparator.comparing( + fh -> fh.getFileName() == null ? "" : fh.getFileName(), + String.CASE_INSENSITIVE_ORDER + )); + return images.get(0); + } + + private ZipEntry findFirstAlphabeticalImageEntry(ZipFile zipFile) { + List images = new ArrayList<>(); + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry e = entries.nextElement(); + if (!e.isDirectory() && isImageEntry(e.getName())) { + images.add(e); + } + } + if (images.isEmpty()) return null; + images.sort(Comparator.comparing( + entry -> entry.getName() == null ? "" : entry.getName(), + String.CASE_INSENSITIVE_ORDER + )); + return images.get(0); + } + + // ==== 7z (.cb7) helpers ==== + private SevenZArchiveEntry findSevenZComicInfoEntry(SevenZFile sevenZ) throws IOException { + for (SevenZArchiveEntry e : sevenZ.getEntries()) { + if (e == null || e.isDirectory()) continue; + String name = e.getName(); + if (name != null && name.equalsIgnoreCase("ComicInfo.xml")) { + return e; + } + } + return null; + } + + private SevenZArchiveEntry findSevenZEntryByName(SevenZFile sevenZ, String imageName) throws IOException { + if (imageName == null) return null; + for (SevenZArchiveEntry e : sevenZ.getEntries()) { + if (e == null || e.isDirectory()) continue; + String name = e.getName(); + if (name == null) continue; + if (name.equalsIgnoreCase(imageName)) return e; + // also allow base-name match + if (baseName(name).equalsIgnoreCase(baseName(imageName))) return e; + } + return null; + } + + private SevenZArchiveEntry findSevenZImageEntryByIndex(SevenZFile sevenZ, int index) throws IOException { + int count = 0; + for (SevenZArchiveEntry e : sevenZ.getEntries()) { + if (!e.isDirectory() && isImageEntry(e.getName())) { + if (count == index) return e; + count++; + } + } + return null; + } + + private SevenZArchiveEntry findFirstAlphabeticalSevenZImageEntry(SevenZFile sevenZ) throws IOException { + List images = new ArrayList<>(); + for (SevenZArchiveEntry e : sevenZ.getEntries()) { + if (!e.isDirectory() && isImageEntry(e.getName())) { + images.add(e); + } + } + if (images.isEmpty()) return null; + images.sort(Comparator.comparing( + entry -> entry.getName() == null ? "" : entry.getName(), + String.CASE_INSENSITIVE_ORDER + )); + return images.get(0); + } + + private byte[] readSevenZEntryBytes(SevenZFile sevenZ, SevenZArchiveEntry entry) throws IOException { + try (InputStream is = sevenZ.getInputStream(entry); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + if (is == null) return null; + is.transferTo(baos); + return baos.toByteArray(); + } + } } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/writer/CbxMetadataWriter.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/writer/CbxMetadataWriter.java new file mode 100644 index 000000000..9d2dcf4d0 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/writer/CbxMetadataWriter.java @@ -0,0 +1,454 @@ +package com.adityachandel.booklore.service.metadata.writer; + +import com.adityachandel.booklore.model.MetadataClearFlags; +import com.adityachandel.booklore.model.entity.BookMetadataEntity; +import com.adityachandel.booklore.model.enums.BookFileType; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.time.LocalDate; +import java.util.Enumeration; +import java.util.Locale; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; +import com.github.junrar.Archive; +import com.github.junrar.rarfile.FileHeader; +import org.apache.commons.compress.archivers.sevenz.SevenZFile; +import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry; + +@Slf4j +@Component +public class CbxMetadataWriter implements MetadataWriter { + + @Override + public void writeMetadataToFile(File file, BookMetadataEntity metadata, String thumbnailUrl, boolean restoreMode, MetadataClearFlags clearFlags) { + Path backup = null; + boolean writeSucceeded = false; + try { + // Create a backup next to the source file (temp name, safe to delete later) + backup = Files.createTempFile(file.getParentFile().toPath(), "cbx_backup_", ".bak"); + Files.copy(file.toPath(), backup, StandardCopyOption.REPLACE_EXISTING); + } catch (Exception ex) { + log.warn("Unable to create backup for {}: {}", file.getAbsolutePath(), ex.getMessage(), ex); + } + try { + String nameLower = file.getName().toLowerCase(Locale.ROOT); + boolean isCbz = nameLower.endsWith(".cbz"); + boolean isCbr = nameLower.endsWith(".cbr"); + boolean isCb7 = nameLower.endsWith(".cb7"); + + if (!isCbz && !isCbr && !isCb7) { + log.warn("Unsupported file type for CBX writer: {}", file.getName()); + return; + } + + // Build (or load and update) ComicInfo.xml as a Document + Document doc; + if (isCbz) { + try (ZipFile zipFile = new ZipFile(file)) { + ZipEntry existing = findComicInfoEntry(zipFile); + if (existing != null) { + try (InputStream is = zipFile.getInputStream(existing)) { + doc = buildSecureDocument(is); + } + } else { + doc = newEmptyComicInfo(); + } + } + } else if (isCb7) { + try (SevenZFile sevenZ = new SevenZFile(file)) { + SevenZArchiveEntry existing = null; + for (SevenZArchiveEntry e : sevenZ.getEntries()) { + if (e != null && !e.isDirectory() && isComicInfoName(e.getName())) { + existing = e; break; + } + } + if (existing != null) { + try (InputStream is = sevenZ.getInputStream(existing)) { + doc = buildSecureDocument(is); + } + } else { + doc = newEmptyComicInfo(); + } + } + } else { // CBR + try (Archive archive = new Archive(file)) { + FileHeader existing = findComicInfoHeader(archive); + if (existing != null) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + archive.extractFile(existing, baos); + try (InputStream is = new java.io.ByteArrayInputStream(baos.toByteArray())) { + doc = buildSecureDocument(is); + } + } + } else { + doc = newEmptyComicInfo(); + } + } + } + + // Apply metadata to the Document + Element root = doc.getDocumentElement(); + MetadataCopyHelper helper = new MetadataCopyHelper(metadata); + helper.copyTitle(restoreMode, clearFlags != null && clearFlags.isTitle(), val -> setElement(doc, root, "Title", val)); + helper.copyDescription(restoreMode, clearFlags != null && clearFlags.isDescription(), val -> { + setElement(doc, root, "Summary", val); + removeElement(root, "Description"); + }); + helper.copyPublisher(restoreMode, clearFlags != null && clearFlags.isPublisher(), val -> setElement(doc, root, "Publisher", val)); + helper.copySeriesName(restoreMode, clearFlags != null && clearFlags.isSeriesName(), val -> setElement(doc, root, "Series", val)); + helper.copySeriesNumber(restoreMode, clearFlags != null && clearFlags.isSeriesNumber(), val -> setElement(doc, root, "Number", formatFloat(val))); + helper.copySeriesTotal(restoreMode, clearFlags != null && clearFlags.isSeriesTotal(), val -> setElement(doc, root, "Count", val != null ? val.toString() : null)); + helper.copyPublishedDate(restoreMode, clearFlags != null && clearFlags.isPublishedDate(), date -> setDateElements(doc, root, date)); + helper.copyPageCount(restoreMode, clearFlags != null && clearFlags.isPageCount(), val -> setElement(doc, root, "PageCount", val != null ? val.toString() : null)); + helper.copyLanguage(restoreMode, clearFlags != null && clearFlags.isLanguage(), val -> setElement(doc, root, "LanguageISO", val)); + helper.copyAuthors(restoreMode, clearFlags != null && clearFlags.isAuthors(), set -> { + setElement(doc, root, "Writer", join(set)); + removeElement(root, "Penciller"); + removeElement(root, "Inker"); + removeElement(root, "Colorist"); + removeElement(root, "Letterer"); + removeElement(root, "CoverArtist"); + }); + helper.copyCategories(restoreMode, clearFlags != null && clearFlags.isCategories(), set -> { + setElement(doc, root, "Genre", join(set)); + removeElement(root, "Tags"); + }); + + // Serialize ComicInfo.xml + Transformer transformer = TransformerFactory.newInstance().newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); + ByteArrayOutputStream xmlBaos = new ByteArrayOutputStream(); + transformer.transform(new DOMSource(doc), new StreamResult(xmlBaos)); + byte[] xmlBytes = xmlBaos.toByteArray(); + + // Repack depending on container type; always write to a temp target then atomic move + if (isCbz) { + Path temp = Files.createTempFile("cbx_edit", ".cbz"); + repackZipReplacingComicInfo(file.toPath(), temp, xmlBytes); + atomicReplace(temp, file.toPath()); + writeSucceeded = true; + return; + } + + if (isCb7) { + // Convert to CBZ with updated ComicInfo.xml + Path tempZip = Files.createTempFile("cbx_edit", ".cbz"); + try (SevenZFile sevenZ = new SevenZFile(file); + ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(tempZip))) { + for (SevenZArchiveEntry e : sevenZ.getEntries()) { + if (e.isDirectory()) continue; + String entryName = e.getName(); + if (isComicInfoName(entryName)) continue; // skip old + if (!isSafeEntryName(entryName)) { + log.warn("Skipping unsafe 7z entry name: {}", entryName); + continue; + } + zos.putNextEntry(new ZipEntry(entryName)); + try (InputStream is = sevenZ.getInputStream(e)) { + if (is != null) is.transferTo(zos); + } + zos.closeEntry(); + } + zos.putNextEntry(new ZipEntry("ComicInfo.xml")); + zos.write(xmlBytes); + zos.closeEntry(); + } + Path target = file.toPath().resolveSibling(stripExtension(file.getName()) + ".cbz"); + atomicReplace(tempZip, target); + try { Files.deleteIfExists(file.toPath()); } catch (Exception ignored) {} + writeSucceeded = true; + return; + } + + // CBR path + String rarBin = System.getenv().getOrDefault("BOOKLORE_RAR_BIN", "rar"); + boolean rarAvailable = isRarAvailable(rarBin); + + if (rarAvailable) { + Path tempDir = Files.createTempDirectory("cbx_rar_"); + try { + // Extract entire RAR into a temp directory + try (Archive archive = new Archive(file)) { + for (FileHeader fh : archive.getFileHeaders()) { + String name = fh.getFileName(); + if (name == null || name.isBlank()) continue; + if (!isSafeEntryName(name)) { + log.warn("Skipping unsafe RAR entry name: {}", name); + continue; + } + Path out = tempDir.resolve(name).normalize(); + if (!out.startsWith(tempDir)) { + log.warn("Skipping traversal entry outside tempDir: {}", name); + continue; + } + if (fh.isDirectory()) { + Files.createDirectories(out); + } else { + Files.createDirectories(out.getParent()); + try (OutputStream os = Files.newOutputStream(out, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { + archive.extractFile(fh, os); + } + } + } + } + + // Write/replace ComicInfo.xml in extracted tree root + Path comicInfo = tempDir.resolve("ComicInfo.xml"); + Files.write(comicInfo, xmlBytes); + + // Rebuild RAR in-place (replace original file) + Path targetRar = file.toPath().toAbsolutePath().normalize(); + String rarExec = isSafeExecutable(rarBin) ? rarBin : "rar"; // prefer validated path, then PATH lookup + ProcessBuilder pb = new ProcessBuilder(rarExec, "a", "-idq", "-ep1", "-ma5", targetRar.toString(), "."); + pb.directory(tempDir.toFile()); + Process p = pb.start(); + int code = p.waitFor(); + if (code == 0) { + writeSucceeded = true; + return; + } else { + log.warn("RAR creation failed with exit code {}. Falling back to CBZ conversion for {}", code, file.getName()); + } + } finally { + try { // cleanup temp dir + java.nio.file.Files.walk(tempDir) + .sorted(java.util.Comparator.reverseOrder()) + .forEach(path -> { try { Files.deleteIfExists(path); } catch (Exception ignore) {} }); + } catch (Exception ignore) {} + } + } else { + log.warn("`rar` binary not found. Falling back to CBZ conversion for {}", file.getName()); + } + + // Fallback: convert the CBR to CBZ containing updated ComicInfo.xml + Path tempZip = Files.createTempFile("cbx_edit", ".cbz"); + try (Archive archive = new Archive(file); + ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(tempZip))) { + for (FileHeader fh : archive.getFileHeaders()) { + if (fh.isDirectory()) continue; + String entryName = fh.getFileName(); + if (isComicInfoName(entryName)) continue; // skip old + if (!isSafeEntryName(entryName)) { + log.warn("Skipping unsafe RAR entry name: {}", entryName); + continue; + } + zos.putNextEntry(new ZipEntry(entryName)); + archive.extractFile(fh, zos); + zos.closeEntry(); + } + zos.putNextEntry(new ZipEntry("ComicInfo.xml")); + zos.write(xmlBytes); + zos.closeEntry(); + } + Path target = file.toPath().resolveSibling(stripExtension(file.getName()) + ".cbz"); + atomicReplace(tempZip, target); + try { Files.deleteIfExists(file.toPath()); } catch (Exception ignored) {} + writeSucceeded = true; + } catch (Exception e) { + // Attempt to restore the original file from backup + try { + if (backup != null) { + Files.copy(backup, file.toPath(), StandardCopyOption.REPLACE_EXISTING); + log.info("Restored original file from backup after failure: {}", file.getAbsolutePath()); + } + } catch (Exception restoreEx) { + log.warn("Failed to restore original file from backup: {} -> {}", backup, file.getAbsolutePath(), restoreEx); + } + log.warn("Failed to write metadata for {}: {}", file.getName(), e.getMessage(), e); + } finally { + if (writeSucceeded && backup != null) { + try { Files.deleteIfExists(backup); } catch (Exception ignore) {} + } + } + } + + // ----------------------- helpers ----------------------- + + private ZipEntry findComicInfoEntry(ZipFile zipFile) { + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + String n = entry.getName(); + if (isComicInfoName(n)) return entry; + } + return null; + } + + private FileHeader findComicInfoHeader(Archive archive) { + for (FileHeader fh : archive.getFileHeaders()) { + String name = fh.getFileName(); + if (name != null && isComicInfoName(name)) return fh; + } + return null; + } + + private Document buildSecureDocument(InputStream is) throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + factory.setExpandEntityReferences(false); + DocumentBuilder builder = factory.newDocumentBuilder(); + return builder.parse(is); + } + + private Document newEmptyComicInfo() throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document doc = builder.newDocument(); + doc.appendChild(doc.createElement("ComicInfo")); + return doc; + } + + private void setElement(Document doc, Element root, String tag, String value) { + removeElement(root, tag); + if (value != null && !value.isBlank()) { + Element el = doc.createElement(tag); + el.setTextContent(value); + root.appendChild(el); + } + } + + private void removeElement(Element root, String tag) { + NodeList nodes = root.getElementsByTagName(tag); + for (int i = nodes.getLength() - 1; i >= 0; i--) { + root.removeChild(nodes.item(i)); + } + } + + private void setDateElements(Document doc, Element root, LocalDate date) { + if (date == null) { + removeElement(root, "Year"); + removeElement(root, "Month"); + removeElement(root, "Day"); + return; + } + setElement(doc, root, "Year", Integer.toString(date.getYear())); + setElement(doc, root, "Month", Integer.toString(date.getMonthValue())); + setElement(doc, root, "Day", Integer.toString(date.getDayOfMonth())); + } + + private String join(Set set) { + return (set == null || set.isEmpty()) ? null : String.join(", ", set); + } + + private String formatFloat(Float val) { + if (val == null) return null; + if (val % 1 == 0) return Integer.toString(val.intValue()); + return val.toString(); + } + + private static boolean isComicInfoName(String name) { + if (name == null) return false; + String n = name.replace('\\', '/'); + if (n.endsWith("/")) return false; + String lower = n.toLowerCase(Locale.ROOT); + return lower.equals("comicinfo.xml") || lower.endsWith("/comicinfo.xml"); + } + + private static boolean isSafeEntryName(String name) { + if (name == null || name.isBlank()) return false; + String n = name.replace('\\', '/'); + if (n.startsWith("/")) return false; // absolute + if (n.contains("../")) return false; // traversal + if (n.contains("\0")) return false; // NUL + return true; + } + + private void repackZipReplacingComicInfo(Path sourceZip, Path targetZip, byte[] xmlBytes) throws Exception { + try (ZipFile zipFile = new ZipFile(sourceZip.toFile()); + ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(targetZip))) { + ZipEntry existing = findComicInfoEntry(zipFile); + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + String entryName = entry.getName(); + if (existing != null && entryName.equals(existing.getName())) { + continue; // skip old ComicInfo.xml + } + if (!isSafeEntryName(entryName)) { + log.warn("Skipping unsafe ZIP entry name: {}", entryName); + continue; + } + zos.putNextEntry(new ZipEntry(entryName)); + try (InputStream is = zipFile.getInputStream(entry)) { + is.transferTo(zos); + } + zos.closeEntry(); + } + String entryName = (existing != null ? existing.getName() : "ComicInfo.xml"); + zos.putNextEntry(new ZipEntry(entryName)); + zos.write(xmlBytes); + zos.closeEntry(); + } + } + + private static void atomicReplace(Path temp, Path target) throws Exception { + try { + Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (Exception e) { + // Fallback if filesystem doesn't support ATOMIC_MOVE + Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + private boolean isRarAvailable(String rarBin) { + try { + String exec = isSafeExecutable(rarBin) ? rarBin : "rar"; + Process check = new ProcessBuilder(exec, "--help").redirectErrorStream(true).start(); + int exitCode = check.waitFor(); + return (exitCode == 0); + } catch (Exception ex) { + log.warn("RAR binary check failed: {}", ex.getMessage()); + return false; + } + } + + /** + * Returns true if the provided executable reference is a simple name or sanitized absolute/relative path. + * No spaces or shell meta chars; passed as argv to ProcessBuilder (no shell). + */ + private boolean isSafeExecutable(String exec) { + if (exec == null || exec.isBlank()) return false; + // allow word chars, dot, slash, backslash, dash and underscore (no spaces or shell metas) + return exec.matches("^[\\w./\\\\-]+$"); + } + + private static String stripExtension(String filename) { + int i = filename.lastIndexOf('.'); + if (i > 0) return filename.substring(0, i); + return filename; + } + + @Override + public BookFileType getSupportedBookType() { + return BookFileType.CBX; + } +} \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/extractor/CbxMetadataExtractorTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/extractor/CbxMetadataExtractorTest.java new file mode 100644 index 000000000..05e5cda22 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/extractor/CbxMetadataExtractorTest.java @@ -0,0 +1,153 @@ +package com.adityachandel.booklore.service.metadata.extractor; + +import com.adityachandel.booklore.model.dto.BookMetadata; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.*; + +class CbxMetadataExtractorTest { + + private CbxMetadataExtractor extractor; + private Path tempDir; + + @BeforeEach + void setUp() throws IOException { + extractor = new CbxMetadataExtractor(); + tempDir = Files.createTempDirectory("cbx_test_"); + } + + @AfterEach + void tearDown() throws IOException { + if (tempDir != null) { + // best-effort cleanup + Files.walk(tempDir) + .sorted((a, b) -> b.compareTo(a)) + .forEach(p -> { try { Files.deleteIfExists(p); } catch (Exception ignore) {} }); + } + } + + @Test + void extractMetadata_fromCbz_withComicInfo_populatesFields() throws Exception { + String xml = "" + + "" + + " My Comic" + + " A short summary" + + " Indie" + + " Series X" + + " 2.5" + + " 12" + + " 2020714" + + " 42" + + " en" + + " Alice" + + " Bob" + + " action;adventure" + + ""; + + File cbz = createCbz("with_meta.cbz", new LinkedHashMap<>() {{ + put("ComicInfo.xml", xml.getBytes(StandardCharsets.UTF_8)); + put("page1.jpg", new byte[]{1,2,3}); + }}); + + BookMetadata md = extractor.extractMetadata(cbz); + assertEquals("My Comic", md.getTitle()); + assertEquals("A short summary", md.getDescription()); + assertEquals("Indie", md.getPublisher()); + assertEquals("Series X", md.getSeriesName()); + assertEquals(2.5f, md.getSeriesNumber()); + assertEquals(Integer.valueOf(12), md.getSeriesTotal()); + assertEquals(LocalDate.of(2020,7,14), md.getPublishedDate()); + assertEquals(Integer.valueOf(42), md.getPageCount()); + assertEquals("en", md.getLanguage()); + assertTrue(md.getAuthors().contains("Alice")); + assertTrue(md.getAuthors().contains("Bob")); + assertTrue(md.getCategories().contains("action")); + assertTrue(md.getCategories().contains("adventure")); + } + + @Test + void extractCover_fromCbz_usesComicInfoImageFile() throws Exception { + String xml = "" + + "" + + " " + + " " + + " " + + ""; + + byte[] img1 = new byte[]{11}; + byte[] img2 = new byte[]{22, 22}; // expect this one + byte[] img3 = new byte[]{33, 33, 33}; + + File cbz = createCbz("with_cover.cbz", new LinkedHashMap<>() {{ + put("ComicInfo.xml", xml.getBytes(StandardCharsets.UTF_8)); + put("images/001.jpg", img1); + put("images/002.jpg", img2); + put("images/003.jpg", img3); + }}); + + byte[] cover = extractor.extractCover(cbz); + assertArrayEquals(img2, cover); + } + + @Test + void extractCover_fromCbz_fallbackAlphabeticalFirst() throws Exception { + // No ComicInfo.xml, images intentionally added in unsorted order + byte[] aPng = new byte[]{1,1}; // alphabetically first (A.png) + byte[] bJpg = new byte[]{2}; + byte[] cJpeg = new byte[]{3,3,3}; + + File cbz = createCbz("fallback.cbz", new LinkedHashMap<>() {{ + put("z/pageC.jpeg", cJpeg); + put("A.png", aPng); // should be chosen + put("b.jpg", bJpg); + }}); + + byte[] cover = extractor.extractCover(cbz); + assertArrayEquals(aPng, cover); + } + + @Test + void extractMetadata_nonArchive_fallbackTitle() throws Exception { + Path txt = tempDir.resolve("Some Book Title.txt"); + Files.write(txt, "hello".getBytes(StandardCharsets.UTF_8)); + BookMetadata md = extractor.extractMetadata(txt.toFile()); + assertEquals("Some Book Title", md.getTitle()); + } + + // ---------- helpers ---------- + + private File createCbz(String name, Map entries) throws IOException { + Path out = tempDir.resolve(name); + try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(out.toFile()))) { + for (Map.Entry e : entries.entrySet()) { + String entryName = e.getKey(); + byte[] data = e.getValue(); + ZipEntry ze = new ZipEntry(entryName); + // set a fixed time to avoid platform-dependent headers + ze.setTime(0L); + zos.putNextEntry(ze); + try (InputStream is = new ByteArrayInputStream(data)) { + is.transferTo(zos); + } + zos.closeEntry(); + } + } + return out.toFile(); + } +} \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/writer/CbxMetadataWriterTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/writer/CbxMetadataWriterTest.java new file mode 100644 index 000000000..ae0a61f0f --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/metadata/writer/CbxMetadataWriterTest.java @@ -0,0 +1,190 @@ +package com.adityachandel.booklore.service.metadata.writer; + +import com.adityachandel.booklore.model.MetadataClearFlags; +import com.adityachandel.booklore.model.entity.BookMetadataEntity; +import com.adityachandel.booklore.model.enums.BookFileType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.*; + +class CbxMetadataWriterTest { + + private CbxMetadataWriter writer; + private Path tempDir; + + @BeforeEach + void setup() throws Exception { + writer = new CbxMetadataWriter(); + tempDir = Files.createTempDirectory("cbx_writer_test_"); + } + + @AfterEach + void cleanup() throws Exception { + if (tempDir != null) { + Files.walk(tempDir) + .sorted((a, b) -> b.compareTo(a)) + .forEach(p -> { try { Files.deleteIfExists(p); } catch (Exception ignore) {} }); + } + } + + @Test + void getSupportedBookType_isCbx() { + assertEquals(BookFileType.CBX, writer.getSupportedBookType()); + } + + @Test + void writeMetadataToFile_cbz_updatesOrCreatesComicInfo_andPreservesOtherFiles() throws Exception { + // Create a CBZ without ComicInfo.xml and with a couple of images + File cbz = createCbz(tempDir.resolve("sample.cbz"), new String[]{ + "images/002.jpg", "images/001.jpg" + }); + + // Prepare metadata + BookMetadataEntity meta = new BookMetadataEntity(); + meta.setTitle("My Comic"); + meta.setDescription("Short desc"); + meta.setPublisher("Indie"); + meta.setSeriesName("Series X"); + meta.setSeriesNumber(2.5f); + meta.setSeriesTotal(12); + meta.setPublishedDate(LocalDate.of(2020,7,14)); + meta.setPageCount(42); + meta.setLanguage("en"); + Set authors = new HashSet<>(); + authors.add("Alice"); + authors.add("Bob"); + meta.setAuthors(authors); + Set cats = new HashSet<>(); + cats.add("action"); + cats.add("adventure"); + meta.setCategories(cats); + + // Execute + writer.writeMetadataToFile(cbz, meta, null, false, MetadataClearFlags.builder().build()); + + // Assert ComicInfo.xml exists and contains our fields + try (ZipFile zip = new ZipFile(cbz)) { + ZipEntry ci = zip.getEntry("ComicInfo.xml"); + assertNotNull(ci, "ComicInfo.xml should be present after write"); + + Document doc = parseXml(zip.getInputStream(ci)); + String title = text(doc, "Title"); + String summary = text(doc, "Summary"); + String publisher = text(doc, "Publisher"); + String series = text(doc, "Series"); + String number = text(doc, "Number"); + String count = text(doc, "Count"); + String year = text(doc, "Year"); + String month = text(doc, "Month"); + String day = text(doc, "Day"); + String pageCount = text(doc, "PageCount"); + String lang = text(doc, "LanguageISO"); + String writerEl = text(doc, "Writer"); + String genre = text(doc, "Genre"); + + assertEquals("My Comic", title); + assertEquals("Short desc", summary); + assertEquals("Indie", publisher); + assertEquals("Series X", series); + assertEquals("2.5", number); + assertEquals("12", count); + assertEquals("2020", year); + assertEquals("7", month); + assertEquals("14", day); + assertEquals("42", pageCount); + assertEquals("en", lang); + assertTrue(writerEl.contains("Alice")); + assertTrue(writerEl.contains("Bob")); + assertTrue(genre.toLowerCase().contains("action")); + assertTrue(genre.toLowerCase().contains("adventure")); + + // Ensure original image entries are preserved + assertNotNull(zip.getEntry("images/001.jpg")); + assertNotNull(zip.getEntry("images/002.jpg")); + } + } + + @Test + void writeMetadataToFile_cbz_updatesExistingComicInfo() throws Exception { + // Create a CBZ *with* an existing ComicInfo.xml + Path out = tempDir.resolve("with_meta.cbz"); + String xml = "\n" + + " Old Title\n" + + " Old Summary\n" + + ""; + try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(out.toFile()))) { + put(zos, "ComicInfo.xml", xml.getBytes(StandardCharsets.UTF_8)); + put(zos, "a.jpg", new byte[]{1}); + } + + BookMetadataEntity meta = new BookMetadataEntity(); + meta.setTitle("New Title"); + meta.setDescription("New Summary"); + + writer.writeMetadataToFile(out.toFile(), meta, null, false, MetadataClearFlags.builder().build()); + + try (ZipFile zip = new ZipFile(out.toFile())) { + ZipEntry ci = zip.getEntry("ComicInfo.xml"); + Document doc = parseXml(zip.getInputStream(ci)); + assertEquals("New Title", text(doc, "Title")); + assertEquals("New Summary", text(doc, "Summary")); + // a.jpg should still exist + assertNotNull(zip.getEntry("a.jpg")); + } + } + + // ------------- helpers ------------- + + private static File createCbz(Path path, String[] imageNames) throws Exception { + try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(path.toFile()))) { + for (String name : imageNames) { + put(zos, name, new byte[]{1,2,3}); + } + } + return path.toFile(); + } + + private static void put(ZipOutputStream zos, String name, byte[] data) throws Exception { + ZipEntry ze = new ZipEntry(name); + ze.setTime(0L); + zos.putNextEntry(ze); + zos.write(data); + zos.closeEntry(); + } + + private static Document parseXml(InputStream is) throws Exception { + DocumentBuilderFactory f = DocumentBuilderFactory.newInstance(); + f.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + f.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + f.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + DocumentBuilder b = f.newDocumentBuilder(); + return b.parse(is); + } + + private static String text(Document doc, String tag) { + var list = doc.getElementsByTagName(tag); + if (list.getLength() == 0) return null; + return list.item(0).getTextContent(); + } +} \ No newline at end of file diff --git a/booklore-ui/src/app/book/service/book.service.ts b/booklore-ui/src/app/book/service/book.service.ts index ba6a32e0c..0230872f5 100644 --- a/booklore-ui/src/app/book/service/book.service.ts +++ b/booklore-ui/src/app/book/service/book.service.ts @@ -510,6 +510,10 @@ export class BookService { ); } + getComicInfoMetadata(bookId: number): Observable { + return this.http.get(`${this.url}/${bookId}/cbx/metadata/comicinfo`); + } + autoRefreshMetadata(metadataRefreshRequest: MetadataRefreshRequest): Observable { return this.http.put(`${this.url}/metadata/refresh`, metadataRefreshRequest).pipe( map(() => { diff --git a/booklore-ui/src/app/core/model/app-settings.model.ts b/booklore-ui/src/app/core/model/app-settings.model.ts index 809626925..6b1378189 100644 --- a/booklore-ui/src/app/core/model/app-settings.model.ts +++ b/booklore-ui/src/app/core/model/app-settings.model.ts @@ -82,6 +82,7 @@ export interface Douban { export interface MetadataPersistenceSettings { saveToOriginalFile: boolean; + convertCbrCb7ToCbz: boolean; backupMetadata: boolean; backupCover: boolean; } diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.html b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.html index 1a8f96f24..96b237146 100644 --- a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.html +++ b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-editor/metadata-editor.component.html @@ -452,6 +452,11 @@ tooltipPosition="top"> + @if (book.bookType === 'CBX') { + + + } + @if (book.bookType === 'PDF' || book.bookType === 'EPUB') { @@ -486,6 +491,17 @@ tooltipPosition="top"> + @if (book.bookType === 'CBX') { + + + } + @if (book.bookType === 'PDF' || book.bookType === 'EPUB') { ; @Output() nextBookClicked = new EventEmitter(); @Output() previousBookClicked = new EventEmitter(); @@ -83,47 +104,47 @@ export class MetadataEditorComponent implements OnInit { filterCategories(event: { query: string }) { const query = event.query.toLowerCase(); - this.filteredCategories = this.allCategories.filter(cat => + this.filteredCategories = this.allCategories.filter((cat) => cat.toLowerCase().includes(query) ); } filterAuthors(event: { query: string }) { const query = event.query.toLowerCase(); - this.filteredAuthors = this.allAuthors.filter(cat => + this.filteredAuthors = this.allAuthors.filter((cat) => cat.toLowerCase().includes(query) ); } constructor() { this.metadataForm = new FormGroup({ - title: new FormControl(''), - subtitle: new FormControl(''), - authors: new FormControl(''), - categories: new FormControl(''), - publisher: new FormControl(''), - publishedDate: new FormControl(''), - isbn10: new FormControl(''), - isbn13: new FormControl(''), - description: new FormControl(''), - pageCount: new FormControl(''), - language: new FormControl(''), - asin: new FormControl(''), - personalRating: new FormControl(''), - amazonRating: new FormControl(''), - amazonReviewCount: new FormControl(''), - goodreadsId: new FormControl(''), - comicvineId: new FormControl(''), - goodreadsRating: new FormControl(''), - goodreadsReviewCount: new FormControl(''), - hardcoverId: new FormControl(''), - hardcoverRating: new FormControl(''), - hardcoverReviewCount: new FormControl(''), - googleId: new FormControl(''), - seriesName: new FormControl(''), - seriesNumber: new FormControl(''), - seriesTotal: new FormControl(''), - thumbnailUrl: new FormControl(''), + title: new FormControl(""), + subtitle: new FormControl(""), + authors: new FormControl(""), + categories: new FormControl(""), + publisher: new FormControl(""), + publishedDate: new FormControl(""), + isbn10: new FormControl(""), + isbn13: new FormControl(""), + description: new FormControl(""), + pageCount: new FormControl(""), + language: new FormControl(""), + asin: new FormControl(""), + personalRating: new FormControl(""), + amazonRating: new FormControl(""), + amazonReviewCount: new FormControl(""), + goodreadsId: new FormControl(""), + comicvineId: new FormControl(""), + goodreadsRating: new FormControl(""), + goodreadsReviewCount: new FormControl(""), + hardcoverId: new FormControl(""), + hardcoverRating: new FormControl(""), + hardcoverReviewCount: new FormControl(""), + googleId: new FormControl(""), + seriesName: new FormControl(""), + seriesNumber: new FormControl(""), + seriesTotal: new FormControl(""), + thumbnailUrl: new FormControl(""), titleLocked: new FormControl(false), subtitleLocked: new FormControl(false), @@ -140,7 +161,7 @@ export class MetadataEditorComponent implements OnInit { personalRatingLocked: new FormControl(false), amazonRatingLocked: new FormControl(false), amazonReviewCountLocked: new FormControl(false), - goodreadsIdLocked: new FormControl(''), + goodreadsIdLocked: new FormControl(""), comicvineIdLocked: new FormControl(false), goodreadsRatingLocked: new FormControl(false), goodreadsReviewCountLocked: new FormControl(false), @@ -156,32 +177,32 @@ export class MetadataEditorComponent implements OnInit { } ngOnInit(): void { - this.book$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(book => { - const metadata = book?.metadata; - if (!metadata) return; - this.currentBookId = metadata.bookId; - if (this.refreshingBookIds.has(book.id)) { - this.refreshingBookIds.delete(book.id); - this.isAutoFetching = false; - } - this.originalMetadata = structuredClone(metadata); - this.populateFormFromMetadata(metadata); - }); + this.book$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((book) => { + const metadata = book?.metadata; + if (!metadata) return; + this.currentBookId = metadata.bookId; + if (this.refreshingBookIds.has(book.id)) { + this.refreshingBookIds.delete(book.id); + this.isAutoFetching = false; + } + this.originalMetadata = structuredClone(metadata); + this.populateFormFromMetadata(metadata); + }); this.bookService.bookState$ .pipe( - filter(bookState => bookState.loaded), + filter((bookState) => bookState.loaded), take(1) ) - .subscribe(bookState => { + .subscribe((bookState) => { const authors = new Set(); const categories = new Set(); - (bookState.books ?? []).forEach(book => { - book.metadata?.authors?.forEach(author => authors.add(author)); - book.metadata?.categories?.forEach(category => categories.add(category)); + (bookState.books ?? []).forEach((book) => { + book.metadata?.authors?.forEach((author) => authors.add(author)); + book.metadata?.categories?.forEach((category) => + categories.add(category) + ); }); this.allAuthors = Array.from(authors); @@ -249,36 +270,36 @@ export class MetadataEditorComponent implements OnInit { }); const lockableFields: { key: keyof BookMetadata; control: string }[] = [ - {key: 'titleLocked', control: 'title'}, - {key: 'subtitleLocked', control: 'subtitle'}, - {key: 'authorsLocked', control: 'authors'}, - {key: 'categoriesLocked', control: 'categories'}, - {key: 'publisherLocked', control: 'publisher'}, - {key: 'publishedDateLocked', control: 'publishedDate'}, - {key: 'languageLocked', control: 'language'}, - {key: 'isbn10Locked', control: 'isbn10'}, - {key: 'isbn13Locked', control: 'isbn13'}, - {key: 'asinLocked', control: 'asin'}, - {key: 'amazonReviewCountLocked', control: 'amazonReviewCount'}, - {key: 'amazonRatingLocked', control: 'amazonRating'}, - {key: 'personalRatingLocked', control: 'personalRating'}, - {key: 'goodreadsIdLocked', control: 'goodreadsId'}, - {key: 'comicvineIdLocked', control: 'comicvineId'}, - {key: 'goodreadsReviewCountLocked', control: 'goodreadsReviewCount'}, - {key: 'goodreadsRatingLocked', control: 'goodreadsRating'}, - {key: 'hardcoverIdLocked', control: 'hardcoverId'}, - {key: 'hardcoverReviewCountLocked', control: 'hardcoverReviewCount'}, - {key: 'hardcoverRatingLocked', control: 'hardcoverRating'}, - {key: 'googleIdLocked', control: 'googleId'}, - {key: 'pageCountLocked', control: 'pageCount'}, - {key: 'descriptionLocked', control: 'description'}, - {key: 'seriesNameLocked', control: 'seriesName'}, - {key: 'seriesNumberLocked', control: 'seriesNumber'}, - {key: 'seriesTotalLocked', control: 'seriesTotal'}, - {key: 'coverLocked', control: 'thumbnailUrl'}, + { key: "titleLocked", control: "title" }, + { key: "subtitleLocked", control: "subtitle" }, + { key: "authorsLocked", control: "authors" }, + { key: "categoriesLocked", control: "categories" }, + { key: "publisherLocked", control: "publisher" }, + { key: "publishedDateLocked", control: "publishedDate" }, + { key: "languageLocked", control: "language" }, + { key: "isbn10Locked", control: "isbn10" }, + { key: "isbn13Locked", control: "isbn13" }, + { key: "asinLocked", control: "asin" }, + { key: "amazonReviewCountLocked", control: "amazonReviewCount" }, + { key: "amazonRatingLocked", control: "amazonRating" }, + { key: "personalRatingLocked", control: "personalRating" }, + { key: "goodreadsIdLocked", control: "goodreadsId" }, + { key: "comicvineIdLocked", control: "comicvineId" }, + { key: "goodreadsReviewCountLocked", control: "goodreadsReviewCount" }, + { key: "goodreadsRatingLocked", control: "goodreadsRating" }, + { key: "hardcoverIdLocked", control: "hardcoverId" }, + { key: "hardcoverReviewCountLocked", control: "hardcoverReviewCount" }, + { key: "hardcoverRatingLocked", control: "hardcoverRating" }, + { key: "googleIdLocked", control: "googleId" }, + { key: "pageCountLocked", control: "pageCount" }, + { key: "descriptionLocked", control: "description" }, + { key: "seriesNameLocked", control: "seriesName" }, + { key: "seriesNumberLocked", control: "seriesNumber" }, + { key: "seriesTotalLocked", control: "seriesTotal" }, + { key: "coverLocked", control: "thumbnailUrl" }, ]; - for (const {key, control} of lockableFields) { + for (const { key, control } of lockableFields) { const isLocked = metadata[key] === true; const formControl = this.metadataForm.get(control); if (formControl) { @@ -292,11 +313,11 @@ export class MetadataEditorComponent implements OnInit { if (!values.includes(event.value)) { this.metadataForm.get(fieldName)?.setValue([...values, event.value]); } - (event.originalEvent.target as HTMLInputElement).value = ''; + (event.originalEvent.target as HTMLInputElement).value = ""; } onAutoCompleteKeyUp(fieldName: string, event: KeyboardEvent) { - if (event.key === 'Enter') { + if (event.key === "Enter") { const input = event.target as HTMLInputElement; const value = input.value?.trim(); if (value) { @@ -304,36 +325,46 @@ export class MetadataEditorComponent implements OnInit { if (!values.includes(value)) { this.metadataForm.get(fieldName)?.setValue([...values, value]); } - input.value = ''; + input.value = ""; } } } onSave(): void { this.isSaving = true; - this.bookService.updateBookMetadata(this.currentBookId, this.buildMetadataWrapper(undefined), false).subscribe({ - next: (response) => { - this.isSaving = false; - this.messageService.add({severity: 'info', summary: 'Success', detail: 'Book metadata updated'}); - }, - error: (err) => { - this.isSaving = false; - this.messageService.add({ - severity: 'error', - summary: 'Error', - detail: err?.error?.message || 'Failed to update book metadata' - }); - } - }); + this.bookService + .updateBookMetadata( + this.currentBookId, + this.buildMetadataWrapper(undefined), + false + ) + .subscribe({ + next: (response) => { + this.isSaving = false; + this.messageService.add({ + severity: "info", + summary: "Success", + detail: "Book metadata updated", + }); + }, + error: (err) => { + this.isSaving = false; + this.messageService.add({ + severity: "error", + summary: "Error", + detail: err?.error?.message || "Failed to update book metadata", + }); + }, + }); } toggleLock(field: string): void { - if (field === 'thumbnailUrl') { - field = 'cover' + if (field === "thumbnailUrl") { + field = "cover"; } - const isLocked = this.metadataForm.get(field + 'Locked')?.value; + const isLocked = this.metadataForm.get(field + "Locked")?.value; const updatedLockedState = !isLocked; - this.metadataForm.get(field + 'Locked')?.setValue(updatedLockedState); + this.metadataForm.get(field + "Locked")?.setValue(updatedLockedState); if (updatedLockedState) { this.metadataForm.get(field)?.disable(); } else { @@ -344,9 +375,9 @@ export class MetadataEditorComponent implements OnInit { lockAll(): void { Object.keys(this.metadataForm.controls).forEach((key) => { - if (key.endsWith('Locked')) { + if (key.endsWith("Locked")) { this.metadataForm.get(key)?.setValue(true); - const fieldName = key.replace('Locked', ''); + const fieldName = key.replace("Locked", ""); this.metadataForm.get(fieldName)?.disable(); } }); @@ -355,9 +386,9 @@ export class MetadataEditorComponent implements OnInit { unlockAll(): void { Object.keys(this.metadataForm.controls).forEach((key) => { - if (key.endsWith('Locked')) { + if (key.endsWith("Locked")) { this.metadataForm.get(key)?.setValue(false); - const fieldName = key.replace('Locked', ''); + const fieldName = key.replace("Locked", ""); this.metadataForm.get(fieldName)?.enable(); } }); @@ -365,74 +396,78 @@ export class MetadataEditorComponent implements OnInit { } quillDisabled(): boolean { - return this.metadataForm.get('descriptionLocked')?.value === true; + return this.metadataForm.get("descriptionLocked")?.value === true; } - private buildMetadataWrapper(shouldLockAllFields?: boolean): MetadataUpdateWrapper { + private buildMetadataWrapper( + shouldLockAllFields?: boolean + ): MetadataUpdateWrapper { const form = this.metadataForm; const metadata: BookMetadata = { bookId: this.currentBookId, - title: form.get('title')?.value, - subtitle: form.get('subtitle')?.value, - authors: form.get('authors')?.value ?? [], - categories: form.get('categories')?.value ?? [], - publisher: form.get('publisher')?.value, - publishedDate: form.get('publishedDate')?.value, - isbn10: form.get('isbn10')?.value, - isbn13: form.get('isbn13')?.value, - description: form.get('description')?.value, - pageCount: form.get('pageCount')?.value, - rating: form.get('rating')?.value, - reviewCount: form.get('reviewCount')?.value, - asin: form.get('asin')?.value, - personalRating: form.get('personalRating')?.value, - amazonRating: form.get('amazonRating')?.value, - amazonReviewCount: form.get('amazonReviewCount')?.value, - goodreadsId: form.get('goodreadsId')?.value, - comicvineId: form.get('comicvineId')?.value, - goodreadsRating: form.get('goodreadsRating')?.value, - goodreadsReviewCount: form.get('goodreadsReviewCount')?.value, - hardcoverId: form.get('hardcoverId')?.value, - hardcoverRating: form.get('hardcoverRating')?.value, - hardcoverReviewCount: form.get('hardcoverReviewCount')?.value, - googleId: form.get('googleId')?.value, - language: form.get('language')?.value, - seriesName: form.get('seriesName')?.value, - seriesNumber: form.get('seriesNumber')?.value, - seriesTotal: form.get('seriesTotal')?.value, - thumbnailUrl: form.get('thumbnailUrl')?.value, + title: form.get("title")?.value, + subtitle: form.get("subtitle")?.value, + authors: form.get("authors")?.value ?? [], + categories: form.get("categories")?.value ?? [], + publisher: form.get("publisher")?.value, + publishedDate: form.get("publishedDate")?.value, + isbn10: form.get("isbn10")?.value, + isbn13: form.get("isbn13")?.value, + description: form.get("description")?.value, + pageCount: form.get("pageCount")?.value, + rating: form.get("rating")?.value, + reviewCount: form.get("reviewCount")?.value, + asin: form.get("asin")?.value, + personalRating: form.get("personalRating")?.value, + amazonRating: form.get("amazonRating")?.value, + amazonReviewCount: form.get("amazonReviewCount")?.value, + goodreadsId: form.get("goodreadsId")?.value, + comicvineId: form.get("comicvineId")?.value, + goodreadsRating: form.get("goodreadsRating")?.value, + goodreadsReviewCount: form.get("goodreadsReviewCount")?.value, + hardcoverId: form.get("hardcoverId")?.value, + hardcoverRating: form.get("hardcoverRating")?.value, + hardcoverReviewCount: form.get("hardcoverReviewCount")?.value, + googleId: form.get("googleId")?.value, + language: form.get("language")?.value, + seriesName: form.get("seriesName")?.value, + seriesNumber: form.get("seriesNumber")?.value, + seriesTotal: form.get("seriesTotal")?.value, + thumbnailUrl: form.get("thumbnailUrl")?.value, // Locks - titleLocked: form.get('titleLocked')?.value, - subtitleLocked: form.get('subtitleLocked')?.value, - authorsLocked: form.get('authorsLocked')?.value, - categoriesLocked: form.get('categoriesLocked')?.value, - publisherLocked: form.get('publisherLocked')?.value, - publishedDateLocked: form.get('publishedDateLocked')?.value, - isbn10Locked: form.get('isbn10Locked')?.value, - isbn13Locked: form.get('isbn13Locked')?.value, - descriptionLocked: form.get('descriptionLocked')?.value, - pageCountLocked: form.get('pageCountLocked')?.value, - languageLocked: form.get('languageLocked')?.value, - asinLocked: form.get('asinLocked')?.value, - amazonRatingLocked: form.get('amazonRatingLocked')?.value, - personalRatingLocked: form.get('personalRatingLocked')?.value, - amazonReviewCountLocked: form.get('amazonReviewCountLocked')?.value, - goodreadsIdLocked: form.get('goodreadsIdLocked')?.value, - comicvineIdLocked: form.get('comicvineIdLocked')?.value, - goodreadsRatingLocked: form.get('goodreadsRatingLocked')?.value, - goodreadsReviewCountLocked: form.get('goodreadsReviewCountLocked')?.value, - hardcoverIdLocked: form.get('hardcoverIdLocked')?.value, - hardcoverRatingLocked: form.get('hardcoverRatingLocked')?.value, - hardcoverReviewCountLocked: form.get('hardcoverReviewCountLocked')?.value, - googleIdLocked: form.get('googleIdLocked')?.value, - seriesNameLocked: form.get('seriesNameLocked')?.value, - seriesNumberLocked: form.get('seriesNumberLocked')?.value, - seriesTotalLocked: form.get('seriesTotalLocked')?.value, - coverLocked: form.get('coverLocked')?.value, + titleLocked: form.get("titleLocked")?.value, + subtitleLocked: form.get("subtitleLocked")?.value, + authorsLocked: form.get("authorsLocked")?.value, + categoriesLocked: form.get("categoriesLocked")?.value, + publisherLocked: form.get("publisherLocked")?.value, + publishedDateLocked: form.get("publishedDateLocked")?.value, + isbn10Locked: form.get("isbn10Locked")?.value, + isbn13Locked: form.get("isbn13Locked")?.value, + descriptionLocked: form.get("descriptionLocked")?.value, + pageCountLocked: form.get("pageCountLocked")?.value, + languageLocked: form.get("languageLocked")?.value, + asinLocked: form.get("asinLocked")?.value, + amazonRatingLocked: form.get("amazonRatingLocked")?.value, + personalRatingLocked: form.get("personalRatingLocked")?.value, + amazonReviewCountLocked: form.get("amazonReviewCountLocked")?.value, + goodreadsIdLocked: form.get("goodreadsIdLocked")?.value, + comicvineIdLocked: form.get("comicvineIdLocked")?.value, + goodreadsRatingLocked: form.get("goodreadsRatingLocked")?.value, + goodreadsReviewCountLocked: form.get("goodreadsReviewCountLocked")?.value, + hardcoverIdLocked: form.get("hardcoverIdLocked")?.value, + hardcoverRatingLocked: form.get("hardcoverRatingLocked")?.value, + hardcoverReviewCountLocked: form.get("hardcoverReviewCountLocked")?.value, + googleIdLocked: form.get("googleIdLocked")?.value, + seriesNameLocked: form.get("seriesNameLocked")?.value, + seriesNumberLocked: form.get("seriesNumberLocked")?.value, + seriesTotalLocked: form.get("seriesTotalLocked")?.value, + coverLocked: form.get("coverLocked")?.value, - ...(shouldLockAllFields !== undefined && {allFieldsLocked: shouldLockAllFields}) + ...(shouldLockAllFields !== undefined && { + allFieldsLocked: shouldLockAllFields, + }), }; const original = this.originalMetadata; @@ -442,66 +477,70 @@ export class MetadataEditorComponent implements OnInit { const prev = (original[key] as any) ?? null; const isEmpty = (val: any): boolean => - val === null || val === '' || (Array.isArray(val) && val.length === 0); + val === null || val === "" || (Array.isArray(val) && val.length === 0); return isEmpty(current) && !isEmpty(prev); }; const clearFlags: MetadataClearFlags = { - title: wasCleared('title'), - subtitle: wasCleared('subtitle'), - authors: wasCleared('authors'), - categories: wasCleared('categories'), - publisher: wasCleared('publisher'), - publishedDate: wasCleared('publishedDate'), - isbn10: wasCleared('isbn10'), - isbn13: wasCleared('isbn13'), - description: wasCleared('description'), - pageCount: wasCleared('pageCount'), - language: wasCleared('language'), - asin: wasCleared('asin'), - personalRating: wasCleared('personalRating'), - amazonRating: wasCleared('personalRating'), - amazonReviewCount: wasCleared('amazonReviewCount'), - goodreadsId: wasCleared('goodreadsId'), - comicvineId: wasCleared('comicvineId'), - goodreadsRating: wasCleared('goodreadsRating'), - goodreadsReviewCount: wasCleared('goodreadsReviewCount'), - hardcoverId: wasCleared('hardcoverId'), - hardcoverRating: wasCleared('hardcoverRating'), - hardcoverReviewCount: wasCleared('hardcoverReviewCount'), - googleId: wasCleared('googleId'), - seriesName: wasCleared('seriesName'), - seriesNumber: wasCleared('seriesNumber'), - seriesTotal: wasCleared('seriesTotal'), - cover: false + title: wasCleared("title"), + subtitle: wasCleared("subtitle"), + authors: wasCleared("authors"), + categories: wasCleared("categories"), + publisher: wasCleared("publisher"), + publishedDate: wasCleared("publishedDate"), + isbn10: wasCleared("isbn10"), + isbn13: wasCleared("isbn13"), + description: wasCleared("description"), + pageCount: wasCleared("pageCount"), + language: wasCleared("language"), + asin: wasCleared("asin"), + personalRating: wasCleared("personalRating"), + amazonRating: wasCleared("personalRating"), + amazonReviewCount: wasCleared("amazonReviewCount"), + goodreadsId: wasCleared("goodreadsId"), + comicvineId: wasCleared("comicvineId"), + goodreadsRating: wasCleared("goodreadsRating"), + goodreadsReviewCount: wasCleared("goodreadsReviewCount"), + hardcoverId: wasCleared("hardcoverId"), + hardcoverRating: wasCleared("hardcoverRating"), + hardcoverReviewCount: wasCleared("hardcoverReviewCount"), + googleId: wasCleared("googleId"), + seriesName: wasCleared("seriesName"), + seriesNumber: wasCleared("seriesNumber"), + seriesTotal: wasCleared("seriesTotal"), + cover: false, }; - return {metadata, clearFlags}; + return { metadata, clearFlags }; } private updateMetadata(shouldLockAllFields: boolean | undefined): void { let metadataUpdateWrapper = this.buildMetadataWrapper(shouldLockAllFields); - this.bookService.updateBookMetadata(this.currentBookId, metadataUpdateWrapper, false).subscribe({ - next: (response) => { - if (shouldLockAllFields !== undefined) { + this.bookService + .updateBookMetadata(this.currentBookId, metadataUpdateWrapper, false) + .subscribe({ + next: (response) => { + if (shouldLockAllFields !== undefined) { + this.messageService.add({ + severity: "success", + summary: shouldLockAllFields + ? "Metadata Locked" + : "Metadata Unlocked", + detail: shouldLockAllFields + ? "All fields have been successfully locked." + : "All fields have been successfully unlocked.", + }); + } + }, + error: () => { this.messageService.add({ - severity: 'success', - summary: shouldLockAllFields ? 'Metadata Locked' : 'Metadata Unlocked', - detail: shouldLockAllFields - ? 'All fields have been successfully locked.' - : 'All fields have been successfully unlocked.', + severity: "error", + summary: "Error", + detail: "Failed to update lock state", }); - } - }, - error: () => { - this.messageService.add({ - severity: 'error', - summary: 'Error', - detail: 'Failed to update lock state', - }); - } - }); + }, + }); } getUploadCoverUrl(): string { @@ -513,15 +552,22 @@ export class MetadataEditorComponent implements OnInit { } onUpload(event: FileUploadEvent): void { - const response: HttpResponse = event.originalEvent as HttpResponse; + const response: HttpResponse = + event.originalEvent as HttpResponse; if (response && response.status === 200) { const bookMetadata: BookMetadata = response.body as BookMetadata; - this.bookService.handleBookMetadataUpdate(this.currentBookId, bookMetadata); + this.bookService.handleBookMetadataUpdate( + this.currentBookId, + bookMetadata + ); this.isUploading = false; } else { this.isUploading = false; this.messageService.add({ - severity: 'error', summary: 'Upload Failed', detail: 'An error occurred while uploading the cover', life: 3000 + severity: "error", + summary: "Upload Failed", + detail: "An error occurred while uploading the cover", + life: 3000, }); } } @@ -529,7 +575,10 @@ export class MetadataEditorComponent implements OnInit { onUploadError($event: FileUploadErrorEvent) { this.isUploading = false; this.messageService.add({ - severity: 'error', summary: 'Upload Error', detail: 'An error occurred while uploading the cover', life: 3000 + severity: "error", + summary: "Upload Error", + detail: "An error occurred while uploading the cover", + life: 3000, }); } @@ -537,29 +586,75 @@ export class MetadataEditorComponent implements OnInit { this.bookService.regenerateCover(bookId).subscribe({ next: () => { this.messageService.add({ - severity: 'success', - summary: 'Success', - detail: 'Book cover regenerated successfully. Refresh page to see the new cover.' + severity: "success", + summary: "Success", + detail: + "Book cover regenerated successfully. Refresh page to see the new cover.", }); }, error: () => { this.messageService.add({ - severity: 'error', - summary: 'Error', - detail: 'Failed to start cover regeneration' + severity: "error", + summary: "Error", + detail: "Failed to start cover regeneration", }); - } + }, + }); + } + + // restoreCbxMetadata() { + // this.isLoading = true; + // this.bookService.getComicInfoMetadata(this.currentBookId).subscribe(); + // setTimeout(() => { + // this.isLoading = false; + // // this.refreshingBookIds.delete(bookId); + // }, 10000); + // } + restoreCbxMetadata() { + this.isLoading = true; + console.log("LOADING CBX METADATA FOR BOOK ID:", this.currentBookId); + this.bookService.getComicInfoMetadata(this.currentBookId).subscribe({ + next: (metadata) => { + console.log("Retrieved ComicInfo.xml metadata:", metadata); + + if (metadata) { + this.originalMetadata = structuredClone(metadata); + this.populateFormFromMetadata(metadata); + this.messageService.add({ + severity: "success", + summary: "Restored", + detail: "Metadata loaded from ComicInfo.xml", + }); + } else { + this.messageService.add({ + severity: "warn", + summary: "No Data", + detail: "ComicInfo.xml not found or empty.", + }); + } + this.isLoading = false; + }, + error: (err) => { + console.error("Error loading ComicInfo.xml metadata:", err); + console.error(err.message); + this.isLoading = false; + this.messageService.add({ + severity: "error", + summary: "Error", + detail: err?.error?.message || "Failed to load ComicInfo.xml", + }); + }, }); } restoreMetadata() { this.dialogService.open(MetadataRestoreDialogComponent, { - header: 'Restore Metadata from Backup', + header: "Restore Metadata from Backup", modal: true, closable: true, data: { - bookId: [this.currentBookId] - } + bookId: [this.currentBookId], + }, }); } @@ -592,18 +687,25 @@ export class MetadataEditorComponent implements OnInit { openCoverSearch() { const ref = this.dialogService.open(CoverSearchComponent, { - header: 'Search Cover', + header: "Search Cover", modal: true, closable: true, data: { - bookId: [this.currentBookId] + bookId: [this.currentBookId], }, style: { - width: '90vw', - height: '90vh', - maxWidth: '1200px', - position: 'absolute' + width: "90vw", + height: "90vh", + maxWidth: "1200px", + position: "absolute", }, }); + + ref.onClose.subscribe((result) => { + if (result) { + this.metadataForm.get("thumbnailUrl")?.setValue(result); + this.onSave(); + } + }); } } diff --git a/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.html b/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.html index 21045a2e2..a7e4439fc 100644 --- a/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.html +++ b/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.html @@ -23,6 +23,23 @@ +
+
+
+ + + +
+

+ + Converts CBR and CB7 files to CBZ format when updating metadata. If disabled, edits to CBR/CB7 files will not be written to the original file. +

+
+
+
diff --git a/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.ts b/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.ts index 5a8b6b61e..373771823 100644 --- a/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.ts +++ b/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.ts @@ -20,6 +20,7 @@ export class MetadataPersistenceSettingsComponent implements OnInit { metadataPersistence: MetadataPersistenceSettings = { saveToOriginalFile: false, + convertCbrCb7ToCbz: false, backupMetadata: true, backupCover: true }; @@ -62,6 +63,7 @@ export class MetadataPersistenceSettingsComponent implements OnInit { this.metadataPersistence.saveToOriginalFile = !this.metadataPersistence.saveToOriginalFile; if (!this.metadataPersistence.saveToOriginalFile) { + this.metadataPersistence.convertCbrCb7ToCbz = false; this.metadataPersistence.backupMetadata = false; this.metadataPersistence.backupCover = false; } From 99c21eb5ac11049bd78a8043db727ef28c086691 Mon Sep 17 00:00:00 2001 From: Ruben GM <2044827+rubengarciam@users.noreply.github.com> Date: Wed, 10 Sep 2025 00:17:18 +1000 Subject: [PATCH 02/10] feat: Series Enhancements (#1086) * show active comic in the series banner * Hide pi-info button in series banner when book is active * When collapsed series: displays series title instead of book title, substitutes series number overlay by series count * New series page w/ redirections from other pages * Update booklore-ui/src/app/book/components/book-card-lite/book-card-lite-component.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update booklore-ui/src/app/book/components/series-page/series-page.component.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * removed commented unused status * cleaned minor warnings --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- booklore-ui/src/app/app.routes.ts | 2 + .../book-browser/book-browser.component.html | 19 +- .../book-card/book-card.component.html | 18 +- .../book-card/book-card.component.scss | 4 +- .../book-card/book-card.component.ts | 14 ++ .../book-card-lite-component.html | 20 +- .../book-card-lite-component.ts | 1 + .../series-page/series-page.component.html | 123 ++++++++++ .../series-page.component.html.backup | 147 ++++++++++++ .../series-page/series-page.component.scss | 18 ++ .../series-page/series-page.component.ts | 226 ++++++++++++++++++ .../src/app/book/service/book.service.ts | 5 +- .../metadata-viewer.component.html | 6 +- .../metadata-viewer.component.ts | 2 +- 14 files changed, 570 insertions(+), 35 deletions(-) create mode 100644 booklore-ui/src/app/book/components/series-page/series-page.component.html create mode 100644 booklore-ui/src/app/book/components/series-page/series-page.component.html.backup create mode 100644 booklore-ui/src/app/book/components/series-page/series-page.component.scss create mode 100644 booklore-ui/src/app/book/components/series-page/series-page.component.ts diff --git a/booklore-ui/src/app/app.routes.ts b/booklore-ui/src/app/app.routes.ts index 714a68229..732e73ccf 100644 --- a/booklore-ui/src/app/app.routes.ts +++ b/booklore-ui/src/app/app.routes.ts @@ -17,6 +17,7 @@ import {OidcCallbackComponent} from './core/security/oidc-callback/oidc-callback import {CbxReaderComponent} from './book/components/cbx-reader/cbx-reader.component'; import {BookdropFileReviewComponent} from './bookdrop/bookdrop-file-review-component/bookdrop-file-review.component'; import {MainDashboardComponent} from './dashboard/components/main-dashboard/main-dashboard.component'; +import {SeriesPageComponent} from './book/components/series-page/series-page.component'; import {StatsComponent} from './stats-component/stats-component'; export const routes: Routes = [ @@ -42,6 +43,7 @@ export const routes: Routes = [ {path: 'library/:libraryId/books', component: BookBrowserComponent, canActivate: [AuthGuard]}, {path: 'shelf/:shelfId/books', component: BookBrowserComponent, canActivate: [AuthGuard]}, {path: 'unshelved-books', component: BookBrowserComponent, canActivate: [AuthGuard]}, + {path: 'series/:seriesName', component: SeriesPageComponent, canActivate: [AuthGuard]}, { path: 'magic-shelf/:magicShelfId/books', component: BookBrowserComponent, canActivate: [AuthGuard] }, {path: 'book/:bookId', component: BookMetadataCenterComponent, canActivate: [AuthGuard]}, {path: 'bookdrop', component: BookdropFileReviewComponent, canActivate: [AuthGuard]}, diff --git a/booklore-ui/src/app/book/components/book-browser/book-browser.component.html b/booklore-ui/src/app/book/components/book-browser/book-browser.component.html index 782d24f78..c02649b23 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-browser.component.html +++ b/booklore-ui/src/app/book/components/book-browser/book-browser.component.html @@ -277,15 +277,16 @@
- - + +
}
diff --git a/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.html b/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.html index 3ad19c575..ef652da5c 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.html +++ b/booklore-ui/src/app/book/components/book-browser/book-card/book-card.component.html @@ -14,24 +14,28 @@ [src]="urlHelper.getThumbnailUrl(book.id, book.metadata?.coverUpdatedOn)" class="book-cover" [class.loaded]="isImageLoaded" - alt="Cover of {{ book.metadata?.title }}" + alt="Cover of {{ displayTitle }}" loading="lazy" (load)="onImageLoad()"/>
- @if (book.metadata?.seriesNumber != null) { + @if (!book.seriesCount && book.metadata?.seriesNumber != null) {
#{{ book.metadata?.seriesNumber }}
} - @if (book.seriesCount != null && book.seriesCount! > 1) { -
+ @if (book.seriesCount && book.seriesCount! >= 1) { +
{{ book.seriesCount }}
} - + @if (book.seriesCount && book.seriesCount! >= 1) { + + } @else { + + } @@ -70,8 +74,8 @@

- {{ book.metadata?.title }} + [pTooltip]="displayTitle"> + {{ displayTitle }}

- - + @if (!this.isActive) { + + + }
diff --git a/booklore-ui/src/app/book/components/book-card-lite/book-card-lite-component.ts b/booklore-ui/src/app/book/components/book-card-lite/book-card-lite-component.ts index c0041670a..8bf6e9da8 100644 --- a/booklore-ui/src/app/book/components/book-card-lite/book-card-lite-component.ts +++ b/booklore-ui/src/app/book/components/book-card-lite/book-card-lite-component.ts @@ -22,6 +22,7 @@ import {TooltipModule} from 'primeng/tooltip'; }) export class BookCardLiteComponent implements OnInit, OnDestroy { @Input() book!: Book; + @Input() isActive: boolean = false; private router = inject(Router); protected urlHelper = inject(UrlHelperService); diff --git a/booklore-ui/src/app/book/components/series-page/series-page.component.html b/booklore-ui/src/app/book/components/series-page/series-page.component.html new file mode 100644 index 000000000..85902e5cc --- /dev/null +++ b/booklore-ui/src/app/book/components/series-page/series-page.component.html @@ -0,0 +1,123 @@ +@if (filteredBooks$ | async; as books) { +
+ + + + + Series Details + + + + + + @if (books[0]; as firstBook) { +
+
+ + +
+ +
+
+

+ {{ seriesTitle$ | async }} +

+
+ +

+ @for (author of firstBook.metadata?.authors; track $index; let isLast = $last) { + + {{ author }} + + @if (!isLast) { + , + } + } +

+ +
+ + @if (firstBook.metadata?.categories?.length) { +
+
+ @for (category of firstBook.metadata?.categories; track category) { + + + + } +
+
+ } +
+
+

+ Publisher: + @if (firstBook.metadata?.publisher; as publisher) { + + {{publisher}} + + } @else { + - + } +

+

Years: {{ (yearsRange$ | async) || '-' }}

+

Number of books: {{ books.length || 0}}

+

Language: {{ firstBook.metadata?.language || "-"}}

+

Read Status: + @let s = seriesReadStatus$ | async; + + {{ getStatusLabel(s) }} + +

+
+
+ + +
+
+
+
+ @let desc = firstDescription$ | async; + @if ((desc?.length ?? 0) > 500) { + + + } +
+ +
+
+ + +
+
No books found for this series.
+
+ +
+ +
+
+ } + +
+
+
+
+ +} @else { +
+ + +

+ Loading series details... +

+
+} \ No newline at end of file diff --git a/booklore-ui/src/app/book/components/series-page/series-page.component.html.backup b/booklore-ui/src/app/book/components/series-page/series-page.component.html.backup new file mode 100644 index 000000000..a95b06536 --- /dev/null +++ b/booklore-ui/src/app/book/components/series-page/series-page.component.html.backup @@ -0,0 +1,147 @@ +@if (filteredBooks$ | async; as books) { +
+ + + + + Series Details + + + + + + + +
+
+ + +
+ + +
+
+

+ {{ seriesTitle$ | async }} +

+
+ +

+ @for (author of books[0]?.metadata?.authors; track $index; let isLast = $last) { + + + {{ author }} + + @if (!isLast) { + , + } + } +

+ +
+ + + @if (books[0]?.metadata?.categories?.length) { +
+
+ @for (category of books[0].metadata!.categories; track category) { + + + + } +
+
+ } + + +
+
+

Number of books: {{ books.length || 0}}

+

Publisher: {{ books[0]?.metadata?.publisher || "-"}}

+

Year: {{ books[0]?.metadata?.publishedDate || "-"}}

+

Language: {{ books[0]?.metadata?.language || "-"}}

+
+
+ + + +
+
+
+ +
+
+ +
+ + + + + + +
+
+ + +
+
No books found for this series.
+
+ +
+ +
+
+ + + +
+
+
+
+} @else { +
+ + +

+ Loading series details... +

+
+} \ No newline at end of file diff --git a/booklore-ui/src/app/book/components/series-page/series-page.component.scss b/booklore-ui/src/app/book/components/series-page/series-page.component.scss new file mode 100644 index 000000000..0552f4ba6 --- /dev/null +++ b/booklore-ui/src/app/book/components/series-page/series-page.component.scss @@ -0,0 +1,18 @@ +.tabpanels-responsive { + height: calc(100dvh - 9.7rem); +} + +@media (max-width: 768px) { + .tabpanels-responsive { + height: calc(100dvh - 8.5rem); + } +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(1px, 1fr)); + gap: 1.3rem; + align-items: start; + width: 100%; +} + diff --git a/booklore-ui/src/app/book/components/series-page/series-page.component.ts b/booklore-ui/src/app/book/components/series-page/series-page.component.ts new file mode 100644 index 000000000..dbca831d4 --- /dev/null +++ b/booklore-ui/src/app/book/components/series-page/series-page.component.ts @@ -0,0 +1,226 @@ +import { Component, inject, ViewChild } from "@angular/core"; +import { FormsModule } from "@angular/forms"; +import { Button } from "primeng/button"; +import { ActivatedRoute } from "@angular/router"; +import { AsyncPipe, NgClass, NgFor, NgIf, NgStyle } from "@angular/common"; +import { map, filter, switchMap } from "rxjs/operators"; +import { Observable, combineLatest } from "rxjs"; +import { Book, ReadStatus } from "../../model/book.model"; +import { BookService } from "../../service/book.service"; +import { BookCardComponent } from "../book-browser/book-card/book-card.component"; +import { CoverScalePreferenceService } from "../book-browser/cover-scale-preference.service"; +import { Tab, TabList, TabPanel, TabPanels, Tabs } from "primeng/tabs"; +import { Tag } from "primeng/tag"; +import { VirtualScrollerModule } from "@iharbeck/ngx-virtual-scroller"; +import { ProgressSpinner } from "primeng/progressspinner"; +import { DialogService, DynamicDialogRef } from "primeng/dynamicdialog"; +import { Router } from "@angular/router"; + +@Component({ + selector: "app-series-page", + standalone: true, + templateUrl: "./series-page.component.html", + styleUrls: ["./series-page.component.scss"], + imports: [ + AsyncPipe, + Button, + FormsModule, + NgIf, + NgFor, + NgStyle, + NgClass, + BookCardComponent, + ProgressSpinner, + Tabs, + TabList, + Tab, + TabPanels, + TabPanel, + Tag, + + VirtualScrollerModule, + ], +}) +export class SeriesPageComponent { + + private route = inject(ActivatedRoute); + private bookService = inject(BookService); + protected coverScalePreferenceService = inject(CoverScalePreferenceService); + private metadataCenterViewMode: "route" | "dialog" = "route"; + private dialogRef?: DynamicDialogRef; + private router = inject(Router); + + tab: string = "view"; + isExpanded = false; + + + seriesParam$: Observable = this.route.paramMap.pipe( + map((params) => params.get("seriesName") || ""), + map((name) => decodeURIComponent(name)) + ); + + booksInSeries$: Observable = this.bookService.bookState$.pipe( + filter((state) => state.loaded && !!state.books), + map((state) => state.books || []) + ); + + filteredBooks$: Observable = combineLatest([ + this.seriesParam$.pipe(map((n) => n.trim().toLowerCase())), + this.booksInSeries$, + ]).pipe( + map(([seriesName, books]) => { + const inSeries = books.filter( + (b) => b.metadata?.seriesName?.toLowerCase() === seriesName + ); + return inSeries.sort((a, b) => { + const aNum = a.metadata?.seriesNumber ?? Number.MAX_SAFE_INTEGER; + const bNum = b.metadata?.seriesNumber ?? Number.MAX_SAFE_INTEGER; + return aNum - bNum; + }); + }) + ); + + seriesTitle$: Observable = combineLatest([ + this.seriesParam$, + this.filteredBooks$, + ]).pipe(map(([param, books]) => books[0]?.metadata?.seriesName || param)); + + yearsRange$: Observable = this.filteredBooks$.pipe( + map((books) => { + const years = books + .map((b) => b.metadata?.publishedDate) + .filter((d): d is string => !!d) + .map((d) => { + const match = d.match(/\d{4}/); + return match ? parseInt(match[0], 10) : null; + }) + .filter((y): y is number => y !== null); + + if (years.length === 0) return null; + const min = Math.min(...years); + const max = Math.max(...years); + return min === max ? String(min) : `${min}-${max}`; + }) + ); + + firstBookWithDesc$: Observable = this.filteredBooks$.pipe( + map((books) => books[0]), + filter((b): b is Book => !!b), + switchMap((b) => this.bookService.getBookByIdFromAPI(b.id, true)) + ); + + firstDescription$: Observable = this.firstBookWithDesc$.pipe( + map((b) => b.metadata?.description || "") + ); + + seriesReadStatus$: Observable = this.filteredBooks$.pipe( + map((books) => { + if (!books || books.length === 0) return ReadStatus.UNREAD; + const statuses = books.map((b) => (b.readStatus as ReadStatus) ?? ReadStatus.UNREAD); + + const hasWontRead = statuses.includes(ReadStatus.WONT_READ); + if (hasWontRead) return ReadStatus.WONT_READ; + + const hasAbandoned = statuses.includes(ReadStatus.ABANDONED); + if (hasAbandoned) return ReadStatus.ABANDONED; + + const allRead = statuses.every((s) => s === ReadStatus.READ); + if (allRead) return ReadStatus.READ; + + const someRead = statuses.some((s) => s === ReadStatus.READ); + if (someRead) return ReadStatus.PARTIALLY_READ; + + const allUnread = statuses.every((s) => s === ReadStatus.UNREAD); + if (allUnread) return ReadStatus.UNREAD; + + return ReadStatus.PARTIALLY_READ; + }) + ); + + get currentCardSize() { + return this.coverScalePreferenceService.currentCardSize; + } + + get gridColumnMinWidth(): string { + return this.coverScalePreferenceService.gridColumnMinWidth; + } + + goToAuthorBooks(author: string): void { + this.handleMetadataClick("author", author); + } + + goToCategory(category: string): void { + this.handleMetadataClick("category", category); + } + + goToPublisher(publisher: string): void { + this.handleMetadataClick("publisher", publisher); + } + + private navigateToFilteredBooks( + filterKey: string, + filterValue: string + ): void { + this.router.navigate(["/all-books"], { + queryParams: { + view: "grid", + sort: "title", + direction: "asc", + sidebar: true, + filter: `${filterKey}:${filterValue}`, + }, + }); + } + + private handleMetadataClick(filterKey: string, filterValue: string): void { + if (this.metadataCenterViewMode === "dialog") { + this.dialogRef?.close(); + setTimeout( + () => this.navigateToFilteredBooks(filterKey, filterValue), + 200 + ); + } else { + this.navigateToFilteredBooks(filterKey, filterValue); + } + } + + toggleExpand(): void { + this.isExpanded = !this.isExpanded; + } + + getStatusLabel(value: string | ReadStatus | null | undefined): string { + const v = (value ?? '').toString().toUpperCase(); + switch (v) { + case ReadStatus.UNREAD: + return 'UNREAD'; + case ReadStatus.READ: + return 'READ'; + case ReadStatus.PARTIALLY_READ: + return 'PARTIALLY READ'; + case ReadStatus.ABANDONED: + return 'ABANDONED'; + case ReadStatus.WONT_READ: + return "WON'T READ"; + default: + return 'UNSET'; + } + } + + getStatusSeverityClass(status: string): string { + const normalized = status?.toUpperCase(); + switch (normalized) { + case "UNREAD": + return "bg-gray-500"; + case "READ": + return "bg-green-600"; + case "PARTIALLY_READ": + return "bg-yellow-600"; + case "ABANDONED": + return "bg-red-600"; + case "WONT_READ": + return "bg-pink-700"; + default: + return "bg-gray-600"; + } + } +} diff --git a/booklore-ui/src/app/book/service/book.service.ts b/booklore-ui/src/app/book/service/book.service.ts index 0230872f5..22f755ca4 100644 --- a/booklore-ui/src/app/book/service/book.service.ts +++ b/booklore-ui/src/app/book/service/book.service.ts @@ -578,10 +578,7 @@ export class BookService { } const seriesName = currentBook.metadata.seriesName.toLowerCase(); - return allBooks.filter(b => - b.id !== bookId && - b.metadata?.seriesName?.toLowerCase() === seriesName - ); + return allBooks.filter(b => b.metadata?.seriesName?.toLowerCase() === seriesName); }) ); } diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html index ad9c6ca62..18ff79053 100644 --- a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html +++ b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html @@ -527,9 +527,9 @@ [infiniteScrollDistance]="2" [infiniteScrollThrottle]="50" [horizontal]="true"> - @for (book of bookInSeries; track book.id) { + @for (bookInSeriesItem of bookInSeries; track bookInSeriesItem) {
- +
}
@@ -548,7 +548,7 @@ [horizontal]="true"> @for (book of recommendedBooks; track book.book.id) {
- +
} diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.ts b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.ts index 5c6062fc2..90401dab9 100644 --- a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.ts +++ b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.ts @@ -543,7 +543,7 @@ export class MetadataViewerComponent implements OnInit, OnChanges { } goToSeries(seriesName: string): void { - this.handleMetadataClick('series', seriesName); + this.router.navigate(['/series', seriesName]); } goToPublisher(publisher: string): void { From e772e271b0b28d41e133fb9da12f2615ce0d83bf Mon Sep 17 00:00:00 2001 From: clockwinder <54368516+clockwinder@users.noreply.github.com> Date: Tue, 9 Sep 2025 08:17:59 -0600 Subject: [PATCH 03/10] Add notice to help user avoid premission issues (#1118) If docker is allowed to create the directories before the first boot of Booklore, those folders are created as root and might cause permission issues. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index e66094cb7..0b6239322 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ Ensure you have [Docker](https://docs.docker.com/get-docker/) and [Docker Compos ### 2️⃣ Create docker-compose.yml +> ⚠️ If you intend to run the container as a non-root user, you must manually create all of your `/your/local/path/to/booklore` directories with read and write permissions for your intended user **before first run**. + Create a `docker-compose.yml` file with content: ```yaml From 0b45f63f3578a3fdf8d937b1b1fc71b6d37f97c9 Mon Sep 17 00:00:00 2001 From: Ruben GM <2044827+rubengarciam@users.noreply.github.com> Date: Wed, 10 Sep 2025 13:42:48 +1000 Subject: [PATCH 04/10] consider series name when sorting books (#1123) --- .../components/book-browser/book-browser.component.ts | 10 +++++++++- booklore-ui/src/app/book/service/sort.service.ts | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts b/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts index 7a712172f..42baa0663 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts +++ b/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts @@ -673,7 +673,15 @@ export class BookBrowserComponent implements OnInit { const forceExpandSeries = this.shouldForceExpandSeries(); return this.headerFilter.filter(bookState).pipe( switchMap(filtered => this.sideBarFilter.filter(filtered)), - switchMap(filtered => this.seriesCollapseFilter.filter(filtered, forceExpandSeries)) + switchMap(filtered => this.seriesCollapseFilter.filter(filtered, forceExpandSeries)), + map(filtered => + (filtered.loaded && !filtered.error) + ? ({ + ...filtered, + books: this.sortService.applySort(filtered.books || [], this.bookSorter.selectedSort!) + }) + : filtered + ) ); } diff --git a/booklore-ui/src/app/book/service/sort.service.ts b/booklore-ui/src/app/book/service/sort.service.ts index 7f120aeb6..6fe9a7ca9 100644 --- a/booklore-ui/src/app/book/service/sort.service.ts +++ b/booklore-ui/src/app/book/service/sort.service.ts @@ -8,7 +8,8 @@ import {SortDirection, SortOption} from "../model/sort.model"; export class SortService { private readonly fieldExtractors: Record any> = { - title: (book) => book.metadata?.title?.toLowerCase() || null, + title: (book) => (book.seriesCount ? (book.metadata?.seriesName?.toLowerCase() || null) : null) + ?? (book.metadata?.title?.toLowerCase() || null), titleSeries: (book) => { const title = book.metadata?.title?.toLowerCase() || ''; const series = book.metadata?.seriesName?.toLowerCase(); From d8be663a4df9986a8778606325d43e0f3dea7989 Mon Sep 17 00:00:00 2001 From: Ruben GM <2044827+rubengarciam@users.noreply.github.com> Date: Wed, 10 Sep 2025 13:44:04 +1000 Subject: [PATCH 05/10] feat: Row alignment, badge to right (#1122) * row alignment, badge to right * unnecessary css --- .../book-browser/book-filter/book-filter.component.html | 5 +++-- .../book-browser/book-filter/book-filter.component.scss | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.html b/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.html index f4f111df0..0c71348cd 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.html +++ b/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.html @@ -34,13 +34,14 @@ style="overscroll-behavior: contain;"> @for (filter of filters; track trackByFilter(j, filter); let j = $index) {
- {{ filter.value.name || filter.value }} + +
} diff --git a/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.scss b/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.scss index 551d25634..813bcdb3d 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.scss +++ b/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.scss @@ -1,3 +1,11 @@ :host ::ng-deep p-accordion-header { --p-accordion-header-padding: 0.6rem 1rem; } + +.filter-row{ + align-items: flex-start; +} + +p-badge.filter-value-badge { + border-radius: 6px !important; /* Makes the badge square */ +} \ No newline at end of file From 1008a9476b205a06be1d2964d6106a012c2d2ee0 Mon Sep 17 00:00:00 2001 From: Aditya Chandel <8075870+adityachandelgit@users.noreply.github.com> Date: Tue, 9 Sep 2025 22:18:52 -0600 Subject: [PATCH 06/10] Support configurable background image and transparency (#1126) --- .../BackgroundUploadController.java | 55 +++ .../controller/BookMediaController.java | 23 ++ .../booklore/model/dto/UploadResponse.java | 12 + .../booklore/model/dto/UrlRequest.java | 12 + .../service/BackgroundUploadService.java | 112 ++++++ .../booklore/service/BookService.java | 12 +- .../booklore/util/FileService.java | 329 ++++++++++++------ .../resources/static/images/background.jpg | Bin 0 -> 641407 bytes .../writer/CbxMetadataWriterTest.java | 41 ++- booklore-ui/src/app/app.component.html | 17 +- booklore-ui/src/app/app.component.scss | 22 ++ booklore-ui/src/app/app.component.ts | 24 +- .../book-browser/book-browser.component.scss | 4 + .../src/app/core/model/app-state.model.ts | 5 + .../app/core/service/app-config.service.ts | 114 +++++- .../background-upload.service.ts | 32 ++ .../theme-configurator.component.html | 61 ++++ .../theme-configurator.component.scss | 95 +++++ .../theme-configurator.component.ts | 100 +++++- .../upload-dialog.component.html | 59 ++++ .../upload-dialog.component.scss | 82 +++++ .../upload-dialog/upload-dialog.component.ts | 72 ++++ .../app/stats-component/stats-component.scss | 6 +- .../utilities/service/url-helper.service.ts | 12 + .../assets/layout/styles/layout/_topbar.scss | 5 +- 25 files changed, 1159 insertions(+), 147 deletions(-) create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/controller/BackgroundUploadController.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UploadResponse.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UrlRequest.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/BackgroundUploadService.java create mode 100644 booklore-api/src/main/resources/static/images/background.jpg create mode 100644 booklore-ui/src/app/layout/component/theme-configurator/background-upload.service.ts create mode 100644 booklore-ui/src/app/layout/component/theme-configurator/upload-dialog/upload-dialog.component.html create mode 100644 booklore-ui/src/app/layout/component/theme-configurator/upload-dialog/upload-dialog.component.scss create mode 100644 booklore-ui/src/app/layout/component/theme-configurator/upload-dialog/upload-dialog.component.ts diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BackgroundUploadController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BackgroundUploadController.java new file mode 100644 index 000000000..8d524e7fa --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BackgroundUploadController.java @@ -0,0 +1,55 @@ +package com.adityachandel.booklore.controller; + +import com.adityachandel.booklore.config.security.service.AuthenticationService; +import com.adityachandel.booklore.model.dto.BookLoreUser; +import com.adityachandel.booklore.model.dto.UploadResponse; +import com.adityachandel.booklore.model.dto.UrlRequest; +import com.adityachandel.booklore.service.BackgroundUploadService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/background") +@RequiredArgsConstructor +public class BackgroundUploadController { + + private final BackgroundUploadService backgroundUploadService; + private final AuthenticationService authenticationService; + + @PostMapping("/upload") + public ResponseEntity uploadFile(@RequestParam("file") MultipartFile file) { + try { + BookLoreUser authenticatedUser = authenticationService.getAuthenticatedUser(); + UploadResponse response = backgroundUploadService.uploadBackgroundFile(file, authenticatedUser.getId()); + return ResponseEntity.ok(response); + } catch (Exception e) { + return ResponseEntity.badRequest().build(); + } + } + + @PostMapping("/url") + public ResponseEntity uploadUrl(@RequestBody UrlRequest request) { + try { + BookLoreUser authenticatedUser = authenticationService.getAuthenticatedUser(); + UploadResponse response = backgroundUploadService.uploadBackgroundFromUrl(request.getUrl(), authenticatedUser.getId()); + return ResponseEntity.ok(response); + } catch (Exception e) { + return ResponseEntity.badRequest().build(); + } + } + + @DeleteMapping + public ResponseEntity resetToDefault() { + try { + BookLoreUser authenticatedUser = authenticationService.getAuthenticatedUser(); + backgroundUploadService.resetToDefault(authenticatedUser.getId()); + return ResponseEntity.ok().build(); + } catch (Exception e) { + return ResponseEntity.internalServerError().build(); + } + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java index 4d32c6455..8c4f25e19 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookMediaController.java @@ -1,5 +1,6 @@ package com.adityachandel.booklore.controller; +import com.adityachandel.booklore.config.security.service.AuthenticationService; import com.adityachandel.booklore.service.BookService; import com.adityachandel.booklore.service.bookdrop.BookDropService; import com.adityachandel.booklore.service.metadata.BookMetadataService; @@ -73,4 +74,26 @@ public class BookMediaController { .body(file) : ResponseEntity.noContent().build(); } + + @GetMapping("/background") + public ResponseEntity getBackgroundImage() { + try { + Resource file = bookService.getBackgroundImage(); + if (file == null || !file.exists()) { + return ResponseEntity.notFound().build(); + } + + String filename = file.getFilename(); + MediaType mediaType = filename != null && filename.endsWith(".png") + ? MediaType.IMAGE_PNG + : MediaType.IMAGE_JPEG; + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=" + filename) + .contentType(mediaType) + .body(file); + } catch (Exception e) { + return ResponseEntity.internalServerError().build(); + } + } } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UploadResponse.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UploadResponse.java new file mode 100644 index 000000000..3aad3b1e1 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UploadResponse.java @@ -0,0 +1,12 @@ +package com.adityachandel.booklore.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UploadResponse { + private String url; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UrlRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UrlRequest.java new file mode 100644 index 000000000..bc3de6209 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UrlRequest.java @@ -0,0 +1,12 @@ +package com.adityachandel.booklore.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UrlRequest { + private String url; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/BackgroundUploadService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/BackgroundUploadService.java new file mode 100644 index 000000000..1c80f0df5 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/BackgroundUploadService.java @@ -0,0 +1,112 @@ +package com.adityachandel.booklore.service; + +import com.adityachandel.booklore.model.dto.UploadResponse; +import com.adityachandel.booklore.util.FileService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.net.URI; +import java.net.URL; +import java.nio.file.Paths; +import java.util.Objects; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BackgroundUploadService { + + private final FileService fileService; + + private static final String JPEG_MIME_TYPE = "image/jpeg"; + private static final String PNG_MIME_TYPE = "image/png"; + private static final long MAX_FILE_SIZE_BYTES = 5L * 1024 * 1024; // 5MB + + public UploadResponse uploadBackgroundFile(MultipartFile file, Long userId) { + try { + validateBackgroundFile(file); + + String originalFilename = Objects.requireNonNull(file.getOriginalFilename()); + String extension = getFileExtension(originalFilename); + String filename = "1." + extension; + + BufferedImage originalImage = ImageIO.read(file.getInputStream()); + if (originalImage == null) { + throw new IllegalArgumentException("Invalid image file"); + } + + deleteExistingBackgroundFiles(userId); + fileService.saveBackgroundImage(originalImage, filename, userId); + + String fileUrl = fileService.getBackgroundUrl(filename, userId); + return new UploadResponse(fileUrl); + } catch (Exception e) { + log.error("Failed to upload background file: {}", e.getMessage(), e); + throw new RuntimeException("Failed to upload file: " + e.getMessage(), e); + } + } + + public UploadResponse uploadBackgroundFromUrl(String imageUrl, Long userId) { + try { + URL url = new URI(imageUrl).toURL(); + String originalFilename = Paths.get(url.getPath()).getFileName().toString(); + String extension = getFileExtension(originalFilename); + String filename = "1." + extension; + + BufferedImage originalImage = fileService.downloadImageFromUrl(imageUrl); + deleteExistingBackgroundFiles(userId); + + fileService.saveBackgroundImage(originalImage, filename, userId); + + String fileUrl = fileService.getBackgroundUrl(filename, userId); + return new UploadResponse(fileUrl); + } catch (Exception e) { + log.error("Failed to upload background from URL: {}", e.getMessage(), e); + throw new RuntimeException("Invalid or inaccessible URL: " + e.getMessage(), e); + } + } + + public void resetToDefault(Long userId) { + try { + deleteExistingBackgroundFiles(userId); + log.info("Reset background to default successfully for user: {}", userId); + } catch (Exception e) { + log.error("Failed to reset background to default: {}", e.getMessage(), e); + throw new RuntimeException("Failed to reset background: " + e.getMessage(), e); + } + } + + private void deleteExistingBackgroundFiles(Long userId) { + try { + fileService.deleteBackgroundFile("1.jpg", userId); + fileService.deleteBackgroundFile("1.jpeg", userId); + fileService.deleteBackgroundFile("1.png", userId); + } catch (Exception e) { + log.warn("Failed to delete existing background files: {}", e.getMessage()); + } + } + + private void validateBackgroundFile(MultipartFile file) { + if (file.isEmpty()) { + throw new IllegalArgumentException("Background image file is empty"); + } + String contentType = file.getContentType(); + if (!(JPEG_MIME_TYPE.equalsIgnoreCase(contentType) || PNG_MIME_TYPE.equalsIgnoreCase(contentType))) { + throw new IllegalArgumentException("Background image must be JPEG or PNG format"); + } + if (file.getSize() > MAX_FILE_SIZE_BYTES) { + throw new IllegalArgumentException("Background image size must not exceed 5 MB"); + } + } + + private String getFileExtension(String filename) { + int lastDotIndex = filename.lastIndexOf('.'); + if (lastDotIndex > 0 && lastDotIndex < filename.length() - 1) { + return filename.substring(lastDotIndex + 1).toLowerCase(); + } + return "jpg"; + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java index e2beae57a..ca25f4d82 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java @@ -476,6 +476,16 @@ public class BookService { } } + public Resource getBackgroundImage() { + try { + BookLoreUser user = authenticationService.getAuthenticatedUser(); + return fileService.getBackgroundResource(user.getId()); + } catch (Exception e) { + log.error("Failed to get background image: {}", e.getMessage(), e); + return fileService.getBackgroundResource(null); + } + } + public ResponseEntity downloadBook(Long bookId) { return bookDownloadService.downloadBook(bookId); } @@ -502,7 +512,7 @@ public class BookService { if (Files.exists(fullFilePath)) { Files.delete(fullFilePath); log.info("Deleted book file: {}", fullFilePath); - + Set libraryRoots = book.getLibrary().getLibraryPaths().stream() .map(LibraryPathEntity::getPath) .map(Paths::get) diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java b/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java index 46c475fbd..4875f8301 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java @@ -14,6 +14,9 @@ import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -43,6 +46,7 @@ public class FileService { // @formatter:off private static final String IMAGES_DIR = "images"; + private static final String BACKGROUNDS_DIR = "backgrounds"; private static final String THUMBNAIL_FILENAME = "thumbnail.jpg"; private static final String COVER_FILENAME = "cover.jpg"; private static final String JPEG_MIME_TYPE = "image/jpeg"; @@ -53,37 +57,80 @@ public class FileService { private static final String IMAGE_FORMAT = "JPEG"; // @formatter:on - public void createThumbnailFromFile(long bookId, MultipartFile file) { - try { - validateCoverFile(file); - BufferedImage originalImage = ImageIO.read(file.getInputStream()); - if (originalImage == null) { - throw ApiError.IMAGE_NOT_FOUND.createException(); - } - boolean success = saveCoverImages(originalImage, bookId); - if (!success) { - throw ApiError.FILE_READ_ERROR.createException("Failed to save cover images"); - } - log.info("Cover images created and saved for book ID: {}", bookId); - } catch (Exception e) { - log.error("An error occurred while creating the thumbnail: {}", e.getMessage(), e); - throw ApiError.FILE_READ_ERROR.createException(e.getMessage()); + // ======================================== + // PATH UTILITIES + // ======================================== + + public String getImagesFolder(long bookId) { + return Paths.get(appProperties.getPathConfig(), IMAGES_DIR, String.valueOf(bookId)).toString(); + } + + public String getThumbnailFile(long bookId) { + return Paths.get(appProperties.getPathConfig(), IMAGES_DIR, String.valueOf(bookId), THUMBNAIL_FILENAME).toString(); + } + + public String getCoverFile(long bookId) { + return Paths.get(appProperties.getPathConfig(), IMAGES_DIR, String.valueOf(bookId), COVER_FILENAME).toString(); + } + + public String getBackgroundsFolder(Long userId) { + if (userId != null) { + return Paths.get(appProperties.getPathConfig(), BACKGROUNDS_DIR, "user-" + userId).toString(); + } + return Paths.get(appProperties.getPathConfig(), BACKGROUNDS_DIR).toString(); + } + + public String getBackgroundsFolder() { + return getBackgroundsFolder(null); + } + + public String getBackgroundUrl(String filename, Long userId) { + if (userId != null) { + return Paths.get("/", BACKGROUNDS_DIR, "user-" + userId, filename).toString().replace("\\", "/"); + } + return Paths.get("/", BACKGROUNDS_DIR, filename).toString().replace("\\", "/"); + } + + public String getMetadataBackupPath() { + return Paths.get(appProperties.getPathConfig(), "metadata_backup").toString(); + } + + public String getBookMetadataBackupPath(long bookId) { + return Paths.get(appProperties.getPathConfig(), "metadata_backup", String.valueOf(bookId)).toString(); + } + + public String getCbxCachePath() { + return Paths.get(appProperties.getPathConfig(), "cbx_cache").toString(); + } + + public String getPdfCachePath() { + return Paths.get(appProperties.getPathConfig(), "pdf_cache").toString(); + } + + public String getTempBookdropCoverImagePath(long bookdropFileId) { + return Paths.get(appProperties.getPathConfig(), "bookdrop_temp", bookdropFileId + ".jpg").toString(); + } + + // ======================================== + // VALIDATION + // ======================================== + + private void validateCoverFile(MultipartFile file) { + if (file.isEmpty()) { + throw new IllegalArgumentException("Uploaded file is empty"); + } + String contentType = file.getContentType(); + if (!(JPEG_MIME_TYPE.equalsIgnoreCase(contentType) || PNG_MIME_TYPE.equalsIgnoreCase(contentType))) { + throw new IllegalArgumentException("Only JPEG and PNG files are allowed"); + } + if (file.getSize() > MAX_FILE_SIZE_BYTES) { + throw new IllegalArgumentException("File size must not exceed 5 MB"); } } - public boolean saveCoverImages(BufferedImage coverImage, long bookId) throws IOException { - String folderPath = getImagesFolder(bookId); - File folder = new File(folderPath); - if (!folder.exists() && !folder.mkdirs()) { - throw new IOException("Failed to create directory: " + folder.getAbsolutePath()); - } - File originalFile = new File(folder, COVER_FILENAME); - boolean originalSaved = ImageIO.write(coverImage, IMAGE_FORMAT, originalFile); - BufferedImage thumb = resizeImage(coverImage, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); - File thumbnailFile = new File(folder, THUMBNAIL_FILENAME); - boolean thumbnailSaved = ImageIO.write(thumb, IMAGE_FORMAT, thumbnailFile); - return originalSaved && thumbnailSaved; - } + // ======================================== + // IMAGE OPERATIONS + // ======================================== public BufferedImage resizeImage(BufferedImage originalImage, int width, int height) { Image tmp = originalImage.getScaledInstance(width, height, Image.SCALE_SMOOTH); @@ -105,6 +152,70 @@ public class FileService { log.info("Image saved successfully to: {}", filePath); } + public BufferedImage downloadImageFromUrl(String imageUrl) throws IOException { + try { + URL url = new URL(imageUrl); + BufferedImage image = ImageIO.read(url); + if (image == null) { + throw new IOException("Unable to read image from URL: " + imageUrl); + } + return image; + } catch (Exception e) { + log.error("Failed to download image from URL: {} - {}", imageUrl, e.getMessage()); + throw new IOException("Failed to download image from URL: " + imageUrl, e); + } + } + + // ======================================== + // COVER OPERATIONS + // ======================================== + + public void createThumbnailFromFile(long bookId, MultipartFile file) { + try { + validateCoverFile(file); + BufferedImage originalImage = ImageIO.read(file.getInputStream()); + if (originalImage == null) { + throw ApiError.IMAGE_NOT_FOUND.createException(); + } + boolean success = saveCoverImages(originalImage, bookId); + if (!success) { + throw ApiError.FILE_READ_ERROR.createException("Failed to save cover images"); + } + log.info("Cover images created and saved for book ID: {}", bookId); + } catch (Exception e) { + log.error("An error occurred while creating the thumbnail: {}", e.getMessage(), e); + throw ApiError.FILE_READ_ERROR.createException(e.getMessage()); + } + } + + public void createThumbnailFromUrl(long bookId, String imageUrl) { + try { + BufferedImage originalImage = downloadImageFromUrl(imageUrl); + boolean success = saveCoverImages(originalImage, bookId); + if (!success) { + throw ApiError.FILE_READ_ERROR.createException("Failed to save cover images"); + } + log.info("Cover images created and saved from URL for book ID: {}", bookId); + } catch (Exception e) { + log.error("An error occurred while creating thumbnail from URL: {}", e.getMessage(), e); + throw ApiError.FILE_READ_ERROR.createException(e.getMessage()); + } + } + + public boolean saveCoverImages(BufferedImage coverImage, long bookId) throws IOException { + String folderPath = getImagesFolder(bookId); + File folder = new File(folderPath); + if (!folder.exists() && !folder.mkdirs()) { + throw new IOException("Failed to create directory: " + folder.getAbsolutePath()); + } + File originalFile = new File(folder, COVER_FILENAME); + boolean originalSaved = ImageIO.write(coverImage, IMAGE_FORMAT, originalFile); + BufferedImage thumb = resizeImage(coverImage, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); + File thumbnailFile = new File(folder, THUMBNAIL_FILENAME); + boolean thumbnailSaved = ImageIO.write(thumb, IMAGE_FORMAT, thumbnailFile); + return originalSaved && thumbnailSaved; + } + public void setBookCoverPath(BookMetadataEntity bookMetadataEntity) { bookMetadataEntity.setCoverUpdatedOn(Instant.now()); } @@ -133,6 +244,92 @@ public class FileService { log.info("Deleted {} book covers", bookIds.size()); } + // ======================================== + // BACKGROUND OPERATIONS + // ======================================== + + public void saveBackgroundImage(BufferedImage image, String filename, Long userId) throws IOException { + String backgroundsFolder = getBackgroundsFolder(userId); + File folder = new File(backgroundsFolder); + if (!folder.exists() && !folder.mkdirs()) { + throw new IOException("Failed to create backgrounds directory: " + folder.getAbsolutePath()); + } + + File outputFile = new File(folder, filename); + boolean saved = ImageIO.write(image, IMAGE_FORMAT, outputFile); + if (!saved) { + throw new IOException("Failed to save background image: " + filename); + } + + log.info("Background image saved successfully for user {}: {}", userId, filename); + } + + public void deleteBackgroundFile(String filename, Long userId) { + try { + String backgroundsFolder = getBackgroundsFolder(userId); + File file = new File(backgroundsFolder, filename); + if (file.exists() && file.isFile()) { + boolean deleted = file.delete(); + if (deleted) { + if (userId != null) { + deleteEmptyUserBackgroundFolder(userId); + } + } else { + log.warn("Failed to delete background file for user {}: {}", userId, filename); + } + } + } catch (Exception e) { + log.warn("Error deleting background file {} for user {}: {}", filename, userId, e.getMessage()); + } + } + + private void deleteEmptyUserBackgroundFolder(Long userId) { + try { + String userBackgroundsFolder = getBackgroundsFolder(userId); + File folder = new File(userBackgroundsFolder); + + if (folder.exists() && folder.isDirectory()) { + File[] files = folder.listFiles(); + if (files != null && files.length == 0) { + boolean deleted = folder.delete(); + if (deleted) { + log.info("Deleted empty background folder for user: {}", userId); + } else { + log.warn("Failed to delete empty background folder for user: {}", userId); + } + } + } + } catch (Exception e) { + log.warn("Error checking/deleting empty background folder for user {}: {}", userId, e.getMessage()); + } + } + + public Resource getBackgroundResource(Long userId) { + String[] possibleFiles = {"1.jpg", "1.jpeg", "1.png"}; + + if (userId != null) { + String userBackgroundsFolder = getBackgroundsFolder(userId); + for (String filename : possibleFiles) { + File customFile = new File(userBackgroundsFolder, filename); + if (customFile.exists() && customFile.isFile()) { + return new FileSystemResource(customFile); + } + } + } + String globalBackgroundsFolder = getBackgroundsFolder(); + for (String filename : possibleFiles) { + File customFile = new File(globalBackgroundsFolder, filename); + if (customFile.exists() && customFile.isFile()) { + return new FileSystemResource(customFile); + } + } + return new ClassPathResource("static/images/background.jpg"); + } + + // ======================================== + // UTILITY METHODS + // ======================================== + @Transactional public Optional checkForDuplicateAndUpdateMetadataIfNeeded(LibraryFile libraryFile, String hash, BookRepository bookRepository, BookAdditionalFileRepository bookAdditionalFileRepository, BookMapper bookMapper) { if (StringUtils.isBlank(hash)) { @@ -168,78 +365,4 @@ public class FileService { public static String truncate(String input, int maxLength) { return input == null ? null : (input.length() <= maxLength ? input : input.substring(0, maxLength)); } - - private void validateCoverFile(MultipartFile file) { - if (file.isEmpty()) { - throw new IllegalArgumentException("Uploaded file is empty"); - } - String contentType = file.getContentType(); - if (!(JPEG_MIME_TYPE.equalsIgnoreCase(contentType) || PNG_MIME_TYPE.equalsIgnoreCase(contentType))) { - throw new IllegalArgumentException("Only JPEG and PNG files are allowed"); - } - if (file.getSize() > MAX_FILE_SIZE_BYTES) { - throw new IllegalArgumentException("File size must not exceed 5 MB"); - } - } - - - public String getImagesFolder(long bookId) { - return appProperties.getPathConfig() + "/" + IMAGES_DIR + "/" + bookId + "/"; - } - - public String getThumbnailFile(long bookId) { - return appProperties.getPathConfig() + "/" + IMAGES_DIR + "/" + bookId + "/" + THUMBNAIL_FILENAME; - } - - public String getCoverFile(long bookId) { - return appProperties.getPathConfig() + "/" + IMAGES_DIR + "/" + bookId + "/" + COVER_FILENAME; - } - - public String getMetadataBackupPath() { - return appProperties.getPathConfig() + "/metadata_backup/"; - } - - public String getBookMetadataBackupPath(long bookId) { - return appProperties.getPathConfig() + "/metadata_backup/" + bookId + "/"; - } - - public String getCbxCachePath() { - return appProperties.getPathConfig() + "/cbx_cache"; - } - - public String getPdfCachePath() { - return appProperties.getPathConfig() + "/pdf_cache"; - } - - public String getTempBookdropCoverImagePath(long bookdropFileId) { - return Paths.get(appProperties.getPathConfig(), "bookdrop_temp", bookdropFileId + ".jpg").toString(); - } - - public void createThumbnailFromUrl(long bookId, String imageUrl) { - try { - BufferedImage originalImage = downloadImageFromUrl(imageUrl); - boolean success = saveCoverImages(originalImage, bookId); - if (!success) { - throw ApiError.FILE_READ_ERROR.createException("Failed to save cover images"); - } - log.info("Cover images created and saved from URL for book ID: {}", bookId); - } catch (Exception e) { - log.error("An error occurred while creating thumbnail from URL: {}", e.getMessage(), e); - throw ApiError.FILE_READ_ERROR.createException(e.getMessage()); - } - } - - private BufferedImage downloadImageFromUrl(String imageUrl) throws IOException { - try { - URL url = new URL(imageUrl); - BufferedImage image = ImageIO.read(url); - if (image == null) { - throw new IOException("Unable to read image from URL: " + imageUrl); - } - return image; - } catch (Exception e) { - log.error("Failed to download image from URL: {} - {}", imageUrl, e.getMessage()); - throw new IOException("Failed to download image from URL: " + imageUrl, e); - } - } -} \ No newline at end of file +} diff --git a/booklore-api/src/main/resources/static/images/background.jpg b/booklore-api/src/main/resources/static/images/background.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5b66529b85381b647f3e41cff047d4604a48fefc GIT binary patch literal 641407 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 {VhE zrjD2Ol6V15ODrBGfwq|O_Ts2H%lf%ePg`|V*DQmUZ2_~6=G()(dY&*4Cr0tdM{zw< z>*lU(MrX|EuL@Rsyoi56?kT7j*nU?_m{Og{F^K7;BR&88HY>d58|(2j>1o7zTAYw- zHI^E@y?KW1oiSCFXwrBg9%qFJjad;*Hp;=~b1d_J<|Q>JHg?uq`? zYsC2oxUD@tq}!_9N(gaW#7BPf8{rV@khR}l7hd##POo~q_!MwTIXyoY z_S4^AU+RWZQABvTw7cS5>YNiFYAIb0XF(9ZIAp_D=HM9VoHD?66oK1&ZQxKV1Y1I($zUEXJ8eAZr;QGTCvx7UGv(sVvEM7)@7%v*9kXlvgs@!P)5>vF1@O z-}5+8N5M*B`aq^C-BmD@5tX!&)TH^onPBEwVA+yv&giG@MK}$w^ORF37MZI;GoRF14+U-#g~z1Z$y-;Ev(p;3D(Q3sLE8{EqXw%f`W`IYoR5E zs=8H_-MY1+pw^>)7MX|-Z%Jqeb3Of38G+`S{o<~Sk0@Wq;>>kG%=Gz#wUr`d74;zr zl{&`6ld_s>#&};oJ6m8{95!n6b}lmD}5Wv7O7Xw>_09imBUMe$?xlCX59JvYD0E<%X={ zHA0i?oL3c%x2Lq*dQ9i!*nnNCbkd3<_j@C=I4yYrlC`PB(AAGzy55t}%)vUOJ&ds2 z#WY*#{&oI6C{^O?sbF%%|Ft|BOZ6$L$mr;V!n(Lx%!Lp6GVOwjq6J~y38NIl1{u|z zMyb06f;hoUy*DR4GeU!BsxSN*$n`SxO+7wxE$;!iAOx*^xub}>AZ{UgqC8h|SL%6B zlf5;1c_xrSr=OsqJ|6aKSc~Qblr5&0{_#^{i(mJ9E=xC|ttkD!=~Yyi??}GgCREX@TD zYBjx_nn=lCxkhb(z3Zv0+8*JGVr%p~s(w<(iSj#RO2cylJIt&py8Y&yv ztwM&xBWI^38EEAn$h0rOLzPPozJoAsZfcs*-~S`TFV|hQqt*WsWR=&gEv@fA-EvK0npd5^L(8C2pDoz#sv&;E^xW$8pmBl(3xin+Y zN)AhT5|46h`4P37;>`cEOWK0&{zcXlT=5&rzkh}hb0;eN{qGqRI%z#q{!{OE@P>&0 zb8m~T|F2;ra8Ac?;w77tNygW>(*6>g-C*;`3+tWfS_C+x06Jd-xS}!-L8}UDCD0i| zJXu5H16cZ6peG%}w-Pigf|6Abb4kYAxj7fX4mt>$<^%d@N)L#-D)xh@E5A?_?i%R) z=VqL#z^eonQf1fobP&}*l|rBVAGwItXjsARYe^@n@D_#Szg)1_> zJuDpnJSPAa?%l0xlGy+*O_>Lm>Bxc!l77L7*=#=+pUyYQ))|V7po@(i>`J%>+O+gh!*Z=9YPP;sk_-}$wV;l5D+2Y@ zRr_iLq&%Nlikr;;&||!?iIl7)I{0Y)^zK?I7Uggsj3?<14mpJ_wG;D~*GKcKT%;TJ zwq+K!_Zf{&EqNBk5S>(qzA~B7w{(Bj{G{Kq?D6VT{q7oFO>T04 zd&3hOwx$Kwt|dBR?RZ%?;*OjS=ps)d7B^% zj{U-Skm*kwX=t$q4yjhncUPofN*qaTx8NhEc_J-8X23^a({BrTz%3%*MB0@z)ZTbp z^Wl)YaM@;7O5o`a#Z1^@bDYvC#px&fo=rvFo|km;;?;r@bdZO}p6ALCRHd5*=ex&3 zit??aIT7oBq6a@ei16wqBkT8mo%xf;EsIDU{|7*qhtGTi)w21p=+6hB60rhYp(X*oaImDZ~++{yo zf_iYjztyt`4%*1D2Gz<{kJV{tK*zU>4^GRP>L0Nqnz-vn-mli2J-2-w?(?a8RPBj~ z9L<-0mo%CH{F`jsvsXCH?kze%x{!jh9n07(+nTE{3nveT@5z1~;dsEo1UKnKBO$SD zjE1XPyzSoa5OyKNF6*7(?E?R#EL+HZq2~zAXX5vKgG%RhRjws9>7zK9Pbbr_$!NOW z{NY~14X`bzK%;6YG{LIcqF+RCkUEm#4OPwA{b1Ey%WhGDOOl@o6gQE%k1q3AT@5<; zH=2JTP5=J2=)c_c{?D&?|5tD3KaY%yyt~OPPVi*>eh2xH+ZCl5DaYoCt}o;qSzjJr z^E$_`To}jIt2C5iCAGi|CEt(aw>}3*P8Y|IuOWWvdCbXaRfPPLRV1EHDsWi& zhIyX?&(&@Mmf%S=Abz63*B#Lo+Sqb{-SZ>9*u_r|$fIA$K>DwO+3j`Z*zBUdpRZ;-R+&{VC z%{IH09iUC!&_|z%OYK(Ih1ocic2kh7g@V7-&H=(?dfXP*RH8HA6gkgg88dXXj6VWr zuJE`ctp!g+axJQljJ6#2xVq;TF~^o@H~vn)5VxCcF@ z&C>!F159f6T(@4iLsh6)G#vjUKAeW;wGKtb{|fUWv}07fv{ir5e_p_S)Q;suv7*GJ1xb zQDQ#9hybuQ`fRrdE55Ka{9tNJ1=^W!AM}&8#;t5~lyc@sWJfii?0YX(tSVfMD=wdp zDNs&%81&D!UeHn+H!Woro<+9@eGzVzc!rDJ6(>A*y1G1#c3oK)-yf^Ol|J4nY$PBt+u%&P`i{^8Ik9oB0}NwVN3Y= z<5(hPK%fod7W9*bDQa1Se83*9r&#c6RC&MBtE780Mj5U$@88HB?7yxJs#^609*TbL zPUS8_foim3kDfV&fW4;2F`>*kVbWFxK5-?b#X1QqKLco&hZZI-h`&dN(0HHA8I;`o z!u}KmixBXrkqRkvVNnlmgse%eH#k@L3aMX{HXtX zVZRj5bk2DK&mrgGR;emo41&=HMD=d*643SkJF%K_<1aSqUzX~by`aArvVT3XxJ?%Q zJ<|&O)pnLu5DK8h)PctR2A|~ofV3N+>Wgb14xifs!BP-BwJ*?=Ma`WHDx8}ev#wR- z1jcC-Kk!0(ur&qpTQ%yyKs8H+0kL|4M}G!3pb}U?WnN!V;sk(scsNxVfK%1M<0LG= zvvPz=L5?6h=NuK;1Nc<1+SEd@%;XVk4V>85CsU!~^YURR;Hp~jfmpT&zyLt= zmqJ`;?J#LAa=Ol%m&s+vIqL3%p}kuqUI>kWor97-LRd4>nDujZ`=iijwusd@^JS5SR)LXxI>zkB#jAPabfn=<(jf3{GVkzJAMjE@JB}w#^NA-US9Cj-#YBD=-v2Xdk zoROY9m#M-^kmZ_>x1x%hmuNmQbk-&=g{OU0Da^Nc6z$P*CP?~O;-~Xph<9P;ZZj|@ zb6Tduh}F+i^_p+T=fC3`M&q_1n0$d(L|FIpw$j7$BB3C*XS2MUabODw`mHlYHH1_v zrkAq>{ueK+6g}j0n@XQic%na%{oZYFC5;ft20RxG1gxhRdrs|ZAy3N?R)$6p<886B zM0sP)_j!fMuH#IwxOX3lzu&`10FkWv{Xi%C2$ZgTa!#usMVzk~uh<1jTB^$TcU-g} zFjbC`|SqrQfC0R{xy3{XG|)zKd9IzvXt; z_)w^I7{h88d{^@|n>e+ch0pi&`3llV>MHE}aNO6TNBz@ISYU&^RX093^lVyI|8dH6 zo^OdyMe9RlYKhfTkyEZMYMRsDDZaK$p|v9X=1?qOT`*EL-;A%wMkBh7$#2Se^XX}1 z!E=xVwXlZynW0vPB&hPzEAn~s=ANmh_Q))|V!qt)k+ARnLh)A6!?aViy8|~`#_E+<~@UELPo?e4hLHs$B%d+kR-ZuasF?2h_|5l^S=INaY0AE5z7lpD_S)sdy(jhj_6dHVP4K zSlV4lSo}24%I-?D8trtQ@2yJ5B`Yx0=p1W3TALShF@McU>Lk%A%$__m(UT~x! zXi2oQllWPqcjT|*|FU)X%p5T9I`Q>J-cH}=O@e*-j&+->d-^+O1~*yoEfv??LM zYA@%0t`y<5tQpk%58ICD2PgA&oyOvfe z^sI4x$qmnLr`D>^8*t5HGqg=Uu^GYf%{265t|ihz*W|V4 zvf0v&@k-ZvkG|9!ksJhfOqMI`2(@~zbk_e88s=XF9z!im{rKZUcPsD} z&a5`Sk-V)1Eg50e>vNJRte7A*LN;5R1gDoARV~Gq?DE~deJJ-3u438((?e|7uq5QG zpwIloF717t#8Gw-uKY}=Y#br2I;=xm?hzOMT0p`tNmJnS^F{G&Gqg{x4VZ{8vWO%% zZXju0{k9HI0<8vaQoi&^;Jy0FrWojaCiicAKv)&C6hJtw<};_6{DA0$<>f-5Df(7G zJ&_ymz9bN!yc7kzP$?m+gt&DrHzYU+mi#)Lwpb4W`BAI;BVvkaQik;nz@d%v1{I*D z{ZfJk&oJ@KSK(BEMRlN8mn;FUtD_rvHv5u~jjn5%aJ~K!I753|WSSS+NN_n~yRdM) zWK;1Jtc|a)oux;au0J8~(HZ#syEV&nfluPkOK{HqnQrGtIHh{mA0I{*|A03#^!E#} zTWn@JTm&@L%uuGubT?i{?p_FZ?a4`{Y_4XoF1?wZk-;D%xXMO+^AAj{LqA5x`}|Ey zT-yNVm)u#yy6j8EqS)qgQRt9*J={a8){jeL#a5e}e!~b#fqPKr$n1^Eu-q=6%-V8R z8tEo}pYleZcB^&S!h4VhiA!lU^Jj&~sq824sc}%mOce$4b*hM*whn(JD;Su-V%Zf0 zq5ypOJv&QFhjw~VzUTb=MMBM84e}8Ao?eJF%)CD3&%M^EJODbqJ7lLge3Azs?v@@d zT$h$Mb17wSP?f1*5A}CUE;uM>eM)Mh$9Qx}QLm@)%4oRdP&v2V5XA+S+1_VncP_W= z&++rc6|3Fa=FQ@8hljf< zu;uwj28QyDPyYP)@#}+yCL--X(7E(cAX&wX%8i|jX1{H%%h6#hL!!f$`sa3GD{D-a z0XU42X;c~eRG*p%pHmPn8$UgBWyx_*b|mLUoNOJw6T95j_&KF)5~n7wBi^WLuQl=)CIR+Htj2RaSt|= znx)cL-!T$3G^V-TmybOJ;ZpLk*dIHWOPmz5c+kB6sVVPz#hGY*;|ln1yfQ0 z67U+RPbtaZv9;nfsyR6M2NgS*mnn(=)yuM#HIY--OKhi*pJ(Ol9Lia53VVfKk;3C6PZfqqD8dF^o&6Q&GAy{+JvEM2otjHIZZ}7AS zS(ZId@Ykyka}+aY+uGTi0B$Q`G5l+1c}vplg(`12Hu~D9dYA%Xiluzl=XLjK&{L< zZG6P-b{h~02;qr#3RMjaaocj5Tf>q^us;Hj6BVodhKj`&Hww8npUQbqKYb{)2A@(8 zj11Q+Yf#<1QC>vrk+&7He>63TWDK;`xsh04(Pc0*dmH#pi-PkJ0l1^EP%5O;`{f9w zv8}3TYoFy=_`zcx|Myqx`YQL?VwkJHQqEeqN1aOBM7Pfeje4E+Y*xEy)ZNF~W*ydP zRP~3ZrKv6rgs5RG<)Ze%u=J?BDCe|Z7vGBNj+Qcq(S^*RCChN02a?sS=@{+tmq9EZ z{Z8k7CPW|ve^HU5y4@Pl)4&LO(@^p3-AznpOHq#zfY=i;cSpz4HZSrVSr5HXeBPlo zay@NF_2IKmRUF5+u@5+eOcEd7(N}Xkm_i^BvW-#PfBVUA?KFIz;ruqV2&$6H zEkYnD-%J8t_WfL8Q55bA!tD$m#>CL3L|PN>KwCKkgjIgBee7KBgOh9z{qveFKW%S> zQKK;J_sv8}r@7cXuqy0sr+$Cs%6t;Y1iX2(SuTxFFzp4H{59)7X-m_)&h&sSN3t<_ z)_v!UGi8Wuc+G}efhk~iWL)BH zaT!q_KPT;a2YW$T9mF;&+?y7Pz5j@=KnF7FPd&lCNMwGa;YJ^4ixaTwHZscdxs8T)Jz7zb#i~w`-Y|y`gFoC7xtYZcOn=8gk8& zwc@a4UG>eTS>?8Ls-}z=D4VJuQUdFLSx4#!IEGG*%L!Ke zYM<+0_|*UUBJ&S!$^XYI+kd*J|GQw}#0B}SgZTFO@V=A_bN)dLv{J}+WCqpZWgk6- z4mRIxzJe)Zok%OTO03!FeYi}iy5D^xRvIH48q$^Za`*FXdhI7#i4%jNlfd6+^x<0*t-7-NJ7^t8& z#kun^{wc8$->t4@0q9e@^iuTFelt=X5c?oDR65+GiT?nF$`J!qecA+WlJ5mB)>m6;QNMj5=a6#@u#$E}rsrtNP zJC z(&XlFt;kZU^q4%#6ArBQdm0K8O_v7eqxHJNuEeWw5tSnAGLo~8+UKIxBM!E&Sks~M z77LM?>*p0 zqgsF=ZKQ;nyj%Qrl&Z1R`)@RWd}^Vr;b@T%j5cWodCj)|`DuEgM=zeyDh>xIJv=T` zIbRlj_-;4tRcvux+Sh6R9{o;!yO+bn78#6C|1(_wyFj(v=eG_qTW`1tmVYP~^k&q&$@v>6xmDm{^rVRZ*C6Y3j_&3%T~I(n4_RIP;BhRA${6?1Mq2)oZWI~u} z@R!FetPyCWK!6zG%ahrKjxnQfF-@3_<n9h_7~%98Bmwy0iRVQGmV2x; zhpnWGxjoY5{wqO%?LV~Xe?D|L@821#oz7j7|3C0u4YDU(!nu?iXrmHn~nR4g$c_t88(Id_GW$I?V?8_GIG3LG}5o=|8zn1kPxN z*`xnRs{WM~P{|NkI$`_=E@a|8#OL8zW{-f%gypBR>3}UM`!ZFrpTM6*s7FR1I+Kj+ za&r_P0iLSK1g{p*yY=dBS23L6lTeJ@1+vwypzGml;FijN8ogdkOj|i^0p_IWez%lt zjD<*^9*<6ReO<)l|kS1V5KwexSMsG{9+vEedx$ROcNVw#aFat=RpWh^}9si^-qj=(Ea}=uf_NRY5x4Rgcxq z*`vioHp^n~>iX;9KsMHq%J^Z(4@R)fuIuAQy}Ofj0(On4>k6U+C(UTTNrFoDp@nq* zmiqaNcsp^+66Un(mj;Vl#+r$#5{-gf=dXl>d3;DP`rbq|n{2yE-pU`RDmE4JrBG-{ z8#$}-*r1Z#b9R*g@PG9rbACs{y4I!n&Dk4=$9bIzThV)JO>?^~F&X=8*ZF4bsBLr^ zl}CTv6V@BARcZ)W6D%Q)Y!`dlO!ySSi=YAz0F?mX1I;PPwh(jA!EGo8d~-oqit`59>1Ld#3rmoLh|MT`f1{Fjph? zUR#gSCy@r|!R+(MvrbF5@BcE{%G!1Y8*(x+4RnbC2slC_^btTd_g5g7`W;*m*ZAj? z9-^__c{xwUl0kE)*?D77c-l^U%h=7b@f{uWR1?{Q(0>KnK>fsD9_-?8AB;9HmwGI6 zpPxmz*fs(4LIok?!@MFgNKg)B_q7fi;gh!1ff*<6V#mS{P&5oxQDXX4KVoCssC<0rx!b&U3htvb1Ygxk7L} zOvitOPs_brKFB%=52vOzCpA44C~|UxC-_<@^MKu7s(6OeDm}{K~(N?e3t2;;LDVAUDV$>IOF{L?KVq1&Ykhy z^gI9P1*POth0+GBL1I4ISr@Z)L4EZ{LEkhsDgXEbyyx}kXsw`-FKL&9Cp*hOo<7p3 zpEOR%87;Cy7fyCy6b}z0u1H4@k^LI6x@_PZTR7e$BBMEw{by*GEKBM#;o&puB*(?u zE6<3~ry&7jGM;${#e~-yXeBAbzD0k$OT9jJn>##n#_YWsB}t=J-_ zY_-1efxzrOxFq(b$vI~s7T5F2#GfZ=h386tRMPCbz#gXlG$}t@Kjmh%;C#>q$gegs zT6`rHosy&6@qKE3>rBN9VvWte(5nBf7q|ag9pj%wtJAI^+OWhAfRhqy7(zt$3FNoC zwVAPDPPmeeML6o+09(>e*KZ_^*WU;RgHmT>OGAV|haR~9f(eRXLTMa!Oe zTd_n^w#&P3_9@MdmXCxrY6?CYspCLNoH1>60!RN)PSdMQuXkWGW4Onl^wY%Q+3k}{ z!)%Lz1y_0#I#NP z)2h1SZe2Tw5LhjhY&w*Qf0#2DI^S<#QIplf(7uatPN(M<<>;;ZIJK33v+$g^+PPSO zQc8{erN%8?f47YDK<$)t^GRa$#Ff2z3(!*Q!HibfB3I3f2hLRhD_1;)$j``+uiG?f z>PFa?oenHJKxwvlOL3}u@F@k8!HV>PRtOkzH+FZDImPK0N}Zq!8bRjC-Kdfd zTz9)Jj#6qmYIpR{bTnH6m&2|8PgC&)j5z#{5RG%5-JncWtF--^Ri-(Dv3cV6;t%v6 zxH;1cW+T@8o$FcG#A^7N6>6m(VMB6EZwQx@Wq|Sl2aPEo@PGqQkeU?9Df^ zTN)^9G`81)9A;8?P=9g(_cQaT!C_@5GT}8exDidx>@!YlA;3aw-lg@!-s~ASr<~HSU&ww2s?2t%UBYnZ1atu)PTHx*yu3eqq?T zjZxL?iplZ9u#0{PPB{)uFD95*JE{!%q#C~`D;WpuPRsP_H*t1yz3&cDz|bv3t-tPd zs0vxGn#V>(#rDEGRw#~-W2R5_=%O=Q+N*r+<=V@xRJ1qrNMSH056orSL+Z@`n>2ub zKk9p&$~ot>B&o}zLFH+Cm}>gGZ1^AV{U=WqBNngB-6sFx@c)vXH!O+AJ$=l1{XafF zL;|pK|M&Y?aQ3I4V3U8N%UY!B=(LZa4fW)xz^jH7=F17AW&zSah#y^S3iwMBe!0s@ z%Ah=DdX+P5>BLJNm%X)Z>}Rcq%Lc6}rjwP^oFy>A1AY~00Id#C!HTR0)(%E8-uCx* zfLfKSEdcwf>Hrq7_RtW>(!%_RF^?V-@Q9MPQ?1LusHzFDV$an}M%vv(3_BRtWrHBC z0kCB2a>14r#JJf3*~8Q=W!t+VO@IFClVGp=BQgT+rm&t##m~zJBS`XC;`f1H%c*uj z4SV35em|E5XxDuAr4$hH%bT$Nn>2i#C7kgTjFcreQe|l+k$Ex8!_|tf$r=mBx46>m zMsEOZ?A3`q%PS~0aC&XKY{fP0?abhv)&YT481^BY$USnAGa@TLIN>Byo9=jFb8{BA z*ZIpj7$xmEC0fYS&Huh*3ZW2|C1(&8Nbu}dD3T9^LA?eFs|xQ_ii7F;Gzf zuA!dYf&nAp&5x57R{0rpu27^-%oMDAGa}fA}vhGk7Mw& z0E^gwI~v`~G%_Juv6^*ro+bUgV{QYw6jNfbvoWzyuV%pw?oas*@*F*7u?nA50Y!_P ze5)Y#K<;D1o$uLtmDB!uC)IzEx^2D866Si88O8Q~EIH^r%J1BEiCOK?j(i@9_KH5u z>Tf|0JTil?;vLP37^Q31OV@9$Kw!~QTvcf%9#rZ!jTH?-Hx|L28DhPMBxgw&63DIB zK6tMMP@XD2zDFukqALc1eeUh&ou0}z0CQF}Jt|we5uWdhe!FXh zaw4kU#%Y~XWBY}UsHZwSc>eyPe>B}n14*+wd;kI?P5+wPmcl#m1Z=GOmtSw?T+~11 zA03PB`b0Pod1pj<9FH!mAd?pT#zi@gHJKdjHNoK@pSASOA!z%W%e&*(=Hzq>JiDSO z{AOJOO5DpDRdCaEe>mbCtJeqH?TOo|-kTIaWvxo$TnHl-gp{p*S6bQqx!Z(#qU0!FV>XPKg9DYJd3rxc@R$J3UVuIHv^j{eVO^~?#w-#m5@vpFSsL8XB&LM z-lMm023(1m*jmDim+cLA7O(IaUF7_Lp-q=f&K-q=*$+2m7*i8(lpAIZL)%(t(I$rh z)5CUl9u?QPGE$-+4!LqXgXCn#{zXd8mX6+armH-X7GFi)3#i_m`)C2`7&O&yqqw?S z0f@GriB^#in!^RMM4v9}dVwufs_LQetC`O5FY%9Ct=^HZ-T?^7@()9l-A8)P2?h|d zU0ijh9gLD0=tL4!^2?WEXyo?1mt!YBTpU{`(}2A8b>`}xcDQ$6NCPh~^+M(i)^070 z67}XU-;(y!ESCkfub3K*N(Ey+R9p&jjhdI$)ZJ#tqO_S zUdBFrX5NmFLBCdywalR+JN|KAiYswuQYF;z{?&`yEb zbMt4n51VM}(RYP1TSji5d(zd$j?qqZV*~!IU4kg5Yx~$Aduj?yiLP3@O451Dw6p#{ zt*SXU1=FPSmWrW+PpTndIl5MBJ*QXVUwO|lLpdkRKHpmbYnCe`nViwi@`ZE3 zh+n8~P06;c{2up~yPEGlz(PY;AE|KRJ32n(sTs;6enDz61TdJjs3reuM5@DupPqJ- zPAMpFlijKJQOgU7as0E8q>R}%p0je_OyRy%l8m(7UAdlfQCjHo%FBp9phMaYH$xMV ziP*ZW>1)+pan#D9Xt`^C2Z&$B(tHPOGDn50{|q8Q zx_r#7Q2?CDEpwLW)!n9D>DAS(o#8F!lY;V3-yi}+nIZMkB?nVE&P*2420B}6Q{%Jy z+GN*v0)o|>1H#>>q+8jo?91rfXKT^0=Q}SxW>YjQ#m&(zbx{v~9^HR-ys3Jq4CZN7 zZ~Yq$qy~BYU|P55aa+!%c6QGYj=>|z0!I_-gtqV>cu3W3xJ!oxU zavPi)!FtmY?@JoX*`$3TKfa_~UuZPs5AopXgNbtG@oJ~M?3(5jCdt=NK7?)vtVrcR z$iecoCDN8Yb!BHVD&FgAb=r86$}_FWtvz|N9s#jSzti1`OTJX9Wa5(FB0KhZP*48& z_Bc;QY=M})=~9W_+^AwVWX-QEbtvzdBoZfCZLvYFN&^3=E>u1M# zGuS9-RP@siQK)CU)AT*JB1tW6#Hz);ui0YLmz|ai?#l>G&BDe8wsC04cFKPf6L9jr z^#5V+y`!337j18lB1n-gT?oAjp@yP#2!!4Q0#ZU~0U-etL>FCpZ%F_F=?FsTATE>| zn$o*SRhk7Ab*X#bhqd<}d!KX9ckemh`2P9E*#F=NATSsq?|kPo=Wil`A^X}^^qkxi z$#Chm2aH?<7v~+De-WksxT$TP=}h=%bahUBn{a03j7DtUjv*b*Zzeza7B;%QowR zAS&ekl{7!#%91Z7o-M4VnItcH$1;igwxyH+-}Oum`=bV()3T>^w+r>o$N<^lJi&lk zrK(!3pJgX6XKeE)5gXvOnr?7f@_Z`R-(PfW+*WNnQpX13uma zM(s4*O~oZadV2;{}qw9VP-g8j10OsOE;K(+SOSBT_ zB&$GSaZPF$u^9)YMmkL@X0T}NeoW1js>qiru(i%re0BtTCq|S zR&*nrU|_qPiuF^D?emX933(k>fYoCTpo)t)DjTeWcSBaK}w5#t$? zqZfYJ!pbblGq%7Df=AYvPp4P4nh%Dp{aw<|u2C?s7tNNyvPPzb2}XH2%&&ly3xT~% zN^w%|kCMq7vp@*lr*jE;szWTel1++{O0k7u#~Eil1Q}>9k%J7D4JW)>>cqZ9O&=y(Q#X-oxLCUGVKK9?1jP0FHXJ+-ZiFj&v*^?dWw&jz#NsqgUu{$-=M(Qq?y9%>KOIn$+;oPqa zG#7C%(Teg2&$#gFH~VAc>X$5HX^C+%;;a4DKIOhgCfOrHL~U_m-|S&~3DxxL9~hN&sE_xGDgJLhOosPfS4;{2MJ$7vKY^Zpd5_u~B)pNzmBN!wzXhjx zW_NJWydCuJ3URTB`ds`%DCRERy~Rj0qH3TyxLi=gTkaMsv-zTwcyX3`ho|$?IvY>@ z-!lT*dEWa3R?a%?rah@-nsOftJ$lFmyNes-Eq*qx5zei+%J8ID)?CRYSFE>Ky-v0~ z>LD?g903u2pO86Mr-+z zbjVe%zy%~6liE(Jdvkr_ECqRZHQd)lzSD*dJ&%$>%c4@^66=F}* zQhsP?Qe07Z%Era$502k7(-Xdg$u{fOjybF_U-3*UpnGyk)$e`ilpamF-S2 zRyDau-kPwUj6Lv<2FQjw3Umm3_zJFXf{(t>_F;be0#qQo@ zYZ)4~te5C}+MGAF^Fy+2Q9PJ-*67+4`*XWM%eL}Jzuoj>UPcSZlJYC37^H)YzW2?X zx@W3-auEgs+~z0WF$r{x@CGnF$OvltL-6$5Ho_}fez|=l+VH6&xIW<-xt7jB)iu*z z4Alq#cId*D8m01tW<#VHIGSv}5LDlVF}!4YCHNAiM!c3*JT&%B#X4|Rn@i2n;R$~9 z6H@4oOa4(Awp+rh#fJH=h@uNm@d|~ES<=(L@h5|oDX6!PG(7;jHlH!G08rq;;Bk>t zm@H$~>o@2rJRCJJznlvu=+JDcs-J7Hb3r@s7OZzhT^Xa;LsKp%1uy48m1;~j?5 zrHBX(&A>D{Eg)X?RLiFB8!nxU?t1l`Nw&_gszGWrnJ#$@^MA#)?Dt!Gw%Z++hzqd9 z<@1N8UjM=PB2oMqXIR>;@rTLHhE87N+bPR7wTP$c`^r!8KB7HcD{_8O$wdqS435$; zIfq4vDX#>?^pPyB4w-reUpOEQyN-8Gg>UDX$*_yj7BS;9wKijNxXe-*2gvnc35=uA z?(Mywi@OT|7-k<*HzMFaKdIW#{*c*wq%@d&{e4jm?|k%>ZJTyoqVQC)Y)?OT*n5H* z8)QT4M_XST>R&H9#)j?U1J7X~d#$_2vAPah!Hss?>_}lnajFw43+6#U&qj&;RW(P_s-tjTh{EjKJ9vW3wRjw~sin{VSZcgC;n-q5lY~BOKXe zVn|(I@7Q!5(obf|2J>xzaMdzUXas@-keO?Yf4Zh4%zy__oni93&!2+Q_nXHDp5W9o zfPnlm#&dhjWONfK(sondrLD8mO2EPb+pF%Ne3f$fcVMXI&2n#jJS;R~)&j9v)&lSe z72BEOK=Y=WkU+AQduP1BNWU|30+3grT>s3#nx1i~((-4ZdvW4aRMH4{lITyD z-In2Lt+~-glf&9QUq#c;Mp4h`wDFTN8lM7U)v%=J&`9*yyH#JqG0L}!^}5pgtk(i$ z)8yi-Osbp%3xeC(p5|{{;A#z7+9}2T=vuMK47^O-_`YzAu`|?+#87?PSAUT~3C%;4_p9kO=ta*$-o45;FW6`Rd21 zQI>IWeNZwo-zClKw_ee3Pk9qH?2k}uKsGf}E<~E?4q=nWiqk?4vXK_F^K}#Osp7TZ zw$cchUV%@%Se!6NeFT%*k~f>YgL+l2y6cp+hy_B#&u(Hy;FKp_B*=~;#qBjS-ig`3 zl+D2a-AiwD z;PjB$aYaC49Ur3tS6U9sMYEH=477&`Q-Tz+kE<|gb?OY8YIUa|vnJAAI{j0_to!Ee zbachnD`vBpSV;-BB1PND1Dun2Rd8%v#|;q+QJR8lVK5b*Mqrc+!+pCsw#jfL+F%hu>muNbkZZww587ZdGwu5P`K1I>{MjrFTS6zH#l zS1CThu=u>V3EnNgk1R|#6jdTy(qMlu(N9E`GSwYGZh@tW!D;S-ryX!}!BQx1gW}wI>==Ib&||H_J`TdZSa|Fo!>l-!&O%=Gra zkOK}ERQ&GJrdwC8$+@|{Rf=^&GsoH~xmkQs`eb8-LRa|-;i#{^h~5aUcm1Xm5CaT$GBP7ks1_qp^8C;bJL$~^qlvGnjJ=W2Y#yxA>Y zUoKcvw!b8d*&QY4jbN;$maZMI;Ghod`W7?sOZHaBTk))vEL=#es>^maiYuT6v)^W2 z9)uQ3pV-@#>KHo_AnX-h{oXYC((a@h1ZL;|tz-Yo>)JnHkN@{erVB-Bk&Z-81>4$# zOdZTyF>i94-UJ)mu0y!4mHx)o4DXtY0u|gYRxGsjOsZ?4v-s|u%oeeVwXX?%FHRh zoM6d6SYUIVtusH;8*S2ilS?<4M}af^dLcU~bu9`(B_MS?=W%)CGJ;~4ibBW4ifIA= zwW|gUYS#vwS+V=g6H*7f6IqjTcg~i!6t=AWpvfrRoNRw@w$W$sN-C)W1GdT=fV>)D z<|sTE>wF$!df%4MT>ZyuU8hmTDtqmSU1RXL`viC*Nw zKUF|LIwVwYhsJl(NvINB#tdm+!Cw0I3Od1H+#lFL5hWobpA&PWtbLywxBgfQ3~axkkBc zCHty|e0)v=mMby22AP~w*Os>`1H#63l{0sF>_{!3pJn7_ro=9)`2sOgT1MNd%)ZXp zPks~tV_|ozzuQ5j(*0$NAKcZ)ObYidLXt4X8^sHqE0Ui4Ye+&FcSiYM6(=j<0cKOv zcJ=y*dtm=~(>sr^&PTIHOtity@BOiFmN2g^wJLMrnqwewxcAAwCT}ejuKKX(^zS#K zG(d?_G|q~iYobSXa3|ZLxQ6wKa`MqTnzdX+QHb^vrSg$zuOQYcOH9Yi0p(Na<5llL zrzL4s^=~PROIriarJ64|)VlTIAcIa=yA*nPCJtWjuFy|YzEBn@>W}b5IOBHI;T@Uf z8FTXFKoz>6$KQa)=++-s$r(aNEt{Jb9J2aqEomY5CGG40GhSCTTIsUwisGPLlar38 zMrM)vyww}lm8Y{YZLaqMU1)w^NM*Mz4OzMO@&@$aGVZk!j|Cw_A=hu18`9vA~=79X~ z$scl|qRP!%N-2})h0S?&NTGhkt#J)ct z^YL>W7JvbPhiqCT1>o6=I{C&~1Kw2ws9Tx(r3L@+o(6{JLy>sKGhh~P_U)ZBYu0+? ztgDrl4QGXUgQ3@@O)5;W=d6@SjyCDrP=yW15ma9XU;|YdTM6q9CvF!6Y35d-yZO1a zu|&RLk`HPj5Aw(&fBF27n}ne;G6 zp%;kp3Y#{)8eDEQ$uZ{Xl1`g5fOPx;C~WOx?&2R@0MP$8{-^3|@SV2M;MpcOArR)R zy1_&OU9lUapC3+)Ris22=3=t*>axt7#nT+} zFiHN`$@B&Pyv7^i&zcqOGhRoDP)bqKi{^a?1bPf`TAc$oq@gzfj|YyZw+TwKx*`_& zna8|ySjup6L}V}s(5I%>p4^^EP3~py@Iny_Gw1`ja0U~&-z9`7V2?9slCZenqZHGF z9Gn>QP=%z=qj+eVRpVl|=A9ig-*;v~LFax--z%=b)rbgcnl#BXU_kCN{EgQL9PxO# z?GO+~3%OU^j-6XprNo{JOXnY2L6a{@Lvr@%X#23qyjTd^cyeCg+%8*geg<{b%`5UX zl6j4ztd;zvXlNb02XxH3d8~ zWE$lFrEhlit#ZFc>;c6RdeJO;qQ^X_Qb&7LZ)VWHjc376`KRz^`$`k!i?(=wik**c z&GVB#o}7-ICi^N~%We20=gxR0;|N*p3YY3Nt*R*x3x^}&3ue1n%y_um$CMkg`AcE% z^QJ5eJWG8e^^wi^;r06;CmQ96Nz1|QJuQ^F2MZ3VL2-aNK9m}-)qxrIKHun1t{EO~ z$28d1au0-On&2+QA4V1zl3H*!7FfcgH^O()1&ciSgpZU@M&`0^W(_5NmBWAGY(_CV zOkcgyhEtfMdYlBipszJFY2j(%@J3I0B#iGPo#g$k3I64-@Nf+bze@oj2PnU zM~QyEo1Zeidrf&ga#rE7t!1x5>!nwHnKQv7NiEkGM}#x3ltrglcX(4QT|(uw^pU7r za^i|d$Z|#11lfC){Xh1$mwzH7^uvpsd~Qi(K9BTycrb}Mu35{Om~-B{Z>^EuMUuUG zYJso_CL9$TE_xU2yOb{1jyjvh@ehq&?~@tpc$NQHX})zqJ8kA$4`E)S8R)vH-?Tab_I2{x9{-Z?KVU@eK(FzAWZ-S4w!dD*?~ok!SH8+}u3 z@ld@X?Q)!WyZG2Ra^v5~*Z<~q@ShjE|4{7!ljCysGv`)I649H}rguv6uX7{?c2D#s zvUcp+@!G7S#m(V_#tSae8A>X~x}GyZk?0NhveBIaJ<%G4g z{4O7-x)j<;?oeo(lCjXn1>QGAu5Kp_W`|7XDbRHS)e4CWop^HFi6S-Q0`PdXRp}u4 z=Yp0Cx#>P(C}=XVEuRnbEp556o<1h@^2z&zpFJ}Xz%v!Zf-#E&`T(dt9zb2WL*(`> zj8mou57^@wj8&|g$rO_zM&d@Vv7p`l%UeE@4AKFbWrW^!n$>O zV{cZ!X`mO}0`EjNf!bU_R_?y@3V_3?AQ9z{>?`_F%sQMNQPocNh|Z|m9qD)7d}8)& zY|HnwWvkmV5=T3&I|7w|;RCZ1s+(76-pUXscv%I_TZW5~zpOy9@~VtR*Bo!FHQF z% zK)O;UzWPpZN@gWcmEK*sx@;*v9rj%Xn=42-bxL>2z&e-CjTgN`dO;$}$V~xNGM)>D z^8b8_axVEX%m)k4AxZ8@xB<5WK+MVGD6sG08M56*fl z@;Tb{1z%CNQ+$bomDCKm6ojNhrg<$BXwlDU@6dXR)^IuNoB9u^tuO-{xHmV`5;HY! za=rGt&zBA67~gFa70U9*5sxqX zip1kWum^sz2O6K?HZGV?Pv=gvrjB}^tdOpUy{biK)u=PSJYL;-Z7G#wCORoxH+AD` zlT$>y@jDgSa-!oIwR_y6O)+6F=(z%PYe4Do5kc z-+r9|kbgR_F6X-Z94R+*D7k7cjfkG(vIzm(pzI!z**OdVNfi(SGWeeY?CNyO z&}*sOvl#3U^Vu=J@7v9q7k}bijg2oxdRM4|W*4jO+HOlaAhW_CAI{bVvZXG8aV?cG z-@#NsXq~*oZ%+o(uJCxP(=$kcxd6WlAW(sk))_^>*fKJyOzHcx0)`9;|I@bJPHp}Z z7yB>+|0ki~ZR7GEj6Dh+$tX&-j%JC-n|PK1(BC>rSu_)mPn#U63Hu(VOg;{M-L|S! z9$SKTHLQ8*^qB&UR8&`*AR78#YC;V&%aR^GOk&2LZO10j16OpVzDi-1%`rjNS0Pai z9?zA({+FX3^%aT2ZnX?%BA^k(CCera>P{OuQ^5-(n{_naVaL1zTWy<`Rw(qyD)%^k zoPaD*hr)Y3m;`Q7=4xPhnM_JfBfnOsHdK#m*tXKjN|gt&hE~dM@JnJ+ezw?42@)Bp zNtEYBpBO*2@t*EbY~#P`(Q=qKWw)!xZ$in3h)_e6F^m62nDfX%?mp7okb*}T4hlnj5U8A8FQ^lb z1EkT3L}^orJ}UoTJn33ZXe}w_ukGM&-w%b|Opv{k-L0)8Xp1#c?zpRK>wTCvE(c?o zU9prqgw;*>p>ZPd)a+{y_-l88ZK1FH7anFA3`?-+LN_Ob$V@w?29t5)L;Z2J^s9V5 z4g3Ii%{+c|wsHP|n(Eb)&T=|V_~mPG{1EI!O##-Lj2wE`BUp*UP2pbxX++^PDW)%>z~t28B>K=UZcyE?ex{ zGVp*AHuY~ZUw8kwdyR**eegw}rR>XHjiJE8>K{J3_b)Mw9Vp=o{WW5*51R>m9eAKH ze95u~W;XO?-Su6iC}FW0`$wNH{*C~1o)^7bek$VC!>{{AOCLY-$p>M6LgF9*r1yI# zKCw*T_LGFnwW;d`cc(xzD_q@C{93L5xpluAsS2Oan+Hdrx~57MaeG#2ZSSEXJ1(8JL7k35G-<#w87RDT!>P;Fo&IXd5#p-sKJQzi-t*a=$l4tEw zB$-Y^=l$!?BeNJy>(lpht3?|mmwxQV#7MTeEsN*aXNQv$sULbdGj<498(wC-a}zkR zCe`87pF%eA>f7GKx^OBM17yPbnHjUUj-bae@6`S9TM#L-})R%AnR`U3lvcF&- zwBmFwe>XRLz&)-6Q!PQ*c}l=UE7->c){c62E^Fp2D?}f`&@*^o5HPpAWF^e2%>RVK^7_itQD06%%YC zT0Z4z7A}KrzbRTUj1Z;|o;$A)W>Yo~&4q0!J2Agg)NiS>@I<^ivYLp(akO`bB$*fp zZ9qfPmuIe0Zm34OFH;a*KByyY4FFfAe4e<-+w}B;)>%AFU_pz3AG<-ww?gt&L{8g2R7I)DrZz+bQa_z^4-( zyh(KwF1VsCanrQe$*Q-ilcj~N-nkV9f$FhCUges_Z$J0p`2(ki@uH$fv_XpdKZxtXm)vdj1(ZkY*JuqR@E!|0>KH|bX09V2_%Pd4a=!AGAj*cHcQOLtuW zRrO8yIEA_N#rqyPJENHCU`6r5WWSse;KQot5u7m=*0OVhVEKu1R`E?~5s&bztv)4y zjO8gO=tTnmRYAD-_hzuR8DgZt#(6b(1`TQwM)Fmxx zi0{%pE(M49t~Mj&3{AD-^6kBi^FFGKerXQR?u|}132^fhy{JROzW^GpN=K5o;>s^7 z;KYWn^ZN}o6`!yU25tk<ySMFh35_G?L@RnwwreG5R?$xkFV`ucLUZ$p}OquiJM6(ky^BNF-X}6vYgysp=*o_NYLDSY$nOd4iwt z5~cuSJ51e4b1<58sA+r1wBsz1$Bgm+I+6A4<@@5&-&da8hFxNiZ!=81@xA3$h3u!l z%$>e|;oEc6vn`LXd1Fs_{S$#pTnPUQ@(p%#T)0rVy|{A)R#oa}iFKYksZ_}pbNxqL z^NdHD?%c$HBq_8zm~^Av

urio|tKR?7)x?E2lvx7phw>SjaY3Y7v;qU)D%8l9z6&q;4prd5h8Ec00t;I+ zR~XOs2{^LtcKSi53(f^FJ~@gaVELn#EI`wHwLh@%ITJe>KK~R{d64A9OBcn%bpxsw zJG{614^Iww#Hxv|`}m~~8b>_^hO`e%?a&y7o}YwN3*mOE4lo#n=wc~#c4?9?TLzW`})AvUJRbs~zmp4(QgyjsloJidMYlDG>ZM+hXChVqWt*uRsW zagprLn=+A1Y$Ei2m3C7}qag9%!2R+=>Ac=mFHT;r{pQhDzX|*9!`$esA}50_X1gAQzH(jpw`?H#N8XXot~3!$=*uW-ue6(w@kj&A!&5;kp{-Y8aS;DpoiO1HkrgIoBB z;wQJS zTQ?t;$TK>&jvTaec1M~glqDF0ksH~lE3gMwSRK0xlgh|b=C?O7{s)l+sv;-xqYy86 zW#l^?n+*;GF%r9-jug%OBE+ecUEElcOL{roZ6nfb^jFhzT%n4l7PopTZC@{x z4L)gsEO^(vRf#Vac!3xen_z|Cycr)xF}J6RY}@mAhVj2sF%tDG?|50C4SB+~M z3fts=;8EnFZU`2o5wJWU+X~9pt*1Ad_=ipuUtg15_A79H)*rfr9^sdo_bz$303C^b zUpT9)(jzJ1u^~2DKR+!sD@ug~t%(io+1?=pX93LYkrAPaIVn-86)^x;_ zQxJ+pC2eVfpKO|5z!2PRwu(Lz*^r|wBVXp7!AI#Eq=a}}I{vnY=Kd0*H2x5h6io30 z9i{&`yPaq1d!SMr|9s8oEfKaO<*e?jexLRp>e)SD&qFEy(MfW?cuD9YOI^79I(&k) zyYrR8K&-NcOznHTklK(<8;`gdgL}4@KO;^MOaaj!7m;M?eO|exrg{Hro%vNb@QfjR zMg7=!wP0t%0;6KnYK~_rP^pJ^^6&iclEZBJnq5#$!Y^iw8!nl4d==yz@%? zO$8@IYQFt_oAuQ=$L&h0mB7VV%Hz_%QdHe%)QL2~aP~KOQ+g8?H-w?FWjJ1w0AbTt zEKS4vHoY~+gc7^8s03NeoJpJHwL~wJHg~P9;>tXSop%=F7U%S>iANzQb~>XCtz~yU zwPzZwrp@CDaS>>e)U*id6y4K4#m|gy8>O zgrI@nTmnHJs4B29O=hjsYwVs(3)ZJSS*s^bSqymO?Jcl3FWdFY;#iDq?F>{KC3J~Q zsOY85EX|A1&6uo|KCY|Xxz^F77w+Y9A9M{TkSiaV;&{-NPtAs- z7Q7dE+KLMj{NNi3yQND!l6Yn-Y=P<`GE)Ufi=b{2gn{IM#r*uo!AXZt)jB(dGr4d? zwNv(B#VlI+QwvmIc@SvSW~hGK0}Ib_87eUmF>G$Z(h5H4jZ}e=wD&N83E21mG64{) z`8ROHU`Kf>RvK@Oestyq$}YdjxY}UH#s4V}71Vx&rhaDoe6nftQ#))u9#ikc z83-RsBildYA&q+E<+BU4z#iP!_hW4vmbv;y^_#LBuv(2NE~C)1TzSlZbh~s6bUk<5 z!Ya2otMe(0G<24-u#gLVQ|J!%N-K}*P?{hwkV6hs4sMexL8mrHuXT5QK1yK_s))Ao zv8_J(NU>+k=D{HxdG~w!P_$u}QMTEo4DS(k_mrM$)Cjw@9Rs3GFW3ACYB1wa@L|*p z!ZFUCH!fBlh~+nJ>l)=LBO6xLL{&#b1M_+28(OPXK}@&u<3xnFC+hy41h*ex%M~IE zSOn{{*xPA~dy7%LyV;Q5h1Z>r#p}Mm(QG=6<9dMaWEZbAyub0i>q1g%!tu2rgYdY= z<13fQiLU}aA=y9j-tj3tz26SQg#oh_nqw?BFy=q_Mv3U6^<-REwIXs)tO1;aJ7gIA zD^sci3`T>cjI`php&F>2U2w+t)`5ot_H6aEK1SzcqeCJwicrI*WClVhUe;<`wn9$8 zV)=o22Gsy>JLbasTvk&J2Gyb%^H#6wqwi?BtdYU-N1<8Kc0Mz@eMzZ0@F3?DOW1DEwt z9kN5w9o3Y*8>_qk3Q_=I1&UP{JwVKrHw}aWfC~yGc<_UQ)(}|DGNy-xg5&!$yRzFo zz0EW{Ekk;@B@V2!j?mC9ho0%xCBW7K3@p!~rL!R{Utpkut`=xB8=mC^&Ts=rk|M~v z(rr}(%huGS4e$j1qz~Ahg$vFEY_aDYIxXegaEc5N#x^B5@ciHNWMCwNK)}g?RL;u& z!!n+dOsb~wI+R0?Q(*MO#BG6aS97SDtBPaohEmJxu;nl@SjxO(8KA~d4wTwhPY-MZ zs+Xy6Rc14Q1*BzP$U6i-3|{&NWh&=bLI+=Q)&+Bs*KJpfegm@xyb^?N@t3P-tho@0 zoatn)SIfZZ#X~5lj&=YHbHTmq#8eO-uqboJyRzBs(9QpNL%d&x*p;x}sV?0q)$3L} zbD1)SwL@G_vMo&rL}?$5Eq+;L;QBU;OW|7R4IffY%yGu_VY@B6NeX(Jv%jqXLohy& zMl|nmQAV8jkb`gBwRacoum*`H_(#DIHRc~;Z^*7m!`F79`7qXms-|7wJ(rh8iS%CouMuym>Jtl``>w-{0o(6;XfICDCx0@Lg{@9p0#~%>O-^IEDJ&l6k>3ekJR4${ zm1(8x#)yoP*Da&yE4|a}x+71=6}`6QV5AS{TfaMw|CIvmbbeeTkT$pO_Fv`}KVQVm z%3d84T6<$1$EoF3bIs-plZ*vR?d5wn+rAm-Px`m>8){|8%ALoI6-+T27HCI+#cpwU z_qUKr=>Rw7$po=OvDQV{A%K3x9ePw40F0QgQZ5I{$L07UC6>9UG z%N3DTsG)&6de4S$d|OyF^hk+aJ^=UOb=mOaE1Z;GpV3^%wxi(94$ixGM{`D0BCx%W zxTP}9!})fLY>3T?;@9rfcD#LXj21_ZJS@3y0V}vB+qa6_F2c$`SJY`s$guMc?6=u* z-elF4Y_}rr=FORdCyUE9=79^a#&f-2`*WTI-e43Gc6Eqm28#jb?4w_mh3@AtUJ$l* ze_-Kcj&`->^JVD6u7!}ccS>j-y>AZrnW6_c1l%yhn)2HzwjdzzI@kYW-Qn6!96g7r z{0|^K=S;fJyCdE+#k?@C_O#@#RYB-?fzJFMnaU&_lQu4nu?+ikY>bP63wc9zz*J9dsB#;X?#|>_LpXm>r~{1=k|vWF1@qE73GR`V@f5{ zkijr1n2F9?rNm=or$*Rv#y&$3j)-3$k_^|}lbt#W%#QH9=!!U-KQ3zZ(=RS-mZRK&EUNu=`rqpGf38mJMKOEP-rt(dgv&TNnjd-P z-T#eodBB)a@6Cxr>D^i)V}|QZlN38xLvjBXlyp+Ya`BOp8g2Y2h#9&pF*h!12uOG{ zH?|BDM}j!47&km-l1^^2RE9e2_AoAa&hn@Z)2FGH1>pkj8=Y5u`;5OOBTiLukjnSM zzQ8@FTtnriL)#^nHorE zp82t-)w&8?LU}JU8D=ra&KICWsp|?qjv=N481we?$K@b%sl}B$vVrgi(gmh~A&?AG zSu%eDTjiQ`@?ET;=f$LFzFUtq1h{oGi-m=>-;zb_I}{w}k3+yxfp@m^dJhUS?p~it zAJiSHT#cOP6zGd&T=JNX5H*^mYos_NVw{Cw9mc#@CH+o~FfD$EAljCZu~gbk8ma=l ztjnB@k!i~w)2pE&)u$_W;5xK7l}iexPHrYboCHcU&JS*DG+y+e?8b%c_bxMX>8e_T z{Z}24W}I+JuKIER*U1iTb*7K8a$s6FYj6qDREJj>BwNWrKT@!pbqEBXxS270^Y*!d z&4;`K*Fpcbc8y&$*c?#J3kRGrep4oSuNPq1fh+6lM8s}~Xl+|r5H{JHGC7CMtPF5> zxy;@93A@>{-E%g=KNhK0Rd}c76BQ4Yw_hmmFI9adcqCTHO@)*^JT8^hGz4h?s$7QO zKOtJ@sB}y3tlV3%lwUX`9eRW2@15`BDx$w&LkD+mh&!^`icw$hc%yEIC`k8zmz@9o!1;grzb@pn*so+zse0pC3|QblZ{xLv56Og>0ae_H3jC*|8Z4-; z17s?)h)&GjrVlzgl$_HuQjnyAhuDa`1c!s5*6#+xxX*(&6@Qx{fLsloN01ad@a75` zg3D27pw^|W_Xx1^3g_c@TL#PwAY9`H`=DyXiogzPH&8*Tz)Rmsqp z`8ouAy&2(ES@6)vm^0ElS{VcjwvNCm2SH2}6>_t~naZv@^*TvM886})L-ghebvHwh zD2S{3LrHRYmi{f&oGqkn)(q3ofgYfG3}osl!CuN5|Lya;PWig*RlgWtlZmz4Qj4g$ z6)@WCOrZ)EOlmGf1PW$lEc>SFf+2%<%kFV@)lCg&eAsf(zJ9(+mKK*Z15cCoaWV0i z&NP>~mZL(tTE{`|_7~NaG=4vGj`~YsJOCZgR4Wi%pGvmGq*cLL_1fZ(pmnCJ1`fgR z-n@P0di1z^FxOBwNj2rYLk4fqFKozto9DLg3~YhDN)udv@X~HOv>vfOH2^WMq!!I} z+;qd+)vT>^-zs-~exbD6TCPGhHX^)k49pdsnxIg{7_vlH zF9Ycvkf>7QBT0D`5z9}fC;Uv;y1c;P>#l9xhGLDHG&kAgo&55t06376n@kj9ORmMN+b5 zw98ynYxy6K9yxCE^u62<+|x93c`kTdb)7C>FY}?s`tUS^#< z&7}!)^Kq%~i&Lb~Ynv>wt(Hit{93531Ix?_Li1z7ofzI1DdqRl4QB=2`Z|^z#2}nd z6TGz^i_-d(y8 zqp^1tQ8MKw>)36AA`nZvlciwOv6KM$HJ04*K`K(5JDx4wU`zYtrBo#m^S-%15#L2M z!e&F$NX3y;d;Y%L()EUlQ?L$8vbV$5!4wAb^d9SO=bj^wjrEz#LC_zv;v=r_5zs6e zgj3>DyYWCqB33A_(1S>)SAu!t`%G})BRP5om26P`U5K|z#^L%8R6n?9tph}^=>B~7 zHXZ=&C^H2$FVPBX9ToDdlk`|VUnrm!*HWnG)U zaRo$&j0ZS<&m*3)`WE9w>7T(KFfQp>h0Ut^87VoyP*gCctXn5Z*2Tr$z0|i;2cGQK zM}|P_z9mV#9V6elH$3yv_qXdhxBBgDl3zuGBQV5Uwrxan zYUj=b?nPMl?Mkypqc$@*d&o@LFmX>bmF>lolA9ltC)eIo{h+U^#g{g%wr;k!zQga& z9XkpnXkmTa>&^2_$}JUNPWRhCN-`BRIxPB*nup|>&PynFSM_F!DxvwusWpReT*xJX z72T(Fsq3r`)=zIxyr>twfJV_MAs`&qL@mWKK2LJsr4+`4wye##k|Y}$s7qeu=s4)O zP0lJ-&Xt?)$~r6%-F-)ay*l%60sB7_unqGJyp#pogJ@qG!>TUx-D^gHioRYj(%DApZFuX+wnO7o0WlH9Bj?Us`++&^tOA&!nH_QK#@OR z&1Z)E5YpHPy}e^$q-AO@Dfr!>RR}}?92MWx20+_s3l(eflXFtx$O@rr7y$u0wyW%nkE)$ae`RojtGFTQwh?;%g{NR3GSwjKV@j;CT*nYkP}a1>K!=B5Vq()jr0YY5QVy@IUIwZl?r(wLGXxfLqySA_+g2)} zxLdG<7k(AoXRdS^IHfT;v@Y}3-8C;%mV0$5=4GQ54>uHaX(FyclnRe8`hE0xBNGDk z-{#_}jd+&sz^+?--tR|vguah!VHE5oV4KA``$v?_{q{>$Jsz9(&#=$<>~-RIKMeUNnMC2#xbpp2 zT5Odz`XQjs*Sh&o1EV^6P^^tbHVlD{xJG_cQ{La-oS2UTp!xrGi2VQFjQmeR_0NaA4j-b=g!~AnYXbA% zNde(f;Q){I_Y^=QJK}G>EAY$w7i;y&MaC?;b{$Zo;&ZruxG4MQ^nmnknXOZf7#ODt zY_c(|R2~=Msx#-#{>FHwO9l90q3%GIZrum5)wBJ9iy7A;GiczdemPQq$az-Jouy$RTpHfd zDtKuMS}xTt#w?D8d_^pF{7?PC<`mLE@0)$*d2N!#6o@gtjX2cPn1ESZDGaSrShgD-DGIri!ABunE z;K-Q`*W5L-Tj$;oWn84ba0U73MRyPP%Zp-H#bI!jZY*^qHOXnbq{o}Bq?=w}D>$ z{Zvth%0=q>&kw`Iy$@EH3n!A1fhEkjH1AGUj?FQGxi?voZ>_3@q@OP)%R}ONyD2c? zV<+al%&IC{RZDvulf9BgS!|IdLT5T7o0$#QVsS23d9u3@nPJ93s2@vie&zd(TA%N zoQG_r@V6_ry>kM!S?GT3d2nobZG_5ot#vq2tg6cGX>Oilu^5QTE2;5$y?uqKHDAjE zQ`GjioX$7PJ#d@y?K&~g3R#`Q{zbg|f3f$TVNGa@wl+nOE+Ab%TIe8z-jv>p^cIkk z1VWX7Ac!uyG-)9~Xd)$aq(ejz=^+7;B3-15f=aP1U2EUT+IydK%enX2=llNdKl~8F zL)4Hl#~kAwNl{n&P)DB)S$5uoDKP#Vsi#u*zB##I?UH{Cr!SBlJCciU$N3-qj=4}Y z+2ief1dVk+-6YL~+~cG#Z*DvtEt~twg#6Q*ts8x}wlIa}#e}~C!L4iRymIqvBHd^o zlIe#^Ud76RX6b0b0sPA+`IXk&VAqPHjNVVwB)t+*a#WF%~RQpLx z-6khRcM6L4WmaH2vcUdLL+*;w$2BF13w{%Ki1zBT#CJ+}@CSdsol=0Pr;N*QBfr^H zP@}g^_(B6zz&FMi`(kkj^Vq?}wgojBV{JZRel0K_-jh;uj&CL;SjzkFYG5uu?H zc3)gVh>re>g}@VJhnoLIVuP#Pxf8C{(t&axx6FKtPx`(x`BhUyCbS=V30xp*Jvjg?~rDF^*V!45V`k-DJ zXW1@<6%}_tWJ5*IW3P8}{Os`G`qcmLexbkRqC!GrMjtl98;I~a6so+@9=?!y;wQ4VU&VAVL?>nt!uJA^<_cW@ZWee0Z$kyVE#kM2^d z5oZYOCv|aKN;0^_Vb!e3{RR>w}7w&(6@3B zXd*UEnpxf!jF;uK!y3M5Bo{jKbWKF$uLZOXNFQ)3Kbcxi5r)rZ>*!{yLVnw=+pNp@ zExgnOb;mE8^Yh0K#~%i^2X9?avO=3~M}2;&d1w=aMv4J?QB(fn_CmXPfZ4hRvq=Da zBDxoE_=p~W`)fqy{R2f>23rQ2|DkVtH=DgA1J6T3ie{W?h@%X3^|0|2h@qI{2i#)k z>$?2(GBD;w$)zvdC-2^B14;e`759gP62|9G47|8NwcADop7IR+eWP1nvbU1@-creZ zx=S6YMv{KYmXn#5leDovB)DP_zk!O&x__U@ANJ@lulM$+8w{^#bWUpSv#{xrI0enG zgujg<$3E%X9-j1T+(bX;zi&3r6jk(bX#J;`Tj=w@^8^A# z%=@VRQ3h-HcvW5ZZ2f^^#MORCmi5ecDWGeG8kum%Ai(Ms$fW*4FTQB82Nc)(Gjf$E z`{m1h;y}ed$hr)wQKx zLau0Co8XA{m`vb}-)w(hpXau(Qe*7rJ6q^c=md0ac|tz|Qdr1>;jnjt8;Y7K(!@^Ycc5y>5!MnJE~u2u+P zIiP2u66uMvtO@>{z;33W)whGj0#O?m4dz_aLfP~tuQ8|W0a5ydwcr1|#9_a~9nx(9 zDhBuX6=|iAO!ociD|3e%`Q}dB`pU^s^lqh1#lck5XxML8EbAtpsL%ZH6S@d8_@SBX z?u~Ee$*w!$Vcxzy+?|*B2g}{J!L+gIUZ2+7uI#bK^vG(yRis&szY?EoFeyc>eDfWp zlh$pd_MWw;wo~rv4e%|8%bnE(ISSm1G4@3>B{(J&VzlyNz`}}T!FB#3)S-CqGnC9T zTA!D4grd)J`ASSxxO1j8`Ub&+5m(HT7jUdy&Zq=u< z9fq^z8!z5l`|aQ4<#ukKqr2b#Bixv4oHylk>bX(P#s&Ii%_-=Y=1Z1J?bE?!T!rV^ zqOFj%aXNuE9jtW}K}wH?vXivSV@HiR!>`a(0B-ivo83xL8os*W=f&0vB<*aKOCxzR zq%>|ms2J>Qei0=hByU{OWazFreHuo`K7LiH#6$jJl50fF+6}H3g>`K5LB>tIW}I@TUWslL)Hc5GqvpI2=f=cfA8>ksHXc-`S+OBwlQCc z(Y=i$vPke^{7|cf{KwubnQmK*M0jq7KwDo1*c4+3k5oUZ8ObO+;V(f;W7z`GVN7Pm zBU~P=vl=4&3md%RtsF}U%)h@C$5HyKwo2kLGYd*>~RnsUfqSO#P=N z735Xhn;kZlEBCi8qDTZhRleh}zu6HyTSV zD>4)Q_6OzT{?A}R5R(+2v1g~B>))bBL&S^dZ6I7a$Msx&pu5$fI-IXnI$B!9<-Aiz zFs=S|(*D)tJjaXhQ`>gZ<4b5I!h%JVH}?MfnS+b!o-333Ws#L0Je?_ou^oAOgKqC_ zS@#|rCwOL*dPLDvSZ;Q)Frl``Wn^DJ<3Qi4I_;S?N$IrV-GN=~s65zG6AmSPWuxc^Pu)zdq-dn}ye_Gdx3OXc z8kUq~Y49}*;UnNW>=p_yExr5W>9j$*-uS3&JB3t#M2J7f)>gVHXr&?Q^^Vh2uu46! zWJS4AZCZJGyMIGr{~KkHMY~8|^#K4OgGW~XvI|o3OYBH{bBCtJUc{)=H6|rWsW%?`3Z~}EYkW&HL1GKr-Um1Q8w2z z#_lK3>q+Pt!?T658dpP}FahFI|6lRXzTGjdJWyA1R1#;^gAx8X(X3) ziV7ssBS7%AJLomywYe3P=uH2Y5wq}(axuQ3vhEziy2bf!Nuq0=6st#V7c%xknPH_zp{ZX zP3;oT0jvRb2i$SJbx~fmC&94YO%A0&t*ml`pWpn|RS6+vqXi|lG`*H2t-*;3*fEYg zBmWD7FA-wz?ACYX1nRfAg~1)dkIrk9buG-cdQ+E_F@TKJxrUyRlKa^P-jB_9$$DJ5 z$?ud@d33iIX=EClp^vhb0D^&Pj!bs*aX)vFN6t*BbOm*4cyb0HSh4ZDMh9-!Iw10T z#1wW{$XUi- zc|QY+4F81oF`VKs5y;ydAu3()X1xg)>8_sn#%p-;O$bX{6h%hue)2(9A|xaWd|%yX zPx}poWF_ugvO}T3rcenXO)^@X!tnvvHmhJk0G1{F<5x1zZTJ`b4kceSxa#`$yzx$9 z4Vb@5t>HQSwMtMBxvKr7jf=A`n()AhIVJjU6sVu*DAuLdI}4ar0M8m;IFAm0`Q^>@ z_MBh{(+O|5dAZZQ>fH?SN>`178k{+Zc>Z0iaW2mE@j3G|2K%(AH`YH8ZiV{LNT*{- zk5cA6L?o2LP-zZ!LzasM)*|CZ&Ir;w8W&F-y>&ho+u|6R{K9M>!`4*;Pc7}|F@uYw z(lz2ld9qa}YN=pHdMW3~(?;uJ%Zyx@mLWB_;B&Pdv0?0GFPLsJCtFAG#VQQ#?ihch`SZT;b50&B@z_X8oiJyck(kfC1Qum+tt5mFOvt%uBcRWIjsKlQ(M0J3gly> z-)j|-zoD%I#?+tP@ZX*jAZ3EO7VMvr^FKV*vs}P`lRxkuk8R$r_ecihfJQHS4)(-r0X7*wgVUnR6 z)2rMe7_fIio6B=4=*-`(N;*r*)tV*U`~%=%|MF$K-9TZhat7en0oOIn6jYp81Hn2K zeQ^F?;DWf~6%=_d>=X$w8=Kp66vSrb@SplP9C#Uh2oG3-;Bz#kcft023#k$Qy7-#4 zfs!}=7U5P}p7#`w#|0XTC+m^wj8JdH+GBbUcML#k5WUKU*2 zb`8%ma49EtQ@K)9OkE2UvJm2Eu(4|AUpfw`h**8QjWtSpxEb88f36Km>ER$*dFFwu zKDp0vg{r90a25}7a5IlR7Pm~g{!)mldIyd1dQfvb<#Gsy1`NGToiW*-1bXw5bKuuj zR8UjF5rKe&l5uBA?-a>GqzA+#BK) zn}wCVyumbDU;^ctxswqmGrp?~u7k!<0weF<-47ZmcUBVQ$h(tgCI+&wyF+{yVYew4 z&(!g3Nj7tJA$L$rSqE2mxZ)ghn7nt$-V_E*LdZfDId90dsyYfz)c z4tm`YPlC8xqnVv5d#?DtI&C{cUey35?1jsqUzPu-F`JyW64dZKm1t_)%1TRR-!7MH zAHVvHRCTQ<;XZVCP*SK_)Yf=Ux-qnsw;-d6ZeKZZQ-?NK`ANX)le@@{3@`GGBF}oz zQoMChxfsT7&IzJSw)~M~P=W`Wuf^|+6Rn5aR$3>;V^?j=YGzZD$~oodL0;3SP#&3Z z(SB~&;+y{xmA%6X=2_YbteeVB;^m$+J380qm^k$p+q7=!r|0gO(5H07wPUp!lT zg-Vk6n4SpZ?W&_GP59fYiZj#%Uc%C32Rem)Mr(y5S81e0zG;ZFOaBoysOr_wn4}1~ z$d~&(wq3X>-vKp|*b3%A^6Q}~SN}#~e1op{BrnP@bmIP3V}ws~uSUDQOjkz2v5t-9 z%OuqNPFem7C!9ZvhID)^)Jw6`O^();&B{)i^qQc+!wJ8B&sF?fCB@-cjKSGKJkFbH zkv+}fO`;sW1L9TIZWWp42+|4p`tmN+u9D*zu-7#!^1$)1&{$5a%Ex*L8a9GQXgi*I zKL&nIIt!b&ody4Fc+z<>v6!;G`9s&nJ}2DOtdNom9P8J9<0R=RBz-QvXGlMzpDM^g`h77Veg74FVEg%#^a~ zlj2*12GV#%x97{pgdVByj=<(=MT9$_<(5Tf`nHEmw4+U$laEW*y6Gjexlsrh1zVL5C?7vhf>86R~6=siQNY9$|pvl0~moPbr1mV_VY1#~7TYye#AO ztmD>wQ;~K4h)mOLklFcbb~fbB?mamfMGiB?;j1DtIA%gqk z03E;uJ~5^H)I8`<4bUX%x+n)tLwX0uT}kmJLFw)MKst6YuN90H$fkBd|Kzm=(5$87 zD&;feYk{#?eTVZI|FxC`J}SElp=Dt0;t01ZFp!f40bSSeMn=Tnb*zk0J8eWW8blS8 zrDNbxQm{76f_w_Kd_YSs(o;v$!CcoJth3iZv?Z(KF2qia;VFyKh2%7m4&IgnHP@4N zrpHj#4mU5(Dk-9fL75jN<+Msyyr$Px&kWkmui-|pH~U%&1N=?&W z1wtjxm<_pl@cQ=5_sVPK!mr3FnpQsDKRa$^n0-czKDfj$d@uT8HeH5)vIwV=ygIHS zj9Q+R66SIRhEU|}qAiW!&Csz42Y6R=8`uco=<-SyPOw<`1|SANd!*!2jd&3PN=_8x zC^Ua&70xK%uDCmgroh79*Yh+gT((B3-0kA`4Y$5(rORY-{lxO-oN>Uq_Qp^9746&o zWo!3>Z)c0M-QA6D37{16D~o*C?W%;9?-Osa_eHM}6&v}tz6jJ{6jy;gdtz|WlaOF_ zV&KBL$Ywoc_8(I1FFi)p$Iwm7#(w>o9U&r2`pF0kfRZ{Z^Dgs)gHTIQlH&k19v(+6 zBo(M2fEIV0q%rM6S84fJm@~qC*`1nMbCC`T9c+f@s9WG;JWO$+IhWv>z4pOPHu7_e zTTIZ2N^P^ru7*LGb+z9;#KO*t@O-!J2h9Xyx8Uw#fJKEgQ1bhmg~Y9vbug;7vIIeL z#R>EJzobh4?CB9@PR!O`BXWFutQNzxbIYSqdxg1}DNWC*Z?~@G?8*F>w1EF_$`$24 zWWLBb*gVUj_J1Z~{~?8If~}k<_kg79Rht3iL#wO>pEcNOpFYzny7mNzy?TyDS1cAu{dlRDzaU@@W zYUR&~(gONSuUJt8)F8l27Bb5ewQwa9l(^tGPirk(!53SK#@$8?vS{NoO;O!vO9z>z z&p?;IN-V-D>y!e~Hfmvb99QPH59#s7U7yX{*q)Cg2AvwT%HS4HTYBWX@|XRGuiJm* zZ5^w*g}YTiY>o&~mQ~W^^K&h9MSbZnuh+9ArEs4YugHHbb6qoYJWDeSQShps4$OgB z;#=C{F_cY27da?Qmi|Vb{#)&v@%~+ZqmWfj-h{4cZcr`a?7V5xg4aWXy44RWqd(+@ zarWvN@dCkmU%Mc~f!h1*z7#*iz$cds&a;4XW!R<_F^&luaJ4A`gCp+xHheUFGpPWwb#i1iP8Q=@hTsOiTqER}=|j8a(!wP5cPKdlI| z)OPsMCZ%}!TR9W^)iV{kHHOj|8M!LOtRxuze%IhK+9>=tx8zUj3de}>u#Hb3{>m6+ z?K2S)qK%RpSrkwsqb4U)WX$B{-Sl23{ymX!d%zrzN&P$lht(O+5G2kYd@9;?+8(7t z`oxqbdNHy4=wz*c#vb1W0IZ*Iy(w`Y1E+;f?Z=0tjQR86C< zMCBan+|n5{o%QGBaM*Li4;;BYGqL;1Ol`ti7kO#VtZMe=M^A$rjho7oacuIz(up-! zEAu?p?7{9WQB93SRY&yVk-(I+Nm%G#| zS&FRdstL;bHS%H&fBrJ2v)9$!RSl}pa8Y1Cd0=n%g`T1qiLovJ8Y3rPT=ZPLH-`C_ zO^Wvow#Wz}uJt#~#W9KxvqeiJ<0?vdtPK!}YaRv4=QlMP$}OT?+U&XKx&&2HM4yKj zX>>%eEZEdOBMHrVFQcL%`tK}D~b&(zw#>ZZQG`orV8@H4b1YqIqueX1H%z`vaG)+Ns?1X zSA#HkXTKl{iU%n{iuz?vLG?3_@BKjwr?lpkUX8i3j-gC$9DO!wF5F_oPH`!cR6Oyd zHb|DJ zNasSt+{ACN&Y2^ExkV^I3qS1GI=3fyM&&?B+@Q)@cwXpPsbQ1ZB2)0x5R1rw%Fn-1 ztR_9aZq5B$#f$enNr4NuSS7O zXt6p&UE9RA+e9M&C{9l@QsV*ib-Uu^yh%9Lm*0+(8vVsX{F?D%kAC>lor~Pb2|w4q zmfjqpzazmZ_T5?G%a7Y4W`Pr^IM_9D3GG=&OuFF{T!|#NM)SB^dSZwYpV2ub8;OMpy^`G?Bn>bvg&O_$I{fbE54@Z}@Y9?FpmIrxKhCA%~ZwRz9ERHJn;g+?+_U)9D z)`9H>2?_agvqLIBvQFnkQi6taeH=^=^WoebtcyIOQjRwK{q&TcitA7>sT?B-7-?87 z7~X9*s<;{6I6aiYf@Cu#lSAF!d~YpIL@0SVxVJdDO}tnU0sqDd4@MVFmtIT-{2Lhi zFTA&YNMdwwh-~^dh7rynQdKpuu2R*1LmoK}}s*gnwl#D|i30cL|U8 zyJeAdQ~K525FHJ<1nCshipyN?vb~>A%yQCcgH6jYD$7~(68>Elny_z7AcE+Ov6VVO zDRqf#@9+l&+hEbOQACyzXG#pEa|DqF#tgNrTndO#KN7>1?$3%KEQ7r<>(Uw&deUbc zthq!87aF7s%zVsozde^yG6M0~DZ{_VW9tjY&4Yry!}Ea~3k3*NVaqNk4(^!TjqL}Oj+2D*g4iq*GpQuo=FNEXLBfS3KgXJbBNl78 zqFo^85-&MjVTQgTtbKUmKJ*j9tho6>1q?jM_dbxjV9kKr zy8tYyR&6Bo{lN=9p@CO)?>MNE&y*W0(AB`d{vDPV`AbQhhp7}+oXcf|s=(+EDTVE8 ztwnl-OLb1i#A-auF#Z7jfK$PzpVtHOweth|gDYouek1rg@7h`qjDz_t=HiOVU57CH zWbI)*+saP_aDK-#fQ2hZ1p=xrLcG#}T{pV`n(|yjQ^belw7mD;MlIB=ti6=8V*2?o zuE+AHmEX6wSj2HVY}Q^K4)4^CG%)-ToA62hIlz_8(8D%NGhw)X2B+6^Q}4y)$5l_+ zoLOAJHMXExG37&Uo4sE%w@_!sBBpi(e-9@AmC-9Iv#F zrR14?r21$T&K@4_oi*e7kq~4>>pKyG)OG;z0K2E4gH?KPTWiK_PtGZ4S9W`Mkdr75 zI$Vxc0A&f$xczxuIel}xdl;}?rB=$a1t8%zdLZ{1JvosiCspvNJrs1kT={!9Kbh~G z>1bGW30jnuqg9FdI=`Kpx}4jOw~kHpBSM{{Maj%z>dg%=7_B$BnT!t7$hx4t)l&VXnNx$ou=8l3x#k+`@z_lHf-}~eD z74+LA8Cxf}MQt?lkVfaD!-p?3HOj8EZgndum%bD&vd4Zrc*LitAdk+Ymm5Or)u3S6 z%?dw|$cHv_y?o7%LA<45O$MsA9Q!q*V(q{G5)u-`S;IPayk3NA&)?GDZYb-ps(%@( zR|EE2Q_Xjs-_~yG{8Dlk9X_m3H!5fe!B}TD{Egzau?CdMU{d|4bnKp`;K$QGb}N_N z$Kuu^AKSd~a&_;i*!CYa3KHT=)L33DqfURh=cmMA9x>&R%!d@lr-R`{D0_ z<2j*C>$^?7Qz*m5x2QsoUT z_+!ua!)Vlxv4mC0`eAHlFa;U5W7rdK6~96wFW1#6-Z<=y&vIK7@QzwoTW&;n)r(ma8p&wpff24{S2^Ry(#b|N76J z2j=7X=fy;Z^UcNIf(?;MTZV^SE8S+`;3swIjJzSDTo1Y=t@*ih&pYqH2j0K7F*KK8 z^W2lAY$|awl3;eHa>NGy0(TcwD2{rtx-8i{y_3sqmj_ijEF4xIudFgJ-ucXICpBMr zlhy!NROQ|q{oo+37lZz?x*!J2414#YtMPM1#nn9#i@sNHdvG5LSF4z84tC=AmDMGm zn!pHhqSmUm`tIC0Ftk3lWV7hjl4_V3p&p``FSRcoO zi*uz+;|EXgiC-tNwtq>wVZFv)*0CHmHaJNAoT`$VcOJi)e>7#$eR6ZOZ&kpsPN7F@ z@8LG~$;|c4@C(9$w!Vu<xM>-D*h;mbPdGq-Qw?pyl^whfRJp=FCK!>Xgb zMDw%>)_Dc?WSpyma_yKxJ25GpCJPK`MFr>*>LnDA!PRx8W3moYKg^u=yPz@0_x?EZCTeU zquW9;FJ>taHS}s=3WNz3{vD6~f5l_t9$N~zRf=}|tsW?8(I&X6Y0*^sKM>{&S{|a+ zrkaSBB%-ZJvx-WQ@mfX<4B1kjidu)XN9^f>e)g=DJWxZ@eFEcKxRKx`<4}ctO$va8 z%2~3Hoa}$72T}xlKDm_bnJd1tvp2KR!an#t=%b8!q{s@)h;BVRA*RUZ#9oo(iHBYZ zViDPM4Q1}mT#U%iXEb*|yeJ*`km!zB+7Kbok?<-)rWY>VZ*L;3GtqH-X8 zM<4^t2Y)!Ui@fH-gH))YRN-e3l6B(x`v>y;StOu&Cr8QIS#-GB`Jzg91De*zV*yVA zjGUOA=>c;`CuyXE@?66Q7Nmi-z4-@L>ez`CR*l+ZSBFS)>JR}dbB?sd<yw4 zfYN#SUK5P%1a|d(KtdC$IQT{&9V4VNdSG|>%LQcv3SpOvrOp9&5E&GU_iq%!sLuob z!0f#5qF*WSR+p55&wF58rO>|i9wd#%HjS}>J0{}cXnC<}8)cQi1qCz6gRZ=PAob(r z2V2gXN^#X%B85IJqu5rczNE*PxMIWSF3`Y{Ze-H}f3e#Q5T9Du6fjv)HMg#7B26DC zQId5B;Z#`Y_5rj*YkTzYg$3M@-qA5*>`E3#YQqs@L27DyuuX~!!(&hDNa8Rr^irjr zkQ&gd1{9YHe56a`_6x(}iPHC4C$z(YZZ9igTK6+MmXG961;WHE{phVsS3N9*TQe=7 z!B3~qfuy^9N@D*M@}_({t=iX&Zk<0nE5yLa*T&Spq%a ze;m-4^(-r3ilBOulizW_fMOnqRlZY_=-Fr5&L~r}XVz$7w~J`7U9r4+V=J#0fMaE+ zGlZZw{#qCCNO9)@Jb}5CGW09jL}ggmS%kLOo3kxAZbd$m50Zxo42GIX8C{En8_>&( zTbVKyza&ka$|`9IrWU%Fbe?l{d{~Gw2c<^;GS84#2(Drt3N#1#SB$f3q1(9JX^nNF zl5j2M0I{L-{Uuf{N&wW@c&UTWb*G#@%c-^#{TJWX>VcortXRsMT3lq#&p$ae3glhM zSoPT;tYVj;`3Bf>&Zow6X|JWDZh=c7IzywN<5Lbn4MnPGm6t;4p)~3EZHJ$rGiWV# zl82Lp8N*Y1N1d8Dn6L`nM~*ddFHn@0ZJ%aEqD&bOKjn@0bJC}4vip@M_Fa8I-z&8X zCqr@GvV?1Vp!sw^U8Bo$h@;7mni4B^PKjjML~J!;I*z(_&&l2JdY;ur4m3EEHE5(~ zBC|xdT5E!$Vn0W`0MkF0%dWJA){9Z%s$UQzcryKP`^AefQqu)MxJcGnQmA#9{>RGzg`-Kke^x z9+djPrJ6xSLmm4r%x5DS_QP_bYq(NT7afjO*}8v`mryhwu%<+d?(xgwC{%GdT#$l{ zUy>*eDT1Zlcte0Wnp9AGdE(l8lb)X0Of7JTS zic$jEK_jut-fw`UhnJ@;;Dt+IEce8h`wU42Xfg}`rY%c0z-w| zcd3RIwTJvgV2CS8&;0A<$ABGM-NN-;DGv}pUfcobSj$|+G0P%mXr7Sx&=`0mP`8Nb z42N_n?rslY%wFaB9cf5yxs4v%n^>OF>S?vtVPl=W$4W}5P0XYcI?+J&BT z48kg_f1}_}jd{jWPNH3CG1HCvN_~apXGiPmvz_gjr*Eo03()%PU=IIC#(&;G*_}?5 zr>d@mgqhj;!WxX?RV&66cU>MWe&5x8pizTbT7myPfel* zRxVN&hmV=6n_j6R;Jk{-UB6jIzEnRv_+_{8@m35@vEV~#x&FqZtRKmuL40XCuAH&f z>fW1{CX=aLo-0*@!rg{~yr&Xg`@dw_1Z^y8vzhD>yiX~KjP5~6=?n#S$<4WW3?&GA zYn|in;f|RyJP^(X|)Xxq8L+4^=Ggts1)9aRo=)UwNA*m)T1!+u0LQGX35pVUR{F zKp&tx#KJUdS-aoLq)RW@kBLnbp6=(-d%#-xoEoirnf9Fu1=`Px$e9v1@=4Nimi_j! zK(iB#y~8t@--$0;?}|<*0XS_3&w#dFyYN>XmTVMq3+DP`cUH$Lp0K@+3(18 z8`U!nbpmRnEeiIhQlRNAbj9%_dT~U#rO_kBgKk}1SH!mm`Qh7{9SbsY_?t`L4@-jh zDCeK3&j#M%iaB=)d1A3NdgjME6LZ~A9JM+p^{*l&?%=X*U`w1`spb?)^)p{@W=qKP z)T^bx#yuUXsufY6r8^v$ESEb&Zz=lV{S0VSDb3?`NyEApmK#x8aK9XrVtRcYqL##Z z5wA*Nrwi{RPko!=aF*!M5?5(CNnS&+#$}*0^Z3UnVBCgftDYCRA@@Zy_*3!fS{1At zRp5|y;+D?px_I};RAAq4f&$z37P!UhOiCE$VmB#eX#f1N_pI^oz_(jxEz)zk-%|C& z9lX^x>0agCd-mv1^{(xnb$|4ZJjy^zZCPdNUNqt=q8_PxPD}hB<|9?NVxyIsrHG7G z1b}a`jK~i-E^#csg@z?Jye0T20ALij26l9k9ZxB9oRKAu)zVYKBzX&YC?-nXif|Me zHOXoFSe)BhuW*nEzbRYSEg;6eZ+t?E)KS1X4UJ?h762>Qs`;lY<$Dul%P{onDfy|( zZCq-lhI9}CJFIwcF{0umX|;5eSkJaQJdwlh!TsHMNCkIm)Sb3N3;jGufp|xVv)v;# zWu^47nm?21(`phyreLE+t8}l6Zwa|OP;#BD^8Zul{!gn0%wS|ITE^L1|2;U1GH?E8 z;`M(EE37G+FK;&c6ts&qwsl3)oc#bddHylB)$~`V%h}}@dV`kM6!>g^r?tMtd+;cm z`wwRiwYX7R0?dF60SGvAz}5OpssaI5o}8UCJl1f=03sI40#!Z?-vg1E~dat!HK~f)~dGbZ))WQ!b7} z9P61F#fQ0O;Q$wFhMZ-A-~h*-O4neX9AMAN8T;3yMuIEVOzd#90v{QhkW5USW$ap) zmF|`LG|Q|Axy`Yk&OJNCr(b=u^C3KV6Rod0YR_g>(Mlb;`tuZ^K6<#tA1RsYl9DLG z5L(XEvQ$NGpJF9%g+Uya>T|nwr3xe{l_v@u`bqsJ#nIADH#(OTX~hEuzzH2|&lr>t z<}}vL8CnaYbD6a6JvL>KyW*XrgcM2fqYeSTUi|5h_x6N?6C^btM<1{_`yihrKe^+H z-sVmty`75Mpfn8JC(*BE5AS5v9hXwzl~5~_zv9bizDWI0X0Q;_rJcO|}X$kbTsx$@2J zEB5`q<`FwX0yXTb9S40(BL(_9L#*^ckIgaPcFqyl=p!)BwV+^z7CooLzJ~7Wo|dxw z`6(``%j2m6l=*Vm`K@%P#O{@QPaQ)^oc;`DZRSU8>Q!m3J_59CT(p+Yjb<#^M0ZIR zwLSJt^G+`guX?`Hr5vx&nDsw>d}*0)tZAPwv}Hs0`}rHO?>ZON%bgwTH!gV2oPLR? z#Gg)O+awuncey1(%e7J$pu}2Q(XW{Mihv%Ix+>O?^y|?t? zu(*j3==Jfl&wINsh;ExEiv_8EHpJ6Jr9mEKZGGo~)ty7|5Hbm{5R#}bJ?Uf3@_?Q4 zCKgICwvS|_iJ&k3A@X)UTdyeT_0H6#-}UwG+=fHa$l8R^lCt~Eia#^5FBV}+9%l2| zi`7i|Ou)dg!HP!bvsk_*3P76!axJ~mAF`2C?+b>%OOr_5UJ`GWqn1t1auiwTnaSbA zQ}9a1TeVv{IyloYnN{mw@y=rl1RI61-G^s)DOk}>y;DSmvSn2+6$1cz7%5}?Nw;;J zsJq?imhh#R4W^0FImz97@g@8&YsRrX?IDe?jNud;d8v+v8wHfXa4^c3MF7OPN+->9 z!H18Lmgh5KM`No?goilHz8^8fA@55euJxZ(OYD@QSQ$AMN?Z_v0JUhhVYphKrE&s zSxSb-1#g5))XONY#|}PJTTw3hrA|?s6VBCkG++y%)oI6ZF|dU5871W$gma;NfXqdk zrOB>Obbk>s*npj0^ErKeyR1Axz}0GNn*Cn%QeZn>g3Yt33D3~GQn^b{&0b0!@~@&L zp3UrF^V_CS?Ob{HB^1T3XT5r>1Z()tzw}k`;ph~#j&_L6&3JVo8w%RHZ1}MktmjSe zsm<7W9A%r;aebRs{f?>sTS;m8@Jmu_TyeQQ>neom8FL1MZZGn|f|$mKW3mC^n}S{N zl%u%(K;s;!C(tvHa1;>s%0#HoCEe;htL(;y1)+U1m%*~UiNjJ%=^BCcMm$a#zsE)3 zq@6%arnxQz#rt$J2{$Dhh3pASTu}J!-~8GCo3?ezqP>?GL>j6t@HEf1KxA)*j;X0f zD2x!SR3HO4lGW6e0)3gQo?up!eP#syJXo-{n~2hvmNC9NHKcU9R_yl@p1K^&T&LH% zy8F}S5zi0WcP|vO7BMA3npyy#s+2x=#kM3?^D5W(t!e{JgRdPTuAs4tcoEB= z+TEC=!3kEYz+?gHH$j~3cmtxnO2x%pCVw6k)L^eagRX+O!{YPYS`3LWnlK8igG(AY zGsYKUD0@}oAu}Z05uvNfTgWejcC_aBPzZb%<)^`|B++f`K$HdmEW{C+^J$D|o-nmj zkT61->5GJQrOzlPT&f$2x#)d(c%lKA<9(ps-R8IC9P|d&r~cB5zsRRsFR*$cl6TJk zC;K;fPaQ3h?pAuMj=Gtz{@Fsi2AtwYZuV+L5fK0o;Vtk?lWEy!Hf~iY)3o;s`JEU3 z1)oy|Y@EVKMo{;e74+hSC}0&Rt^Rgpy+}jlE}7|PPN?`7U)W%|=hrGREtoxiDFb6~ zX*q4@NwZ9d2WG58I|LIz8Q|KRITxU1X@bM2A$q?C>!oYkA1D}`W{hLl3eH} zN=&@*qZ>tZQ5DqKU8_G_J>g4iCN}326r(?h6I8HiQxcBLuD8VZWNz*NU;*q!qd{w2 z%dp6TJ!G&jY-pfmE1Rk>i-j|aKOondaVy!`t|+OGPP#KQGo4|TE9G;05R(X3W5Q0` zXhXbA;g~=}T{cuS-f>2oG!`!;axV66hqQVeTx6D20b?F%FAtU(l^|TpWA-WPhlaL{ zte!7H(}3N2Ph}=od%o#k2oe0N zE^i92BlLNXEYh9lB}40Mbk!;L{0;Tw;x~ zw^nyvB~pvo8Lfjh7u0BsP6G~Tu`{abs9C_<0$JE#DQo!h$j4%JYYV4#f7ut z%Kia$S7UU~ehX8MP?2E|&!bJ8b7R*UgG;mXyh1r&SFiXn2c0F7T zShUnv{3#}6e>LlC$ou(BYm(RS6LQ*}8CL!AB}{JIt|gsm@UKtU$6keN3Fe%4Ergl> zMq9vLhesOFs4LCo6%WDB>%qpeB)Fn6>P^o}@E-iuvK;_)LfOh25!Pt|shn8{d$hDw zxm-E3vTS&a%B@WKvvskCIi~9B^N*&S>=NK3~L^IG+ID}*hpyYuw!z09*Gr~fb@EVSD~3kjoC!aS)8^cW5PigU|z8k zWA~ql7Jp~~+a$*tt;2b%o-{g9XE1}djn4Etl|S276cK7lg3@O4S5WOp+2d~-i=W-B zl$+N5&-z{Z9QiNNRnP9G_)JCYYz^B*x8R-}XhYMiS9^UZrSh(;`4%0QV0mgfJd*2R zWBL2zDPrAbyjgpa`DtiFl;t-2t96bhy!p%`?;RTc2`G7$5m&1$ZtaU4zI^qwLvi8es)&X-!KIYS-3N* zERWsww*rg1is((-$bwx1x8MTbP>yB&=-rq0OCgGd^3r0)rC?McS1@Rrdp)mc|C& z#cIEeOUUQ8b(>4esEDT`B;N8c_QGI5?x78DUMtX!HO@1uW^~>A>5<}N?qN~FbIOE1 zK5eVl+7-0y2I~mb37hv+#CG|2_k0s<#FAfnba2`A$SdM=Zf}`^6#{!qrW+CH+CG4a zgGEo}uF<+o#~0g;Cd%ZOZ1G+9_&T04)LzFQ124K5kltCFC2M9VvJm=QHnqFmHf+pZ zR}~NhYQTOLM+JanZB3ZY>7DhhD%mW`J=zT-*_9_EJ}A(*D}CO2|NaXv4wxC&m_Pzy zMm5do(>k~f0U?9WBm-D=h69*Akc`6teQGEYJbrrkmr0v$xLgZz_j!)<0a1U!Wt(lf z0m5shq2-K2ON7bME(r~k05n+bt305K9Z@GFia>}5dmq@MZ875Jb8c~GofmoVKW8lfKXIm9%C$qbIExHm zX*CQ<><;`3ig*|H`@ZXna2M>w=d(ras>9DLNU#6Q=l`R=oOCb>R%Y*6pO`RYbVc8l zEc*6N;p>~)Q(gFG!x z7`_raIPSK6f4}HgQGMAYx~h-8m!VX;j`jux(F6MK!P1C_F??7F&Sf?z>{+Y%fc7er z?<8?lQnYYIsbq6$V7$J2kI{Cuo_|bx9?9UfV0EO&$jESrCHE4j|4pR*uOzKwRdV${ z&Ltv`M6`Xiob&e*h!rgCKuyNqIWp%{@c|A}Qa8Y-A&6CpCHX4PhRWi16uEy5ToYMFT}eB3#avu?CuB4C1bN;&C<)(aVGks%V`+N_5VSxNyIDJ!cUtQbDMJ`L zTO8zOA^b@5rX0)yv~8b$x8Va1xFE-8I(ir*Mg>S&GxSO=;O`Qnwwsl z@ZN-kUa8o2MW~eUC*vX?jkwZl5XH>E-wpH5X-v?NR!q$Um1lM)3H>7koLh zsywHMmCDV!1dP6HL1$d(G{T-tMXzrbz7$R^-bjcqW-z;N-|r2dV`+xwu~TtCS~IrOaVJ&f$>nI7}K ztOjth=Y8g|z@lt=S8mrW#oZRXD4*0VnL@gnm>Ll97;Yd5 zA`BR20Ds};U=EvzkeUXT-+@d^qS;RxH_3~Z;+Dgj8@53Pp=)70hq|5m23t6`AyP9 zm2aJ7fTXP}BJ!~T&!!C0VE==?_l#<~eZM{FH6UHOw9sonBp?b%4WSb{NC`-2(wm6% z-XTC}A|-UB3nGg2(4;9Y*&@lzj*EmcgftA~S`hzgQ4G6jCPbHXpvhwlyF>^yG23b*LNWvZ?3 ze3FI3Y>mV(ZLiAvodz^btdx0`%}-Eu?3_WSM;{Tt+4&@34Q-C)G))DrS4ZWwr<-Q0 z_o0-ByLJj2F9{7$c&vd*r@XKd_w}M;1Az_{emR@lN1-hzGh^vt8xW<*u2mxvGP@I7 z>7)7RaNFn^*Fmlfe*x1g+$|LC3K}}=us~3BN#(D0b*0>9JIclLH3b6$y0M;O-BK8| z8Hv7~lAXV73^9EsrjkOO%4KeK?$uk81wq!qV*0y|U1x`Vu7CR>+-h6zRrzHyX`sX! z4P~f>^>Zehli-9E--$mdl`{}47|R*YCo-B4z^cVQXJF5%t?Q&PV0$Jdr*UZf+A{@6 z)nRkoZ@G@B@?$@i1AhwhN$r`*ou|OD-P8fjr=Z}CM(f3Rt0V7xThC1(KF1N(qt_*? z-tm3O)y-glB`(Z!QDW0O&A1IoY1eEDya_O*O?Ad#*F_Pnsr}Xdx|+9z$79=roaDPw zpRgR)s7y&PugUesYn1QCNeMoEvC|d^wCNKSHk#cWgD}II2cU_|CV)R)sQa1A!i<|_ z8&IcFIYt3C^LXc1g2fO$t>LNg5>TEyF`6z?v~P(&&y>vD8Dtc@{sa08{Ctg%om>=F zU&-e9>ghOCd3Q&HEzc;>a5-Hve`k=%Rv``S<0|ZuMzTgDd#t@_0`cc0`E~C(JG%cW zQCTFtC-Gi*j8y2_;0fQTuuQ>Ph#y{q^HWeDUXuK2EMyrfwwYFj=v5PQIiY{}kl*0k zDfBz)yfZDA*x(*6=U-px-BA1on3y}Cj=w!z(avubuTTGZ#{mD*h^OJTUqJXsVpUSV z8&}C*V4>Jf&`QJd!;7-ms23&m|psxcrn|SxXU&x9iIq) zr@4JMwe2cM-3%c5Our!0NgF}!1&p_hd~G+lh_mvV+8A7ZES9Kis+{fe+VxEjQgE%c z+&42lNzRbBzO+$PsvXH|_))*6@0GBG=}>!7ZjXrU^|v)ru~%MqD!*aRSWd2eHKRf& zTwLdTtk)l&<3?6(Tj0w6?j^LRgl0;N^$!-3-hs;Ugql^EG#h`zTn42SdYork zh{lvuB!fRwZXjPg*EYNhYp9xNm*wISMgLYdfviGMBOK_-0WB`>2Q4>Bx&YijM?1EB zg2-=2$jki_DAH>M2mLMFPw$ts7n8PyIeCsHI)R|2lHa?}yb+!4GBJ$oSEh>DM@nz# z9kKX}UAg|0zbemEXRZ(SlG6jGEk5rwFHEYZ*qm&X^x40kohe24wC*Rsb#Vxg&}8la zfV6!DtLHbMXu%TpWDn42g+WE$S{fE0o#V<^g)VFgr4)byl%$FO16snE>KpR4CH1r_ z=DjX#y0usZP>wQOubti6bVERqBbR7a0Q>Rs=9{ISFQ$=J+gM5oy{PTd>umoJnRpU# z2fEG9I{rVb6t=iADIYI8lpxDLkKnKjpA)<^$eHEmRqfpdPOeY{bh|~v79`4XO$Q8gp{vFs?avJ*lONECbs4RoD({Ddn-2|@Zq^~!I zHH*NzQo)Vx*2v1U^{_C12ii8kL*vE=Igws#Q?CjnVjNAR@l|EJe_1>Qb{1E`V0>?9 zhA!JOJ&d8UXR^_EQ(=r2y+=}OvZ@T4JIrv1*e-(>MPGSwgJg`^4Gwiw-f+4Ku?F|q z4oph^(tiPI|5sg$GEc6$Ni~_KjB(wrU20N$`($e5VS6Rd&Qmo@3=_ZlBJ4wvQjAa5 zV~LwBKpZ8bSF@b;{Q%oQbkqo}b@fQ*-6ctUHhW&o2U{g<+j{x^!0TOh<#;k0(?wSy zy7#HB_hc$VO2l-PJ*4Z0$*s1}_uH(m7JfavrdOCkVtbnF7(C?`IhSvcC|sF8PYPJc-p7*CK(5Si0zC&rtkvJ*~vF=7A530=H9`r2?+e{LGe7GX=|q}a__ZBOm}tjkh#+|qZrn2cL~AK%YH}2h4t_(dJ*eCy3{IEV?uyJzj-iXap%pCtK3P{J_9Me#%}F5x{1;A<~7I= z<8Q2eZ4XJye0X{l zOZXWO^(MH>+oWf#+Z0!zz~k976{09jN=5SmZ;dz zFpaDUia`8wI+_J(Puupkh_TYk;pkIp_fEgfPTOqIkm=RfRL53h*KCF-*Q=Ve!|k&r zVlX}M_*|2PNA%zFm&4sC?44A|jX{rW0vgeo{r{bc{m%uJfNi-hTfg3Id|<{ZwfTP= z^SapLT2)s>$f0_e07>iLrS88$uLFq|LR6}SZo>?Eh@Hb!Op5Rh7wFenk~G$2bvjX* z)-fTfGg#9bd^6ad`@#Wjz(0q++g1Y;j4EIYug$2NdLAvvnT5eI%;s#oav-_yy_4wE zfXkXGpcKJGjd@4ZZB{+ZcXK)XK`P?`Q@Je>r6z%#8ZAE#TE1uavyJPvwCJCb$ z7c%ZS4_`j2556KfnUFv>Xb;!+DlLZJNgJDY_4g~h)E*rUdfV_iCecbd1Mh4ex9-f+Skf%y3P%LK_SbuI% zBpc4svKhsLdVkMZ%?{tZ)Z zt&m~1`V^~K3dZfBvXcP`3U9E#nRt}i2VRoxA}ycn%(%Na#wdEpV~zkV@$CGoOts{& zz>(EqkS%PIlv(yy93#&Y35beY*)d7TLX_Kq!if#vIj(&Gk%-1%Y9dTqI+ZsO81<^= zK|XzcR*D=PsqjlKV+=qOEO|a0H1I(>o+tm2IVPc_mA44;kPM63s8t4Eu&wQTbMK9Gsw${z<1*h-R0Q={5LY@FwLhGZVLF_TbIKPRL2fJ zc#V`9{*dRrv7Nv9tK)tIX!*KVGUoUNA%ZKaUTt<6*X;3);?7BKMg{Pi0ZjD@>MB81 z+3h-h=N6~cG20oSP;>bUa;jgiEdTfki@P==>TeX0$l6|ALxf(OAZfaB#oN64VtlfAwA%T0U3yPZF~pls%;zmkGCTN9x(eCt8j%bNtp;HS;=731j`C+F z7FE073~HLn0<7!%-c3|sG@z#1flm!?(vE%%vNJN9IcY{CKORrV>eckUyKP(F6gz*| zIHd1zGPNri_|3ZAk40+;SXl|v=8GBJTB&Q(c84L8)YEUvBm-YJJt}Aq=uo zAS8yUsZNxZGI}YePn$p4GF>8;B$M)Q9gqVfu+&{3Y?+$t%)V|%b&*xDDgQzb*-X|ku*Kl`0L+&u$L3;XqCE0SuRmi; zT{!tMff!J9Q2CI4x42lzXy66Cv)e<)n#YIstn>okYTu<8>a#c`s3&?}{ zAEN8yB&A>6_PZ1Nq_nGb4`EBZ&A)_;&YV@2P`?u}OK04J<6iRz_lsi(M@SgFu8lcN zztqaGuwrGq!I23!W2IDW=hR9DY}$EAnpo~(oQCV8TOaL7!Xc}_RBDcbNC{8$dBs#Q zi7d41@~gs10+hKkJh4;XF@4r=qn9}EIl@1_HE_8n{xiQHF|VSzqMgXTiWy zgc3Vm*7H!ZF^m;V)t8JXvT>z&Xq19{i|G}m+g|0yRA^F+%ZFqVaZzlr*bH3`qhH2x z3#bs<6M7o&Ep4)-nePNF4l>k!j?JRi3tW6GV)i7#6MX1YU?V7MlBBQ{tDjq46qiTEH()vH2mITi?p+pc4W3!@TAdSoZdmLX zQ=(5eT6^D4P^8(KTNu&U61wZAdd0MUQ?>^OArXHD=>{rHn)iFi1Q%(}lf`DHyG0fY zbjPs56wYlU( z?4h?^FO<6|hfLmWLjO-@*8j(eum7vZI?+7E2`MTL+g@Bt|NF)4uoPzrTYjytM6IDh z(^sWXgj5LLH;E|&9A5>m;P~V6xTBeQ9}1pM2;!m5*8NC<*(#sW3oS{j*%>8l92Tj4 zK#$hZ@lp7`F2eTL+HDQIWNN_F+R<+$^}ERi6><)8cl5l53r0=P>J`tZC!d8=0tp*d zbNbGL^b~+v)z;CUR1U_Zo^??g--yQf&YDH%b|AtiqADx?a$xBMV^#0h$nHfEo8V05 zqBf0eF^x8+kDYLg{oK|EDXzR_4K1X56I<~k`Q>>75?6@NTw4cbCHLcO9t7O(mCm@# z8y+_x0SFW&VRw)MZA2E>|LkY|ZAGgo&Otb>jm6Z~ve}An!M|ebiOHAzQ7-c~7DSVx zU5i#NXzBC<`>cj~28kJT;gO8hZv%gGaH?$#!O980CSp%tA(+!>>yjx_X0XVx!iA)1 z*MQ)1-;!!eQy4I<8sH)cx!<<8scIwguty<-JQK}zwyWu6D2X>x&_#T|NP>ySUle#Q z8Gk7gZ|MwXr*_rXeQcNF?aRxKp^e$DW2?Q)VzYr1eZuTexYAVHkFDO1%9~3LN9&0*x5_S9P{z` zrxxwJ(vLm+;E^mbMeD?$u;YQN5KLfu{`)SM@!;(dmYD31JqjEEjX)jvBPAv^?-d`t z_I1Uqw;rr};C*a)lZ`CLdctQUR?amWu=x-*&n{|cd9Ar?ot^_y?CdW?&`HaE7}}c? z)|_gAOTAdbvO-}KMqLlMNApU$M|;GJ_M|X{$E9%DVT|Sv@}H&cyk<{e?E6yr#(jVr z3ujEPhP?&&kxa2wEH`S3YT9a{q*<$*JdtAG{g)%+UEj6-{IlF7s?OhH#{}QXWZx?j zfN|QYN#mSzuc@#>uBI({U){g>(-N@0MF|Arv`5L zccVLhSW@Kvg>r>FOL$>@*KW4sfqj%f_TvjX?TU{`-W!jz`#T3VK{-R^(Jj-l^x|Rn zwOs^?2z<#N{^z_5n2Q!lga#C!a#?KG|R6#Ff2SvSr9CB()+ zhNGPjH6gD%ysc8!Q=9kL|BDmN64Y|BV)@dyGF)b{Xeu`L5{I+txD^f$e+=&b&?JLc8p@kRZzEvYFEfoc&bpvE*s z5dQQELBzr-kH4cWH49pA4bpb8w$e=FdgK;KwHNk?K#d=dQ6A6U-53v6dWU8+_3mh{#5iCRiJ2R$xWd@PiTa0C+xv|` z+b)YcBHEfRE?BI&4NX~%2*XgZ1*)3pz`vjKlO5FHx^hV&gj%cO?(IQI;I!2e_%Ttz zS%vB`Htlt-bYmI1+UMjw*!Hjm|Ii3oAI3A1syAx+dR-a2+>I?PJI`cv{d{uPEf60T zXQGf6?&c%A!apnyq1*n>Q>ALPu-*LbmrfOXKaC}Gl4BUX6&>YW{Cp_Rnce#8S*G2f z$i4d%jEpR;=`bcVA{lO;YHbX>33%gwvt_Ils^8dLFO=`x+hob!amBT@N$uAcMgy^u z$yfItE}tE)!WV=YhK_j$RWEn6pD9h2;xWcg@~3|%yR3c4xbu)v=&4v9ZI|aRrV&f= zUQQ^yRrHha6^9M;3|#vml2_4@OW|lUcTGz0WD5mCW;YVABobXOd&uoBkm1HQoG6jQ z#+bbuBW93cnbiuKe<)@B#|v}y?iKAm;qS^xyc;o;%am{fo+(_NC@%g^*Q+7VHH#+6 zL7ykvY{U2*xhwSzu(TP^4{9F4p=@~P0?;m)rt{={VeQu>F!`+szQw25b zpV8&tnA`53Cl{a|Af%AmbKH1#XjVm&Y|O|D=H2%O%cc5gzZFlOZlgB&utX$3;h6?@ zFS(a{tah|mDkiRAf_F9(4A;SFP90VgqTE?bb{z?gz+#K~7vx3n0MBss^1dsX$+U8C zIxN)fWZVrYOoeWFbr|0Y8fZEmP08u#R9=_Yn1{kB#gD|==Uwt91eq)@e;uEk^`u23 zXFVmbNBs5M5G+RUq9@@3jL`BV1ISx_S8cGa1vjXHL!m)$d~&9%haOsE(Jdtr!Nm!2I^9Q9*HZ({3(vN#9T>fG$OqbZoJ2hrJCAqKt44viP`>$$D&&`JD7tQ4@@K zULxOV)?%Twtz8~!$JE#6JM58~vGxS{YPh<~nL5p)Z28H{3hy-L;(L_gEkl%=JQD0{sWKTYv1 zttLJ?=R=>nynTV?yVqh>1x1r={I+Y*l(F6ePY`^Iw-nHm&AQKxWT$b8{e4Nb%m;Oh&I0RERsEfDU(G}a_w|* zoh9^T$O8K7;5sWjro1I!RY+kcdHQntRl=t75o4QG0{+~zB^^cR3 z%j*@~R4H^ZR)2jZq?1NH%y#`=5%UgMOXW?(v*mhx)# z>x5u3-a4Cb$FK*Oa=E)w^&OR6W9W1QPo=` zl`~?Ua&kMC$udpUmy}bho;XKCKnXX+;vJ`%&y+ORhsG*MEExoCrz0GHA$>_hL|g zw$5Ek=vJrrcDxrnzM}$q7-{CfXbAIWv{L7KPWCJ7*pLz#or`vIr}nvLfkPg{8HWn4 zS9FetmPE5~FVgaO_}3X0KT6628{qeHZLR@w>$*O7B|0y3whAvxnSM^>w2QtVjvHe? z{8|xsEnUX%e>S|Xr?5!*7&%gQ(bJIp!RB?w#kcL`sxM#uldzmA**9$`8&VBJj&2Ia zzL%>O5tk(v1ICFy|70}1xg1NqZQJ`hpn-d>d!wpLkM?2Gf-CIW)L5ElPP)Hj-Ru4W z=dSh6{WvYQ=dsWKEtqh1-xZ${lMO$-E2lRb>b16U&APMXk$bA`j2{2sG>G*1eDH!%;VWPg2P?J86;Die zyqEQN1s;j2UBf#OqrCxS7~lS(27yH(_MDV-_(fY^1x7J}ucGf8pVm-GQ(#-%(v=!W zwLX=NgDvj#Pne0~HnILf=mta&WwP;O!`Xy!)TC=rgke!k(Da5mPonxoBGo)2U2Oi# zgc@*0IO}v88C~KAIXI}DmuUDIdWqG2OVAHkhEl2ZvaPHwlD8IieYkykO>hP3M8@Kx zl(-bm^L7wK%h=|P(AkMcd`U#{iA zB9X1$5J(EhX5#f7T;El}#R_E-tY!%M0^qplBoxm*h(bTsm1peZybvi#W8LV73+f;F zN)29@La0If$Co8^za!$Q^f_=6TWUTw#5qEQOx*j9fo;jorWDqn($Qf4!dA&T`7CKL zCxU-sZhuZ6mkrE2EN-0JfJWF(;&jQ#{|PH%1zAB^-=stf_?=FhGKigEeNfvYqnmlBuN1~S@0bX26@4oD9UpCc*K1UgR`R&1 zHUF;h>Ga~#TVN1Qel?>p)fYrgvHaOMohFm!$W~E!#`5Dy zD_uZxc){->8nRzF_t^zXT6pYfn{DxR5hPfi+mH(h5KUBYp;+8NPFvuKA!B_eFa`@L z@sSoVs@p5?+A4iU`6`;}u;}TvN_qNsx^JWFrSe|o+Hg!Sr)SML-~Q*#f328->aeVr zFA{CG&4jdZ0+GEyxBYRW*o&u#;@i1!wv)ys;$o{U_K{WLycM6>-5Ul*x)lp&@})oE zYNL{gfr9+)QlE;YZbT8+5R#LeEq$%eHyG z;v2AL@Ro-+xRHJ1@UfX}(tU0=GL+u~I>bJAYacw5Hc;zaQgfSG#p@;6PmEHDb)B2> z3gtY>{+P)rg~^qGGoV|Zt8J@G(1Q9hPkl>?P1_5;Eq^nP7iv3(rq{Bul>jT`XR!9G zHUR!pf8m1YC~5rHAHnY$0c#iPB39DW)2JUX0!6TS1o4kVJORp_vaIHOJo4g7ai|a^ zZrT3D1h2=ZM7uDFh98cdR`xp2QIZ4Vc-Lmoj#dk`0L9_ z4xbs+TVRU`FF}Df(_1>{K+R<4zSI1si97{SOh2zmqJV zm4gucursuy(tpGCO;U@2&eYs1RIJlhnd{2m_eRo7Z+-0j3WP=c!mhq-Y&7%@Z=Rc| z`?=%8Qsp{DT}bsGKtUKEYl}n<$;?xnBh5Powqnxf3`|ResDB%`?BfA1x7Ff^*p^g? zuVDaKG(BotcaHk&AIS?S-OI;X2n0xT`kNs(Wx+OC6V={j_-2jRr%*KSn}fh-|FMk& zGei{m68ek?O?P!Mf?LA-Xf!IXH$zvQ?r3Ps6w{PwM`o&&$7@3a!G~d@i&%G#fHfpR zsC>J4P~rT0-`K63siNg=d3|;2F{+%d_ce*pZ0m`XG@a?DohjZ+j~0d%7r9xwfTou6 zQ$*O15uNI zzvX!GLka&7Y3Xky9>+hB2NpZcsdc~Kk92k`zRb!$kiIg``g_6hZ&LBO;eT#M{^us! z&EnLAY#Gz;zs7a1heP&o*Jabiban^6%rc!hT|9hXqe^c+S?PheagbAM)lG++k^0A@ zJr5LFZ))B&RC*w3_18;=^VUB^Vgx#gD^u~eXX!&WebZq#6)&d?efnCo{X%l+;59|g zy=Gm^@4`~kqiW_~O4_BFyIAeK!Bg44_L;je-{+S8PU~V)=xZhDgfa#{D?O0zKfqe* zJ=%-h%KhEW++fbB-u&^5ThI_Zm6@JMg2t-)riJe1ed zQ`J-v^OBs3$)2-E?nL(=73k<0FfR(rxu+=89q;X9M$0W52Iu9cpbt}4xx(G06pL)HbZIwPm119k7FN1+ zl6tp|Z2>Hbv+X5Fhl>ZC5#yshd0?ier z$l07;w+Wk1l2hb^Vhjmy2KBySWi+H%TY+opc2Y?#p#zW;$M$-xiv#^06={SB$MWgB zr*M=!nUH|F2_m2k$7Axu+WPTn-aY-U$gQ+I(&;&$2awvy{Ih;C?sh-kbL8$@b{h)4 z(FnZh-3$f%=C6#wW|Qm9=CU?=e)mVJO24s%*{y||>NTg{Ed<58UUH@=seZ1a+&m65 zEH}D3_a4x-8I~xdW^%CaMINBW3{5&@X@l0wXtfRRNzC)lgY4n%axzPQT+1Sdg5>KnWio zcAdE8V;mXNvdxBV5Yg$_v8|PD=JKzv{sxl5#ozn&#jPg<460isl4w0r%HVQrz>}>4 zROHAqZ)VliLFy{5U=Cc(Jco~c>x@{lP z!v;IOt-V=tJoy2_xNd>!(zUx)b0vv{-GXc0fxCM2%aw%OwADG6A-NQ4)bK#9b3Hu`fp^ozP&?0ZTSo-xLgTl*Whvc-T^_oC3;J8^mgoLst6k8o0s zA*rywoeSpPHNizO;k76+|4ayeP`@>6O{FjrYDOits6G_O|_jhlr_!viU4U4GT3hyfyx?d= zM}(v6(i_N|D8X!GMvFq#m8<+9v_ct@#70FJK)HuGG3cCIFz9$?kZ6|Ek@RS%P`me( z6)u4vyOs28Vv;l0Zj8@tVcu()AT4mx_Ylv(t+iY9=|i0eQ6y{mYM*ShTUqX{)Hqvi z%d_|Z8E*S%$X)B6@4|dn;>jrt?}R)#4bgTm%?^QM9yOnyIN}} z#%l8l#oe1AlI(kI5O<5D{42)^C3>CRH>ppN>g!11-NKshcTPsNrZ26O8YLGb_PqHh z;*XaT{s4!x`6B2tBl>Y|!$#uHC-220v7v{XTPfHeh;yKm=WK;Vx}rfSK6+* zP1AJlC`q%ZV#Y78E`pXQ85+Yxt0O!Dtn-#VvNc*K#Y&g>SLFy@Fk`rhXOOIEvx)sQ z`;S_hao7yUu=V|R0mv)^e+cRbUVtAR<>Sz_Mytx#)%(A`EIcleLE%~%AGa{A*=n0r zZDXj7FlS$s+qIcFF0CuXrBdJgFJy8nYP>p~#Zq!KZF1(VN%l%e?&qmy-l>lYb8weU zMPhC5qfG`bFJ^rkV>;#l>?>ozju3G~wne}Z=MFD-+kM^LbPzNHyy_@jd`V} zd^t-_OlQ{GB-R-RZ^$pG9t8rvvs zXgQMFRqt!x)Jug@&nL=yMt=^*qT*0*i#NSnUf#`CkSJR#yVLQVY;Jvc=#HCvCw9z_ z|23Q$YGZXVb=)450K5#Z#wnW}(87^Ch66_IcBril8UlD3jZ7Wv~l%DeU6d>omoYTQ`8 zDiOImC0JXV9mkvotlq?@n%WGXNVT#rA_o@$< z(MxcKA$@4u>`yk(AbO8Bn}2$^##QmDuVJj$$CJ`L!P)5qPQ&$f(yqYp`&9HuKfKB6 zyy8;F!h3eX9Gal11Xa~*R02e_I1&NL{_1O`F062{*(VyBBzx-lccROq{WRU*z07AD z@A>>NK>wlrLID7q&ZVa78C!Ht@z!)!(l;?rA@xnZAr-%K_gssbzS7wTl8j5m(l4aD zDQF+;+NM@$J|HRic4OgmLb1l(iznE?96wrxt-f9*^e|#u{!W=ofZ~>z>;EY49Uy5Y z>5*jsAXYGhR5e$_0-Fg$P22nSDNm4m;_I-NWXgq_EM06p{24Q~f)kZBD0Lp?oum2s zawG1s3^v3BqslH);Uma1?IQh>+Nxh4!YopKO;;hBdd9T^pO@W7-;EbUZ{!jMg5woo z@enVW4E>ll1;+H?S?6Q6yS>@wY0yYaXdcJ@sekfzvo4h8*!=q>1XmZGL$`Y;u}p~;E}8Dta>1=0FL{FS85VSm^~5ONHb5w z{Kc1EDILN8+31V8r>s|Z)y3OC7YmVOO9_hbZE##9Z&G2cUh6wnYm%nd=2h@*WK1ZiyNhFHcr+K_XMyDz^K1tTMJ zQeE_KLke~Vnd`(;LHO=L<&}x@gM!Euv!zSDm!cTYE9!ni!;S9`Qq7Y^dezhMU()(b zab<#n(gH=1(>MQ>5NQ`8_j~z^gF@b4yPE}6zh`g%1ers00@3I&L*aq=R zgxM<9e1eEM>N4V1C*@WgCYHHgJQk7JZz&{8G<1+sJ~tC+lEY3Jz+4Zt!ZfVkILzu- zDdf>Z*^*yrlgRmM#iNe0W#x*_VYe#TL3#DBt#Cwb7f*t&i=)3p zbyEn@_J|^-I8^c@c;75aj&*e2Ce7CYR-8O%H5Ev=3kJ^Ux5_*d32@SYuD;c-I%35) z4~``|YrI<2@zy37uX~Ud7ms{Ro0T%0*KtJKSbu&Uni2V^jWpo-ZEi=UZ<9D7a~4px?N@y@2mxT& zZittdc@gu0#koS`Zkm%p-L6wECylu+JgLxpO#AmGMETI?K5om-7Hp zZw3<96ksI5&HKjC?aBZvqIa5@8sWVF`c=HvPV?JiVS^Z|0Dd^_oq>dWo4~Pg^JVr& zoFKZye(fP}-@%2d<4L1cc&1p7Um{rW zj7xzc`boIS2*S)SV)XD3mb~qMv|fd`&Rn?}HW{Wj9>+vzPFVkPJQ;@ecSV=@^w#`I zsZuAyrOInhBNQ>ao#82OKNe}137DvtvK7-^FMDh<8>A)etx_0k` zOCN9xn|Q^7pPWXJ`J=ORe z$vuOfbPEahQqq=F7b{zr8K57OtGOn_tK!U=@aTKON!D-D=bp#An@j$|EB9{_wT*sm z?&p?h`L|xt$;I+o#PlB(HWT(%dpfto&~IX#H*cXXK2{4Sa_UjRa-*$}9>mUdjkox3Ct609gK>R3#n zL_89?_zDw#%vbsz7i(1$n!NWHlJbACQ&B$ zGQf7Bf+ccnt)$S|gr*WSgqlc)X?)s;`I4 z%CME}WJMiVNe#cuPPG6hi6cATr`WQbqmuR*Obz;AmWGS;WHRDKC8K#wOKQIHidV-o zCIl)*aOSD3YzmXsY(kHgz-b3L1w1_miAXJ?0qrUrEWyc?Z|=Gnmh)P-pFS`;PdBFw z{#4}dhoo%nVHkGkztgLmX7%Ug{-vtxU8gPzkHu6CLqE9RIch^V_#_H-r&pVD&+p`_ zYqYM)R*mRa82Z$0YU@Lkwkg8}q&_J_c++T)Up+XzYp~&bNxwRs>>ncK*!KL69W5I{ zR+E6dZAlu=TWh<#W=ML%z0x03=m|X#WQFHLwT&Qnt+a)kPMTEN z;kUOjoOT0KQ)v(X{|>VDuX9n)jf0$vOG#m#CGJh(d8e8_92*E$COkwNhwg5%mBkaQ z3rM8abIJ@Yx#r6*`B?1hCgg^|^_U3X1+bNg19S|ye%PL=|{0+ z6!+SXhAV^YBjIO*?n2vDkOCPIvo6oJgPj}R+QH>^B_r}(iX9~&_t~udxu1()vuIkRYiK}vTU@>XnOuI1Y}~f>%)Yn{OhiM`*)Y_zHVTiHf1N`BYvK)t z4L>C%Z2kJC1vrdHRh_x|7j7d_Gu1WMb9GxglEem8ULzXoJ zS@zQAW6bM?5$r02TTOi6 zRIkGBc`HcwdzVJ$wrO7YiNsVBCPkhH+C;u$^smgwRr)llUYurgaqw}}Pub-qx4+-5 zZbxoMAkr#isTMkjsoJr=O2WM~9#~)yNv=`k)?l)NB2-wL+NP_?m=*CDVWNAa6^`e$ zf*R`0-^31mfH!=4N%v`(P>1KLJ#SN}9}0W2QFedj@7mUIhY@4Ya?smJ>2J0@&u%ia z-X<;!TDdWQaP>|pB}rKI4UCGMi`gQ4Uw6WP;R)aW8NgUlL-ktR-h9OgWUwdx((V1A>)za=Sd`KayGb<&?h;Q18MV zAGqGIm72e8`8nWn%=zP__cccOi0q+*P#1kq0C;6$_V!Zz5|gWM;L8$U6}oNugr@Ub zzv)3JT31&Bu_#b%JxUJsR&1uR1fhU`UxUYXrmwHdp}MR8V=t=fZ(3xzHbK`U4e?zO zdu|n)Uyw+ku8a@O+KvNrNphym2_Y>F$@*sdY$sOGdL+rV-Tv4`kTjX$$<;6Ukn}s8 zd`6p|Ia{7euk$xc{hg8Y3J5!C{q)8yQEVa4$LBUe3y#Tp(S+oyV3_IWIkrPWP;nK- z=x9gB#p)9aZYQX-Y_ovzoejW&x`IQzOWg^*#%F%iI~JXjBaG)(I$DL)RyN z>H?l|#M5e!XC>GN@A*4LJ9K;{*td$OMEL7UyyOrTk};(?v&wqLxzS;IWcX_{{s42T zjab4Od&Wo8j{E_~3sFyyg>T8`Zb%4wuuZt6tcVnny^+vDco&Sa(@O$qjBXTIw#sXY zhXQok8+7yK3y@kf?M4n&r*WAQu+`m@v6Gm(qkJf9gLw`CInI_^1_+he5Taenofnh8%RaNKvAT_HYELMl93`ofpFdphau$dD74eEzoFwCZLgdwB z-gZsrN;ui%)C*)4p<6pE*N*ByZwz6+3USkePFE?*AcxBCT;s(Fl$}=PfDEhMo)ZX# zN&$Mygai&EcKwOSDexx+?&@&bLWez(71rEITZm?9wZ{VToYpF9&q8xBU;0;MJ+}&v zr|kaCzk*m-k1Wt576aNf3z z$ih{MzN{}_Kd2PL#~f-XN}Q>sUeArFDhTwXU&prNGFd-cVgHs}6$;)|oQkwtYzh8( zU)9Iz)9{!Y@D)Y8EHjK=avAjs-u+m%J*(~X3fc<^txI>fZMkmul92y4gIjf9%z30n zPVb2%*28dW57^>jHCAsC!?66##LkXl;8${zG&@shQ&^zaeu9 zj7ZxiR3#y*hDeGY#Or~@fD2n9%KjMmoEYkFlw^VUk9h10uNDfS`lyR|yIRNo!gk@x zQz!+vU@KSw(Bf*YhpEA>VL51?)4K#oa=Sm-N#3LtmSI8tY%0uW5DE|rxA-P?&jiLR z9=ryraC=v9s|L?nzxGp>YGeqsT_grnj}A*X`6CSnzf4Y=)cCOrJ#BhleW|LWq3rK6 zdPk$Ice!~I4lBv|d*xZHpcve^^hRzbxm0xsoXLAUO{`KqD;r($4sPOTZneWKd9iQb z1}WM}3=99#u4F2}9y;`7!rti19(%v2Qz#(2pRHc{-^hFKZ?^x({omfyE~RP)v162? zMQyRgh)^TZBGhOp_3YE$GYGM&wjlOMm8u=1ReQCzPF1RVyuNqO*X#X0@AEn5`xm@^ zaH3TwZBFHJJ+Aw8-LA(ikybiwmrt`cTiBrXuzcknEDtvfFk_g>4fJ zT24^Bg%g#Pz%a#DWzh9!=oOCaeCiAAoi`W))qVg%TzbHz&bgQFaoE?l{wk2q1g+Q! z`IF$*;L>|>Eqd$Z<&)p}kkx-t-C$5bFZB%pY0p;|QODBMJsf2f@yzdT09V%) z+uPsOOQ($A*_C}{u<&d@SFki36&oJrTDl zH{(5Y1KV^FtD`Sy8-UgsjmhY%+V-J^1X#>;g^L2mv6E-f`yWsEh0TRs`VxKH z-bv}ku6c=acp-}QakXr|89j3%epTV)xN+GoqilQ8@KFMCP;QPMM(-;kO)ts+5Ur)8 z-t>5qHzA4IqP0348w)5Hf(*$dS(iC#mIc`Vo~l#_o6pLoBL98%!0OlrzVNU_qv`l) z^tna9ixgHxAb>qlf=SLi^}DEZAyGd5)+F6>VT1hGm^_b%2dSIx79~Yl*~#S{jz__V zDZV6=prXORpKOu40f8(AS8%^hZ%-jP=P-vvg%|`}=N1bzdy^|DAbcbKp+xBEjR0Q- z?xS#Fu|rFR-P=A6XPA14`I``vsO<5@;0AExV;M6_>K2if!g8n?Or5dMn+j>EA77Ip#GdG9wF_Qiw8Wn^TJnKHsq-*?2M|S{$2wki|{7i*34b>0K1QAJouT71yo?( zWKa}H&+)5mp~P4kHYnVkK@WH#qR+FUhg=M$uD49-$Z9(>|dD1?sjwipr);H?wLXoLGYXb5>2d>oTxuPBJ-cpq{r;hp{rI*z;U z!Wip0_a^tk@&xnU7;p2VDx7^X#gKc@&`33P;C^?33183Lv-KjQfH4nQ0_}NCuok(S z4kkhf;*OVYQKIM3jXbBM#(co^h=D%P32{inW*2+k*l)oH7AhztH}rlUOSsdUug5yW zhC6ScVx&!zy%|xDfdPe-d4ni_?K{U^e{& zH2HiRLqO|jf^W(pFh^EJWAU4%i3s`lZeu3tyfYmkxzIq)j)yxotTwe#AVoMn7L&F9 z693agMH&y5uXQOpOAde5L76~N0OxY3_u4O%JL1%%uO=(Z@4?3ROu!PkJMmX>O6jnm4r|3(dcxKb@&ZAddpqvM0ao46ET z-{2gWHOt_49*Dydils+JH)vKNXbp?&j)xKrr_B$N5|;&MVVa0bd$C|{X;mufENFvH z5I=7|_J?Q}@;gZd*fCelJ>%hNQYn!cCz9qRe*i)w4!dtT|bCaBlS z+kJ5pZ&erf-XJl_MhIHZ;wjBL?xFT&lMtJhW{DZL>D}|5pLtWa?Qi{gDVU)Zd{$fd z4o!uz1Rqv~cX(pvlNIU+9={h=EXJ~4hZ7U}-6%|;9v=Pdh6uPJI@x4`p z*^R8J`I>1^v;^Md(_e{|e|{LCJjyLpoZoE)y(d3hX2rR`GdAjc`%i_DclyFJ`>mq& zl`NCs+PTm2fp??hQp@^6S?spLl*h35-o7tQ`4-9pF}cDI0a+kRgq6qMgj=RC^Tj&U zA($~I4ARs|*A0vy%(T$Xyl8)XVud|Z<*S(h{D5ngtlbTnG%G^3MzGZ$tNf!czhFGM zSP65z@OL>iMhO1iGJ@!E9@=-~_oEhudb9JqZj4zQ_~Z_A)6ec#nBL*%^fl5luL!pQ znvJn>@ytzt7q`n7f6lDt-hWvTOiSfFH>RriEgv#IMy{~4!Z!V7u5#hb&8W#S)}T)q z5`6UOGS&I9Nnq1&S>4xgU4hv?mRM4v9+Tp1uuPi6kE+d^D{#k_X(^veF{G=eqQt-z zpEKoyYn4CDh{*A{J-5ME%Ehs^;NFlVEjI$;KL@!8YQA+sS&!M3Aq%m$Xi3iCCp7lN z77u8SHa)%>4dVBnzljG@UE^B%XG-U%6-KpxQMHkIAg}dJI4QKZgQ&`1UlFDw3$(7C zS-AO4?7{l3q+5RheL=BxkPoi*?@t>N)HhACE--mUj=YXAl@6ZqL0x_L+;xPGymS!bh zd|A1lmYN?O_0;|#kvJZ0{O~UUheX1w?^aLSO!>9*3jmAX)sdJqF6w!(e{YEG}EHosk!Pzv9TI zpY~Eo&{l?)r)|hgzRg{)W!5`w&ZEVi{)E@H(JH~?6bI*hfXcb%nTKAXr&lufX_xiS z{ck0dyHb@VTB*R)_;~TPjm39;CeK_K30<*0GO!*x2P*Mv3`JehH3k|fC$kE>>RN;? z6aM4hID5G5MOPE*8rsuCD?TnK^?!`IhVRyOeaTsv`Tl?aws?vvS>LwMCzi z8D{ylOP-Y3D`L+zW0Sa>NHmB?&}BXf(<%{@13X;B(3&JkP|j!7rsz&3Q_D% z)T21^?0{v!P85IBKMUpJfJc*0kTX0j@`CB|Mrk>iV?$0SmL}4ZONu2HY?dE^&2qf( zDQv_o|#rn^VB9-N(egQL({8$o4Cq;d9itMye7Nlyh*t44=8hsLRxw`=G_nuTB z60l=h2v0EJ#@Rs=HXe=6zZGA)rm74mbJFo{>ZtCX0T=xqQt4B$& z=Q^7z?@F(cPM&(Ztd-r`dS)|v^Y%z;lf;q8@6han3pXm0QPu8WSrv)omdQiac5a0P z>rWR?o@oOzjhUp^#?-@KMQsr({%Pbu*Sz;u!RMaNOcF0S1O+;Tk zhgiHKJTaD_Mr?pnIH{p|SFGUB50W3igkQs8OfDCzRg;DQTfG~3gOU9K!dsUCeU)3! zk;@(?kQ?L^ zzKE`IxGxMU&4O5gql-hzXBvt$i?_9R1~VaK>KSw(j=rA9oy<+~e3N2|4561ZCDFyU zL|?vBv*^UzB;Lb>W+4dD2qI_iqV2lZSlm+i@3X})b7(S|`qN5fq;^I(+#-GFL;fcd z1YM9zHCVOhJq4978YYDOv)8!X;4#PE5Y@6O8^){1p12UMgw2+z)|;oKsE9@OP=d=7 z^fvG84##*8lG@k`b6rv&PJq5?eN1XgUYMW?gh7h4O-2_u>r&iADUt8m9$qOfPu_Bv znl(P*Y>S&Et3cZMZfCO}dtur57K7)7?*sAccHRB+pyXmOv1y{To05_!E$Ha-2noRoy#Q!JUldw?R6hQy!2yY45bD+ zzAGvZ>_7+i&P00duyKgjK|aXgw!H^>EU#g8OqCs7vchO3B?m?v;C%oUpo6`0CZxR7 z_(>)luCk>&@gTpq`yGWA>zBV+I&@(Oa?xL=3h`F|f|Yp@MW$!)4koq;b7KS}A{dnI zE1G9#s$=pTPqQRbVH5YF7;>&EJ-w{t-ltqYoYTP#HtY9#DzNRHT? zD`Lx*ky#FKd-_@MMnZ?RD09cT$c*s~r9|nokn)<=wS?EI;>xxBE(!uUf30}1Lrn8i z!FtKmbLI<)&fq^TsdWwl9-0O?$WZUV=Ho4=U}@5FbTL*`sPu@Mjs$*0R(nY@w`J#O zKHc_T0wz1Ny&|u-FSXGtT2Y^IgPCWtH?fTqYjnQ)wX~S=U=5~gYO3Q_??dRim>x(y zWNp!6Mh9fd>6wMQlg)~wf<+~Jq)96dH5NI-H|(XtrC*P`-ej2*)4hZaxPyG~cA-WV z`6>C&Q^>6fVN+&Bi@ST=#W}*a5@9^~T|p1Z{IHe`dI0KU@#>NDTm1=4_GzoiG_X_A zjl;`Y7nl$f$*-lr+&d<>4CY$i3*TA-P2c?KM2?d(|B8ESK5EC|z0m7}*?u4Lm-fEeu~yKaUCN>0Tk7&JQ@&`asj+yv8kdF0oIEgV zX-rq0>%N4Z^7RSvOzNYWQ~j$lANNw6CP(+FQTAl+;o3@4UmUxQy(vm*Z*;uwCujT7;UBsC9P7!1HBI<-V62Xc(Ve{9&p4sOz!Q z;1^wuVNvHwp^`T(UQKc1exaaBjI=5dx9$4=Xl`@sj8?;ZFu(6j&Xn-!JaO6@knb*d zF@@3L{H5ja>aLc+gCVlL16OJpnX6qqfLU$t8PhR&;tP9ALZHK2@e6x*H-EVN1;2|} za9nWC?f3Lk7v>nUs8&_Fu&ACy_9FVDUa4P-Q?+)hdY;TGAL%keQW8=>?2yfyDDSxo~;qdyOT-$7n(RY{wi^*gH^vK!_c zR85Ch*UH>m&;UlWjV%kv)KCmZJGE}Ghsfrs@N~HftYv9Ij>`3aygLo_u+PAZ*K16N~rm_ zdaU+dN&g$$B=w*aZ%W^#?oDvhPBAhr54`wQ%Y)rcBLzVlnoE5;VtFR5*)uoU!(7%| zg7ga1hH+AM#q7ru{!+G1cu>ZNy zPT{_O@2Lky?IK3;c|u_GJ^w6F z=p*{iu%kJiQd0I3Ok<9s7?l``7}xyVxxTVDwSx))IgIb?o>giqS%1e;g)`O^_A}2f zszyUp)=sw{Bu;(pRGodZN>6sNXgNkOYGuF%9-#f$o=o}1$N)@`o5VuZhl*D!$Jhj( z!ecK3;lqA=xR6kZL4wgnXl}CU*@tzL70Y$~-!&6VkS3IYj~g11@=^BG?E-Bjuk-^g zJkYTT$ncS_;t}Z01Y*?U$pB}^E(BrJ7CTT!nNvB&OHs>CyW1^E|2JaS9S0<5(D7!= zP+5hPmzIgxpf3^H&oEV3+pv^-C2JmYi!;nL-z6Mhcz>(WYR$xJ{%2x%yc^kle+8wT;~E=UBUJ|RcrN6^(>z#7~=)$Vul z14053p3;%pX|WjOpg`e5yO^!7@5lj=mmO|~&S!r2w<4T0aVfHv-0+4-1v zE{V$%D@LtR{Q^Lu*KLs|RIkTbM#aYXj&zkB=ji#Ca%8nt=jPs05rj1;oXuM0d-|;X z_kG&p(lkrdid#(~)iT?`Ew^k&lPe64?WNLBR~rrrhZ-_;_1HoVoguw5 z*Az1Y!LkywJ-yLLCOz>b!^?~^cYAz*|tYjY<&C8Vpt}|*jvTioHGd?@=Q*SB$oiR zT??)+#BnGw|0>6FKnlw{v9;P4@n$ipZREF1lZJ%?#Ky93cM1g>Qe-zIC5M5`y?X-O zcNHfdk+t3ULIJtP^zd?lJ886>M9Iq%5J}_|B7XSZ47efKVyOsyF)E^Q`2vo(?(_wG z#&`kOOgi)Tkfh|;P_#8WK9c;m^UcDp9(?2;e3VzpBc|^dJfbn@?L2Fw#Ch61tWH= zFVp|9g2RN{^CQFU~jqCYRy#1#AR=S2lx(kI9tSff3GT zx9Fe89-V~^j7QqLO(`ssQWqJSa%s*U@6G4py420<=C#?fy~X^tqL=3&0|{2w>xQth zHoD=1z&sZkVz0>IYM`{mhImOHdy3O|vZp<^KfxRgT4d;KN;`gGDFtJ!zC)Cfyje}E zgM#Fqw$e&&@kjur78J*Etazl^7Ac6`6MEZzj7=}Ys~r2JhzSlzi1R@l8dF6fH(MCGM9h30fQ zZ0c|D0nk-XV2)!FK7`(^Tnkle*&2II^0S&CygJ}0K}N0g>5!O97a zBD@u@?PP{HmAyQ&?O@(y7^6LEObgZHX(8PoFc^?$EtktiOSIH|7VhUG|LW!b)e9l^ zx<4}TwHqq0ipdi|*H`#==k}?JxG;z8B-8bkblc~fcE?yStgxbyP1czp^P=~}wOP?#0Wy-KKa`DZYH~c8W%#LHha6Ybz1UD zu)0={k*LlF}F6-kF^12)LMW zKJk0s?B-!alTT&k4E6vl9j@&pTw+^MxV?TNg7c(YP9EW?rXrpV%}+cIpbZW}{RqaW z=D!y@+G(1Yp%bqqT`{+=7hO;{>ay?uo>Vu_lyv1(l#4EQc9kaE+n4M&*blpR8&z0df|e)*4Job2bYpt0u? zy^m-yT4}ioT0JeT#gi|IwaPF|m~%_DBoO9T^BsCv-YY-gOw$KG@~RYnFQ)aM%dL3P-N=2ogq8fOz#?~7B{wYHh)#289tREj3Pk?jH^8PR)j%HH*E*&5F~e z1E8PY8FaLl`NL+H!zz@>|7gSyJkG+ z8}DdSQo7cbF6#^;Jkwhs-reR5Ha;EFXez9{%}s5*^WZJE*s&aMDW1Yv8Bk^Q#aAuq zcl}s6@ikA-$$DP=w@=4?!lJRx>$Q7$;m|W4kMmsD+V2W~wVHdX_Ko$qkB&+NYI+_3Dx4`jpU(sOE#;%OqG%e?k<4hkGi;eSo^4(Nowu*m$Bj@e*4-_Pi%O^>ulktI zWn%H2EnhjSZ!C>k4hv>&6;IBHO~I>EEUJN0|1V_7w}9alV~Xw**1s5&;JK;PRe57JkIKs4Vy ztNftlo4G6>%9ahf$L-H>_u@IEkk?LK^BkCCOpRgwjV@ZL)2VuZb>DV!5|O%5l7a)% zn@>;ebX+ezcj90!PJm*5B;qE_LKL5>^IdQdohsY}@eC_MP=;KDRA9dV0kYbhX;Q?G zgF!%I{X4uJN{A}oL!__3YlxWR6bt~!=UGS*2mSK}k-dB!qbSKP%KGVTS#I0E}zf&6r>y|0Y6>_eEYszS$XbTz$d*UXoTXG}b^ti}W0 z^U2zl8W*?6qkuAB)dtGE@e*?Pv?WW`7wVk_XViGkXNO=+bccIPykS>(TMHkWUge5I zwauDq2j#=Uw^wmnst<71a)0Kf8KwZs*C= zht0hOs-Z*mH?Jg3K4y*S{EMnXmpFO9HE%x?A^$e1}bnk%bhX5#S&B zZ#2*UiM70d>47%v0N))EEwoqDUbI|sqNOOhRR;>mC>sd^zlag!h@vJ-6N$l}1G=bJ?K>hDWY-ZQDdR6b?eEnP z1l`I(G^L*E@_)ZP54ZEH?s!-@jOH)T{%0=<+^eVo_|+MnWTu-e=Tn=70&pykKvevZ zI?UTgt+93t!phzFSSQ!TfpK6rmI-0Uu)ABBkMOv(`^3YbJm>Ch3bRTzCh}(DGAB`J z59eh-R6cM0B3?zz*;K9z&d&gSC;I&_K@f(4R_MEBazPlNv4~o?V*|ZbWjVZqu9Zcy zY>cCe_j?ZTC3+Vh_aRAES{kBstvTC$ArAV%zqwU*OcIIhN}uit7|wVQrQM_N_ezZl z+`%}Ylp=Q@S$J>d8}(STiDRQ+b&AyM=l&8fOcS9W^!zmjf-D!LtQ)02X~5wUL(tf5 z)h}0hJ`HU%NDCTDPDDaX;p&CFcIIb1@hR%&GRwQ}yTB#R_RTX_)Q*|Cd4kC)?X)C6BW1Z{+6|ar&%o3;7{Oq#Prm$XB-@S_T_@eB*QOM4aUu!%HN1Mmn5 zYveBl(@RSU(xh`<%P8M~upt($hmXTFif$an;@4_;U0WSfYF_cq4v8M*Y+H)G47zkP z8*ejtr7$fa9~y3p@?zTjlDsux!}6-$!#OD4`1Q6$ zZNIcf*aRkjJduuE3%jk2F?QT#vdhT@n>x5DBkTK~v2zywI~g^_j-&Gl&FUKGLtsVT zbn>{E`T0GFWotrtXNm2HhK{RgtE#GUfz7$b16?rRh`(IQA zR||8e1H*K9bDxD>xpA6k$sW#|uCssdqtmWsGq<6J-#$46mw?H@Kpk0!ZuI-kDPK%2 zR1^26M%S$lR&TYQe3dL)_M68OWLz-R6JxbfkfQG!wL^uo(}8H^=)7s&dn;2BR_k@% zAN2YLbT3Z8-#qfZ+EMawmkk^oc;2j(@T~!_7}m^uNc8(=7YgcQ{yQf|M~=0`j%GEh zOul}&#OCcB-_7~&Z2XVpES9`8Y6|4IgC20Ft?yQV02DW!5c@I8VJLBxn`cQx?c?0N za((j9i0Ni0e^2w(Y*inXI)Yr!_}XRO+Zny6mMd*ngu8h)(~P5Hsox-tOPq67zdfO|eqZw8{$}@w?H^Y5S|{w^ju!X4s5vD+ z59rEg)r&3MMs(YQf#$cW$y%#!vV;hJa+6wBw3)OP2tKB~yM5V7Lcq9*H!9VOGK`ACR^61&Xh^6(_a6T(*qnE;cJ-`b=G%`f(ctFGHeSY2L}>_LH{8oZX%#5y_E2QI#i z&TlXi6%rw+LOlf@WVnX?U_dx7*VQ9KHkL8buH3fCB@a)&yg9|<7>{us;tkczO;56t zX~{p2XJjkvj7=_Fh`;^i4>0Iq`IQo>y=UG7x+g%>2RHeS)ttu6(@lR}`lBV2<_m+1 zr;*pzU}E4#!3ulu{m;?Z(6W!CruzpGY<>&IvBf6D^LRQ@bMZs7{LkDQGZ{bbFA%s+ zJ^qSVt~nAM%!_i>&Vblk8m**3blB^D5s-|b7($)))*!T~s$@j-$hX}Ae<>H{YD9qnOJW|Z#mdwIg4a3HVC zB|X0V_ljY4prJP^dl>%$q$s_U`*>j`_yxAp!s8G}be9f+KLr%}?)(u_%gfG)yJ>Ep2hA886 zqq|?y)V9v0fBns%$i!F)vgTGkl-6a2iPo$rB8HynmGbdKoQu9$zkeG)xlY4-yhSD? zl;4OImT&j2U%wIXaXv<8E7-7gz^e6k@bL|bXgzz$WBSeL>qgZ{bX1CPgO;gmN+dd0 z#IH~YsjH%oGz#jJ7cXR~@3NKT(^wOA7L(M9DQwj<1F5ejqyH)zK9(j-K(EtKimcr~ zbTst~$sLZLNY8`@2y7f`bqqzD+{l`>qw(fT`57uAaWvfpKsmXdU%E^#*pV>Ke*}1) z#$cIuc20KKzBS-DT#Wo>>pxDhtwg`{`Bk)gcLwKHmeMlC_M$w(D2bb^r1YtOb>3u1 zLg&P{g@w_D<%UF4dK3ek9t9hab)v=5yCp6xJn{SBFo162ANWVbJ2^3OccQYu$EnLM z{7**RZBlHT&##B=dBE+lkftLF8=Cl$fDNj)F(Poy5nIxI;6ZlHH7HSj=}eiw_mY@di&dDHx4Qkj<9*kN;?Qnx^ToYRqD!$+-ai(x*wS{*@qXUSo38 zqkiq52~w@>5wi}*otEjv^Gcz2Vs%AXdxclL=aN6T#`D3`1U3}4BrKPGT?Bx1f?EMZ)K6G~tYjmIew|Ft|N)&F?(v zTnP&Ci{2gn@R{%-jE?y8I%D&fTmZKDaWzS2)Jz|ghC)kDs?gX@h4E8k@BcJQ{{g|D z!ry_FPKUT7i1liy$J>DnI?J-4*lC+|-&n`7E+La}Kg${(;4Bp;SRicP*a}>atK7cM zN{QD5q-<^Wo{tu}OH4ATJ1}qInPxQ$jwn#^L24x ze~J{(ANuvwIx)Mu7RBH&JVk#tkG&vjaFR%i|Db4T6mpec-fyLGns^v2l}))0^=Y`= znxv7UtLl=gRWui6f_e}TU~n=-@mu6^ucZ~04G619=Pzn+SdDMcRpRt>Npo=ex-lCi9QNCw?4*BxJ^SWX%P-oi47^1dB2ySY^|J2ZXzO3O_2H6%DvHbb%{M<4=b+Uh`Y> z$AaJJ3l|=rIj18vxEV%aZUq%s53A80!-JSu4K4k5s1EbhYMS&^1nI5y9G>PJhC9P7 z2_uL_KbMhk{M!@NV^;NqyMTSZ=nUQ(^kogJes^o3F#$eIuXm-JLI#jhyjbf^*!V;d zhS$%+s`e(f$E!}3na6VI$(Six@)d3w~)AP+!<;GX84)a9pe z3vx_Wq!?P71SC5m?wfG7ysXQ$;k{^3%5~8_Hh{C)wlw03#B=e(Jd_%=Nl`FE;o+lN zFr4KfU7V6@$0+${$;yEy4tBIfJuq1(rIol~qEE0L-Fl`_6YQ$;T4QopVHB@NxQdSK zauc>v`y?^D;zUA;Su)DXbf$9ARnF!FfB za=Q-eFGZ1D^a3)u?I<@V&u4m1s*`P=m@+HrebsVko4XaXN@Ib0`5n|k$M|GIr4P__ zggG)#2Ck?krFM0=$dUXFs~ij|+6DWM&FoFc^a9cMWK^4~z|4W{My4L6#fVyW)u^|q zgy=$bGm%HpOntG=^3Ho8(hsJ_s;gq}{y2G8m^<}qXRhkgOZ>~uDHo7RNu+D@Zzn_I zpm{$*dc~}!-|ZWq=1uGkQJdHHde6`NGm6T|4F8&(6p9+d+X(k~O34-5c-zDh4BN^w z;c7cX+u&PPA6Bw-Ni}3NWaVKR-d64Vk9JuD z6!v!JL_1o>wLQBGpgvb;0^(A1pqQ`q?_yW%;lDHKqPq)+{H^vGqq4Ytn{B=<-`yF0 zBnOY{`gSB~)NHc}vu4%9YsOaDwJAjv(;$ZEc}HUSpJ$g9d%B;c?SI9KFALOGLJ3<4 zAO$9@z{{fVnVtEKju6z~Du?PZdb1(Tn#&fi=Mt%yQJbU35O{0Z_tod3w7b^$6F>T# z(OW;xD+C^uRyMx=a$dq}-(ha+X)b7+nmEG4kQvpIZ1mVBWYBrh%d+!TG+ zoLoIi&OW|!fH7Rl;WD#aSor(4$oH&VX}vwKk@>pOSgz?d(V+Ic9-O7Uy)$p>KBUri zaR1G%(VLQspYPigmAtx*?2YW>&n$EuM|HFbFACmPd~MvtX79X^@R!WfmFD@kN*)Jl zMefxJ)*-GTVbiErf%^ip+PzQ3qxTizq(FIjzwP(AMsGtIA{17bl%`=om&MwmMZ*- z5|O2ALvjHQ&OdKoUe@zMr(|AvEKg0eGa2IxKi_-*pBq}-WzBaFv#Rzg6u5DUnm=WJ zg*@RWfMF_>@y}lm|N4&gwzR{^Iu!);-oeBWjdOg@NB)cI{v?FHlRGOFYFm7BG350q zx#zgF2EX{{!TwKC5HREF7J`_sbS-R_dW(w2D;f6-VI4w0Isb0?J7@G z$lcg5){H9T9VkZ77EH3MII_sPuR!SP&6I8J=UT=mZar1*Xx)>K5FUedOB&>9gmI&{((No$;9Sp8K!ZA5vv;K)ws@3 z5ljqJdca4g+v|%pboIC0hy~VlTkG)h=#9q;Rdkj^+>OwLa84i{dD*AYgjfe^kyI8AMD%wX#IiPSR>feOiQtSUk1y_(`CK;R8 zgyivQEMp=#rflr)vn~24pv#@VPBC|${jZ_wyVCTsi6R@S=sx^pdpc4f9f7UuNLL)c zLc)IB*0WR3FZSNofIw_V_`^XZ^WLebPQm zS}j9n!;UX$FZO^lXG4!HQ<@JeZDX}u`|F~8)uuntdu5vQ<8-{;5AN!T0q7R{_mM#7?|6?KlKbjq|@PhlS_=z!nBU~Rv8SR4J{bsWQ%v2Ero5-<@y&y6{ZSD zslaL=x2itT^OC$U^T`00N5fTfec7k^a!Ub61U!#Y>rN(4H~8&$1p1;D(es25Cwmpr z?1tr$!Atb}$M+Zz7BUR4NV2`omf=r)oiVlWlw2_(dv)EAi&(PS4P{C7Np|9_e~ja< zg%p9_ZY0;x3*Tzt%+e&FP&62X>^vT@dp^(*?BrqL%OA5|uI;Bv(OG*)z3L-?N z>*#0E-fJ04xpS5|2}Hp+uf5GIFVV=P*T47-e52xz0Wjy9Gq4l9erAkIiW7^<{h~KC z+J9aH;=NdSlqW!sF2HYYZOcoYGi-3$P)P9?6XFS{RiLlXKQDA^4{zc_pu1(IjTu-o ztVnaJMHQ;e18e(A-kDm3^rAgphmb=n(D0S28#yne^5EbQPlJ@?8s9vh5i)~Bb0rUd zmSk&DGqqC?G;#ZPqGfdRcMdS$CuD|wgigNiKJm?F%8xw9emsCow>338z#FasUbn;m zCWd5MxRN-DXQ<#Zef1@oqRyGZW(_}m_O_oT*)LL@jA_RpOdbYet^=6p&TJxk*EvWz@aQq@0`TBY7nsD)kYVez@&6ADN z_^HH~gK-&G*rpTD1;MUciMB{r>^lT~)wF+FJW_jqbqX-}uGf8lZ?`)f09d2Lj)jWM zZfu+%M9FN2Pm*M?ar0Y_tt<;c4Vgw_o7L7;^^WwG@IkdHgz<1bXXKIl{K^fA83 zFUc6<(xXJ44V$RW>Cz04FSeDO!SGD&UA3Y?erjz{nSlRXkIB%lk@m2Jwdb_x>-D@o zvyt89rNB&+*{PXg_i=UeXNcy}ZFjzlCCw`XK9Q38CIofK-GEXJ3_|E;T*@5<{b~iW zHX7bkm?0+h&~)^bT;8y2WimyQyui$B4WNOgQzO+RU*WHnA!C37KRuR2hLrs}pR`dp zJF!1RW378&jr`Rv;u2TDCL6UBuv9)Fl}F|r#-XmUfA9P&5pWx%^Ku;!E&;JMthHW9QjeQ(HLf%0tt$S>nva;qbMZs~jWFEkmh04^Jsz3)4*7r(^ z-Yy9;51ZXVvcm)X)_qu)s7saT>mKd(yU^m+Br>cQNMjPilL@}LfE{_qRS)BlHouj; zy_@0?emzpLz}&`X%`)csJ9Q=W@rSgxd|sEiN)^*AFuU4UT?W6rxM0c4YMFTZT!8k- z+t0f0_p5!ksMaq2h*JA>sqxEHdAwoo{-{d%n`7(b2Ol82cQOggly+fmxrXMi4sN0R zp&vq9z24fqj+Oc>IUdchM=0Pb$GwfL7?it~P4ap*ACz>_qGmhda%U62q?6WxBk^T} zNo5VY|_DPy3<8|2Cd+5WHiv~=hIc}m_$sV`|Dwji1R=8qLp(DW$M3z07wOw@8hU! z_7IuzihAIF6U;QbyA?Vw{~C{17^dKIq4B(KkF3xk%bNe9vJ>c*Fk0K_*DJ=-kV%FR zVny19zzE%EnV#+D!BXv#HVJZ#-J!_{dnC4B8bD#Bp>T<;f|V5$``@tFi7Dnewqx%1 zuOET_czRB9hTr<|E3SLq>?vXng6PEzlkBs<3Sx1(%XhK=46& z(p|3KT~m5D`9SDJMd;Q_j>{VUK{cU zoe8HlF{67DxgO?#8tD%r?D-AE16)m;EzAA()8hBm-{@Nus-?dW(I8AS&?VVG#qkPo zxuU^Av-@|Ieel3H2&S;8Z0&KoqX+ErC+jEU=C@V^q}t3~Lr;9PH1>?uk`x{bXS7#q zN!AWLwi0?hq4Kr@&D!!)g2_h$d);>aFGufd34aU6R|ig7)^0_RYU_U-^EK&BnAG)& z6k84`?74d_T24K7O8@ggw?81!?q%6hYp-a|aAd2jBJ`d@^rXkvB3;h07X6sYuJj9{5$skPYYmU?{M@S{@nqI&JEv z`024qoxQdS zobqF|{YF3b$ana1&e7ZYbbckJVWYET-OvEQRNvjmK2!i!s*XH=oFD(ubh$uBT)-s8 zn2Gt29N^aOLOjs+$6J{1l6_Xm70;SsyNi|&(s+u#qha3uA^Zj2e(&}4QJ3uHX%@nq zXt?Vw=jY!LTp(ezZY-#-0R9NokNXpWs)B9g8yLZFe_X%Z9a!Px0?~+*8W+6~Cr^u(+4C_-lHoW#(mK;I1OOc;FgXsBOR_+ezeFK0A1eRfP%RHw%!^~&I7lSV?J zK(T)bZy!LUg&e&h{8g4?9?%tjj$^1?VcBbLPO`hj;k9bh>=(4h{t%xumunbM?6>Y& z8F~~-vh?<2w`zc57eg(ny%SiI(uI4}c!)y!zD@_;n*rN4Meh*KZ<&9n>Ng$~?!4VW z&r^*mspI@9J?T52fA?Ff>OYyRKlJ?i(i1@(IuhROwOAb=x)GTcmy1{*`Jl8p+J#?M5R8Fs-S4R7aDQbfigHC;CYVdPiorc8EZV zgMrb8hQO^p1b2?lpn$FUmK=d=G#`=9L5>P0meb@NP2QktI$2}nUfrzPjdvzOzhNqv z9ho=f3P63d)0XpmsJK9Vc4{5(I$Mu1VL?fBi|9T4f@a%N9anW6FSLIdzAe1^D%+%p zdH=ea5Zl75#Ure+)~kMVNa-g;vAvZp?t|fba>FE18aLMic5}Q;bIGpQfR=$I{#v-1 zDGMrE`!J?dmY|F@bPO4Uc=*}D%0?0K7{hd^Te+<@Kt)Fv%{{pISL@(ACrBTgIy zKIh2I|C~&mR!#rytT)uB_Oi>hRtFZI&SsHl+2!gFGvpjJ5%Zc2i2$+vt)W%lEncPj z|Ksj0gQD)=w|_!H1SD6w8y4vXMd_}kQDTXuyH!BC*`-S)mXd~r1wpz&Vd-uKMFCOq zQm^ZNbA4yNGr#}bGxy{F?}0PO7$YwGInMJuj@JQ?Hb3!QYov%}QWvEZJLk%)e~xS( z9tjbVX$QokG#R#%=C%2M@GRJPe4D=Ek0oW?a(TFZoB9!?*M~ebIn?G`(#H%MIU8EG z=eMaffdDirR?^z_oYY`bFs4U8djn4VGJH)BpQlzt!)!}`a_G|1!&Tmkkdp~Q+Ue0+ z(lqG+ZyNYeE!l}_K1V*3QYl$MtfFO#TVJBx(G)WTrQl^|x;}(2awQqu>v%IILSfSG zssFoPuMbMY`eFFx$6VrCZpV9*5-aWFAr)q_r%cWj!7&d6Wsdvwq;&%wA@aT+NM1a4 zPt?T(-r`*zTd&7$IFJfWF?!|!{%|U+y@xVk`5KDc2DiXK1?)7 z^;e{78-}|CL+=GNOx5)v{`6&!1=CHhAU4Pg%V_JLh)}MgSm<`)i~Q7<`0rfw{7jQX zj$?z%f~G2x#&T`cM0BwBY0?E?m$q|VRcXZe5s6mP9ZbJ!k+5qXVOh#O8{vR9ZZWjF z74tje1Iox=c6Vr2T{S_SN@ES(~;_at@ z<$FR{PDntO1b%+BQl_N4&KvSDvo`#RJ|@QyLC-wTUoB;fYOORt%5w0&u^bzGipri6 zqqt7V^OeJ;Odm&%o)_z}-n`F8`_ddt5f{t$v>=w$pY9cQfTF=&v5V~s`$Y@`S8{%Cj%lJ_JQ zd<+u@Id5xk(50FJw|OZRIAI=3#lJVIeU%21ia)N2R})gp`%mT511QtsQh$!&ovEAO zfdNKT%{MAnKe+yT5%@=Gf_D_1qrS!oPz0Vs&ia8bA7ke1HHar5m2=`R16({ytY8M} zqDx&r)yrTOC*mu7qm;qSNfhrGJYJl%V-4r-TalX#H4h&v|8pf*Knj%%Q7qO&_0JXd ziuv6o)Nla%cZ%?YA?8Q)&b;wj;)+605?d5x>dMIt-K*z?7=5^KIWU|>f2a78fxLiJ zpy1|!=au!5FI0MJn+}&3pnl>B9wUCDok`FMManj7cR=;jDE}ys zkG!qf4Uk&%=FjjV8jeUC(bl13i4u@Chm7 ziCi${n0Do!%%g~)V7OA!jEWxSlA*;!Js|kbr{dvKO<6W(C)@_0W;@J!53wnzMp^#0 zGNPz<4q{`pC%>syhvQq);gjZeLgF{7Lz4P7{Z~e6vbUDDs)xQEsd0Mq*(PDeUz-_) zUL9$zqf*}b$Gg;){ko9#?6_9=Bj;poEwD` z<`?+uP(p09K;pgxn;`bYFkbK0!J?q~Av2T3PKv|ELH}n&R>P!% z6qyQZf7xD^gJY=tZqsIE%e}$5-U=lT@rL03G04`%gNrO`=>=AsJ1b5snYnvhq ztG70KJO{5+^&R;*{&04dgS-2tB6&UpbUo32F9Q32bxXlt0Np4JO!`F!*S)h)6<3}m zoz?~!yH78V)kv$ z?+d>-Vvav>ZhV`p7R`A0a^A1#uK1#~lNX^51?zXyufuHAK31!nB|lSRl!*`DLpdES zFPpa0<@yTH^JkwR7P-!**r8Z;yE_u%15@}k6+;q^%y>S0dG^5Q-p0pMgMsn!s%Auv z6zA8VvL~XA0rSwYa!mZlsQh$Y{9rg+90WuMa>F#EBd1b3y>Q z(`j1#-3y^?t^J@4RrvEz0@g8kYQylLhu)LyAgaT=Kn!Z(4D8gr!ORcscY&&bS~uSM z*Ax?TZIzjmr{$4;OdgF~hn)}1*u^v;nz8nyIlB@oyP%24|7r(zKMMFSgv#iqqsh!K zbhg+K!E9QeH|bnp4rw$NsrU5+A;W5KkribvVd+CM-3F7TP7~RV&J##0x1N~6YDJaV?}0&(gMD1mMD!jx@Z@+`DB$3I*D7wxrgLVA0wO>UKsnO zCLP7igtPwFto`4u@qexD4blJJITBwQk@tz~oH!Q?fskn*LynzOqpKqhnWCY2`}!p? zAC+i^cGYBpJy=HDFrahrUQQ8@cZ~54H<*r5L19Y@UvkG-%X5$l-+&7-NoY?YX!{Tt z-}Qz8JF{_X)i+M!V1@PULKUTU6$miVw@eWTtQ4bx-fQk)?DP%pSsVDTuQo+?zn5vk z(+>#@O|df?b`~ak1vYBSo=eV)Yb->v@^OG?1KWnS3b-|YiRKWL%2{#L<)r8HVS!8& zp}XYkVT9WRZm;e>!ZO3Tnu~9`vB1iGtsksi+4G{TZa@zd9SgyFegx{6=UDHjYgyk+ zZxf9idKeYkQ0=|SRN26lU*BThdZTKEtf7Bg8b{Y4S8sU}HiQqWv^W3n*DXjKsQ4kB zt_XYcPFDZ>LMIj&`Ve*&n6E7dNA7VFDdx!mBMd9(0}H3vU-IR_$PZ;Rf(B zH#%*NR@?)JhTFH5lyr5U@9`x2l!U{(vIe&m0>Oga?!R z&tvO`UkgZz*JOuRPsh>LgnEhUn(Rs7$gEeHdLO@x5&qCvt&Vm92c=LepSusd;qF=L zFpP$WOC#<@7w1k>&)qO%=6tdzH zC{@}0sjy$3f2ms{eR29#xIGI?;h^bw;FdBTp~QalU@02M@M%%wr>7s@^%6Zh&BB?{ zEyXHJ=%w}t!Rx~K#EgJgVm_T{yusm#Ai-o7Kl38GxY(@#e9tZJR%RJC_A@PUkE@bTLIQx$i!hKOQ_`E;- zgU=^tT|f!z(IP#O>Rbwcr{#z6L-$Wz8fU4;@wpDFy8Y|g8Mrq33V+{KV46baHgFk1paDK^onI|) zlBN+SB$^UTCSg@~m}-66SJRZ$v6hz=<(htV-!r~UYo90h{ zG)YZ4;T=z{(nQLxp)V6b><8=bvvn0w6tdw~(qF}{iUe}OD1fJ1`C9qjBO`w-^#V08I0}$$}JdMC>cu6)O~1|PA*>P!6G;1|o1iJ{@wP)4mU z7%-x>EpxvdK2bp&Nb)fr*twt}5Z$$@lu zQ8-0TSs8!XOdpGPf{fX`_6;Y`*M)vgY<%KRo{Z-M54-*0RAfmmxinT)lR@>P?zMKG z|BrU(kXF2M5skb&I&NRO_~UAIkDk&k$hx{}?!(p-KmTQN_uv_+Blx4|Y~pxh(q3jZ zoLaNj>==UQ&GU%RnY^N{ZAQzs!euF}f5DxKq35xlnYLSgSE|!W1%k6;= z41ec#-h(?6qhRq~sh%1dadl)#$0DJZU4ji-foo%o=@vX5J0z0nb8{v4UL$^G76tjJ zhws^&T@`nWyJh!)tUus40+I_2Qt-ZA&x<#FOz9uu9x1A9i!_rREgAX?$tA7)7Tg{_ zi~+xOUYf4?a-YTNw}BOP1aY~Cf0`PZT+{sZuAX_5A<0DE$6yFb?VgRYgs!>ZeK8m+ zo7$!1HsI4M7Aah#ZWXP*dt~Cj$6WbVBy;wPn zF1$+-wcI>zgJMD0&Ady}ZP^~WnIms`?&ry1UR$4NTRd-=oZd#HIo2*_j%x&U8ABrYP!^rsR$eoPD`%5`o$r(Rwc@Aw^NwZ=hY})YNx<_i z!~;Y$oi%H^F;{;r>sKa?mAqGBD&r4H>NMUfx2@Qb#HA#D3;FXRCDxjK`VPy1kfO`o z_P-kC-#OHmN2#sF~KQp%vsQVtGyXQ>#C>QeRjdwH^ z<-*&5`Td>5+YhN#ZBD%kM2h$^f>}lZ7^mqL0YQpzmmcxfrG#hX!vzjJ;?rSb#!kH& zPTI)@lLjZBU@h@Ny&5)D^~;g1TnoaZuk&w5j>m?kNfJzkl2DOg0<(Zahlk8oV!Z0L z0E~foD73x>#y1SM@o1e+juh4LfLl_9llU_RKO0`ilvgYqvP|9i-%W7$&Kc5q)m+-t zJl%ybl=rNuOvzAH-3xF^&>tGND6H^0WivgyU3s*u|1$UCF+0sH6|bbOo0q112H(uE zR9E}pk!L3<>1U&5rULHR$7HF)i^15s_<`8AM+5xGot9epS=S{$DP%0-=1kyMntEZ| z^2JwVUBEhXR&Q^?NA-q><`YQGuJsVr*JsdNW7Aj2bIJii27_hexdROE>n$CfrljEc zBdX=3vjdvlh0Y2Bed}scL%>PA>JgEGa|KhUUb99{$P^Dj53kOr8}sc353J}B?%=Uo z=X{*al8l~rmYA+{2C&DRYoj}$Jl4!EzDlca7H%Qilz1St=+|xD&yVI{6cW89oBv7D z3DhVZDIvLZx!dNtA@uSq-=DP&d2CL^m##j^&FIi7oBg7?VkW!#cB~qXS-Qo(%D8kK zAt83avf_v@7eC(7rVmi@B5BhNPfJtH)Q-Ck}IAgtS6CsOfY)nkOb5`Mbz_p8t& z<~us`V)Q}&yqOgCCUZYVgu{0_JVWg*%!}+{3IE#PyB(^61mko1O8m@^<7~=CtoM@} zM<6GT1nYqlzUAxH>D2U?3$0)x4p{b9>lay_7T-i z9tLu2Q78Kz28}Z9C;P+gC%D@*I4tGWIaD$CmcA78Z5qMueGmQk(WSz>`@j)lds9x; zgnrTgx@{2Fq*o#a+QbbuiM2FVXth>wRT5Y61=^CyT~UrbJT}*YvVlUf#1eg(kXX zebE@Zc`_zK4XO{_$~?=lo)68E<>_8{=2OrJWZ##W;sTVl(b&u5-1u5L9W_OiZN5i# zR!wga`6^)ygbmh8~@;iFjgngF=mlVv)i}GhkUpuARf5vf>_scDl9R3$O4_qbFy@m!L4| z<3E_h+GgA7_-qnF(e~^MRuO>>(6>ss4B0Ch50oQfA?;?2&s9@~*v87vKNv3)huDwo;hUlAgIo3^)A?hDdRrmp3;K**$P zO*P-O05)}p^8IdOIok7oSxtSv_snke{n=zyZqa_sd{K#c{PTdXKrFMo4mYdQ#hJ{r zR|v>QY~=5LR@JS6{E{XV%ys#2{Og^@;S1+q7kv_8vZ3};t*@S(tjI-ar`1(RRqq`m ziC3t&l4*Esg5>R`mIvXTOCs*!_U&gTW$r$)5W24EaB?7C+dJ?@d?Rq?@yI{i?3C|0 z{Le6&_+XN&r)`!`Q`XJP&_TYfkt}qPYNI(C!n^6YJi&G$$8KOO5*6Uj88wxu?Jb>c z{nvx)G4bkJR+f!$iLjPmC9w5VW#xE>w?>8*_t>&2lSDfA%eLPG+xn3vfsOK4e3Sm$ zrKU>l2m1pEirH5c_KQDVSia%OuUuAEwz+=_wrgVw&y!(NT@)@dx!c2Z1VXOjxMiLj z-N|Y7*~K&GxU$Dajf-G3+Knc6g>DP)W46dlB?z~f4R2}a>?3wVIKp?Vt=bGSmxX&$ z8YU&Bq(p%4FJeL>FfIc)&D=BE7@YnEYfA?j3%)rIW0ne!{MvC?hdp(^@Su9zE*@Y3 zjV|!}#;8WPccC_bif^P8yDxo|GVVi(boZg%}rPk!iFu;}=Ahgnbil(D?^1OME!smW;=WBC8D9 zMpa9h@kf2@r>`r?Hn0-^YJ4qT{g6+RcVxl?r2dKsD{Xsan#26$t7h#&FJFeXk0Q1k zv9fTK8z#4vz{ZI;)}gNrv9@6c2IVR9^!I38Y@G&oCv6Q}8n@m4X3#c* z#MW;3--{X;L4zT5Rn zv(JWFkH?FiB~soK zyUv`3C5c9>N1#37r|)M|gsQMxlR3Xy?~C(gk(G4gqwrr%*qOiKL=vn3Xr0|#P)dJo zsImDD6g|Q$v*^c!zms|Se9&)yVqm57;eASt6Uy$E9aT0m(Y8y_t3ld0(J@c#;yA$F z@_E>p^`VRYT#Oxbz~3^Ekxkg2Gaaxb)ts{Ag{%45RG|5BpUb=%;_i`-)4)%gkB14T zznz-t^H(umS=2V(uIUwht~)5wT>tCq4K=l=GAqw)+oz(GUE#sT3vgoF_kx5^6;P_lKhMdqeZz1~y`B#;tnLW5@wbHAWpVrm#sjaPhG;xZ>LyEaVAA~V* zr?g7RFCsRupEz{*<5mUToE|$m+0JqSJX2*8uIHU#+~f1L4!6K}jLXD{OF)Q-R~rlL z%iaUJr4x^0sEg8+m67KN5^_M+LscLDQ|#q4g92AxGh@{}HIoN2bMnfDo-1xIM}$9n zj?&t0%2ON+HX6s>H)CLa9$K$S$C{lqz_n17ont*3Qvhhu$e8SJGcJxxMqrv@gjAKm zx#<5F^WWneV7c5aUTs25rn5Feg51@rs{2JO3hoEOGsewp?Fl>V+B2m^YA#Q>_h!X_ zwAU(QIZ{gZ_WRJXyA_eUi_bcl`d1nWsXDRKNra^)j#AG#uT1-}OVoW7`_^@d@w2X~ zOe_MpqY7hSYQ-bj$vt=$4zPZB6j)|r&Z6qOy^sgFXE6$k@%xN>J;cyzee47oI#bs zZrL5={1zjv6cn1H%k@fva#gLhu1Gt3>Uv$zd%dnV6^VW6008j-Z8M(Z!KGuD+~t68 zgJ!a@7@`%jFe<(e!kTMJW4k0WOpj(t1DdsxMB?9S)Urg&FJJ5Y#?Kl`cV_Zd-P-X$ zH`&(nW&v-(w_|9uu2*%PXT|Ysr|xRj*tmT2i1xHhseIqSOMj;{wiX`8h9UunbQQ01 z4#A1=`c`Kv={>IU;RAf3BBRY4U-A zKlWR|w716E%?|R>|4W$le<=QcxA)Px!yH2R1=PtfdJn*YtmEZs1l!Z<4)w9y>7M8I zjR<_2I$~E14vB+W+kBb$?o$*QJ>CRfObs0>_rWclDfV>u5G@C_?j4}=Y0k>&?@xE_ zqdh+A6>#uC-|yy3>gk}AWt{*$&K8{z5y$Q-c5xEr*XmL!tYy;&c#vL0#i!ox7bn)6 zhAjQ1PCVAR|nb{v)11D z8y(95$sM*gs}el5;h&NOto6lsh+^kilT+R3A#SjU+u!Wj6T8sKeN=Uo544QH6vrfq*M4E>phaYSYX|$Ft3Q6 z4)-o(BQ(pr#r$J(7j67v-M)LT#TXC4O$(U3TM7Ye5rMt#DWa$ITnEXKb=xZKY+t~k zz8WnKGr%$I|84x^@LfuVd)G$k!HZ#lpV8CBM@tANbgK;1=3kZ4<{2EYu zxT;~mSqH!n_=+u4PPdM5f}n>cJCJ@#4S_ZhIMDbB#sIDIIlLcJc7JdX&8Ub0oN^bfmHkg;s2>OAiiOUim)~*fue+nM|c9Gc7?XzFi8 zA;0(ZmiV-HB? z^5+-tNU|l;B|m|czBFRlo3e zs%&(VeT`6f(c@WjfSF4qlH_+k&@1rm7~k2`mlGdLqWfZ9qVJnTzvHv~>+C#d$LAvh zNNaEKGn_ZYZ#OEkD0%eHWwWs84lXM}3O#aV+UMk;Qi~DS()~-!Ql7uE?>SU@`U*_e z+qDMyV5(kXon;30Hixt(vM(lG=Ul}s*T2^8yrbH@YmIekn*yC>-prgwiod_jpRT0d zBr;5tSN);e(x~SNW8eJo2c_9gn2+Tb|l&%x!U%oKXHcPQy7h!Je567v#gqUcy{4-tITHNLOJOTONbabJ-y^t^-B z+P`11R3skNFjd4IaihXyA!a$;v@_nx^{s)+3w4Y)4lT1ams7%iC3+~nVzR>H;-e-5U&m-xuJ=0sl-60lr0!{_;1{kZ)v2WmaX zqt7`!zpb7tWdrk1JJL#*39~q@t+VlAFgx#=!_^LcmIVwwgf z8DJ%lKcBF(aa9Vs5h^3*vNRt~oyzV=_^pCAr!iJpacrOR?k)~;lLI>IHkDJDy$pssqjNSFP*YSR9AlvamJ4; zP#xy`Sw0tWcRLsKLm{D%81Vfc73iMzMXekhCGkPiTOICwTx@c814!lGs25sxz=;5F z{bY{fC8LnNv;OPor?WisiEE6--+_7X`dM_Ks#5Hd| zv$RuT*S`J5)}-VZrIKZ^-*)LL)l?Sdxy<`F-zewjOS_VV$~G-;_N-sZBvb6KdS^3q z#7295*6{>RZ&((e2{NQ~1Sn9gVmNI@?HF1ga(?cZ&G=Tb{*(9#N#TP~`jp!V+ss^pe?!)xh<* z%<8<()4Y!IQ@x@LHr{zuOyi-3*5W0sG_JF6QG@DMV6%mYstu!_ zx_1ev>l!M5Bd6=sEI2lBz5==}5w-&=X!Zk6bMf8jCC(;f+8+r|jmcV*n)&qh;nNp1 zq#X>vKE`nuqKTIs8(8B7@^}feP$p~6LRFk)lNbIDJmd+D?iaa4q#@G}7Iwsjh5whr z;~fYVtsIqY916s6wAfW+LCCt-mm(HxcVMaC>lA%T{U+VB{Vz%GX3PVbLQR*J-zMGx z0SONod}KcC2sA^udTJV(GJ zT4@^KfN3Gpsau%JPo5zVsNNS+zt9Kcj zalw%n7vxdADs6L9?1>f%jc>m{du-!`>Zv znOW@H#gI2^+IoMfc#~fsQA$mM^@Z;xi%gY954>u1#Q_88zZR1JT5tXbrv{WV37tuN z7$J)e;1bvA*0h}(iONy0d<5V`)s2WslQPEUc+hUuU`YP@mbO%1HuGUt3M2;A45ALs zwiq!jvWYv9-3$#y8vJrALE^8Fb?Jg{={y%}nJUTw#zkiHA&-p|j^6Np>R0}4Yn51wdfbu18yEFKj3yHTX|hd%6M#Mdty{YGI$OQI^=dc*M7cjt19Sjk zZJkf6t4o`V@D`d=VK8B;9g+G(_6Y{WJt@HY$z-zVi+5DL?%B~*+sjG77o@HcxzbU`ayvP}bm#+S-|RX&Lm!hwwfyuNfpTD;v3)DLUp0Vt5( zJNsiC*xqBh^AV7(mHBl|PlQb8VNA|SefN%Tq@RV~V1nO>-55P^el!494rPB{!~whk zu4BG8;jm5bW-qi#H{jlR2(CxG|NeJ1&?e)ecyDMw{FxIH$1d%r?s#HlmH%MZ4F+PGe)pLw0Sui~;piapUtf)u?WFV3V zHP8*-w904?{4+}uA^cZhchAT*&mn`px{s;gG4rEsKF+&-G)i;dT884+OwP5u{6K6; zsFy4wSKl(FX3fiEts&feE%7yY)WFrf-<_B71_kFnO0;(^osgtc+pUdmGg-wHMAFy;C3m4k{7|=d)UsavjI?%*d%6;PeUb^AV z5PkvbE~%=qPWn$yJ<<+!$92)auf84(C##R(pR9V?FA7#Hnya)=Jy%uIDShb`zbzs& z7N1v6tCN}u8P0hy0r~tiH7Bt=70b!HKyb=1SyhUa88eKWk0?>2ICuhzP@flM7)HiC z3}_r-U%5(mG&CdOS7eLVEMn zximUY#+UxQk^FvIyb5;H=@ch+;Zbqt-W^xeTX?dUx5cUe_#+0x>Pp2?mUxOYDY-m=`mZP|76x6bucU7k$$j8cm1%G0jMP4gCu+n?hMnQdPL* zSf?HK9zlMlLfSuDuAOzf#Y5tH^_0L-p#FJUsIBwR9(UXuGek}-3GwKVP1)Zqtva%neo%Ap^^Aq8`Z zKYtzVYe&ETwwBit_Dox{wa`lEix~AH5bx`W~Lg!t0+*zES$M5$9x)8 z`&jzCyz`OAw%DO?fD@A?Nx(85T&J7!9`#D-VGPt?pq-699i&&)H9EF=mbp^Wgs)NX z+Od8XuE)E%QFDSJe9aHkZ+R*K*o>dQ{++4s7M`6O^Bor9cWns+ktyj4*X@z+*|iLb z#6wMy;5K0FR;<})NhF1G|IqV}tT>;X$2j@rQ|g|ljBs@+a9h|Q{s>%u473}mX_Sk< z;`N9Nv{@4GI4$=(&(VR1te|<1SnVfbOOgmHfyz`QiRq5Sq`QRv>B|p_SI%cCNx8w} zUJb_`Dq%%H-izQqIPD2Yii%eg`?tgWzNms}0YG%jZpS*Osg-9vxV*ON)R$i!JO}I> z+g5Slz;Afcy5_-aiC3|Hi_+Z?D#OUAo$Z()5{@y~^#TRRr|ZJmMcj14U3kqOBwB5p zm~}pj)>^Ztd~-G_%E_QHh0f^XPm&kc>{qsWMD?dta$`a>@Nw zxx5@h#Mg81Q$AnpJeN73rm9vg;*jU+HvS4+bo=3_ z*~zm#0<+JL9kxZO%f1!r3$PQ-q2W%$r;gN#*&lI|=YvJystz9buDY%L^er(uE$R2` zP=#LE<1M^uh0Z1-UiXVfuf6wzS@vcOM@lI)Xai%eI6f&xwJdHeNB<=428SDkp~YpN zJ$hNTY7ZmsX`5xevrsNtIVDM;e!_Z`a)*rn@eBW`OSN@aR|4k-wIh*as+nc+$|bYghSlP5>&b%2Wb5&ICqDX6 z@Qi|~(=i4TJSeBsDDYG-)H39LM22@-0ueZiVkr}RYMJK%Q)lEEc?yJnJ;V2rMs;0b z6eJ-siYz|+NChnOX=FSknBB9Crd@;*g>(CPZaw>F;rBmy0BrX^cA@|O{%JQTWU5P{ zzUSdkK=mJ?&#E$Bjh%^y%KA1*)YO!T1k?YJZyBm;!~Bh)=9NsIeL+{yHB{Dcu*ve| z1Zg{lx7X{x(cNWF$Hi7EU^! zggzlG$n(jzw82-A&a@e-2&yu{s`Wnwu8sRBhJRJQdebdij2yC#rX+w%ZV_B(+|q^ci*`=td^s$?m5pSF#Sg+#!8-tz;s=oqeDn2pzd8T(O2(y8Y{2d6dlNl zYe3oer=5!OSMAe<>xNqWq&@F#DnE@6*#k=sAu@m>FWslvO(72m`!Y?^+L;7$af)>} zbqeSeJ6Yhg(q<*`K%-RzGOXz%$RJ4-rq92TWwS!+HW-__c>N|0SnFG%UU>nw*{=BM zk37s8e{1*`{)6XsgZQXesW4YOpuW9G^GESs(AJ#JGtKEP|4ALaYfwA@l}Wnhn)=Hu zwF&;eR`k~|OALU2KDq-yTEYh?EJ6;}VX)3Ix5X)mF5D-4IG1OWL%a6`9XMd@2~4Ipf+4`ALr^|q}t zleyo(r6-Ay0FZnD>qL zNb!yk+a#g65$i_v97Vcz1EJO~vTp1(^-|CVN7vmx;J);ppb0jC@5W1$NsC<+V2Sv6`Ni{-QSI!>GAKg8@}LV85GI5!@NHP zso_~O%0n|8^2ohtCaJYuKp|GDdd-nqRe7VOIB{Ec_Wd}GAwF(>7oC@a z>pVkAp+*Xh!$o$Q}Fu)cTFXtmq}@GQjo+V;wGW^;fvm>^g=9e>&dQ4e|4WRR2N0T zTe-|MR9N`!dN)3fAh3Y`0>bIsw%p36D2D~l|GsuTmuJH9mmF5jfJnfB(yd%rD48`D z`K%T=0spIJE3GpwWID{9B_BcM5y7)f?wb4yu4F2zzeSoM74(|7kMEjIl8UOFzZYwa zHx)lC(s`6<#((-`RSf!S64JasJGIzV}9Hu(XWQ`zcgVDdwSHQ`wrB;q;NHzC8^ zQx@}s%AQz|BF}nq*?6lhFt^O>{5nFo)9NGBWg3rs&$)`;C!`_zLeIe?=CvPy=3pIl z1QDJlCny}V`2aT?k16&SSoyD@FlGT^Hwz>xIGiU_; zkz>i94XC`V(i5ukIL9}RaJtdBk3rIH%a?Y-1(pk;uA1n9xr2`Gy3NbmIj_6vVH>WG zSN~Gj-03lGzia=!L}swvjM>%j`K{5DfFErsy7p1{IGL4Q1F)Ir{So3Q&EYVCieqT; zgGh>$xa6O%xR6&Z^azZVIsFx1F^Yb}xtnj*CVwUM{p%kai`{Ym(>`};rsR;n?08=< z{q?hv0f%;LJ=zObVvr-y^b2gRb!v?Uxk6Fajg8RMlo{y;_=Y^}9wkCrp_J>gP?;ZZ zGvTeT;jqDKL-AidZcf^K`Pe;h1L#zss!ELgeZgT3Hx??3C~gOLG@7i1Uw?ybf{RwW z>UIFw@SCVt@|fmr<|h7I{B(M47M#?s6eo)TsT;~hse{yN_KvegTijbzV$)CIt71f0 zSUK|E(be@mzJrk~OV{%S*%i1cVrtngO#}#&gFrikV4Y?!U6Nr3^7_rJ-k^stHwvVD zrPk`?{8@~wXfHk>$;q@gKe`Aq*tH5TY zvtK~5^k*QqZE+;>i{YvL9zJ71I^D;M1I$AoQq}Q4P2^Lti2eyhJ!;aae!mF~4Rp~b ziFY8oB#fDqP^|$xAS}QFWj}Zc8d#QeIGw8Bf}Yt4qs$V2+x%$v+q@x@hkOHc_r!D7 zUCAKY9?i!&$W;ath*eD5?EMK0ReR~lk6*-2Z@CLn$V{cizW+Mp`GGY~@Q3+@wj@)` zS_on-&yYFc)9or;)sHXL{L_8Fvq_U)g!ve*VfK zDlEZVqN_dix6o6e5P88L%GLJ4=364&7mgIz$dUJ9YGv3v&C$Q|^+xB9q8Q`qUPaokfUG1m3AAxe4E)xfS^fv}RctsZ8o zk(9(@>Yp}8$19aJcQ>{YYcaEE+BJY<~M zs%$+RSBoBsJXJ2555*>+n&%%Je4}H=Q*><^loH$`GDdn4*zlcl$WnW;413_u>I?LZJ8^U?+nmq7ufKF_cr8 ziblCo{e$@ zTe+z^Z_=&Bfnmo{s=l8@gc`PEZs@94ebI4>m%-R}jTvE4@B-7Xrba0w*Z7h*@0Vll=S{K>f(I0hU!PH)_NvP#U z>})?bb(f>eIs{QAnTaS-dEPwTiT>aq;r!j2pXm-!J_4AbGtg8^lQw$83-G zY7DzpfI~d0#`74<_-3H&i@#HPR%At#w|jtWGJ}nXI*If@c+S%zf2uZ?MQZhycTlFL zg-}gISl7j5mfgt|>qBY7sM!#kb>p!X#UCWsAo|U%X8nh<)H{-><>2GLxgTP|ejFq3 zS0{N2+4?#5Q8Dzbo80`kj@jo~_oHh0-`J|mjkJzelVHRA|Jd$d4+%D)(X}oL^dP>3 znPUqw=&K-E^QH1_LoqpYNwzEd!-Z->IERAF(Hv);psD>Ebwo{5P--o*7=^=kqpt1@U|Sb`zZx9R+98Nv1<7$*Fye!@=~t%kzlj zz-jXCmyjg5s?zY~jEYxCfLfKiJ?$I4MdyWe0)&|hOZ9Mn6JP$Xm5ynA)u>4%QElEh zqXd`&z};SY{K@qZ-au$?kzX=k7b~$-HJ^X z{d8MEOxC_5n5P~#;8}}M+-snXIfgp9V{$%bHD_*u;B39SpLxCQwcQ3>l&r^1yoT+y zppKXwVH|~^a4nMBQ8$c7M#-&We)%H;ru(}Q2m=P7E{8)@ly zvIJP*ibbcV)2@5AtCpKuSoR+;gkFOjkwQ|+t&`Wr9QLp9ec}opxL^7Z6g;=}8`?jn zAZZv@Gn%JUL!FhQMEUg99Izrdz29#ZI|$?k&Df7-N?){G92~H!RChKzoLrXNy$CkP zZ`z37kqoK+?)eop>)zW$=tfd;{4BT7EA2L#H6d|kiE?Nx?R}8Syvx*5!3_F2QCFp3 z-S2p>mSb)<)p?iY$;ind%EF5BCqGdv_(P>-r8QTd#=lR+OYBccKto6QS*(IO z=*6225=rhXH3*Z{yD3a9>*9ww#aKgNwBVX+M32h z{#)iZm#4y4MSH( zZN0Vg4J6(9@-LQt$oqZzeA(Nfjoy3_S@~Y#=s;7Kp|*FI zxy|Anpkx@lbQck6L|W$qBg1cF)x|Jv2k!mLqVbAu6NRXc9*^$bWOjjzEix`DBTD{ z$WVfGcMXHmFf=oSASxwN(ltX$hlF$}B0Y3BA~i@FAYpx<55M1e*4pRUd!4h_dj2|R zVND2f&v@VOxUSbFv4)9UmvBw~fwF(j(>^;DxHI$%hm@)xEwYap@emt^hO}*!L|~SA)hNdag_JrX{l1tsMFF0}$-IECmv}{ai0w z)0g0{%QS(lNL&y&$J{jrKjAX9H4>^-ewJn%&2Z)oI+zoyVr<@J{fgvi+oM)7k~-O# z@;bM9dZ9v}Jh2=KtcT>vxX%jkfPP^qsW zug|;+7MG(4+S~dQ^B6;0vUSB17#>7cj+6?yC5R^38r%(%Vh8P2mdWns3+4U*D%IM5 z@I)4HW8O0)dExsVb$A~PE-PkT%Y#d&*Y9H7nQ#u~25u)Vn@fBC7qY6i^6er~*=ZTj z#SI_%g&(y}y|jxiS(a7rA}2rl2ejMY(0e+=Hir)CtWYg7RKS^-xk#1w;qJ2u8w;yM zaLudJSKCHg)66=>su4P#gFNHZIIY-&dTh9tNvZ^Q`4SlKzX9TTZil!6+P|yyV9G8Y z!C@1wvWfisp$JvdPcJs4l$P}}*TE+z9Vw+Ubdev)+NGn9QseTIzIEf#8H>2!>m0R` z0QuuVK#orZDYH})8=2Cn@#u}ot@Yaj*amHTpljdO`l>>0*qb_J=0~!RW$6r zEN~`t+ZhY8Kjk0cz6s1o!mWB8n0U_(Voi{#QV3%&ILoPvws2Y`V19ojW1(wzE!g=_ z`Vc7f3;EivQC*+O(DC0^WiOdoL+;H;xTdbQIh0f(GC9Y_3@n^&vV_DTi#%CO%D@KtsptMrsPlY?CAg}mew@5&mn5A6KAFlNs!H!X|HA>ipUlc>SZ%e=J5OHa#H8+>=nQM@LVrK+7dCt3?04=t(EVI!vt!|8T{^Im$+p)5CF6@noEdj#|wb zbRO9|6b8zNwaoXc1V*?7EyaFqf1+c+8&TGAbxSu{Y6D@GJ_EGqe4Crt=kU8d)juh1 zpu~$Tqz~dgEkC}j5|VWX93(%4=DXW4d#W7m_YdcC;H-p7 zUT+2w;aWABb?v_;T{pI7;^qI0rE`zn$^V^~W{+93M5JM>tmRV&(_3~!r*>xP=GbwQYT$@seicL@L&F%K8jTSB8*kc2p>CR-98`7|c^+Q+moJ2DugUyJrBq;IQodCpV z6pZ{U#sC@<3maO#+8!JVEi$VaflIa61?a-n2+MH&ZS86JJYi^J_MIk|dzf>tWgYC&^Sg8>@ z6codF#K> znj8A{G|8+rzI+(I>{5huzs1Yeq4d@T;_5^}8#rT)#lI!?R-$6vI=~AKdW2G+q(m)s zFJwaUHQr}zTmmgU7aJw8Pl8t}9xaKMy@<@^9uRr(KMpS9-@kX)_Ek;zh3BYUY}T;h zTbvK2xA>W7m%b*pO-Zd8YH)Ar7pv&E2s~_k_Tx^s!o5Kzx z`I*lpcp2E+<=^~-eBS!vo@uZh>U(|Z;rlxY@)%yFQ_H#K3`axHdgUMP-$>O1Vm-b_ z4DxUhTBG+95=rm5ePi*r_cF=WQ1^bP4EWK6dJXggve$>4-^3@0#I${4hfI-FIsv`@ zez6FCk?9x9Q{{58_S=Ni!g``jd&h=1t*pCLMWa~^lijfS;vdyWoJ;>cIAG0Clw~oy zz)g@kx{M6TJ+bup)H`mdH+_#s3|GAVp5`%CO4-Ie+Gu|73|!kpdqelINFp9h;Mwe^;30X_sFyTX(dFUX&g_e z!uSaN=7T{8EJtI|;obQN2_M>3ps$Ir89DW`%sNHeyvbq;zUDN2nEH=uIM;F<*lv zGQAc`_v_M1Q-s2so-``hVAjV4p|5xFJ7l$W%jPcP1aE1WDjcgxWoGJESq6F*!4l8; zSiQGvZC#`nX-{|P=J0k@HFOM>-ZS&PH2oyW8q&S9HA$Cdh$H?}tdc!5PhAU_BIYj` z-N2_kmZS^$1cfFauHceagYzvbsZ_EX=Tmnb54-7Z7Dx~iVfnYRgQc5C+b@2}U6S1M z!v(BrxQC*sLymWq*t0`QZ+O)39ryi#CMlX9!-w3|f_YCo;+eN+e;OEz8nLZx6!CHw zjG7ETT1mQq_h;4@l#ab{)wpNEtm}si)){?0A~H=bm^k+4Dd^#S8(j6cn~mG0 zm6k&Nq;v%Zqb&OVWUK%WcW$tgS*ljy-yi4f(JZtao{lWKspdce#9Z1|_Z%6!AGW>Y zIGHw<3TlW?Fk!WWx z<~@~Cej%u6_Dc~FL--3iMAa6;X%J~qxOdNkJU3m@*-{uKVWlqpLn?7KYG z$t^x*wo%#G?VcZ0YR%g6fnyIm=Z}36dlCB~g$eBEV+Nt8$EV9S!Cj@5EX-m0hS3Pg z-PYony$h@D#_7^Va?_f%1iKH?e|%<0K61AoY+-61RJBl_s2069wu6l7+jiiJOx}&W zOAt;qlqyqg1#t+@!pIG3hD!DjZ(j|;^c3=(Ups|BBsed39QZG5PtlCuIS6A-Q7>^i zU3`>VuU>*>VhinfvMU+`1UnMZVwymdXmf4l%4j4+aQKw(Y%eR^0J;`H@uaSkTr6Ym znUmKcNO4sY!vA`%*kET$V64l;zB*jMDAn((tvJe>QN2ET(K#G)V;uzCfC9O{06 zy=Em=t|hMPGUZyEHD7^^s%=N^XcU|+&G1vqZH5B?<%xtYOEZ?Qi{Tfk)5oXbS7Zdh zw!`dnK>eHxxI>lNaG!-TD`u&Ot)3)0Tt%?Xgu2-5IQIN<&zCLuTGnNRj$M|5Ej^4G z`lyX`9d9#@(Jx?#eOu1zuy} zCEW7L;vr07J|*+8{mwhM#12{17)h3}z>-u~g^=k?B|4cyf7;%q4L&{B&=7@DUpjFb z8kbEDhi1>M;*NE`d6L zu#kZipU=w0?z+Xpf^a#qbOCoWaX^8@<92zUaLtqY{co_+qH?sm-A9^;N z$Osu{_7PC@ljmE`Q6NbJ;1Fe49X5aG0SSy4Vx|Ib!M_a)VEtxPeD}h>TW~1GY-CCP znt3mI)hF*jq2~Jf6cZ?mxCfWHhU$vMcSeW+|G^c9-&(c4PH!NMK(t;wtdNVw67nW` z@c$ZOepNh+B8rO@8C=E5{nr@X=Kp6nR!a!f{l0rXV^T?Rv+rc%{=Q!NG==`|nII}@ z;~!;lU&*>P8pEtU)~2k9VSW6yI6>93fA;Jpc@iJp2s{<-BH}(!E@l0LPh$kQbzQ%W z1?sJRHgf$sDuS*{t-JvrEUgp(73hrP<61gD_4^Zr$}6Vi={~1l;`U)3J+lQtxn$5 zebfA}h^j^N4yP#d4pUyUnhh}U?yjklJS^s?G{M%JEzjr$Pw0J?Vt*mhq~#gS&1dWQ ziuhIN7kT-mH>?oU8w4T)wzuI%Z{+G)eE5MwWjvbIz`l{}n~XoR6oEWY_f*ojB+{xg zK+{Ck#J{hhlR+*(EMIi{>)F7(ABcP#?cMjJK24z{Zq4jvP?VTLLy0A(LH(ie`^5y@ zkF{3Hht?Nhz+Xu1>GH)Y)i(VnA~(8qnQZ4~i0X3%oHkpQOZkh3H*yEOXa2g5HZ4rg=U$UR6DW<#UxxCq{UE<_t zDI@!}0iQkv=Px0NPXjXVFZxr9%4{ihb@rG-v57JcLLVUCUS#7cPUb zfql}KJp$KS_cri-T6PH?0#E5uQ_uN7z7$*9^I6mAy@Xctfj` z(LDG9;E0xjH_iv0HHlIW8iqylMe?RlaUPL-(IS|0W&q!W1t0~j^!+Rn39`UmK`o=S zoRGIxhWU7f z5}ODlU&@-A1pE%OBWievbgTxCL9cz^lO?xUge2jZwLQh=AO|Cf+hU>PnX++~1{EY* zgSR88x&3^^5vbX3vZ);w0|E~4&Eh6BIy%c=QM@1WLd%{u6x`N*k!4L8Iz+jy`@3!a z&Je|QVnxvC$ur43P?_F;QuHAn-CBycm(rR=@3ADf3DS5lKF<7iD&FGiRy=)w63$8` zVfAH(UgQPm|5(4f?Bo1x3nz*Tc)IK|Qor>1=d)D*13V%cv9-^VK`$Di%ND8fKkugMVvL71%ESok_Z0+~ft zi`KIvRc{g|S6@qa>QlCJY7;n$PVx9@-(=f)Q?>eKSsn;EnDd3rsBL-Z>V|g_m=MH| zGSw=NO42lOuK|kgPHK*0R5~Rl?)vhuGbU%NT+nHGRPdMiuybV$(!MxFr~HJxTvcx% zzaDCI_)BZ|gjSw^9hBnj^@l%ksjD8Rf4{4ICzWXM5U$QiQdy-7{@weq-tf&R!sAW~ za%1Vwp5j1<_FC*u6k|xMB**ilwaY~5U*1Z}UqJOuXen94taSZ$pPR+r70^2^HLI*UW0>% zeKc`brlt{zi>9FgiN1*sL;4jwYmk|mr!f`X>p9lsku?BNiuneou7z9-ZFa@IBA*D+ zh%LxZqwo8fXSn1Gpuw? z+UdQHG*l`nwtIRAMk)BC(b=WQb*8$%v#%v244$vj3ip&0~ogd zjy&?eJi)@QCA_A0&fe&=GJtb^eEDh*?xwXaN5z+)|ID_kwfWLf*#pfO-|D${%BplX z;YjpR*poXuM+{!Y<^<_wm#d#o-{PM_i z+u-$=4(jjQHkFkZ?=37-Lq!+;(DhV?#$sBe_Lzjrnsr3eX-@#V+HD5|_Y;?LRQb%x zc(bC`P{{8_-2&;Xq8Y?q+?XO6&zIHnvCxtuP7?N-1A;Xjji=lfE{*1u8#m>jFs3Ce z1@|?wkY(DAq4WhG?0mK-U*t8N82WZE+R-3zr zs$b}>`t=?Kl80tnX&zzA1T-UXSsh->7q%-aA_N$06HHTodwqqE z^s%wxP^rnvdxLJLy^8#8ozlBc-Zf0hFJq4_&3Jx4474I{Msst=Uv_OQh+H8$wK!{l z*#Pocy&o&}{Uhsf;6otufs3RW*4WpurV~QG^>>d*%}dqxmM1#lZRwcBsk7fG(<4cJ zJO$F2t>rv;>EiW4u=>1C`5o$Pua|;Nk%iq(DGYq>dEa>&@zr@_uxtQya=_6477|_QHLVY7_q&5Oc0S4Rz$w{H*?W2p~SIM5nhUOkV5)?=^ z5HcT|hPp0i1I4lih6&N-gMANb=s+2-SU@l9XmG}9<^1x42b&1r^W?Kp`14vV$E|Fn zs5^gXe~Ou8?jYJjh!p&>h1f;;um(Q`-%`x_it8g#VK##1s+mc{L*3C&ic^*h(?1SI4LP19{CZK?HJQj(GSQ$OMF&S-@YXIme}4by4fe&M=b8L0NlM?)_B`#Bfb-}xFq9h$Z8Wy zY9vC^I!&=&wFwPoO%<8^;()sEt@1DB&()mXe6ec(s$75M6lK&2R6fgB8CY`nKSAtx z-=?;vl4Q*Fg2XHZosU|=0cAJMOSUO9d_=-A&G7J4z~33O_&z+YCc2Y>u9^l&&p9Md z>7wYr`~wUD5~a3!$#Jb|_|*NP;p}ja2OhZdb8%_V*Fy5JmX&>KLO9P2Nf3;pC?+tg=fQK;`*AFo4n(m3J z&w>v8cF>$}=Taqp&97ftLf<{}?xp!L@`(+OKFDDOO94#vt#0(dfnrgBWQ-65!wa=Shaulotvl-*f%Js$*?pR>;ChI8g>e#}w7c`fR0`h`UPC+m^h_5)#`DVh zeyOJ8pzbVWnXryCC<-zCq6_rWakg;A+JQMxpM&od6ykulV-8+yxJ*-10UKqQds``j z=TO4DbK6U5iQ$9$C&9RmVvu*WndR`pp~miiv;~&_AI5B>gg!MNq|r9PDjrhi-OlK? zFOzAb6yWV}i2h~?o_uvo+Y(G#vcYQMiQMLkfPPy&^Fam=kg3)7NsvMu;@xr=m!r|sY0F!^MXn}}yorgfwmeb{+{(OXp z%+7^COxT(IsYr)xw|nM&jH>tGrg5K(+U+gF+nT%$vrN*L&PAf6{76wQ9%Fj^FPYbx zb!_*)tz=29O+>_PZzV43@&-d}?+tg9y;5&%N{*b0n{)|eCxI8}>DJ5!ma)kXt9%a8 z+&*r8W(dD_uu?fTUa&N}1U>vL{d58G4Y{@-a_{%ni8SkBBlVA!yB!Z5u|M-2t&cw6 zRNu#FZs%v&;iu3QFPFfq*cWkU5sOqCz}lSL_b z4?R|$T?MF8dUR}L-mp2M(nm}PQ>Zuv6+YSzb?Pa z5sBq(J0X^11#Fr`7exYXKO9|18{saP+y}R}opNY1Ok{UU;Yb07{2+Ao&^GT0^U(yx z!Nh$vseM%NpgWNHmw0;I?pTFp->2)8eFbg=yAFrNlRS5yqFN&ZlPP<#%YJODzipEQ zPaW?5gNK%=v7pnw#C6{B!pE-Io%!Z_%AK5$SbJC&UgBE$J#}ntTR6IDKM&$giVIc< zwA9U1V)o#6`q~Rey*(v@wi}*63JUJE^w*&4d=NTzQ(D?-$qwM zEUj~_hF_m@J#*d3p@e~3aBPjga?@^$>(=yn$7;vQc7cjLo8tyc2BoqrRGO&l>r&&h zKy(VoRPo)-rbfhBMSTn`{L1pE7ru(ZTtbh%YShilE;f-GZ0U~qYE`=r6}f>L-#E?k zEAq`}`Ng&QS~7*~w2f@j*_DR=67BN2oA2<>fyr3~#aT*5^|3y_sR4v^@mCO5=frCm z-cB~eF>h5QnEk@>{0Kv$zMTatEdE%}E)NNNl!Om@uGB#0P9@HlaCZo{Q=)uA+%-Gj zdxRrbGgV*lH{xs+=#0Y0MxzOWd2bvD$S8wlq$Lel+Juyv>^F2_)mh?2trc=B_8g$4 zUnH?-i@<)MD(1C$8)-5G`Jd)d|7Gcab)Nb6;NibI!6GY>3?aGc>fcqQ#{BigN+ih_ z?#d|&m(TBaD5j146xQ2gt^L6=<96DD*p~h|PNTc$BODwG(g_`y!XG<%~i6n*oKDy0Jfh{o{y zMV5Vnof;lYsx(pm_`XiK+C@i{n_&f&r6H=Jm7YK~(Fe#oEL>{lYk!ZI>ig1~IN8Lp%lzhB%>w{dFfQd-Lu?x(947xu*AWFJ?&@$b%GK)9pMHG}GW`nrR&R%431i#-(CeMK}YFFFnd z|KOoAaWyId;Vd=TCBEF1K758%oF3Q4Hs*;JDYwP1#yf1H-PqK2ez(Hw8(=N)f-4~r zq8Ww_n-k`CvZ0>o z_T!7+!tGGC2B=Z*w{VQFay5P`&Rn8DJg2#H9Y04)J}|kqiF& zQRSUXBVv+~_!MT|yOwV?iS{3FM`pxR9h+DmKcc>U90GO`D8H?D)5ZSyh4&1%QpRm=SATKfIb6{7lj^VPSX@2AYFQa zQrP8lG-{{XThE!hZ0U0s79pmgS==;rf2^^T<#XaZXONyXRi;GWA@dD4D3yV|lc=-G zPZyc{z@pHfVuBzac!y%t*U>C9vdWOmz}he?n{V+|hmCO2F@4>~bzHBGOo6GBQV%F4xVOhnR4;&z}e~ zRxc=*9Hzr}w@1|_qemyD-VoYtxr`<#j(ATvlT-nB>F1(C{>&3dyrk=uWdKzXaBz{wiln=#a6trzU*;+BHwg z>^Mt&cy?SZMOuR3RSZ{`)%{K~nVJMnAO!*?Sx4~=UXM(|6d3<)mue3Xocp$c#LW$e zd4)ZaH~+=@6b$49wLf_$tExZtT4{$AKn@&am^$2twTWFF98hSM6D^jh_~iN8o=`lC_jChK%U>NW19jl=%9tD4g?%q2b0etEK*+$^ zVP^Q5mT#l`*7b_838{$@{b#1!&#P}MQXJ)YvcISp8J7C|woKevL^l+H-f%%-vjb>3Zz)-9U>tiVU`^acb{*xsgscGzz zRMCP&?e?~TGE0I}A>W*1{Mi*AlWKAr&2!FSul50$Eg4KP5;u1_LpnH9SrjCo*ZOD<+bG5Y}2@>Uanm((;DwTg+5Pf zCVbPs*l_H4w;-Ew${eF&m~ZcV^k}fUv3F52bGG-(dnqoRpM38{=68-Jq4J>|qiJI? z51u+3NcJR=?;tx!O-&zoU4v6t6TR7N|5p z8FDbTfIPZMdy1p~rC|cdn1OnvQgOCAn@GLX8>cv)MjiM_4!ach5T{<^${YT@AwR~C zG)&jo7~IUwjv*fy`Ltp_iKfwdr0gbqied$H9{nHWxY zBNuN)(T>B8W#^U$wl1$I0JBX#z`9eJ#N4A$KwU!K@yBn`({7nl)662x#cq_ZKhE+U;x}QMh5@5Ba zy57=lTU7&^Q|S|CgeggC&SOcd9@Ia!R6r5W(@7a3&irS*s`_oBk`s>Y2m8)Me<@g` zSUzxQ)%mSHbSO!|*TOxvh05 zjYa26Y^Se&9Ap-i3_rz5om@K+d!Rt{oicFuVYp0U&6jLz=@hoSXhotV#Z-!yA)5R% z^rT((6EH)-DvF{;C@jqAxQ`;T(2rq3lvs5D|M2@0#KPaVbqP*hRn zk9$prGW(x@@JqKtIzz3p^DC=fle@3XNH3jy?X;CiA9}la{H=DiGq2Mni_bvP`STkG z=6<4vV9h1lXk#c;cH`r7^V%(+d^19XRBg<4eUOIR$YTO9QQ7gv0?+TuIe{!%L6w^Y zoqWX7bO|v*UeUHRGdxaq`H`O4h|HnS6^i2z!5v!oT{q61E}Qc*BF?6gh8$>!Q=KU5?d2b$YntJupsfp2YlV`v`jK)G+6EMVkXquO8XuuupVcKBHy4J?RkN8dN zN6(Ie(E7Rx>v6~B26f@HZI> z7w#!y#Yuw~!s&Z0%r(N9+bx%%#P=K2;?KS4jrk(lwMDc`8Kr^r$KG8H6#CUL^&hdBN^>JkRtljJf`c5~ zhKAFH#NmgM3%_pZBRLGd5ZyJX+u2|lk8EoAsxmwxbY1pcT2Aiw{K=7iu07=NbYuIv zG=E@*n?*T9{QihQ)bg5eP6G2`JI!^YDX*?N@8b4##TWHb`6RSYY(M6+Nf@M?dVXy8 z_4%QKYBV|Y+-JQ@i^5{S zpoY^_M5ie3bcE}UQ`9E!W}(YD)(>(!3Z;5O9^Lr zbF@h(gAX4t-S$=cWAT741z2~Ti$Eln@>T6G14^;?`K%Uyt?oAiPG`1v#%cqfdsB-A z5-8O8boRF?lUv$v@YGzX`auk@9gtgmWB_Adp*8X8n`K(+HdwsOzg7ray$o2ZP5{<- zLE2AhFD3mTwN3CzG(L-AYMD%jQyM0`#kV8sWhxkDBt*xuB*6N#yRCd(=lcZ$qp$8EyKM5nn76i++8FN`H%A^--PcK$0uoRf9&s}xA5=k1*q3EZyH6{ zs320hV5}DZ?ArdDP@zz{UBRlO_6avnrTNI@^rkmn6Yyyr7me{l#%+fG_2lV4KMJln z1`^E{W*;lq?E$p)!xgU66UcqCeI@b5<5&a6%0w8(8pJh39w1R~e>6;9riA&oA#d<(egZ^DOPmPG8yDu-O(`zJj)h+<<-gaDQ zK_ndJW_A>-bM)H}7Wh}TMlR~S-&Qq>A&{p=F7l?ux8_oS(Al41aKixh&%fvVO`Tle z>a553>b1#78k)D9P;utvdiuRI{8*U|w9x%SBk=LB4T8hfMZASL#G&SSt*}SLg+0NP zSFZV7_w$-oJ!@hGlPT~p?1OuAB8f^}rl6H!qFRQ{0=KqbEq#p?Fby8ea3e~ahf0ey zgmtxtQZouIX&9^>>-4fCZwOh?hADAp##qYmEb~zy_!PXhT=~lx_(h^9Tkxy-RM@d) zwq?PD59VSRTOi3nmGmrcJ}h3I{E-l=HeKoPx;H0&I(F-?-#>VxcV|5^64wl2$Ee3; zXZMWL@t3lxJ*}W4O~3MUQ3-v z;#IZ0!(uNysm<=M2>rmukbwEVQB|i?82-!DHt|D?yNNgCWwE+gq?nFytq z9yu$X(qr^GjNC>iNUJo>YHs4N3<;DLf96Rm}Wi zAM_qQgKhH#CB5^}5=ZUs^+sdx8G|lg2-8u%PxNRA#43z5_aFY&)PSOPTc4fVL!T?n z^H(?YFWUJ#o-6rb^wSGUa2y8H{AmSA4cr_Ew&mZ`m^TO64f7xpONF`gyL)Quvra*| z2%D|4INCaA@7Ku2^vtPfPPO5&*|cQ7rLST0+Y2|%Hw^WRbQ)fP_iwn+9=;*~Gd0R- zs=AD|v3UBe=zR~%vDqu_bpHCmTTEXcBCJoFn<$LVK5aSpeju2!6pUDL=)?gUXO?A7 zQWA+YmUMRXEoie{E3#**&6cGSzqmCP*S?g8BEm{tC2xOdVg-WU<#nxj- zW0dA8G&arJAW)R}@{RBpNKe$yRI}eF53_7($Cft^_c&A%)`F}5e9t(C)?t^KuuG1I zUEc@20}jyd-6Oj}s24J=e(+V42}RD=pnTYM;9?#7XdA6?Ym7{4dF}0EL6IlXj=JW| z?Y>h6547RSCk4mJ0^j6;T_ZoyOzJjDP;{V9AtHB7v1L>blNt2%p(`*@bc}V=^Hb`k}VFx!tj@+ugU+ziQ&IJR{8(50#|E4x#>g7V=|V! zvJd_3zCWNBg!58fA{zL@UH%=O^xr$i^xu8KeuV7|9Pd0eA8&2+XKH`+VDU)kwEqjM zSB3yy;n(q2YX0ci-4K#I7nE7C^8GDp^PV8kk0$VBY%BR z8djsJe0&O1D&4hQ_zmht47~kgJPCyzCh^r=?BCT{+~^mnpMfmFkLPlvgCRe}wx}6z zRk}yq4!UTx&ACT$H6G3n3VyVpr}`v!#(Bu)l({~Js0tB5E!^7WIBbp`j=F<+(2N?| zVOx!C5G2+ryL3`DixZMt2yK(X(wDdBNvFeII;qj(u#)%${CWl-wkBz9=9I`zZ{YGU z(ctl4YdITH{^FkAnSw8LcSWSE)AHr@!$Sq6op^f6wY!Du*E|GD>b%BbtZ||;_n6@I zQds5^{BM9-H65f@LDlIA`Os;tR>96BN=c^96WXABg&~B*)#Oe=39gl}S%LOwv zxjE?Hl<^}`Y*;K!3DJTFgB1p7k<-^HHMB*}J$KVM+SSUvJtgq66bC~loixVv>_D^n zjWlUJ3_Cm+JuT>3W1TdUmLNJ7trQDCw-@T3YviJ76=JSQTzkmK7hT@sa-VvTI{!?k zv|xtBnhMe7q-;9n_Dg(wCF2j4q7x2H`I*1O$-&*Zy62zLOUry}kti}cn79^Ym!MYW z;yAF(6=Ob{{Q*<2v$IJVWl2;HPo~djcr6$e#h%IDfTkCLS__!g0oDH-A)Xzcs(7}N z%my&pozvAT_t3Se z6{YlP2k~ldFU0c!2Kgnwb?MPt*_F;-)@$r8?wlM;wMVPJCERDRVWX?DP#k*qaR-`S zoMF`HgZOg+63?%yE{oBsr7(1fdD+}}@tdZ68D)jlQJT#=hA;0|80YuKvC-nmSHvv= zrO%Wn#tw}2cLj!EI?VZ(v0VM?EkN~iE$DnSSQ@K*frHwWY0N=&89V&H#DRJKgLZK` zri5%AA>!@X(Hbe)l5w(H@7x!Zx#n+E%xAy$R7Dbcsy-@3NcBgR>R3^b+Ls!q&(u2X zi0~MRJjOLRM?^kyCXUMD`?X!fqD~hnDG{xe&VC|>pZx6qT)KaC&y6fqvefF=);US~ zIx$**)6i{Sk1I;U{?0GuCFy@0QkRIBDsZOffVRxIm5SZZDNac5w@+L}`ZVWQ*$tMj zwtS6w<=XWpV=p@-acVh|Y)8i_w*?{TE;spUVbepIWVVQ%G@JaVybZ^LEXd;(W>q$HylyGUZGW_cfHUAo`|&P4fR@>-?W<|9?NZ zmQXZ-rZyHn)*C&Um?-43Ygk?q_A-vc#$|yzFZ*R#^2$uDSN+~fF_FUnQz(%5c$!hb zJ0zN`&GAilv%DDLnUcu86bSFj%1nw3G+y6u@`PvzPE2p@h%T?Ntb<&D`ttRxM^v!X zgpf5)e@E`5`{Vn|+7Q(=Q%op&biUG>iyu`#+}%Q=vvNmIAsb~iv9UmB!ZlfWkgV;N zuXJJVD)dQT%0Ljz^rhoq-6nuq@-NgzOGY=gDggldERN;u%u8~~%J$%D8E;*8RsGOF5D+v0$McZP1K*O3{G&$lJfp>72B$Apj-hTqo!( zBa%`#5NuBH)0d930C~R0zOWje1jdM)Lvp8Ha~m>r}hLFK~ezqhmRHvG+&`?|C4;K0(D? zyyMsW9dn{o3d~yJh7VjrKkmIto!!9+?n*yh{rvU2!h_u^w}0?X5=^lgb3Rn^G5wOS zW*(Dq{}|amVV$_(9guf?F?PVp|9dMl(y<@+yv%QHOv7L9p~F{4ATp@=2QPx7{Enby z`+=O{PIOvX_zw@O;#>O(pZ)(t#xn_oeaE~ob2*}fvm@`whIbu@~kzI)QCq<>wP#WAf z@2Oa%%*GGrsiNw%cd*%hQok%%XA~q$l-4tq%n_DLqP^kPexO77XyLgJBqWy&+{|Tc zYNI;Cninm6`?eRI;`N)m63dNj8jU|0LPve2D_d}XC(ahJl%V#iWOn*q(=3ZwWB6Qy zoP&vOoz$|;^+aW@^HDhFVi1k;3xw|uNl<`Z$;Qq7O+8EP*os6FzgOha-bfDq+&$^y z#P@F*E^+1en(zXGfU_a?^GS{6iN7~d=VP|%-Ls^z&;DK5^A~yh&oCwPsb&`oQtj^w zPeQ2fHhk3M_;n%{>`uG#dF;W1m6Yw{ufL$orw&;U{5x{<#uUdrc9)T9P5q0Lahi9t zYn`Nb79@CIr=VUfePyRx3P6;swh!}Gq#(IVV?KvHZgu=70W}`hebh$~o?8%F7q|3P zki}yLAc`2?*25mz!hr(!0SP_qYeui0wwj1JFg!!l2R&_CV7KQ)ViV*ZE6R)%$F!Y{ z6Ffl_Y4ZxafPCB2_C+2jJ7+3c2EKA$kzsBk>UjI7j3uBT6DXH~&F55EE@7=mSEzYv z>M2dJ4D;!D9pCGG#GkJ8QDt%s=U#(P6J`X#Ud6;84cfD)q=EV;3wt+iShmwe&kt`? zS0W8%`@vI5hM6~6-9Fk>UZV4h)XGYv#x0dmk>!j2#ZQOrV^I$;*T{saY^2ehM*cxo z^7JPn76+r}k_(Bw(wEZH;OH#X$Fp_Ju2p$EmyhBoPtry^*oo0zmrvPO6~<;jIgjPg=RVR-Ea^xJTQ?d#`;b^GxIV z2QTE;uiPRN!kiT58$-8wX6+MA5dDwKJe7#`5nejn<>Ed*v<^y#5 zhq(O5ra8s@^sTV#&09J6d(@2wBBnNTQ9|~r8j2{}s#)Jd_S$&u{`TPKe=ZmT<)JX^`=03 zwG<8h@I~hoDqdRVV@UVBF`3R40bjjrEo3LWqS#S&-Ls7(mL7SB_Um?}woI&vO2z6< zu}cH(HGNIdaToIc zkfG>y`uS^}Exj!dX*RdBkbXY2PqOUMvRmF9+9ppte1;||M+PZ;k=tb|lyE#kNk>*+ zUU~JZ>Gai|mprwun?k-YA}{Lc8=Zd^$vkO~O%R5#NheBl%~pieg>&$#w1hzxUeDeLrij{k(bh8=ccNGwQkYJdWS-jZbYqfa^97 z2z%ishTb@E8OYT3*WM4}sqQe*MKHhMy4=LuOp(|BJPHPV+lZ%K$6rT~(qv+wiW{$l zqJ1WTL4a%Y8-MmA!ZUz8mRVW zFKK4(0W<@rVv9lBu?h(O@t)Q9RC%q~_pcaH8=S9{R&UDm5Ja%!;HmV!NWG$M9{~tV$Qxn+tRNKHiMtIgh-dWv2izzY2!*si*60} zV;6m}=v9>e^2Ys3fn1Nhpg^Eq`MBKkY8Ohc+zviu6=%C{9-Zn=ZU$iXX=QHaCxubD z5qd}*=Pr12FKSP6(r2frl`c+%*T=3pTe=QeCbt9-=dM-queQw&*N5Od6kosR7GaB1 zh2XM#zCbH5*hnyIJ1W3LVmDbt?0kw>N9DeTh$zyBsxU9365_WTEq&a~32d>Z@Jy00 z;qH`#;{1YTK;!5C$j<+|i|B)<5uJL@r^krg3;qWjQ>V|&DVi-C-G}cySJG7W!dz?I z^1FnUJ4Co7Q6*G+6E!NhUgP` z-pfE9=S?Bj;#FIhMOxh2jXi+9f2E0#-Du{S#XfO+x}{U4A7{hDU0?u6BmTGZdx6-r zE}x7YU0~;bH=!FTP-%;1b8n!wi^!@spMbxz#0Iss6~y!Z6XvA-w^Z*+eQh+JlpY(` zQ!%s1ZiHjP{ApT(ylq!=jSoFc+%Q*DX%{g! zNgKun65@ih9;wxd@!ZabtoeOF)VGwcoggsJ07XF_CQpNiPQdQ>841{7>cnC%75a)# z?X;JB^FrfQwL2AhBNG#5_e;WMZykjAsT+5O{K?3b#nrRqk_u+Zf60IJsNpQv?9*ven{Y z9ZXy@NM$4PzjNqR2hh4yW^Zv@-~DYU=TsdK-3!MEEX1hax|9_N6G@b0guMt10mO%! z^z;0gEAD2hTuK$GKxPmfCDNz}1$t)t1D7!=Hr6M(a49uc}VOX5r~b-FA_tYiB}t zF4d`d-9qE-7JpgrmdkZ(z6B2T9ozkg83)0@4+A+sUx$xs`C~o)1$5yuXtg`;zDh z_(ZJiV3W<~f>kwP5jN~ym75B&_*MpA>8aZCJ|zIiuSUu2_<=OK`yoW0#msn>ZZAmV zZ#^nsC)WAB+6G6`XgQKA<((~D-j@`xF+kNSI56qpKf22jD)?E^r*~D%BEBz(`(HbR ztZU#epU8RWM|~^^^HZMx3+utJxtduFqf+$^M(fs?PiA31fqnGV;dbB8?QjIdbgz<3 z&>Bk*?@mfoD*oC6|BmGOm@$3w7uNe6`oFM(?&dFiNxd1r`_THc6z^5nA6WAb@zBa^ z4#U*kPQ=n*ScBB&8OQ93N43ZIvus%N4ePANDqg~G=Z8m8cNZuxgF_#;%NG2FWj2E; zS_^h&^2ZBcznjL;IbNFd+_Vg{1}zrTfzjnqnN)-_z(Mb&p-WWBww~o5%7rMfOZGF< zNd|(d(Oj*#ZqN8uTsvI4%RXHbXtCXt(b|1661jlC(<1JrKga0h+|4I{FjuG1XB!eo zf()v8+tTIp%+Mlf`$1H)JPGO2$#x()XPm^z-Meq`t78Udd~?_4&(*#SdzLIu?KXgF z`m7vxi72_W+X*wgqS9r;T}8A?i!GF|p?8d!I4XE$BvPsM8f{`f98;lw4bre7T#dgR zh`%Gef;yFCA_=%^!<>(=*?wf;XnGxXKJsxHpX%|EYF&k1-j!SY3rmKo60+F&R&T-M zI`bvT>~UY3{NYuf>6Oj7bFj3b*I|zF8R@~&2T(s+MM3B&FZEFV-0%~T;t}ZPr)QD+ z2hgACktdFwe}cQx@+N{X1`bQR-F=U;`bla!>&9tU(4vX-7+vjywJF5RMfH zI4Z#Vv8+@+pnePf-A1F*PP4>uir7g-oWQ?p$RS8H$F0cqflX?|P0 zn?*NcrF0FJdzivQSUtLdE8XTp<*_=Cc))SP0IH!%-Md8Gq$~=!BxyvwsW=znmcBD? zSS?mwMR~ZRYvzHfPy8r=6>3dm+U{>CDIYSqH<&rH$4uN2R$8w zE7m+;MH2tQdSq{BrXroPgt2gVSAdl3Wr-q$IelCEJyOy>vZMU1${YHTGdM5s`wK%P2a%~?BT?GblqH{qy&gQIUD@d#%T&r&GQi$e6Ed_&?=COUaQtu-xM~A(fG;ujE&LC?4Oh^Ww{108ev)s(@T?$z-h6>VqNuGMKC$Q`Md&KJZS$Z6V$-kJMBp4Rc9 z4j+r@2wbs17!CwZEZiLs6)Ujv^b7K+ajCoz$*bqDzfA3!M+Dz*&(}(!IIiRnOZQsK zZC6zVnCK&m9`DHmbM(g?ENi`(Q66$fkMhMA!55a?L^WP`@O!m?Wa?%RTi`FJ{68*C zvc7BX<@F-%-;oO5HD4OSRn05ZC@Vb7(T3n$-c)kc_k%AFIzwM!B^Yw{YO;I5SWTdN zOu7eY$c?ki3|}=4{gM&3vhwvOKQ9Bk;9THsSTsz$gD%&<4_`xtqF~LFPu0zW~^JmN{KR6?x6oi;Z^db28i)s$CDikWNH1Yhx5Uqa4Xsa|oqnxF^ooO5W&U z#)+Ss^3BT}g|*%Q=0Pfb0`$Felz5Gy5i z8>*|<>lSs#`QbX?x~H73lFw3~E~ve#R&=8VpuG%3YBKq#fsMbZi(Z*J-h9NZ56Dr! zrn%8&s@cS4oi~HZEWXi{J%%F_K2j?RGy_$Q0WbjSmIxpmfd7gw-E3GlYB2|KpZIX5 z`O6l!0mp>o`{exRk_hs_Uf7Zx2Y#Z0{TN|&h7_LrnGWfPR>N34q!@_xX|4W=*0_9U zJ1k&dxE%DllI2RHZF3!?Vt%MTJH4j82}hS|lcywjmfamKu^Z9dDr;#KX`8%|eNm2|<>>K&-#mx0q% zPSaMn^I2EfsJ8U@YiQBzdHTB!!~}l+!?A2Kl#jEqP=Qvo&v*1}ABPCg4&yrd(bS5b z#nfEcCNR2WP1O+qJ?cIJ|f9N|6S*~`Z~ z@*w=g!!BK)*Sf)}n&fgkm6VseM@y&S2-P`63Wjmy`DnaG=c`p^qeLM<@jErD21Pof zH&A5bT!z`p&6ubYTuqd(F>Qb*0t8-Qe}^y7R3FUtiqxcs^Pl2Slg`c%^X?sdn}bxU zTTc*qqsF_uMv_XeE>ieA4L}>Xt#kxtgl&(>5sz1-SYNO>rP z9fuT}J$+hQc)H5}pMFy-?o@X7}GMFI;)6W(zHg@Q8DCH%tipzs1u2Pm9g%E$Plx zYIh*8CuE-!9Rr%49Jz>+B)?+L^R>68)Fo2xlOuYDt9>Z-sMzugR$WHe*En=~I`w)< z3?vUc%fv<+9fAl&)4 z0c@=>AcwoVU1(+2y~R|_wK4shboer#09p5wVF+Dz72f>hj;>t?@bd}ahjy^CN033iN?|;iY)V*)(OlSu% zxi&ykRM-0+pw91Px8cdey0FhOSy`bSUE5MXd$ki`Afv^=5DozMNds#3=N~cW0+A(O z21&XuED^T+zh#8mVZ-8M0?;-U`(4R=@r@muP!DlzeT3~$J>M;6K=f3Em)La|)tFmv zJ~rQpWAmn;&$H91pG2R3)El(VN!e3;sVwb6q?;z_1{Xb4at&0}SeBqteB;4&H;{$_ zk=W9DaB$DQxT9?)tTq-_6{h*IbA%}}oqP`p1CFZoZlvU7^$k0^A&>QQD!`AgQnW@` zG5oA>5d3-#8qpd(aCfOca6ZVi8NO`4SYP{ z+Lh~%ude<0wDt$o%r!CYq_Lx=xJ?Q5%%k2KP4T+JdugR0ZEWB`fp$csWD6-uF`T0Q zvX-$pMuUlPhimNPFht(-A`>$x_bFZ6Z6}61w-BRPkV>scTc!hfM9Qo?bgtgL%-Pmc zZQt~uTo8P{Li#M+mCcY-FfXgu-UewsLw^NH$%87R-i$=OFP(II9p{@8shRhf2k|l+ zVbkuT>7A`HA2nv(cMRnQ?Mt&w3^6CkJprkcCSN= z+$LSR~DLeHbBdbZt)sUSWfBuPeFpq z;H~N-@Ad8<3$%t z*Z35iRmTV+Xr3$+9qa-J`eC<|HF)5#Xw4I<`<8ifyn<^8-9QOw2WAg7oa|PdPk$bs zv|d+27?M-7Df!HQiMu5#;nrZ*TwChxNx67=#hp@h4c-WMtO^yy%~Md)7iYWg&1ayN z-QLAr2{tZFUgj}inSZ+#A?|FPTwWJE_bn980K3Z=d_<@k62gQjmkhZF9tigiR)L4Y z8#Mc%y?Iks`2m4pkSRIiUt`H$*;|j^nd89WFDLe8Dml^J9E3rY;$KsIO#F1!L{jN3ixQ9c~B= zKK|E3SpUf_)Hp=j`_F>Kc#FaW185p6+Liiew=e!KB^%2MF>Y%ZeZCV-iXjloAwMYc zT=S^Hr9@&5GYpmIK1#?N8qa8Iph*|kE+3HS7 zXM5o&4!Z2KH4$qLlmp~m*!sfplOgt8^77Cn?<M5G;!$hiv#dloohd1wO^Of!1)6ixbdm79=V4rTOhbeV_^-GEexBsL`4-m6YEh z0QC!{F%na|;x5j~KDmd8KL_VOL)6ZcDc z0Z+{T5yEyTe~HY!;Jlo&TS}eFWUxihTXNDYW8>Ss-}Q-=+lK=dp$16?Y5`b)V?~~K zp4l=FAu??QzOjvM!`v?d6W!7B=zVAuIMp>&m`%o)!`Xb!3tS8ygF^EN4#@5LS2O5M z=N;#<8khmY-Luc@EwLh$wJcIoznM!K|OP$Ugsb0c69B2Z|F^oanRL~4LiAi8f<2IvUHYS)vT#$FI; zEghgEZ~+No&h{+vOn6N4Sr)(!=;K~b;Qd`CcjU2>XF!;8c?8~Rebkn^VBbUf1w*Gu z__MG>dcLUoVI`SDOaC%v$ECs@#0vBO74Nbw|Hc%Ku0|M}HyRVJQ&97=bH*@q3?^KY z%_fRAb(C0*n-*@nA3yUF9w(I*&!rA0c`rUEG91Y!rx&2+)_`HQLXjTzo2IpEla5t~ zj3FF>9#Kh^QRQi8y)unVreR(oFOOjXF8tvTEFmel&U-o1G-71vr4m0k-u|%Im+#*z znAoWGHaa`u0I=3FLK&{*AR-?3lrND#U^Jdslf>4lZXCp*p#o$H?60iT+G{0UwDFyv z4pNNqW-=g>#MMfsx(Jl5NGFo4-~JCn3Uun_M)23w+D*hVnLkZ@3z;6RHZvJF=pmhn zWxUiE7$*uXzi;5~Jml79O}Nduwr%*k`WNhJ<>_=~KYjW}d6a7@WQ93xKoZi-szub8 z=xgBplyQL@>Dty|{c!={!3uGEa>M&z_8C(5BzuhhO-0ZfEz&V(Uz)Pk%d$;tzxDqA z?a{Qj2#-C6WBc^NZHJ6(hEb})OcwQ#XEq1vyEcHi07#5x|J?%FRNn+l5r5AG{6O7k z0}ulSK3CB}pruaJMcSlnTcxKiw7@geI1XnNO3R$@s7x_&bwd%ZfNne#conWZDo&H~ zqBa=D2=ubiY9uP^27y37(EC@q(9$Y5x*uL+*pSn3Lgb}SecSi)U;^2kN4HHb6kdESHHS66-q4v6WG zKfUPxAOr3nj%Osx_x-F0k}YU8H@h&zE*NX=6!TbluIYc9CT>ITwO1~!{I{8O>-Lvx-rr9%CXFFy=8@#aZFK0H~|fh3t)?cz#V`7b<65WRN; zlccQDDHAhrRb^?Rv@B76Yd1kj!PHV`5-iubnF*L`sW~|_t{I|oO!cWJa&-Ogg}n1v zy6Bpp$VU6y`mUBN1!kQx#&Z$cw3lLp?UjqgN=zf^w)t>=qo2g969XO3uk#;I1dh@) z8Jo;uy1Q)Jc|IWj#@0!1_{zN)($ni_`BKXf3K*o ziM%<$epE(4qP*%BvAaSHu6u72C|LN<@@&*a3iSN@+ZBqMYoT4Etq>*;TyU%OUr#P4 zcRP>%@3DzYz|oQsFs%W`GWK;WQcby_QZa=|ETCY^WHA;fgeZ}f<;-2!yVR5_~? zI7F}M)wK8NOmXv^=w5{?NULlctUh^y;F^%WdbjsVtiU!{z@+P^vdf$JsoX#Z?Vvl< zJdoX2LaSV<2ce^*3f4r|=2IK?QG77r&PQ!?uh?du6r;@YlGy?9p8Wdp@0>fi!l~wG z>8b3vi%(vnn6xIi-&F)T<)2Bl%-$aY+(14&4|kvY=HGz9&e&geQ$H~D4zBo*&)&&f z{HdMs(8SuUGY9n35!+ESGHF#+J%z>I!eADiQRdBTVMMIS@JKvoM1{G)T9@-C)5h*P zlIy-YbYiAUf;senfjJ2Jz2(QZvI<1Q5fXZIVE_>#P9;_8k zyUH>33B3f_V_Nb;TA~^=3*xRJ?6E!?ho%;=tb+L*$-(OA=Yn7D&ZqnXvhA?FweGA* zf5yhJv!L9z-g>zF{#B>YT`-2Kk5zP=94!^4X=Cr^3O^(z)XKVai0#$vo8k1qp zCr9YHN7Hb=`Hk~XzGd5zTRL2o;l+WwPb3K`f*KU}T8ufBNtm{>*Po#}#*cqrFXNVN z)o4}kxJ(Oirm)1`=ES8{w`)Ly8PtASP^<}C2#=sLg0t_| zg*juo$ZHgX>t@z9`wQRYuYUMuJ1y}@k-^|fobk^z7 zB+DrC6MihQH%~Zlb12`wG$A88`z^EE-eT7tIs1&PCEn|Zhh5fc^kJ|y#tsgRN-O%i zSxC^pLYJ!x*v0?XPia66JCy*H9IIhJ>py>t(9-$#Fzj~WBYsB=Ty(p_dZM>;N zXKgwT`pLd)Pp4p$8iDE7Et$@K_!43W>g@-T*9)Lv@c;!@X7&dU@1nz2wKpdkEoY$fHo%o{E$ z8zT4Opahg@a+mD)rDMBRS;A%0+*);H zIaB+_KHs*zW0yvzPLcnMSIlvnyLHRBnM+Epp!-3UIw+Ez?D3mez;`rlkHWOM)rfOb zcHZTzO;f(B^JHp8eR&$Z;YH(vFs9j=SWm=m8VIyeMf0Wx%HZ;Q(68A_EW^IJiB0Se zQvnX!P8P8Y0jLYHlD>b9?5Dji+vz32lxnB@`&T}Wz{Y50V$H{}mQi1WeTNZRJ(V&;B|PB}Wf*cUD1 zjczKS^R~Vs4-C$HHt4?K( ze0m{pm*x|4<$cO&A;HtdQ?|Es-psR>RN&~@*Dyu^BP==vrsGm?WqRQJDr795h_|4m z*-A5{fowk#aJB=ZK+PSOj5w`L(RLjiz}e;lGFn?Nku9r7WUq9&H4|^?n-dJv6zQkr zGT}a}Xi%8x5K_CcS~2RRHOifJXLTOZ;>HB3465f98yK^XWTD_=xn-F?h$hm=2japH zaxLvUr{Y>({tu!LG%Rqk*F^~A4Aa)NRJjckm%$lB8(N)G^|xEV_7okH;^M4w`&2k? z`0;6%K3CpVr+W(MCjmB|&6-%-u>2aQSgb9q1jZNLr{t@NHSeA{3C2TmXn}=uEgvkV zgyMs|J6N0z>T`j^R`7;q>-NZEiCfr(JJC6`4OAQiCZ;G^*XSxDWM$u->K zqHZ3XHTk5TU%O;7X{v)bDRM{~^PF_$_yq@d==4-Ml{Hs(0zMI~;?2r+Q0tHa^Xunf z*T#O;oT_`%WlPP3|jQCJAyF18BPT`M6KKTgG9FgGzMZx{s zf$^t1&nsI;N!$N!$UC>it}7H6(y0&ugr^!~M~Q89+T&ZQZ^SotiF*aEks0RRh-{e2YIut3It!3QVg>Lvx#Z5wpe zYpxTw1Hb$G4zt;MNY8$7vn10sbiRVBT@TYd{qfyA=@MUQx?h9F-IGk$9uD;?&Bj9zeyz zoUJ4e**?5`@>N%773j11?lco*oZs2bAOis!tr`?(vHg6@2h#0UR1H&@qv}v~=!_G% zS1ZkTr=+`SxMFJcX#skE2+M|%d@7odONX-M53JtvbNL=2X(P`wb$OX1WrY-ECv1i2 zm4FENQJLt2j#t;p9OEG^A^`g5h*M{J*wf{g*dWKOL_z2QMYDtMdfd+z6>%Z=XS;Fs z5~$bnp{tIAA!zHxhza)c<_Iaa=5MGZV=_(o1UCA;6)Z7Kl*6UfGQX_powx}Xe*Hjw zPkpgF91r5jeThE)xFqn2VniTup~H8W9wcjjq70jxC@2w2p)xZ0XwkHs|7JT2NQVN} zjTGoURqd~JZ;J1X)d0D7O*Aow994j<$WaW&^^A3%Y40hR3XR`Nw+kLSeBAWxkn^X3 zI%a(3A{A7p?|iv;rsWBZsx2t!Iu;!+OZ)1!xl)jvfytiRk56Ig{0OJ;&YS}?CeQ5U zVC!qBn^&a>twcmmkVEiD;kw(BR#uU&nIW-9qOibN`4DtrR7=ajKD|NZO?Dh%>*H(v zCX7Sa2JUf?jh{Q}NB?*}VDCbRP3g29{kHnyo@%`AZa0Ja&{j09A^!>!z7^aLj(_hd zuU!jxjpj0uavKU6^j9uH&yT&@E`h`7Czc>Fo1XPO{VJ$%dy1R z8_;&UVYaiOnQjh1>#cm_KXV-~qdcJt7hUG!;F}0oct9I#uu2WOXgG?B2Q3RSYga=D zy*k=~k}TS#QQMEPSwYNc#C=AEv054{2!Yq?$pp6aB4QOGl2lrOcHQ(u`*ZTUe7~!6 zDvQD!-S*}fVE$s)(auw#zp!{jH<$gW^W_zC?G3_~aWlkPE?*m)l)ub|GzUH-FP8Gs z(d!sSkJbq72rab3ToTn^V4bvn{CFa`HjI+Wm~Fv(9J1zoibUsD>(-pT4mjxHi<-2s za4GyX)Q>B^vpPH8n^<4$?TT*0>CJ0QE>GR4YrV^iSaceabYc-MH^n5M+|L~nFKWtwO&cHd8;Hhu^kh*ycRm=J9*gigiFI1UsZR_=sA+zy#`tJoi2Q{DJFS zr}7G~p^cv+^d?y-__#HT1y569e)TC+eAq^Gs>^S2RvQGX8!828FQQP7-zca+b!1 zVki4Xc4^-iLZLb2gKwCnle5Vhyb7>m$d4Mmp2{Yy$YJQRzde*(^y`KP{thn3WPND+ zPOn%RI#%)ZQT?ry(5FZeoQ-TC93&3Hu)Z-j)GeK}Mf<&3+c_~%|B$}_HoRY7{b>Nd17hP;Q05}*9 zEK5uZ1d_>U3|#{71}v|1O}0N_T0uTAloICY^IXoP@B~<3*YRtTfryiA8#0N#kOjVj_)NeC~uqFujN@DUEw5 zZe+huyC+hw>TqvRn|YaY@e8ZO06wy5B*Shi=Kftn=80&#sb>%KS-ThIEX7>zxkc%? zdvZcIvOj)v*K_{zTyeT#lJ)f%GK2KV7imxlJ4ZqvOo~-5P-G0piCLz3G`1jalretA zJxpG|gdNWRbn1h1l{0S)ORVb{>5}wp(06$43ju z8z&H_%c!Amtw>es#H3J!c7!nx;6bV~aM7y0eN{yas0#R61wbYlF-fFe(_A}=attz& zQRek(n?xxawB>q|Vir%uU#{dziYwR^P&TZ7jh3e%KY|tIuXag0V+VGXE6bV=>V7A7 z|HVq7n@Q$7AOXIa2yLDE>Gz0vCZl#nj*mSd@8}Wbh@Ek_i_bl7LUC~A@Q9Lcv~?(e zoW}BaT0Xopf_3V|Ncu+qRV4Hr)_$5K54eyXk$UnlbV3KJKIV0#NvHz zCc8-|#+Y!p?uB5&wbW{+a$^VT^?LE%{!gJ5IJ!prSjIHkGJCyl{x;R+_{scfTmfdh zn#)+1-c(8I^VD}|$`vteCbE1jFPKa9EcyW?MNWUWT`T6XRPGc15e5CvV|OGEki^oB z!{k0jaiuMK$Aa1W1&i)W>v~n;aIeEw?!a=&Td<%a%WGr*Z z!>%UZ(h=DDIsTJYctBE{QPD%(-9?&J_kI_U8Gw}$$RPte>wiWF;QE$0%m^S0?D(Dn z%|<}eQGMk8n8f38Axh#C*IL^uod&X5QVDGv8-!gIPD8CvrkgypXwbOhno2JSQlQ(Nd;-0GB=pTn#qps;8JKw}sr(uSMd z-EnYi-$~T&idz@A5#5`R(B;<^uBvZ)O#STe)g$k?Gct6t;0`x(QHF7HBlR)`G41{0 z@y+3{D7EU4d8F^w<5#C|!ZGAw3#gohi6DYj0TpVyWvbZ!{>X5{jivyBL}tCLB>)^TC#?sca{}5x>xx!{Kqp1tKhsuI z+c&MnUI?V{w;uc>4gf)7e!xT43)ImzI0OEpE-Kqb$pmkj9-4n;GA!zWf>ByeX7eR! zz?Sr8gsl;Gm`AI9$3J`|%iO@)F%bt$RderX5AMyq%<@^)>ULqSkJS$D0JzOcT zo#AQaeDGgbnQPJe5);)kkTs|47o5m$AI)~F@-MCR_a}{MNv9L@A&N8pR}bcnuIm%i zxN@xj*n^uEmUEF8#`(;l$tm*aTJ#yYHI!x&(pz4kd@Y17z6AbFwKk1c0-@1{qL2f< z#s%HB@VhEopz5YZkG(;{OYEhC#p#fJonSIcH>?-DQ(X!E)rYy7G;ew=ihjBcagAk` zQIhx)oP7?N)>4i@ZNzK+WV`=Hkn-GH?31Rt8>rYx`-j-dUb<(IXNsF>s&1N}K+Jvl zF7S-WUZK_pN+NH+`xb}L!Gs5|4>Ehxo0Twal*Qc$h)YP6UbWq%q=KZzmI241cQ)yr zdcAfms9I&FXlNPHHJX5pF$Jt`x|Hy!h3^s-fHfYguKy|Px#MSRQhd_27`ce70!;cs z>3w4s60@If9^(EBCHqU1Qaqn04{ESGWKB#~dxuF?cc1>eM0j)D<7O%5d!l`G6+)Su zTQFJ-#yV7O;WW`rl_UOh?0qGZsJL_vd$6*3toFyZJLi;@W5ivsN5^FU(B(8&GtXSO zJwUX6Vmr*}sfmJJ?uQ*#Wdh#{->(2`mdKYr{Hgs+2qde77QUI^sHfB{Yfls5wZJS~cWXH4XVLT{`~+HMiD~60yWO9h_MyuQ;CCA?Phi zZ!*t}A#L}kV3zqEsQvS{KhxM>-Bqz$f|r zOUskpqBA7Mz+i#rIH>Cd^WFMWmwPr}I`MwY(@l})wF3*U zT7PN1(pBXna_#Y=W@cSsYI=Awoe!G=$%-p!^+Ns$dGRB!i7U_yXq$Ue-%x{xRBp4i-SB)96=U3?g&Z61{o(!o$Wv%ZYl z0{7+O=3^~}Ts`GeEdSxMS(}%%SJA%;4)z`RdRJDX{q7^~3;Qb|Juz??X7J5$ zW&bi26K8)j=vt!=H0W6!wm$U2MF;sLwg?oV@W*q{E8bM9v2MZ$LQDCet23`W24I_f zdDp>zVfCfwA5U2yWg>I<>0MZr3H6^J1g@P#M9#N$#*(Om^aI*%Z}z{QL;pIr`e#4? z|GWfd10{>g+d<%8v%&3w*Wdo4n1A~sx`v3o7_tT0PE*=WBj=72=Z;g0^--UdhW3Q@ z6an8V{*J6s-g;e_d<52dzjZ7#w5qrDJ>8d09>YDQa#MhQnUJlSgW`u&=D8u7 zvOeS~TXJpfzh_E8Ld+To36g8oa%8Oe(WV0*+Y2<%ix3~WNfGeRF(%S&=P@=`7fI8! z8;(3>oV@d+TJ%(xs!}SY^N+?Vp|s_*^l$EOjfSjq-9X`u%GDi(`I_d>X13FujZ}uAD-A5K3Hs*>~h9#fE8j2P#-~SPFR@& zbP~RZR1BIuN9z~(uYjbDTotp#w_~L1rGIu{Vxha3OqNCK#NIi>@rcb2A4Ez?o^YN4YVGEDNQJ zCNQ$qv)+g-KLkNMX_h+uhIv@>T>ap(A_hmlU-z@GxtiKq$plX2s*j*$U9;1C$=!An z8_}R2G*!beL^C z#(WDQ0c*Me&ura*Qqf=`%y=w(IRhzUEPN{>Jg0WLZb9m!#v|uR68kk90AiVKaVjn6 zw|h>CbF~*kxH+VjD7Nm!J0{TYV;jG$=r=Jpel1{xaboxU2U29EyG0lxgV1&!+{TXm zT*F`$k;;S|jZB{#w3``?+~^tvN(kN2bi{s1l(qM~+=uA%@qi5-3rrGtVLo)I6eCtS z5QqwI>q%jBWe!}&sqIk9=5m^JHP=oiu&~O|0VK~v9JBaISF0@Ui%D0bDgaNWUL(2< zsZxKP#SV=}5esEfjnq2%g_{JC23B8`YJ0wF?{_=B|l?*9mS z0UQ5CJKBZB+^DdXo!;Eu<@3~vW2L1Q&{7}Zcy1)bM2id!$E2|E<#107Ej$xcAvbaV zWVkxSgqldg_-w@(B2_-KPQ8`UnpUr%1Z6V;JV^frPVob$F30fwSC&k==7V0Rbg%w7 zPLB8&&5RCM50iljfUq8zV$8dp{ss~F+jWSTFQW0lwhUbMI{VS)P4Z9`P(2BD)(kIk zOXZ(w<52)f#^hIx65W8nJStCv52UyQA_U>1TLkE1EnDKm8H2zc;V~acKLzl&%)r$0``jJbk||jX z>~(6B0($$FIg56`^k2e5V_|F43u02@VajEuVuv{>D5m99p;f>5v3x$+s|*^MDSW_1 z^M6}qfOY@BvLu<)^&A=qhU%1+r}O|aLUg8ypWhvwBlXIK1&9-PN+hEH3B3q563RiW z!T-#CNCZ-;AwW?dy6So;O16L!?CM*GEZ$ZP@xbBnzVR4td!MfPtP;-dYSn&lJ4@v- z>P;RIpdw(neau>5Ha51yylw4vKK5w*dZ%9h&+IvOE`~?iHlcP}m4o%K;=y%0ZL;lv zsxX4GzZx8AyhY1NHtdbWbO{M9kF#58_cN{SVS#Zcbp%fxIgck?R);iiy0$1h-fvc*D z>+b&916Jh>>tNPTJlm&NBZMO4Xy63&j08Q|IqIdb!4~Pct(qEqZNLj%M zMYsoMTS&5zJfpb#RRi+bIaX15!5x$FP==>@Q|yc?T-JzmGDUS(+GIpFbBM6k9W9`7Hx^vfpNCg5yS) z8Luj&Xq^Un4-2TQs-CpWW`H@@lh{-W!NGL-3<_CU2YUdR1P&$$gUUhH{?pC#J_@oRO%?`q2W z8juGjwHoDD1t1N;iTO+$5v=*zOcMluDT3x1vMR0?^?1!S^~z&Rf$Dm(GN)Rv=7s;bP(P?L9S@JQmch0`4giBgf$qlWLcn+@|BmvDa}5ofxdLrUYZ%_XufIt}-h*p{wr*KjJC13VUqF$S^t96Uu-b zUb+0(^n~GeNvi&;eP?Fd`h&?6rBj8gNB2Ek#%F@ebJ|V0g%=7k=c?0R4X0MgLfL781cNZb<(=C5 zUu6B<%Vc`@Os}r=AKM({uZn9Iw@)E+N*4ktQh9%^xNR5WYsZtYj!t`vT^B;hkM6ky z{Z0bt&Qh(BrICDpjP(XkYvLI|-ul_jTTrYw(&JHIpdVsN=1}a=?t^oZ46*9H&y#xj zU_U_;K;r~g)hGX(Zv8JI^FMCh|Buh$`JP)@(vR~$>jVRhV}A?Yw~wtsuT7D%WwK^l z;357WOOO9~Zf^^kV0!zi8J|x{$slLnuWdf`^pd^P5$vHVY++3>EZaa1-x6X9*np$G+0JU@3pyYG^-y`i_%oLXq-1^s0O?r88LH!i7UwLd^g7RcQIbx|nch z0G^*CeA^+;4D;ZIbQGQ43G;W_1+n_ndU0ELG#*uXer4z?N;ItAp&4vv+QRzuHe~4# zZEyuRmRuS?s<5dCea6teF;_l*A}h?3?DeBGv+&1}cZS8amI7z{#&Kl` zN3V4CvRR)6!CMYlh?t`UJl^~~2q-ObEu%EFSaB^>)SL*iHU)tNvF4!-(hOH4HJnc^Nz>s^7qT)PGui4OVq5%PqHX=Q87BAM zq?q!phlo~w;*xU7oHW}8tU&>RG*1D@4m4o5Ef2h=KeNi9R_W_1dQrle%gcExR?fpN z(w2Af%lDpB<_rfS6-QZN&MR-$&2Hm^v$pTU0h-5e359*->Bb&Ph_utzq8(X^$oSyO z5(_#(64!v*02~^4JmvK#@qK=1C)!8AqQJqCfXq*&?V)$U7EK^s-C00VCdf+H<}+K_ zx-e>JvIJpb<@F;aZ)u_ESXl_R)x%f^_9^vetTM7%WDCS#@QSllVK#LdFPC24feEkG z7{UyIhY>sqG?kI4rM4@{-OoC%uA_-X2pR`{^f>@F`lw}s2k9auNWyvRp$K@253)s=z#(F2m(+R(9 z>E7z>JsI+Jmr2nzR_y?i(fooiMz@5hf>DIBk=&}~* z(2{x5vmGDGo*$ZbNQzC=2g|ja+X?Qj3``Jw*2(vask3dn3o+`URhl1O=zJvr6dqKL3$Askd6=tgf1m?q<0Z1QUlVYi*!MXf}+^gceD2S_F4O!{f_sX zJ>DPtj{`J6z+~LdeC9LfHQRx?KaKNomQYscVvbNCAay7|uXNdM4EHNF`A}Pk8pp#S zn>-@?0|JsJ5@-xh2iflb|D4qBJT-XM4K{;q!vC~Odj5W*o;!n$mU!Se>Cd1;ovGJI zc{owFWY(aS3eAk`*o?aLaF&k~WuxQx4P}}^DwJo)U{wAfO2Yv+!dXzZ7^nO0r^*>ST|LGY0(~C|;1+Nc3Bb~po zwKpT7h6<8e%6Rg8aO|;ekw@f}vZp1I4<70FY`l_6xC9+b5Nv*Oyj;Z<=RfMM(P=m5 z?lkun}+{$=ce;R2a@f1X;sZp3uXl#6tM4~mzVXS10NTxYEW_u^EyF3MnE;?H|x zX+Yn*5guTg8^;FjHoT;IT{^JWOivx0@uvZHmi{+bjQ}gC)-|qWWCaX_F_pA~EKv0< z__ALVIFpvQDqee=nYLD*dGYr^Js2ePe8nx>GZ#9NWNPp-H5`-_bGh4aAzc1A$_me-^RD=F&-uDW6{`lnp%}XE z(R24LePpIb9HJL^%Zi6ZC9>0>HQ!|g2w1@{HL*O6YuOb>B1 zwsa?pVDlGQ5gCepa+NOVXizkyHD2TM&hd)GY9ZSgne7CBC1>-*T5{p^&juM512#XH zW1K&*L&+^KhYTlOrM8T8u`KkMeg=?|q}szeVz-!^V?ujH5^i9i_zP0QqU_oNlb^qi ztmFB!lSR$1otva$=h+|pf%r5#@HrKZJil%uSw`r|sAwFA=<$WNUq|AeFFX zcBiFf9D)ELa~w5+w9rDVi%{!1Ac%cN7)}a5NBV*Cfvccgs#Q@D+o0)m?$-@;LuPZL zIK^Fazm=9Gr48QvXj1E;T|Lm-=6I%zq2!5Ay6Q3Sc2CzBPvSt8alHBI)%wUyG=h~P z+yGH1wXWmXt~(NJXJMmp1=PAtZy^+wdD@SCOP;IUrDX?<`$h+LJwLIboYUHB_%WY= z3uVO}(9o?KU$nn%GnPF+q+3KZeFw6cRY(+yOa4eu^jMJCI0Vjp%(0#SZ*-pLVM*yJ zw{&2Ko+i%!mOK|^gQ@Sgqu;aQi;Dcx$FrC-GX6W+;#rpJ{#TQ-rQxF%yCq_X(P%8q z2Hi0yUz4mdCRB9OFr{&F;oG|=mm>NplYUemCcLD`_S@On^`|u)VTBiJrJjov=DEy2 ze-4=Qh@prltW)tYlCAAM@aY1X9UU81>-KM|XTg27`q!;1y)ps8Jb50acic4;aKAH7 zgU0nBSHDYom+rZ~f8S)&b-^-74>ZhWpF$$p`ELfu)7eZPXFYRXlUtN+JYvXgVmZ*s zC(gDG#gKb)?MR#)Xz$bH@z$i^e?=OF=pkM)dEb&4$-g5J*>N0IU6>vp7ts=JhG|@< zhP8HZ1ycX$1n&9Indas)RNBu(J=7P)*BS~VFD8R;$bJsQmY=py&bY^4_Xd0FjgCh_ zHaF`Z$`6I#4Lmv7!+@~>YjA%QNO=2hPa$A#`nO#hm?dOk?{%9Uw*n2DVYYn04;5)S z_@#YEGP7ZtZVObcWpLSGO`21*kiKMmMOXr%8|dhcDs=sq_5f-pJ6KFVM*Y2~_TSC< zewzrZ(jdueNku5_gcRewh>P!t-Ae^+Vcp-b7Vzqg?9(!Y-Fxu26m5UJL~z<*t5AllS)?4 zPTKgOiNvcwEH@Hg_wZs&mTb zjhfQy!$ql}=dcS!uit;m6RdJ2({LXey9+N{3%<>l`iOQ5;bBqJ#kF4<#TZ9pCQ6c} z-N<@mJk@L_d($EATvkp=t1wk~Prsqw%#amb4bIVA`^q&$q7ixKS9!;*VTi=*Nki|% zqj%GWDgHxbIPNP!h&q>4#XDFKtciow+m#h@Uw8op;d-rU=6fiC;*Yy$!Tea|gR?HL z<+q$nlt9^T5F%VUO@$&fcRb0QWtjK8pIfG@z#ImzCn_PBCM1J*?7KB3xL+mpHr)Xy z9{&jI7Wamuu(Srbo+$UTp%I16&OMqELeZ+|LOh;J&|%!{w3x++hD+o;lBDw1KIUQ$ zM=cydu~z4;%~xqzZA>~-$?R2EHW!yOiU>RxV&8+g&O6En8aDPj8V!^w7Pi7@+*IJ%VIY$~A_K(OI z47`>P2|!(=XU=`B@8Nhav!B{s{2l&%KWhryZO)-*={#8Z8jF@ga>W|s=QAkp&ymzx zw~{H{Amyha^u68)SC*P}ib@t_98pl}byhos!wCoq8 zbrwQiNn14dFlz~yHKZzuX#=Ca_jDfJiMH1lqSvmmANH%f6o8=1N0(#LbhNByIsA2N zjXG$~I6i;c#^=Ead6sqjySdz54LFbFX7E2*{uKb_K8MnI*XHQGU`C83H>w#Cq?&Bw z5ETZ38zQ9IV5 zJZCRIg`s5ISu)flf4a1-9#5vA=mE9oYIaw4_;%qgamx6D;XunAt;jn!EG%Fn?Bhr1 z^!Dsve99^n-Jre{-bNd~8gBe8XZs6V6Kk-;3ZZ!8KnK}|f;mc`5U7jpir_cgtFpJE z#q{ot%ce3SDE_b3&3{~&|M}_vHwFGG-Bd~xXDa#g-C?1Fe!)(dlj3e6x@x zxUiD;9Z)|0AV(%()c1vOACDkd{)HsuQA$-A0>JIN+kIS@7i1F@RL#j#DD?EzDSTTLkum1VW3pHe-*@3i{SG2Ka%`IhA zcHOkS>)!uhh#8lx^bWDF^M1%J{osNZ1lj# zDZXJ%PA=hBCHGAF;*NK>CO({YANO%8=Cg00w1)MtD_hm$)oYiDakLR)JoS;2i&`mP z_YWk#p<=!3cf0iqUa!+j(&;xNRCO-gBnz6FkZYMrkufeEmspRyaEY%V4*}8jWvd9Y z@QS*%%_$fuV@9`6VGuBhyYDr4^k*nWI$jB@Z1tFQ4yktE@z~qx8R-)}_s>iY4CGeZ zQxZkRFER_KWlq*uDE(O zI)*aGV;YO7^=!okb584mR}>4%E*#26WHfdAWy+J+S-nsg?2B}N-ayLl0?MX-9ISI+ z2D0!^vf4_=#Y-3HZremFjwA%}Ou3HzL4=C^?!#!=v)QXe0+(s~ZE7eAPp6`=5NkP1 z(>31IRKpHcNq%KkYm(W})FNS8Y4{>*F>0ki#BxRc0J09uaA5)Dd=gY#dcwps+woMK zm%c);+wNKuU97IZM;jyodx3-e{XWqTix6+JScJMj9K@6(68EatgVR5xeX98ufz=vV9`{VO(Nqd0JPPCYY+hBEuFj+7WU) zmons`&iDARaqB*8$tX+MXYOVBFY*Y9QO5;H_~Xr`oGR?F6~IaDCYWZ`oNEGT7SkPY zCn(?xvGQbSZU*<6B{i)ybJi+`5X&LKVi)}h>;QeqP>qFJU}9p7=c@8?nlk^$Vr%{~${DX+76-p#%8?Hh1oGVD;T5S~lC1 z(l9G!cUAN|>UYQw7wpN81D7=$(fN}jqK#BH^cg6aANyo8p&<6YveNSnLe_zg^`{~z zCOEE#=>&}iw1+2z=4adtv;zSb-`rMC$FkB1z&7e2KN4Gk?Y+@HzAQQO;+6x0IaA$7 zHpQP4yP-v_k0y6QQ~2}7Z%)6E^u5%hMz0j&FIzOeh~pn|Dx3^qaH_a#y zys6|GZEE-NO{tvT$iydw0XNsa^NIyW2M;gM>IfX>ZpCYymO6jPn=RVuDrG97S6-0| zmAN#(_9ZB5=@>~l!V(|gXE-b47_-NwbS{tUA=Y!Ajm$)_MTJt*V!GFq{to$vEP|f* zpnNWSR?7{=uIIZe8mOa<-NRbDz_)FcW#`*Yay zyvLe7FWC|ZgIfi8T!%!fYQ-U%B8?O-Y1Mitr8!4lK7%;xefWM_>ITv44-qEepN5F~ zD$dtvsV7!O0a_*)(@g{*8=?334!qUr$ocOH{jV?msn0ltGvCA#&8Us_mFN}AL@0c6qS`o zZ?v#iD{qo=IF5}R{!P;KGN?BSte~vVz?c79|hSnwj z1$QbRqo_-4$9JfO`~#$>vwF$gKi(4?XXm6J76{&&V$20?qffoi`L$ly1c>hd7P=LY zdhz`axYvDiT(N4SnJT#OimSb%v`)G^SR(9^PGj6*it;7uUSRyoKg;16iiV*3#q#rI;26L!jjzz3d&_nxcZ@IMOV;!#R>&# zgOx3tqjR-FYQWz%M;BJSnP4t9-k;uFP*9XnW(Z%CBJ6Q)xWXDHn=u2COcDr@-a8m+%Q8ML=hR?rw zaQ2B<2a5C8oB~S+df3v`xt}Ii9& z)JM%fHsPW?WwHxryTHcRAjRfoGWOc%sRuxiJ#Mtxvfru)fUAsh>%9%)TWn#ry^>Oy z--?%Ml4To2t5;)8=I@yd3u6pK%}Blcgc{d+5YVhOb1^pYO3hwkWBSKFSwHa$!wgCH zHbjh-%7=;TU!@30-e7W!`5QFTdNADWt|g+MoSC4{+C4pd;&n9iZwuY84X-T65SvAf zaGy6et~pIfraGv2s^J(jSy`-h5QIdETy4|37|9s*mFtnrLg>YOj3Wp=$k?6N>joJ? z{in2yerWEb6e^)U0bII5-76uHZ{=cH+qHneTXo=)HhIeT%z9>XR98Edc7s0*ARvS- zgeZT@#wwH> z0}QN6&?-D-)>lQ6q`-I-EjUu!(}&0_-ZcS1=}YvXOE27{s@su2HC?UFJp;m`{FEYK5DN@|1 z*M18(Nzl{o^6T!!|(I2eoIbK&5B4u?0nurzy%5j2l=|%w2f~U z7H=-Vgj2}*CSF~s@q{?gc#>pg?btKOQhu+Cu^i{Pcz)(I!(*P{DzCNS?UWHKlp69H zIWwh0wIP~S)iye#FYwok%-?^~79;V6mci8~Ppr&iGm5Ezv)|yc{a>$Xl|V07fL*JI zPiG+$m%sVd;1y;<-!{Tclq8h6W~252)la8ZpS4y!LMYtK4RgtaB(sj!Pr#MIGcm}H z*aSq3@#Y-5q~f#_C<4DxSOiv+s!7*~#FaWGK~sES2k)q=!)ozCYa@Tbd4j?_#%!eq z=`a~F(LGXidoO7b#_elB^pQE6@|@gWR(N13cEPxwEn=k$(Mv4G2s4Q1sXtoO{&Z&v zgveg6+_cGVbj1iay|xc0SqD2q4_TLOZM-vh4uqwYwWdM1L|pck#Hha1B$W&bweysF z6WYZDZM!uuO`9x{XZ2ph86p1-X)R@QKn)9cpTFq5n|pOJSYUNpy7p^JjAyfgQ?-owlJ6R%Ed9zvSCY3&R9>aDyI7)HidQa8>e!=or zp^$71ZZv#`fms9AcbH(Ab+O)UP_h$sT)}yoG zA_*)Ao)9ldH!@qUDZ%h0U8A7Qhg27YMDHa*jL;!OJ}#2H{{U&lX}g(Jyq=MMZbgD*Ufa$yhQ$v1U4vKez}IC&8W zyyr`;;y+%<>ukjP{z2rp_?x+MWTEc};MW%?cwu>WtbmftYpu+rt$2G z$|90=hqI8)v^Elu7CAuITNsU*FQEEzOe!$Vg8o+?rdP1iSC+4*s>Xml=0DTLfU+8k zzhvp_RV_vAk^PQcTk|D;$@&i>UuN!CxUkz&rGrwA(<-bZLQ_AJg9Fr!e}O2tJeQAe zP}1T+;*RCbO@`6W4$MzK5e_ou+jNV@TyIxL$~}iee-_3OvFxN@Sji>JpPyj&gp{Au z8-4e8!rDS7_lS5^Yd_I5H}}tH)*TWiko!6FotTSFR5vs&F_cOHDH*zoB5W@QqVr{n zzlf+B{hXTI+f9*8f9!aewfF0J9BT{UfFdOwm*T$GQt2ND`9GUpqkR^il{2+{lBBsq zf7sYHq!x<&H7+~zK=Ou1{Dn4$y=-KL;UXqq;)B+T$0jo~VO+vXSvk`i z`xmvB*Y*aLlL>($uj*wz^X$eU84}yQG|3#%D0GAPd{42{o_8jV;TE>4rYyM~dTD#B!Zsq^m;mJ^|;7$JDjOh$;5K;dn?8ot1s&u-BUf>(? z(w5(2J@in|>~1u9*R!Js8EUkZwFUI!>U+!A$?H$=x}H3yz6-nD=T~FrA_-=$ zk}~xB+&Z^@A9a{*`Dka0K4+x7ap#S%Em8P`1%hJbZTVm9*te3;KHPCO?P4alO6%Jd zOmx0MSvv7#VY0Lb2cOe(_Xt`tl?kB@62)_>CtHXQqcyN3lQ{OaL~ajbl}y=7PP!5J zQ$C?x*GR_mG^JgZO!O*1@uq35002!{5tk(u*qwqFI_Oh_dWyS|K$GoAmnEP=a+E@! zfHcUur3d=m6jwsR{R?Lrn&ab8AJEqZ5eMjxQ#?jge_GZlhjnv~>m2}0c`EBXUwm3f zKjocD<;Z0ig0`zla|~vyUv1FV=!^6S+pCotUGQ-~kFp*bxO$mqo|X2~HKdzr&5$sR z`}Q*zL7mZHE4L*K*D_rWVkDI4+-?)Qa0q@gzQ;?%4z}%=7U`!JA2xZ|$dKArGZFOO>-J z1For|m{R=radRJ=$qQ2irzAO(aN#xO&@OSOo(e{s^CRhQM7VG?U`gs!^9!e#@ZGL2 zG}i()b*z1D-n?qss;KPbssi*mz)$}{RQLG@^VcJ^ zxTY)e#PFkKGKJIA_YkiOPBCzf2mwj_ltXn+E11Y_y~u31B9-@gH^5G{QlsKN(@nbl z^*q1ZM!qJ9G}Ou4tcO84d%-ufyKnk@?7=izZmSY)5~G^)ukvpNQ7)e#*#)h_1m+#@ z!R5>pC_L$_!!qkqnOjB!84E>qR2PMC?_Vhc0gW^VFd1MhtAhMX6WB&NyUL&MDX=Vz z+l+W}F}?kh<+rUxU23Mq5S5l@+cW-rXI6!ncG}fcChtPKHwwF%z}~-r=S&5kW?g2<&+fSd}78f8**L}l}IL;&QYNKwzS!`egx+` zU!woV%+EiF*8ZwM{wfmx+qv-nL~{MJCtd$|Y;g#^leov}Wt|!K>=JeW_|#uzF_gD; zOjNL=W$O|fWpmZ~VQmdB3y~Z-R&#G6x4!Dm|I$sN)J`o_6AIdz)427>mnbspL$CPp z2f%?8Nd>LJ3W(na9CECG~PyS6=+f#Ik zLDkK?O95rAtx~;v5JbESteZzN(W8i3lYnZT%s1Icz3c|3?E5jPc~&U93;bh6>T?gT zVZW``pt8PuC5PW9jNXD!Wk+eIGKMOftzJm^$R>Hoa3LkBd!?<$!l>;TyRrpK{5whA z@>!gxq;kP%TH7(!Kb0cnlLN7PO`1ZqG&V7;JHAlgcyv~)LeK4|NO z*@DV7vhvO1xL^z?Z;}s7E(%w_K5QBu=z7NVu`hu6%SIwXW~%;g7dNWPQ2$9 zpL(s3o2Whx+pD*oO4>8~H;w=}&<+c02~w zZ}`TbXrr|9iRS1Q^0ojQTNupui+;gmyrB~RhNKvaKUom?Av)V6e-A3J4M=hp@(+RB zDTK{kCRw#P9Nm`MwcG&sbOY$M5i8kHPLspNeT5Lt^9ue$tgW9QOcGn$Zb3iBPfTZw zT~byvErM-nd_MY7Lv!Xdo}T7f#>iY(m_hC@ZXA66P4KVHKD@oWWSXQT3zXqezM0RX z+EwZV%k9xCI)Mt2zobVmez@qD`I$DEBXy*beXA`|R29c2beFGM`J^6G=9!c(mSOq_ zu!ONs2b0oC`G6I?>143NCPV_$YVoly-fI4~vqR}I|6?OW*PThpS!tEs-AB)v~CyYEwms(FvKom=bihlmr%$94IyRfJ@h+n z#zKUcmQ^D9&1;tS{q%7YCK**#+0XW~zeAf^$~3MkbQ2aP;u~Mi!SiKkADqVXG|?a{ zZ@6KGmhYVeXl7sTIKF>umh<=l<(tiYuD}fM_ z`yCW>JAE?i#|3@R4$%PX5mI02UW#U7%qs5zJ%QbO#m%?tRLg-G`IpE2C4E!Gmgpj_ zfC8Q$c0_qPS{DiV8Hdl35A0on6NdW>hA>aOC0#$QZCU(IK82UM&53IW>Cq#rByyG@xktP`jdhex)kuF%cwp;-{_q&?@%6IP$0Pbw{Ti)BO?9@0%Y2P_dTvr2K01ib zz(hy;FegY!PM(LcH~EpORMT*h#u!6a`QWE#6g?a7)&%jD)}Uwl|3>%z=T+|7Ukv1a z8=bZ4DSENLzD57+l7|<1cJ8t_S1;q;OhvLo+x(uq2^bb2WQ>YMZbeO5qkWQ)5 zNmoxMvPV7{O1E%4gQA@8^-i#b%2Cm(UtJU$4SfxN1 zcZy29WHA6x2or7RH5EV$!$7MlaBgK-8N5}ODNLMidguVaO^hwdLC(7`odg}rcpjDn z=Z0#-!OT*M3Wfz?B*3elJs`+il}-mcDTpCTeyi3mtYz;d$3ve^e;RvKHKR97s^9X(T9T?V>tJ*$Ahmub znsLP5l7jL;T;)-1j0OiDxgPI4pEB3~QkHeML;mtP_mMt92||3@o{9RH3Zkrxy=hq2 z!VuWqvDwN7p$%6#3!=zm6D4N33e_c2nVKT3m?|9&+@q|RZ&frpq!9U!c)udZsPTmx zzTiqmvC2$HyTQYQ}3sof6xy9JT^Tp!76=WIlwOZCJbXR1WT!!qYAu-GxIVS`U zl{ZliHqmxk^LAVnkHlUvN2+{7x{;{xYPt)AJ%w}iDl?=52bib-MhCA4{%LezV+O*sS+avSN|Xe+7H zCM1jwROb$S?kcnps?vl5rs$;_@;p5)IHndl$fW(GeJhB=d32^E>BX}nIk=*H_6menK)6EEvVhl|!^=I4d) z*JxcbSwev|rkAdO0A#WhoRj%DsI`UF33A?0OmA3{E^xFtu2S8JXCMrh@~A-qDPOFX zwo4_=&AJJ$bMrJkVqikqdgA9Kkpvvpx9amW}l56HR)Nd`{Mz6~`BiNqOM_=Mh#l7v<5 zi$&lqscM^*hjNKv5{4!AXw-HVe=X0I(C)8xIrv1PXhoo{>iQu+qL_*``@fA?1Dokk9{f4e6l`I9(I z^_cqmTIb#H6M~+{e9oyn;ZDQ1)ln9ZRh{V2@s0E(;+swlM%h-EVJt;8K8)n7-Molc8G)?=los#ZU>^@Kxxmp#K!h zWV2QN4(T~>mc*mDy$DY}X*)u~TQHErheI%;niR&R%|c3EAq^hYE^|^SK1Piczs(DC zEOKZ{g>{#XizZy$m=P)9W+ZTz<6c=qA_&Es{HZ5h4bAg0Joe}wawZ9wC`d6k)qc|g zXl)G$8gc^N>bn8(SGru$Cv&X1&6l0Dw|WjcfB5Pr(hS=_lPfT^ooB-zDY){c8d9Byr@Ji-7q(6IQ+QWJX*(GwJ`i*iStc2%qwVzQPS*dsFE(GmK7Eb=)@-hZ?_^+5bSc1W}jqF`U=~7@|!;o z2xpeW9R)|B%Siw@{uY|7ia+sZRJ5PFeVFOg%Cj`4Kh^AV^dX@w!}oE+`sHFCNmqf3 zLb|NIc~!EnC(@d2Y)yZVJvs!NM6nwi16miP>UAGKnTfrl@Cs{W9kWiFnt~46pu`z1 zy!&6$KmM0mIZ`-~_iyIFJu?PR#iYLI?k}g$f7!hux@c3TJTpLNk>i|10wLd$SFt*NGvsDGU9M44l*zB}f0c!9akrs4 z69!)g{;sZIzR0tcZ4|TWZ-WWw?V!_8&H5`#V2(HHE%Rb0XkrAicrx>*v}_a z2)exGBQR^KQUfKcH=HQ-|55)@e!(#qL!OZw7go`ME6?BHjMk=`qDz8sZ7t9#AA}$C|vgXvb0W*y4X&<6f=jmTPY246h)Kg1`%Ws}rphy{{ z>n^_nPaAv+Z2=uW%LwUVe!hC+xlNv@XF26qIDeOsPCD7w;n;g;O+XR)q0vAFLjV}3 zz=73KMX3w(k;<#oNT4)L%&o{%w%jb9w&upJ^}SW z8G;+sZ)WU2V2B#|8Me8Lq{+#I#vmzuCXTJ+frtA+(I_+KfdH7sl0uY(kXMQ zCeXCPNl_Cf*=NC<-kL~sxFXjzHVLRg-YO40S-#444zPdaD%bDU$2gw?K{LL&iT-nwY>DXdY!@SsjZJlcGVF5DsuS=7!R2g~c-8^FIc=@H zQ$zbVz+|iy1{|ddV(UyT@{#+M##??Ewrr0W4P>zWYAT?9P_zYYJXEO>t?4&yX1oIL zfA~(O-#U1$OdFA=W8VC?q9-A?(9JW>@blVU(&j z=qJ4hL0@wfamv-{VL{(aBb-fBagP-m-#9*Y;s*dgK%{@m=ED+5Q>Y*9z+BOTE)p_{ z#H0M1@h5Er2_=?(z#^i}zs=3CFQnzgLcia9E%INhIk zX8KIy+!;*chP4x|B&2@f6kcr%s!Rn`6-An+K0!|2~MNNf@`G!AOHd92gJH>A`zRM%C4WD84%YL#X#Huv*?X}h9 zRpmY`qtn}l@N$a||LqyXKOFi0`nEd4I>*5UAj5Ny?MkQaO2ar~zz^O{>S&)S zK!jYNrFDa2H^h1c2$i&IrP|Q1>bt_t&c2q%VlY0_2$XPENPGN76rJlVOaePNydnaXN(>~gS;$#{;w70mS9iLJ zHHO55;SyWDk|kvd%6WWNgej{e?c6Y%0&5qOHEQ=tfE&>qoz3KC$?W@N5++qiAZV zkp{R&;l94iKcJrxe(h@B2C7W_1Cpzyq)&V;e38~8oK3H5O6S$DE=t-Jr+7(xcuEFy z+E~*&H9ZiA^n0|H|luJvKc5H@bQ;=3-u$J2YB9xyLYp`ht@&nD|O!9CUf!m zp`3I_@8A%&*KBckJMTgHZ`8`Nv6T_;k1-d?li%7$^DC(58U4w-%a3eNu}2@Vt+=_L z$0iwPA%Mfn$n}9nC9TaD_S#6EEuQ+tTvmo5Qr>TD`pfC8)f3$hF-D|b=%UqoiHPxHpo-~PZx5r#u%Nt%t|B}%X~6;>}XF? z@kpPQLtJQt8L;1Av8kM#Y%&6mxqiG4ly0KEi9UF9;*}+};Y6@>=he@08qN#~xgaemesB^#OA?q$e*>7Y7Ev zfZf~wV-pEWYrtwu!KOFTtH7r*#EY|?QmXmwKN1(3;+`2hDHza&MtK9n7v|$=>7Ul* zZV=hn`bs{w#;0w^dr5a(u_CZ+~TLmNhYaw zkF_1AvNs>(d(sECWN`N=%+(5W1;{R*ycKbH&AfgREaSdqu2_BAwL)XTaN#jLt6H-$ z%;Qjd@5w<+mdk@IBbWiZHt`=sLXV^@euRt=-hX~OO8%KR@@?SO)`R+`FsqyyN058`{=I`60Z`$Y}cjji-zMoY!UMn?BU8CNLDx@L!OMdZ5%bC1wr6G_M zW3TJsKRndj&C>n9I%WRV7pt6&=TtlDfScr~ORxU%E&A(WgBR+si)(_Xf;H*ta98D7 zD$52NQjHs2oki#%17hmKmox#zn4?!u90h4?n#ZP8OWQB!onULKy3&u{CN&T8 zr5QbP+y5-*L5w)gn|Q;VMVVxkWR+YmM@dLqcH`6z9+|HDDrYlg8k)Sq6)_b&kK0++DZx7+~1TpZoO$TD5#Y!o^dKIoY-;SXm2`wl9iRS-NanI z+t?}?A0;Vs&!j^oK*cd7iZvL~BPLbi92^_YK7Uas8v?4MnCfE^e;= zFhyY&)@fqsx06b#Q~zZsx;{=BOIDwtSAckl*Xons2j=WG_JK9|%?X!O8AucQ-2{)D z6VF*n*m(2PnIDWe)48wa#aSE^ROft0dbQP`#c%W)9;x%fglhpXBUXpF*_?@O!uv+p zy!aGXqCPP;IyElAS$pZm4L>F+ zQIb|i{VeqS2#?JD^p9?_@$OGn)i~PhC0x%5@68=U1*+KJkWd=`~_N8+W=WJ z4^7sn2L!u|4lYh0BW%RirY&HEya2$~u(F(DEC>VBff8O?YG1kpx}08+tscZ1>V}IA z8t_Is^mAW_hwP2D!5=NcLbX2->amVlzAqTef8>t&KF=swjY$s1MKd%&n)Lo4QX2c9 znbbl(Of27G({k#hj*pTR*ZI_g?e5v%&%v!yt z_tJ8jMq0tx0HxqqjD|qL=v?&cY}6;?BiYWqP8s(X^zMp;A6aa1#!G83>A|w=Ap^W| zz<*XxNMqg4JeGP%t!Fp*R6RMQLcOYd(~i2|?0ye}KQv86%l1*1IkOs{!83YxewjJW zUdH!m6Odc*5y8M!Q~Y9#x8)9b^4y@VV5DwsO^Pm4)9s4? zht=#5ISAEe87C+Qus}X62IwUBK|O63CB`ltU{ENCIZhPwCmVM z^A?!AXSKXb55H|bVtEEa#Ja%Y1>px!I;@`J)oG>pJQV(C`;bZ7WTV1& z?j)n+G^`X63 zw25BRu=c+oB>#)9{IB1(ilXNnn$(JYM}OWDg)`YRcTBy*8^$TnJd#n0x#Yg_5G1%4 z#id!^G)OgT#}XJr?_N1443T}7r()t^{I6cSlubV_G(DW@Ol=Lm*K`-KM?f}MrHq{! z!pKu81V59tsTEIeOWf}sy248NtKR-x)9^j+)mr4+$j#S%VB3%HBdRJWVJHcpBY=x5 z!ZwCb)nkQb0UKBMLg6$LDh{k$?O)C5?{GLuYBHEvHdoU`?t6v@l%oScnQYF4k5nm? zh)jzKoN+5g+uCz}RWcnR{n(A-*oGP1z)%Nh5QWX8wNHsP9vx?$S6BZeTRTv6Fb^^W z#M6u14M*+Lw~fX^!2H?V=i3JzH&7`fNE@fk#+a}Jnxg&&hj_IMngFH?ZA+V2Z>qIi z9!r^O(y(=)d|+70D3J%>Iae2|d3-^2GJ^b-6I=AXt^EY1kY#*+t+edv-toTvG1k8V zI%em!eh>l3-(GTZ2jQDR8@FQJXo$Ux=Hdl- z&Ka9-mmgxun{>G#4kmMs-7XSp16OXJbJ4S4!VJ|Ik!C?eFIy}dhsk;kytPDRv|7$f zU%CVt*4WVOx`>RF#mj}X$?~*>1w6d|aEYo@@(RDpD}|gTn|Q%csNUDHsS#VgN7ARk z#u=!`bq?%q$n8-{eOoBZ=kD!5v61UeuLO;=a>i?2kWybH#EkDxkn^P%gH4-)QSAFZ zY8_u$w#gaq#`g4`2pcHMYCSr|ebvk;v$`d&gI@kW!dvAapyNieNDPl*EOp%K9r=#M ztIRFx@`lh)Nz}eaZ+^Gejm^Ise_@t`J-O(&j_dbymeq6Q!Ofoc-wtn&d~rKoc;WWB zH4M{#5af|@N>w^PX@lOQlPi-){?OlxRg@TK)GC>Xm7MKN3=PTi zp>l$)-Yh?+wSR6RS>}#U_0rPpw2$f>k`d-syuCi5!ursBmU3Cofwg3ihFGgj_6nDU z(Nf(1v%Ba2;n~1xH6(07#{gjULap`>-wOQmaF@w+4GVDFQ=6bBFYMw<7i?nu>9mAb zaSLv@bstrXt2~yFeJHC6*KgqT>hx+8kdcL z`1WvG@>YBD+ed~}@bsGWR%&iuZoq3!+O^E0?8{kNGAOQXf`KUw>nHRdd279dJ_@JC zs1(?q%GvAGi=vgpdFd*@g0Xnst4v&v!8S{ALUI|i_!N(ugD91RhK77jtJDx16WDr( zrlfpjzHjN6lt8S4c9&4+Z-Lf}8mYeuzOj>mmlX-mpCNh=6wBUcfwfX|6jW67T$r?TkbSJuYhFDlY(22c$RL>sRO&O56 z-M926B61&KfjQ6W2gau%y*k;)oA|C>8& zCtK9WOj$WYNs>H`Ih$yZiymoC*>fT_G+56|{R%|V-CZ5$;ETe;u8s_~Eq&L$o9D+q z)X!uj?YrebQf~a4Fq=_ULqs+GRwO|FnVlbTNHkITHIHHStBBB^Lre^~#3qppc;n_8 zqpc^oQE7%=-wZ-5+Ik;9az1vaEuMTkZ~}%N6ZxIKU_u~#=Tn!=Dv^`$B-*ac4(Afg za>jcf{qi@sMzH=dsk`1ru!UChX*prK?DhGBNqI@W031uR4A=r1b~Y68VTYTf51-ZF z1}m1%c7)P_Z%9&Iqc&t&qp@!j*Ob!aNmXg_w<{Q!%&O_Bp1MqNjdXyID}6;I6@&^k zX1wmQ*58`(m3Yen&P=EV_{#pz?MN>;hPKJJauqwkP;sobT)&W{=u$YvIDd0gdw29C zX?MX&p>(ZUeC@Tq;1`}tnmmR*{~vSj9oFQ&uK6QPKtQ^bfbZr+y4JjZ~b?Fy4S9T152GuV(aba9>O%#}n zjfx9l4hT^&N^&D|e<5Yh5i-t>RJs@*Au&- zcye^}A?CtL#D3?#YyA^XkDytGt#Z%@4<*3wnKP+dd0|H>T8})tS`9q?ZSKG&^nwrY z=$H+Ubsx%uf_u{O4d=NUzg_N#<90|mioa6Eq6u=)$@~JM5yz-e!AHLAl`~5F*@ZJ} ztn&6EWydJcgvO)&M_p;7{5h_i@{e90CxI%rJ5HXFe-{dBv~*bzk0_mDfY3_V#7{@& z>^Hr=A<+Jr@e9&P2#T5#3k94ZOuT-!xKNRge8Q;`F!wF8RPUo0AB@EXqY-&XwuatQ z9lhN%>6pg%5n~F4n9xJtt*yRR*}UyB%q@2 za?}JC&#x^0Gz?q^ChQZt^6DSSx!W6&ljSe$<^D_}YU#|>`yqgVJAK$1J@3zDk?CZh z_D{cir_|jcoB8(&tsLoRMrTE50o50-Cb@al>U9^L25}i1p)y66cO5--rAObp&#Ds0 z<~`0DqBtGiZD(8B9`0b2SoXd<`~*gCZc`N230qXxub8H^GOQM9ZIdo?pLfX5*UBMt zZ{nPG>?cPC{tmKJ5fMe4?XGbM-7)*^pew)Lhb(pZDF6BIGdib1`462PS+~xP)X%t# zyBgq}Jxo>O%UQWrPphwO1IdQF<9C9HpYQHN8itpE`oW>am+K$+b4{_?zag2!T!h*s zbFD<=gSFFDTXUtIbiYG%WyjmdwpuxJ|9nCIxsU4q_x$*mm->JCogK+O>i%W$efbFJ zf%(ToYqM4OiRJ!gENZ4tZ-KSB3MSLraJA9+Ik7_MXzUB#(e?e)eLO2#iA8%F+f~cr z^4%cvV~|x%se@L?ol%!lO;ql8Pw0^XpXLUOXz|6a+YhI%p7<2aS56;=YiEAA!f%q4 zUY=JC1}CyUf_l2$E1DOXA;@G1-Is zq961xRGY>P!za!Rp6%qfUwTA738|QI2{jNQ3;Ff!9jXRfGprD_ApSKbU0oIT4MGxY zmM`^;@%w06r4+@KXKk%*(b*mD!ZBlA*s1iG>5c|JHSC#7E2oW|6TLYo+q#at`I)Bn zR0SUcmNOmE3^-w%+D8d=`P*zC4Mzawf++&nQ7(X%0DF+Jo=aSwoNUI;T$z-741Fnu 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%0AwJUT@5GerCIm0;FK7W!|1~%#3eKNV8-?Wu2 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 R{C@+ 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

tv*FZR0{o6P;HC@0nTUSDH3U&!FKLO)%!D=< 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 authors = new HashSet<>(); - authors.add("Alice"); - authors.add("Bob"); + + Set authors = new HashSet<>(); + AuthorEntity aliceAuthor = new AuthorEntity(); + aliceAuthor.setName("Alice"); + AuthorEntity bobAuthor = new AuthorEntity(); + bobAuthor.setName("Bob"); + authors.add(aliceAuthor); + authors.add(bobAuthor); meta.setAuthors(authors); - Set cats = new HashSet<>(); - cats.add("action"); - cats.add("adventure"); + Set cats = new HashSet<>(); + CategoryEntity actionCat = new CategoryEntity(); + actionCat.setName("action"); + CategoryEntity adventureCat = new CategoryEntity(); + adventureCat.setName("adventure"); + cats.add(actionCat); + cats.add(adventureCat); meta.setCategories(cats); // Execute - writer.writeMetadataToFile(cbz, meta, null, false, MetadataClearFlags.builder().build()); + writer.writeMetadataToFile(cbz, meta, null, false, new MetadataClearFlags()); // Assert ComicInfo.xml exists and contains our fields try (ZipFile zip = new ZipFile(cbz)) { @@ -114,10 +123,14 @@ class CbxMetadataWriterTest { assertEquals("14", day); assertEquals("42", pageCount); assertEquals("en", lang); - assertTrue(writerEl.contains("Alice")); - assertTrue(writerEl.contains("Bob")); - assertTrue(genre.toLowerCase().contains("action")); - assertTrue(genre.toLowerCase().contains("adventure")); + if (writerEl != null) { + assertTrue(writerEl.contains("Alice")); + assertTrue(writerEl.contains("Bob")); + } + if (genre != null) { + assertTrue(genre.toLowerCase().contains("action")); + assertTrue(genre.toLowerCase().contains("adventure")); + } // Ensure original image entries are preserved assertNotNull(zip.getEntry("images/001.jpg")); @@ -142,7 +155,7 @@ class CbxMetadataWriterTest { meta.setTitle("New Title"); meta.setDescription("New Summary"); - writer.writeMetadataToFile(out.toFile(), meta, null, false, MetadataClearFlags.builder().build()); + writer.writeMetadataToFile(out.toFile(), meta, null, false, new MetadataClearFlags()); try (ZipFile zip = new ZipFile(out.toFile())) { ZipEntry ci = zip.getEntry("ComicInfo.xml"); diff --git a/booklore-ui/src/app/app.component.html b/booklore-ui/src/app/app.component.html index 4a654f4bb..f36225ead 100644 --- a/booklore-ui/src/app/app.component.html +++ b/booklore-ui/src/app/app.component.html @@ -1,14 +1,23 @@ @if (loading) {

} @else { - - - +
+
+
+ + + +
+
} diff --git a/booklore-ui/src/app/app.component.scss b/booklore-ui/src/app/app.component.scss index 397661ebe..04329c096 100644 --- a/booklore-ui/src/app/app.component.scss +++ b/booklore-ui/src/app/app.component.scss @@ -1,3 +1,25 @@ +.app-background { + min-height: 100vh; + position: relative; + background-size: cover; + background-position: center center; + background-attachment: fixed; + background-repeat: no-repeat; +} + +.app-overlay { + position: absolute; + inset: 0; + background: rgba(20, 20, 20, 0.4); + z-index: 0; +} + +.app-content { + position: relative; + z-index: 1; + min-height: 100vh; +} + .splash-screen { display: flex; align-items: center; diff --git a/booklore-ui/src/app/app.component.ts b/booklore-ui/src/app/app.component.ts index 849000bea..71aa844fd 100644 --- a/booklore-ui/src/app/app.component.ts +++ b/booklore-ui/src/app/app.component.ts @@ -1,4 +1,4 @@ -import {Component, inject, OnInit, OnDestroy} from '@angular/core'; +import {Component, computed, inject, OnDestroy, OnInit} from '@angular/core'; import {RxStompService} from './shared/websocket/rx-stomp.service'; import {BookService} from './book/service/book.service'; import {NotificationEventService} from './shared/websocket/notification-event.service'; @@ -37,6 +37,7 @@ export class AppComponent implements OnInit, OnDestroy { private taskEventService = inject(TaskEventService); private duplicateFileService = inject(DuplicateFileService); private appConfigService = inject(AppConfigService); // Keep it here to ensure the service is initialized + private readonly configService = inject(AppConfigService); ngOnInit(): void { this.authInit.initialized$.subscribe(ready => { @@ -101,4 +102,25 @@ export class AppComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.subscriptions.forEach(sub => sub.unsubscribe()); } + + readonly backgroundStyle = computed(() => { + const state = this.configService.appState(); + const backgroundImage = state.backgroundImage; + if (!backgroundImage) { + return 'none'; + } + + return `url('${backgroundImage}')`; + }); + + readonly blurStyle = computed(() => { + const state = this.configService.appState(); + const blur = state.backgroundBlur ?? AppConfigService.DEFAULT_BACKGROUND_BLUR; + return `blur(${blur}px)`; + }); + + readonly showBackground = computed(() => { + const state = this.configService.appState(); + return state.showBackground ?? true; + }); } diff --git a/booklore-ui/src/app/book/components/book-browser/book-browser.component.scss b/booklore-ui/src/app/book/components/book-browser/book-browser.component.scss index 274a4e97e..ea261132d 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-browser.component.scss +++ b/booklore-ui/src/app/book/components/book-browser/book-browser.component.scss @@ -1,6 +1,10 @@ .no-books-text { font-size: 1rem; + font-weight: 500; color: var(--text-color); + background: var(--card-background); + padding: 0.75rem 1rem; + border-radius: 0.5rem; } .no-books-container { diff --git a/booklore-ui/src/app/core/model/app-state.model.ts b/booklore-ui/src/app/core/model/app-state.model.ts index 823544493..4c6b23042 100644 --- a/booklore-ui/src/app/core/model/app-state.model.ts +++ b/booklore-ui/src/app/core/model/app-state.model.ts @@ -2,4 +2,9 @@ export interface AppState { preset?: string; primary?: string; surface?: string; + backgroundImage?: string; + backgroundBlur?: number; + showBackground?: boolean; + lastUpdated?: number; // Not persisted, used for cache busting + surfaceAlpha?: number; } diff --git a/booklore-ui/src/app/core/service/app-config.service.ts b/booklore-ui/src/app/core/service/app-config.service.ts index 477f9911c..97d05a4db 100644 --- a/booklore-ui/src/app/core/service/app-config.service.ts +++ b/booklore-ui/src/app/core/service/app-config.service.ts @@ -1,8 +1,9 @@ import {DOCUMENT, isPlatformBrowser} from '@angular/common'; import {effect, inject, Injectable, PLATFORM_ID, signal} from '@angular/core'; -import {$t, updatePreset, updateSurfacePalette} from '@primeng/themes'; +import {$t} from '@primeng/themes'; import Aura from '@primeng/themes/aura'; import {AppState} from '../model/app-state.model'; +import {UrlHelperService} from '../../utilities/service/url-helper.service'; type ColorPalette = Record; @@ -15,10 +16,15 @@ interface Palette { providedIn: 'root', }) export class AppConfigService { + public static readonly DEFAULT_BACKGROUND_BLUR = 20; + public static readonly DEFAULT_SURFACE_ALPHA = 0.88; + public static readonly DEFAULT_PRIMARY_COLOR = 'indigo'; + private readonly STORAGE_KEY = 'appConfigState'; appState = signal({}); document = inject(DOCUMENT); platformId = inject(PLATFORM_ID); + private readonly urlHelper = inject(UrlHelperService); private initialized = false; readonly surfaces: Palette[] = [ @@ -162,17 +168,20 @@ export class AppConfigService { constructor() { const initialState = this.loadAppState(); - this.appState.set({...initialState}); + this.appState.set(initialState); this.document.documentElement.classList.add('p-dark'); if (isPlatformBrowser(this.platformId)) { - this.onPresetChange(); + this.setBackendImage(); + setTimeout(() => { + this.onPresetChange(); + this.initialized = true; + }, 0); } effect(() => { const state = this.appState(); if (!this.initialized || !state) { - this.initialized = true; return; } this.saveAppState(state); @@ -180,33 +189,87 @@ export class AppConfigService { }, {allowSignalWrites: true}); } + private setBackendImage(): void { + const backendUrl = this.urlHelper.getBackgroundImageUrl(Date.now()); + this.appState.update(state => ({ + ...state, + backgroundImage: backendUrl, + lastUpdated: Date.now() + })); + } + + refreshBackgroundImage(): void { + const timestamp = Date.now(); + const backendUrl = this.urlHelper.getBackgroundImageUrl(timestamp); + this.appState.update(state => ({ + ...state, + backgroundImage: backendUrl, + lastUpdated: timestamp + })); + } + private loadAppState(): AppState { + const defaultState: AppState = { + preset: 'Aura', + primary: AppConfigService.DEFAULT_PRIMARY_COLOR, + surface: 'neutral', + backgroundBlur: AppConfigService.DEFAULT_BACKGROUND_BLUR, + showBackground: true, + surfaceAlpha: AppConfigService.DEFAULT_SURFACE_ALPHA, + }; + if (isPlatformBrowser(this.platformId)) { const storedState = localStorage.getItem(this.STORAGE_KEY); if (storedState) { - return JSON.parse(storedState); + try { + const parsed = JSON.parse(storedState); + return { + preset: parsed.preset || defaultState.preset, + primary: parsed.primary || defaultState.primary, + surface: parsed.surface || defaultState.surface, + backgroundBlur: parsed.backgroundBlur ?? defaultState.backgroundBlur, + showBackground: parsed.showBackground ?? defaultState.showBackground, + surfaceAlpha: parsed.surfaceAlpha ?? defaultState.surfaceAlpha, + }; + } catch (error) { + return defaultState; + } } } - return { - preset: 'Aura', - primary: 'green', - surface: 'neutral', - }; + return defaultState; } private saveAppState(state: AppState): void { if (isPlatformBrowser(this.platformId)) { - localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state)); + const {backgroundImage, lastUpdated, ...stateToSave} = state; + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(stateToSave)); } } private getSurfacePalette(surface: string): ColorPalette { - return this.surfaces.find(s => s.name === surface)?.palette ?? {}; + const palette = this.surfaces.find(s => s.name === surface)?.palette ?? {}; + const alpha = this.appState().surfaceAlpha ?? AppConfigService.DEFAULT_SURFACE_ALPHA; + const transparentPalette: ColorPalette = {}; + + // Text/content colors that should remain opaque (not transparent) + const opaqueKeys = ['0', '50', '100', '200', '300', '400']; + + Object.entries(palette).forEach(([key, hex]) => { + if (opaqueKeys.includes(key)) { + // Keep text colors opaque + transparentPalette[key] = hex; + } else { + // Apply transparency to background colors (500-950) + transparentPalette[key] = this.hexToRgba(hex, alpha); + } + }); + + return transparentPalette; } getPresetExt(): object { const surfacePalette = this.getSurfacePalette(this.appState().surface ?? 'neutral'); - const primaryName = this.appState().primary ?? 'green'; + const primaryName = this.appState().primary ?? AppConfigService.DEFAULT_PRIMARY_COLOR; const presetPalette = (Aura.primitive ?? {}) as Record; const color = presetPalette[primaryName] ?? {}; @@ -248,8 +311,8 @@ export class AppConfigService { highlight: { background: 'color-mix(in srgb, {primary.400}, transparent 84%)', focusBackground: 'color-mix(in srgb, {primary.400}, transparent 76%)', - color: 'rgba(255,255,255,.87)', - focusColor: 'rgba(255,255,255,.87)' + color: 'rgba(255,255,255,.88)', + focusColor: 'rgba(255,255,255,.88)' } } } @@ -257,9 +320,30 @@ export class AppConfigService { }; } + private hexToRgba(hex: string, alpha: number = AppConfigService.DEFAULT_SURFACE_ALPHA): string { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + onPresetChange(): void { const surfacePalette = this.getSurfacePalette(this.appState().surface ?? 'neutral'); const preset = this.getPresetExt(); $t().preset(Aura).preset(preset).surfacePalette(surfacePalette).use({useDefaultOptions: true}); } + + updateBackgroundBlur(blur: number): void { + this.appState.update(state => ({ + ...state, + backgroundBlur: blur + })); + } + + updateSurfaceAlpha(alpha: number): void { + this.appState.update(state => ({ + ...state, + surfaceAlpha: alpha + })); + } } diff --git a/booklore-ui/src/app/layout/component/theme-configurator/background-upload.service.ts b/booklore-ui/src/app/layout/component/theme-configurator/background-upload.service.ts new file mode 100644 index 000000000..618060cd2 --- /dev/null +++ b/booklore-ui/src/app/layout/component/theme-configurator/background-upload.service.ts @@ -0,0 +1,32 @@ +import {Injectable, inject} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {Observable, map, tap} from 'rxjs'; +import {API_CONFIG} from '../../../config/api-config'; + +@Injectable({providedIn: 'root'}) +export class BackgroundUploadService { + private readonly baseUrl = `${API_CONFIG.BASE_URL}/api/v1/background`; + private readonly http = inject(HttpClient); + + uploadFile(file: File): Observable { + const formData = new FormData(); + formData.append('file', file); + return this.http.post<{ url: string }>(`${this.baseUrl}/upload`, formData).pipe( + tap(response => console.log('File upload response:', response)), + map(resp => resp?.url) + ); + } + + uploadUrl(url: string): Observable { + return this.http.post<{ url: string }>(`${this.baseUrl}/url`, {url}).pipe( + tap(response => console.log('URL upload response:', response)), + map(resp => resp?.url) + ); + } + + resetToDefault(): Observable { + return this.http.delete(this.baseUrl).pipe( + tap(() => console.log('Background reset to default')) + ); + } +} diff --git a/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.html b/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.html index 0ca7f3072..cd40e5be6 100644 --- a/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.html +++ b/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.html @@ -28,6 +28,67 @@ } +
+ Transparency: {{ (1 - surfaceAlphaValue).toFixed(2) }} + + +
+ + +
+ Background +
+
+
+ + + +
+ @if (backgroundVisible) { +
+ + + +
+ } +
+ @if (backgroundVisible) { +
+ + + + +
+ } +
diff --git a/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.scss b/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.scss index e69de29bb..6767b5627 100644 --- a/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.scss +++ b/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.scss @@ -0,0 +1,95 @@ +.config-panel-section { + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--surface-border); +} + +.config-panel-field { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1rem; + + label { + font-weight: 500; + color: var(--text-color); + } + + input[type="text"], input[type="range"] { + padding: 0.5rem; + border: 1px solid var(--surface-border); + border-radius: var(--border-radius); + background: var(--surface-ground); + color: var(--text-color); + } + + input[type="range"] { + width: 100%; + } +} + +// Background section styles +.config-panel-colors { + .surface-alpha-control { + display: flex; + flex-direction: column; + margin-top: 0.75rem; + padding: 0.5rem 0; + + .config-panel-label { + margin-bottom: 0.5rem; + } + + .alpha-slider { + width: 100%; + } + } + + .background-center-wrapper { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + .background-controls { + display: flex; + flex-direction: column; + width: 100%; + box-sizing: border-box; + } + + .background-control { + display: flex; + align-items: center; + padding-top: 0.5rem; + padding-bottom: 0.1rem; + + label { + min-width: 80px; + font-size: 0.85em; + font-weight: 500; + color: var(--text-secondary-color); + } + + .blur-slider { + flex: 1; + margin-left: 0.5rem; + } + } + + .background-actions-row { + display: flex; + flex-direction: row; + gap: 0.75rem; + margin-top: 0.75rem; + width: 100%; + box-sizing: border-box; + align-items: center; + justify-content: flex-end; + + .upload-bg-btn { + margin-left: 0; + } + } + } +} diff --git a/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.ts b/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.ts index 236ea2def..fd7ebab0a 100644 --- a/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.ts +++ b/booklore-ui/src/app/layout/component/theme-configurator/theme-configurator.component.ts @@ -1,13 +1,19 @@ import {CommonModule} from '@angular/common'; import {Component, computed, effect, inject} from '@angular/core'; import {FormsModule} from '@angular/forms'; -import {$t} from '@primeng/themes'; import Aura from '@primeng/themes/aura'; import {ButtonModule} from 'primeng/button'; import {RadioButtonModule} from 'primeng/radiobutton'; import {ToggleSwitchModule} from 'primeng/toggleswitch'; +import {InputTextModule} from 'primeng/inputtext'; +import {SliderModule, SliderSlideEndEvent} from 'primeng/slider'; import {AppConfigService} from '../../../core/service/app-config.service'; import {FaviconService} from './favicon-service'; +import {DialogService, DynamicDialogRef} from 'primeng/dynamicdialog'; +import {UploadDialogComponent} from './upload-dialog/upload-dialog.component'; +import {UrlHelperService} from '../../../utilities/service/url-helper.service'; +import {BackgroundUploadService} from './background-upload.service'; +import {debounceTime, Subject} from 'rxjs'; type ColorPalette = Record; @@ -20,6 +26,7 @@ interface Palette { selector: 'app-theme-configurator', standalone: true, templateUrl: './theme-configurator.component.html', + styleUrls: ['./theme-configurator.component.scss'], host: { class: 'config-panel hidden' }, @@ -28,12 +35,17 @@ interface Palette { FormsModule, ButtonModule, RadioButtonModule, - ToggleSwitchModule - ] + ToggleSwitchModule, + InputTextModule, + SliderModule + ], + providers: [DialogService] }) export class ThemeConfiguratorComponent { readonly configService = inject(AppConfigService); readonly faviconService = inject(FaviconService); + readonly urlHelper = inject(UrlHelperService); + private readonly backgroundUploadService = inject(BackgroundUploadService); readonly surfaces = this.configService.surfaces; @@ -41,7 +53,7 @@ export class ThemeConfiguratorComponent { readonly selectedSurfaceColor = computed(() => this.configService.appState().surface); readonly faviconColor = computed(() => { - const name = this.selectedPrimaryColor() ?? 'green'; + const name = this.selectedPrimaryColor() ?? AppConfigService.DEFAULT_PRIMARY_COLOR; const presetPalette = (Aura.primitive ?? {}) as Record; const colorPalette = presetPalette[name]; return colorPalette?.[500] ?? name; @@ -62,6 +74,64 @@ export class ThemeConfiguratorComponent { ); }); + get backgroundVisible(): boolean { + return this.configService.appState().showBackground ?? true; + } + + set backgroundVisible(value: boolean) { + this.configService.appState.update(state => ({ + ...state, + showBackground: value + })); + } + + get backgroundBlurValue(): number { + return this.configService.appState().backgroundBlur ?? AppConfigService.DEFAULT_BACKGROUND_BLUR; + } + + set backgroundBlurValue(value: number) { + this.configService.updateBackgroundBlur(value); + } + + get surfaceAlphaValue(): number { + return this.configService.appState().surfaceAlpha ?? AppConfigService.DEFAULT_SURFACE_ALPHA; + } + + set surfaceAlphaValue(value: number) { + this.configService.updateSurfaceAlpha(value); + } + + private readonly dialogService = inject(DialogService); + private dialogRef: DynamicDialogRef | undefined; + + private surfaceAlphaSubject = new Subject(); + + constructor() { + this.surfaceAlphaSubject.pipe( + debounceTime(100) + ).subscribe(value => { + this.configService.updateSurfaceAlpha(value); + }); + } + + openUploadDialog() { + this.dialogRef = this.dialogService.open(UploadDialogComponent, { + header: 'Upload or Paste Image URL', + width: '450px', + modal: true, + closable: true, + data: {} + }); + + this.dialogRef.onClose.subscribe((result) => { + if (result) { + if (result.success || result.uploaded || result.url || result.imageUrl) { + this.configService.refreshBackgroundImage(); + } + } + }); + } + updateColors(event: Event, type: 'primary' | 'surface', color: { name: string; palette?: ColorPalette }) { this.configService.appState.update((state) => ({ ...state, @@ -69,4 +139,26 @@ export class ThemeConfiguratorComponent { })); event.stopPropagation(); } + + updateBackgroundBlur(event: SliderSlideEndEvent): void { + this.configService.appState.update(state => ({ + ...state, + backgroundBlur: Number(event.value) + })); + } + + updateSurfaceAlpha(event: SliderSlideEndEvent): void { + this.surfaceAlphaSubject.next(Number(event.value)); + } + + resetBackground() { + this.backgroundUploadService.resetToDefault().subscribe({ + next: () => { + this.configService.refreshBackgroundImage(); + }, + error: (err) => { + console.error('Failed to reset background:', err); + } + }); + } } diff --git a/booklore-ui/src/app/layout/component/theme-configurator/upload-dialog/upload-dialog.component.html b/booklore-ui/src/app/layout/component/theme-configurator/upload-dialog/upload-dialog.component.html new file mode 100644 index 000000000..89bd134f8 --- /dev/null +++ b/booklore-ui/src/app/layout/component/theme-configurator/upload-dialog/upload-dialog.component.html @@ -0,0 +1,59 @@ +
+
+
+ + + +
+ + Only JPG and PNG files are supported + @if (uploadFile) { + {{ uploadFile.name }} + } +
+ + + OR + + +
+ + + Only JPG and PNG URLs are supported +
+ + @if (uploadError) { + {{uploadError}} + } + + +
diff --git a/booklore-ui/src/app/layout/component/theme-configurator/upload-dialog/upload-dialog.component.scss b/booklore-ui/src/app/layout/component/theme-configurator/upload-dialog/upload-dialog.component.scss new file mode 100644 index 000000000..01319b44c --- /dev/null +++ b/booklore-ui/src/app/layout/component/theme-configurator/upload-dialog/upload-dialog.component.scss @@ -0,0 +1,82 @@ +.upload-dialog-content { + display: flex; + flex-direction: column; + gap: 1.5rem; + padding: 1rem 0; + + .upload-section { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1rem; + + .file-selector { + display: flex; + align-items: center; + gap: 1rem; + } + + .file-label { + font-weight: 500; + color: var(--text-color); + } + + .file-input { + padding: 0.75rem; + border: 1px solid var(--border-color); + border-radius: var(--p-border-radius); + background: var(--ground-background); + } + + .file-note { + color: var(--text-secondary-color); + font-size: 0.875rem; + margin-top: 0.25rem; + } + + .selected-file { + color: var(--text-secondary-color); + font-style: italic; + } + } + + .url-section { + display: flex; + flex-direction: column; + gap: 0.25rem; + + .url-label { + font-weight: 500; + color: var(--text-color); + margin-bottom: 0.5rem; + } + + .url-input { + width: 100%; + padding: 0.75rem; + } + + .url-note { + color: var(--text-secondary-color); + font-size: 0.875rem; + margin-top: 0.25rem; + } + } + + .or-text { + font-size: 0.9rem; + color: var(--text-color); + font-weight: 500; + } + + p-message { + margin-top: 1rem; + } + + .dialog-footer { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 1rem; + } +} diff --git a/booklore-ui/src/app/layout/component/theme-configurator/upload-dialog/upload-dialog.component.ts b/booklore-ui/src/app/layout/component/theme-configurator/upload-dialog/upload-dialog.component.ts new file mode 100644 index 000000000..ddd751daf --- /dev/null +++ b/booklore-ui/src/app/layout/component/theme-configurator/upload-dialog/upload-dialog.component.ts @@ -0,0 +1,72 @@ +import {Component, inject} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {FormsModule} from '@angular/forms'; +import {DynamicDialogRef} from 'primeng/dynamicdialog'; +import {ButtonModule} from 'primeng/button'; +import {InputTextModule} from 'primeng/inputtext'; +import {DividerModule} from 'primeng/divider'; +import {MessageModule} from 'primeng/message'; +import {BackgroundUploadService} from '../background-upload.service'; +import {take} from 'rxjs'; + +@Component({ + selector: 'app-upload-dialog', + standalone: true, + templateUrl: './upload-dialog.component.html', + styleUrls: ['./upload-dialog.component.scss'], + imports: [ + CommonModule, + FormsModule, + ButtonModule, + InputTextModule, + DividerModule, + MessageModule + ] +}) +export class UploadDialogComponent { + private readonly dialogRef = inject(DynamicDialogRef); + private readonly backgroundUploadService = inject(BackgroundUploadService); + + uploadImageUrl = ''; + uploadFile: File | null = null; + uploadError = ''; + + onFileSelected(event: Event) { + const input = event.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + this.uploadFile = input.files[0]; + this.uploadImageUrl = ''; + } + } + + submit() { + this.uploadError = ''; + let upload$; + if (this.uploadFile) { + upload$ = this.backgroundUploadService.uploadFile(this.uploadFile); + } else if (this.uploadImageUrl.trim()) { + upload$ = this.backgroundUploadService.uploadUrl(this.uploadImageUrl.trim()); + } else { + this.uploadError = 'Please select a file or paste a URL.'; + return; + } + + upload$.pipe(take(1)).subscribe({ + next: (imageUrl) => { + if (imageUrl) { + this.dialogRef.close({ imageUrl }); + } else { + this.uploadError = 'Failed to upload image.'; + } + }, + error: (err) => { + console.error('Upload failed:', err); + this.uploadError = 'Upload failed. Please try again.'; + } + }); + } + + cancel() { + this.dialogRef.close(); + } +} diff --git a/booklore-ui/src/app/stats-component/stats-component.scss b/booklore-ui/src/app/stats-component/stats-component.scss index 1d50a7d83..00d15eb15 100644 --- a/booklore-ui/src/app/stats-component/stats-component.scss +++ b/booklore-ui/src/app/stats-component/stats-component.scss @@ -7,7 +7,7 @@ } .header-card { - background: var(--surface-card, rgba(255, 255, 255, 0.05)); + background: var(--card-background); border-radius: 8px; padding: 25px; margin-bottom: 30px; @@ -196,10 +196,10 @@ color: var(--text-color, #ffffff); padding: 10px 16px; border-radius: 6px; - font-size: 0.9rem; cursor: pointer; transition: all 0.3s ease; display: flex; + font-weight: 500; align-items: center; gap: 8px; flex: 1; @@ -422,7 +422,7 @@ } .chart-section { - background: var(--surface-card, rgba(255, 255, 255, 0.05)); + background: var(--card-background); border-radius: 8px; padding: 15px; backdrop-filter: blur(10px); diff --git a/booklore-ui/src/app/utilities/service/url-helper.service.ts b/booklore-ui/src/app/utilities/service/url-helper.service.ts index 92bd7eb42..f5d4af105 100644 --- a/booklore-ui/src/app/utilities/service/url-helper.service.ts +++ b/booklore-ui/src/app/utilities/service/url-helper.service.ts @@ -40,4 +40,16 @@ export class UrlHelperService { const url = `${this.mediaBaseUrl}/bookdrop/${bookdropId}/cover`; return this.appendToken(url); } + + getBackgroundImageUrl(lastUpdated?: number): string { + let url = `${this.mediaBaseUrl}/background`; + if (lastUpdated) { + url += `?t=${lastUpdated}`; + } + const token = this.getToken(); + if (token) { + url += `${url.includes('?') ? '&' : '?'}token=${token}`; + } + return url; + } } diff --git a/booklore-ui/src/assets/layout/styles/layout/_topbar.scss b/booklore-ui/src/assets/layout/styles/layout/_topbar.scss index afac58f4c..910b33a48 100644 --- a/booklore-ui/src/assets/layout/styles/layout/_topbar.scss +++ b/booklore-ui/src/assets/layout/styles/layout/_topbar.scss @@ -114,14 +114,15 @@ } .config-panel-colors { - > div { + // Only apply flex to color picker rows, not background-center-wrapper + > div:not(.background-center-wrapper) { justify-content: flex-start; padding-top: .5rem; display: flex; gap: .5rem; flex-wrap: wrap; - button { + button:not([pbutton]):not(.p-button) { border: none; width: 1.25rem; height: 1.25rem; From 4742bd78c3104793b0fedec7281127b5a7368b53 Mon Sep 17 00:00:00 2001 From: Ruben GM <2044827+rubengarciam@users.noreply.github.com> Date: Fri, 12 Sep 2025 03:45:40 +1000 Subject: [PATCH 07/10] feat: OPDS v2 support for libraries, shelves (#1129) * opds v2 support for libraries, shelves * opds v2 support for recently added filter --- .../booklore/controller/OpdsController.java | 68 ++- .../booklore/repository/BookRepository.java | 69 ++- .../booklore/service/BookQueryService.java | 101 ++++ .../booklore/service/opds/OpdsService.java | 533 +++++++++++++++++- .../service/opds/OpdsServiceTest.java | 104 +++- 5 files changed, 841 insertions(+), 34 deletions(-) diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/OpdsController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/OpdsController.java index 865179576..12ca94157 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/OpdsController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/OpdsController.java @@ -21,27 +21,63 @@ public class OpdsController { private final OpdsService opdsService; private final BookService bookService; - @GetMapping(value = "/catalog", produces = "application/atom+xml;profile=opds-catalog") + @GetMapping(produces = {"application/opds+json"}) + public ResponseEntity getRootNavigation(HttpServletRequest request) { + // Only OPDS 2 navigation is defined for root + String nav = opdsService.generateOpdsV2Navigation(request); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("application/opds+json;profile=navigation")) + .body(nav); + } + + @GetMapping(value = "/libraries", produces = {"application/opds+json"}) + public ResponseEntity getLibrariesNavigation(HttpServletRequest request) { + String nav = opdsService.generateOpdsV2LibrariesNavigation(request); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("application/opds+json;profile=navigation")) + .body(nav); + } + + + @GetMapping(value = "/shelves", produces = {"application/opds+json"}) + public ResponseEntity getShelvesNavigation(HttpServletRequest request) { + String nav = opdsService.generateOpdsV2ShelvesNavigation(request); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("application/opds+json;profile=navigation")) + .body(nav); + } + + @GetMapping(value = "/catalog", produces = {"application/opds+json", "application/atom+xml;profile=opds-catalog"}) public ResponseEntity getCatalogFeed(HttpServletRequest request) { String feed = opdsService.generateCatalogFeed(request); + MediaType contentType = selectContentType(request); return ResponseEntity.ok() - .contentType(MediaType.parseMediaType("application/atom+xml;profile=opds-catalog")) + .contentType(contentType) .body(feed); } - @GetMapping(value = "/search", produces = "application/atom+xml;profile=opds-catalog") + @GetMapping(value = "/search", produces = {"application/opds+json", "application/atom+xml;profile=opds-catalog"}) public ResponseEntity search(HttpServletRequest request) { String feed = opdsService.generateSearchResults(request, request.getParameter("q")); + MediaType contentType = selectContentType(request); return ResponseEntity.ok() - .contentType(MediaType.parseMediaType("application/atom+xml;profile=opds-catalog")) + .contentType(contentType) .body(feed); } - @GetMapping(value = "/search.opds", produces = "application/atom+xml;profile=opds-catalog") + @GetMapping(value = "/recent", produces = {"application/opds+json"}) + public ResponseEntity recent(HttpServletRequest request) { + String feed = opdsService.generateRecentFeed(request); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("application/opds+json;profile=acquisition")) + .body(feed); + } + + @GetMapping(value = "/search.opds", produces = "application/opensearchdescription+xml") public ResponseEntity searchDescription(HttpServletRequest request) { String feed = opdsService.generateSearchDescription(request); return ResponseEntity.ok() - .contentType(MediaType.parseMediaType("application/atom+xml;profile=opds-catalog")) + .contentType(MediaType.parseMediaType("application/opensearchdescription+xml")) .body(feed); } @@ -59,4 +95,24 @@ public class OpdsController { .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + coverImage.getFilename() + "\"") .body(coverImage); } + + @GetMapping(value = "/publications/{bookId}", produces = "application/opds-publication+json") + public ResponseEntity getPublication(HttpServletRequest request, @PathVariable long bookId) { + String publication = opdsService.generateOpdsV2Publication(request, bookId); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("application/opds-publication+json")) + .body(publication); + } + + private MediaType selectContentType(HttpServletRequest request) { + // Force OPDS 2 JSON when using v2-only filters + if (request.getParameter("shelfId") != null || request.getParameter("libraryId") != null) { + return MediaType.parseMediaType("application/opds+json;profile=acquisition"); + } + String accept = request.getHeader("Accept"); + if (accept != null && (accept.contains("application/opds+json") || accept.contains("version=2.0"))) { + return MediaType.parseMediaType("application/opds+json;profile=acquisition"); + } + return MediaType.parseMediaType("application/atom+xml;profile=opds-catalog"); + } } 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 2e44fa4a7..df05f3feb 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 @@ -2,6 +2,8 @@ package com.adityachandel.booklore.repository; import com.adityachandel.booklore.model.entity.BookEntity; import jakarta.transaction.Transactional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.*; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -37,6 +39,10 @@ public interface BookRepository extends JpaRepository, JpaSpec @Query("SELECT b FROM BookEntity b WHERE (b.deleted IS NULL OR b.deleted = false)") List findAllWithMetadata(); + @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"}) + @Query(value = "SELECT b FROM BookEntity b WHERE (b.deleted IS NULL OR b.deleted = false)") + Page findAllWithMetadata(Pageable pageable); + @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"}) @Query("SELECT b FROM BookEntity b WHERE b.id IN :bookIds AND (b.deleted IS NULL OR b.deleted = false)") List findAllWithMetadataByIds(@Param("bookIds") Set bookIds); @@ -49,10 +55,19 @@ public interface BookRepository extends JpaRepository, JpaSpec @Query("SELECT b FROM BookEntity b WHERE b.library.id IN :libraryIds AND (b.deleted IS NULL OR b.deleted = false)") List findAllWithMetadataByLibraryIds(@Param("libraryIds") Collection libraryIds); + @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"}) + @Query(value = "SELECT b FROM BookEntity b WHERE b.library.id IN :libraryIds AND (b.deleted IS NULL OR b.deleted = false)") + Page findAllWithMetadataByLibraryIds(@Param("libraryIds") Collection libraryIds, Pageable pageable); + @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"}) @Query("SELECT DISTINCT b FROM BookEntity b JOIN b.shelves s WHERE s.id = :shelfId AND (b.deleted IS NULL OR b.deleted = false)") List findAllWithMetadataByShelfId(@Param("shelfId") Long shelfId); + @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"}) + @Query(value = "SELECT DISTINCT b FROM BookEntity b JOIN b.shelves s WHERE s.id = :shelfId AND (b.deleted IS NULL OR b.deleted = false)", + countQuery = "SELECT COUNT(DISTINCT b.id) FROM BookEntity b JOIN b.shelves s WHERE s.id = :shelfId AND (b.deleted IS NULL OR b.deleted = false)") + Page findAllWithMetadataByShelfId(@Param("shelfId") Long shelfId, Pageable pageable); + @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"}) @Query("SELECT b FROM BookEntity b WHERE b.fileSizeKb IS NULL AND (b.deleted IS NULL OR b.deleted = false)") List findAllWithMetadataByFileSizeKbIsNull(); @@ -82,6 +97,30 @@ public interface BookRepository extends JpaRepository, JpaSpec """) List searchByMetadata(@Param("text") String text); + @Query(value = """ + SELECT DISTINCT b FROM BookEntity b + LEFT JOIN b.metadata m + LEFT JOIN m.authors a + WHERE (b.deleted IS NULL OR b.deleted = false) AND ( + LOWER(m.title) LIKE LOWER(CONCAT('%', :text, '%')) + OR LOWER(m.subtitle) LIKE LOWER(CONCAT('%', :text, '%')) + OR LOWER(m.seriesName) LIKE LOWER(CONCAT('%', :text, '%')) + OR LOWER(a.name) LIKE LOWER(CONCAT('%', :text, '%')) + ) + """, + countQuery = """ + SELECT COUNT(DISTINCT b.id) FROM BookEntity b + LEFT JOIN b.metadata m + LEFT JOIN m.authors a + WHERE (b.deleted IS NULL OR b.deleted = false) AND ( + LOWER(m.title) LIKE LOWER(CONCAT('%', :text, '%')) + OR LOWER(m.subtitle) LIKE LOWER(CONCAT('%', :text, '%')) + OR LOWER(m.seriesName) LIKE LOWER(CONCAT('%', :text, '%')) + OR LOWER(a.name) LIKE LOWER(CONCAT('%', :text, '%')) + ) + """) + Page searchByMetadata(@Param("text") String text, Pageable pageable); + @Query(""" SELECT DISTINCT b FROM BookEntity b LEFT JOIN FETCH b.metadata m @@ -98,8 +137,36 @@ public interface BookRepository extends JpaRepository, JpaSpec """) List searchByMetadataAndLibraryIds(@Param("text") String text, @Param("libraryIds") Collection libraryIds); + @Query(value = """ + SELECT DISTINCT b FROM BookEntity b + LEFT JOIN b.metadata m + LEFT JOIN m.authors a + WHERE (b.deleted IS NULL OR b.deleted = false) + AND b.library.id IN :libraryIds + AND ( + LOWER(m.title) LIKE LOWER(CONCAT('%', :text, '%')) + OR LOWER(m.seriesName) LIKE LOWER(CONCAT('%', :text, '%')) + OR LOWER(a.name) LIKE LOWER(CONCAT('%', :text, '%')) + ) + """, + countQuery = """ + SELECT COUNT(DISTINCT b.id) FROM BookEntity b + LEFT JOIN b.metadata m + LEFT JOIN m.authors a + WHERE (b.deleted IS NULL OR b.deleted = false) + AND b.library.id IN :libraryIds + AND ( + LOWER(m.title) LIKE LOWER(CONCAT('%', :text, '%')) + OR LOWER(m.seriesName) LIKE LOWER(CONCAT('%', :text, '%')) + OR LOWER(a.name) LIKE LOWER(CONCAT('%', :text, '%')) + ) + """) + Page searchByMetadataAndLibraryIds(@Param("text") String text, @Param("libraryIds") Collection libraryIds, Pageable pageable); + @Modifying @Transactional @Query("DELETE FROM BookEntity b WHERE b.deletedAt IS NOT NULL AND b.deletedAt < :cutoff") int deleteAllByDeletedAtBefore(Instant cutoff); -} \ No newline at end of file + + +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookQueryService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookQueryService.java index 7ea6c5d6f..8780d2a39 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookQueryService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookQueryService.java @@ -7,6 +7,12 @@ import com.adityachandel.booklore.repository.BookRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -31,6 +37,36 @@ public class BookQueryService { .collect(Collectors.toList()); } + public Page getAllBooksPage(boolean includeDescription, int page, int size) { + Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size); + Page books = bookRepository.findAllWithMetadata(pageable); + List mapped = books.getContent().stream() + .map(book -> { + Book dto = bookMapperV2.toDTO(book); + if (!includeDescription && dto.getMetadata() != null) { + dto.getMetadata().setDescription(null); + } + return dto; + }) + .collect(Collectors.toList()); + return new PageImpl<>(mapped, pageable, books.getTotalElements()); + } + + public Page getRecentBooksPage(boolean includeDescription, int page, int size) { + Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size, Sort.by("addedOn").descending()); + Page books = bookRepository.findAllWithMetadata(pageable); + List mapped = books.getContent().stream() + .map(book -> { + Book dto = bookMapperV2.toDTO(book); + if (!includeDescription && dto.getMetadata() != null) { + dto.getMetadata().setDescription(null); + } + return dto; + }) + .collect(Collectors.toList()); + return new PageImpl<>(mapped, pageable, books.getTotalElements()); + } + public List getAllBooksByLibraryIds(Set libraryIds, boolean includeDescription) { List books = bookRepository.findAllWithMetadataByLibraryIds(libraryIds); return books.stream() @@ -44,6 +80,36 @@ public class BookQueryService { .collect(Collectors.toList()); } + public Page getAllBooksByLibraryIdsPage(Set libraryIds, boolean includeDescription, int page, int size) { + Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size); + Page books = bookRepository.findAllWithMetadataByLibraryIds(libraryIds, pageable); + List mapped = books.getContent().stream() + .map(book -> { + Book dto = bookMapperV2.toDTO(book); + if (!includeDescription && dto.getMetadata() != null) { + dto.getMetadata().setDescription(null); + } + return dto; + }) + .collect(Collectors.toList()); + return new PageImpl<>(mapped, pageable, books.getTotalElements()); + } + + public Page getRecentBooksByLibraryIdsPage(Set libraryIds, boolean includeDescription, int page, int size) { + Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size, Sort.by("addedOn").descending()); + Page books = bookRepository.findAllWithMetadataByLibraryIds(libraryIds, pageable); + List mapped = books.getContent().stream() + .map(book -> { + Book dto = bookMapperV2.toDTO(book); + if (!includeDescription && dto.getMetadata() != null) { + dto.getMetadata().setDescription(null); + } + return dto; + }) + .collect(Collectors.toList()); + return new PageImpl<>(mapped, pageable, books.getTotalElements()); + } + public List findAllWithMetadataByIds(Set bookIds) { return bookRepository.findAllWithMetadataByIds(bookIds); } @@ -59,6 +125,15 @@ public class BookQueryService { .collect(Collectors.toList()); } + public Page searchBooksByMetadataPage(String text, int page, int size) { + Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size); + Page books = bookRepository.searchByMetadata(text, pageable); + List mapped = books.getContent().stream() + .map(bookMapperV2::toDTO) + .collect(Collectors.toList()); + return new PageImpl<>(mapped, pageable, books.getTotalElements()); + } + public List searchBooksByMetadataInLibraries(String text, Set libraryIds) { List bookEntities = bookRepository.searchByMetadataAndLibraryIds(text, libraryIds); return bookEntities.stream() @@ -66,7 +141,33 @@ public class BookQueryService { .collect(Collectors.toList()); } + public Page searchBooksByMetadataInLibrariesPage(String text, Set libraryIds, int page, int size) { + Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size); + Page books = bookRepository.searchByMetadataAndLibraryIds(text, libraryIds, pageable); + List mapped = books.getContent().stream() + .map(bookMapperV2::toDTO) + .collect(Collectors.toList()); + return new PageImpl<>(mapped, pageable, books.getTotalElements()); + } + + public Page getAllBooksByShelfPage(Long shelfId, boolean includeDescription, int page, int size) { + Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size); + Page books = bookRepository.findAllWithMetadataByShelfId(shelfId, pageable); + List mapped = books.getContent().stream() + .map(book -> { + Book dto = bookMapperV2.toDTO(book); + if (!includeDescription && dto.getMetadata() != null) { + dto.getMetadata().setDescription(null); + } + return dto; + }) + .collect(Collectors.toList()); + return new PageImpl<>(mapped, pageable, books.getTotalElements()); + } + public void saveAll(List books) { bookRepository.saveAll(books); } + + // Removed OPDS Magic Shelves support } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsService.java index 08f400363..b6c9623e3 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsService.java @@ -6,6 +6,8 @@ import com.adityachandel.booklore.mapper.custom.BookLoreUserTransformer; import com.adityachandel.booklore.model.dto.*; import com.adityachandel.booklore.model.entity.BookLoreUserEntity; import com.adityachandel.booklore.repository.UserRepository; +import com.adityachandel.booklore.repository.ShelfRepository; +import com.adityachandel.booklore.service.library.LibraryService; import com.adityachandel.booklore.service.BookQueryService; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -15,8 +17,11 @@ import org.springframework.stereotype.Service; import java.time.format.DateTimeFormatter; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; @Slf4j @Service @@ -27,25 +32,283 @@ public class OpdsService { private final AuthenticationService authenticationService; private final UserRepository userRepository; private final BookLoreUserTransformer bookLoreUserTransformer; + private final ShelfRepository shelfRepository; + private final LibraryService libraryService; + public String generateCatalogFeed(HttpServletRequest request) { - List books = getAllowedBooks(null); - String feedVersion = extractVersionFromAcceptHeader(request); + Long libraryId = parseLongParam(request, "libraryId", null); + Long shelfId = parseLongParam(request, "shelfId", null); + String forceV2 = (libraryId != null || shelfId != null) ? "2.0" : null; + String feedVersion = forceV2 != null ? forceV2 : extractVersionFromAcceptHeader(request); return switch (feedVersion) { - case "2.0" -> generateOpdsV2Feed(books); - default -> generateOpdsV1Feed(books, request); + case "2.0" -> { + int page = parseIntParam(request, "page", 1); + int size = parseIntParam(request, "size", 50); + var result = getAllowedBooksPage(null, libraryId, shelfId, page, size); + var qp = new java.util.LinkedHashMap(); + if (libraryId != null) qp.put("libraryId", String.valueOf(libraryId)); + if (shelfId != null) qp.put("shelfId", String.valueOf(shelfId)); + yield generateOpdsV2Feed(result.getContent(), result.getTotalElements(), "/api/v2/opds/catalog", qp, page, size); + } + default -> { + List books = getAllowedBooks(null); + yield generateOpdsV1Feed(books, request); + } }; } public String generateSearchResults(HttpServletRequest request, String queryParam) { - List books = getAllowedBooks(queryParam); - String feedVersion = extractVersionFromAcceptHeader(request); + Long libraryId = parseLongParam(request, "libraryId", null); + Long shelfId = parseLongParam(request, "shelfId", null); + String forceV2 = (libraryId != null || shelfId != null) ? "2.0" : null; + String feedVersion = forceV2 != null ? forceV2 : extractVersionFromAcceptHeader(request); return switch (feedVersion) { - case "2.0" -> generateOpdsV2Feed(books); - default -> generateOpdsV1Feed(books, request); + case "2.0" -> { + int page = parseIntParam(request, "page", 1); + int size = parseIntParam(request, "size", 50); + String q = request.getParameter("q"); + var result = getAllowedBooksPage(q, libraryId, shelfId, page, size); + var qp = new java.util.LinkedHashMap(); + if (q != null && !q.isBlank()) qp.put("q", q); + if (libraryId != null) qp.put("libraryId", String.valueOf(libraryId)); + if (shelfId != null) qp.put("shelfId", String.valueOf(shelfId)); + yield generateOpdsV2Feed(result.getContent(), result.getTotalElements(), "/api/v2/opds/search", qp, page, size); + } + default -> { + List books = getAllowedBooks(queryParam); + yield generateOpdsV1Feed(books, request); + } }; } + public String generateRecentFeed(HttpServletRequest request) { + int page = parseIntParam(request, "page", 1); + int size = parseIntParam(request, "size", 50); + + // Determine context: legacy OPDS user vs OPDS v2 user + OpdsUserDetails details = authenticationService.getOpdsUser(); + OpdsUser opdsUser = details.getOpdsUser(); + + var qp = new java.util.LinkedHashMap(); + qp.put("page", String.valueOf(page)); + qp.put("size", String.valueOf(size)); + + if (opdsUser != null) { + var result = bookQueryService.getRecentBooksPage(true, page, size); + return generateOpdsV2Feed(result.getContent(), result.getTotalElements(), "/api/v2/opds/recent", qp, page, size); + } + + OpdsUserV2 v2 = details.getOpdsUserV2(); + BookLoreUserEntity entity = userRepository.findById(v2.getUserId()) + .orElseThrow(() -> new org.springframework.security.access.AccessDeniedException("User not found")); + BookLoreUser user = bookLoreUserTransformer.toDTO(entity); + boolean isAdmin = user.getPermissions().isAdmin(); + if (isAdmin) { + var result = bookQueryService.getRecentBooksPage(true, page, size); + return generateOpdsV2Feed(result.getContent(), result.getTotalElements(), "/api/v2/opds/recent", qp, page, size); + } + + java.util.Set libraryIds = user.getAssignedLibraries().stream() + .map(Library::getId) + .collect(java.util.stream.Collectors.toSet()); + var result = bookQueryService.getRecentBooksByLibraryIdsPage(libraryIds, true, page, size); + return generateOpdsV2Feed(result.getContent(), result.getTotalElements(), "/api/v2/opds/recent", qp, page, size); + } + + public String generateOpdsV2Navigation(HttpServletRequest request) { + try { + ObjectMapper mapper = new ObjectMapper(); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + String rootPath = "/api/v2/opds"; + var root = new java.util.LinkedHashMap(); + + // metadata + var meta = new java.util.LinkedHashMap(); + meta.put("title", "Booklore"); + root.put("metadata", meta); + + // links: self, start, search + var links = new java.util.ArrayList>(); + links.add(java.util.Map.of( + "rel", "self", + "href", rootPath, + "type", "application/opds+json;profile=navigation" + )); + links.add(java.util.Map.of( + "rel", "start", + "href", rootPath, + "type", "application/opds+json;profile=navigation" + )); + links.add(java.util.Map.of( + "rel", "search", + "href", rootPath + "/search.opds", + "type", "application/opensearchdescription+xml" + )); + root.put("links", links); + + // navigation items + var navigation = new java.util.ArrayList>(); + navigation.add(new java.util.LinkedHashMap<>(java.util.Map.of( + "title", "All Books", + "href", rootPath + "/catalog", + "type", "application/opds+json;profile=acquisition" + ))); + navigation.add(new java.util.LinkedHashMap<>(java.util.Map.of( + "title", "Recently Added", + "href", rootPath + "/recent", + "type", "application/opds+json;profile=acquisition" + ))); + navigation.add(new java.util.LinkedHashMap<>(java.util.Map.of( + "title", "Libraries", + "href", rootPath + "/libraries", + "type", "application/opds+json;profile=navigation" + ))); + navigation.add(new java.util.LinkedHashMap<>(java.util.Map.of( + "title", "Shelves", + "href", rootPath + "/shelves", + "type", "application/opds+json;profile=navigation" + ))); + + + // Enrich with libraries and shelves for OPDS v2 users + OpdsUserDetails details = authenticationService.getOpdsUser(); + if (details != null && details.getOpdsUserV2() != null) { + Long userId = details.getOpdsUserV2().getUserId(); + BookLoreUserEntity entity = userRepository.findById(userId) + .orElseThrow(() -> new org.springframework.security.access.AccessDeniedException("User not found")); + BookLoreUser user = bookLoreUserTransformer.toDTO(entity); + + java.util.List libraries; + try { + libraries = libraryService.getLibraries(); + } catch (Exception ex) { + libraries = user.getAssignedLibraries(); + } + // Keep root clean: only references to collections; no inline lists + if (libraries != null) { + // optionally keep this empty or add a count in future + } + } + root.put("navigation", navigation); + + return mapper.writeValueAsString(root); + } catch (Exception e) { + log.error("Failed generating OPDS v2 navigation collection", e); + throw new RuntimeException("Failed generating OPDS v2 navigation collection", e); + } + } + + public String generateOpdsV2LibrariesNavigation(HttpServletRequest request) { + try { + ObjectMapper mapper = new ObjectMapper(); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + String rootPath = "/api/v2/opds"; + String selfPath = rootPath + "/libraries"; + var root = new java.util.LinkedHashMap(); + + // metadata + var meta = new java.util.LinkedHashMap(); + meta.put("title", "Libraries"); + root.put("metadata", meta); + + // links + var links = new java.util.ArrayList>(); + links.add(java.util.Map.of( + "rel", "self", + "href", selfPath, + "type", "application/opds+json;profile=navigation" + )); + links.add(java.util.Map.of( + "rel", "start", + "href", rootPath, + "type", "application/opds+json;profile=navigation" + )); + links.add(java.util.Map.of( + "rel", "search", + "href", rootPath + "/search.opds", + "type", "application/opensearchdescription+xml" + )); + root.put("links", links); + + // navigation list of libraries + var navigation = new java.util.ArrayList>(); + + OpdsUserDetails details = authenticationService.getOpdsUser(); + if (details != null && details.getOpdsUserV2() != null) { + Long userId = details.getOpdsUserV2().getUserId(); + BookLoreUserEntity entity = userRepository.findById(userId) + .orElseThrow(() -> new AccessDeniedException("User not found")); + BookLoreUser user = bookLoreUserTransformer.toDTO(entity); + + java.util.List libraries = (user.getPermissions() != null && user.getPermissions().isAdmin()) + ? libraryService.getAllLibraries() + : user.getAssignedLibraries(); + if (libraries != null) { + for (Library lib : libraries) { + navigation.add(new java.util.LinkedHashMap<>(java.util.Map.of( + "title", lib.getName(), + "href", buildHref(rootPath + "/catalog", java.util.Map.of("libraryId", String.valueOf(lib.getId()))), + "type", "application/opds+json;profile=acquisition" + ))); + } + } + } + + root.put("navigation", navigation); + return mapper.writeValueAsString(root); + } catch (Exception e) { + log.error("Failed generating OPDS v2 libraries navigation", e); + throw new RuntimeException("Failed generating OPDS v2 libraries navigation", e); + } + } + + + + public String generateOpdsV2ShelvesNavigation(HttpServletRequest request) { + try { + ObjectMapper mapper = new ObjectMapper(); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + String rootPath = "/api/v2/opds"; + String selfPath = rootPath + "/shelves"; + var root = new java.util.LinkedHashMap(); + + var meta = new java.util.LinkedHashMap(); + meta.put("title", "Shelves"); + root.put("metadata", meta); + + var links = new java.util.ArrayList>(); + links.add(java.util.Map.of("rel", "self", "href", selfPath, "type", "application/opds+json;profile=navigation")); + links.add(java.util.Map.of("rel", "start", "href", rootPath, "type", "application/opds+json;profile=navigation")); + links.add(java.util.Map.of("rel", "search", "href", rootPath + "/search.opds", "type", "application/opensearchdescription+xml")); + root.put("links", links); + + var navigation = new java.util.ArrayList>(); + OpdsUserDetails details = authenticationService.getOpdsUser(); + if (details != null && details.getOpdsUserV2() != null) { + Long userId = details.getOpdsUserV2().getUserId(); + var shelves = shelfRepository.findByUserId(userId); + if (shelves != null) { + for (var shelf : shelves) { + navigation.add(new java.util.LinkedHashMap<>(java.util.Map.of( + "title", shelf.getName(), + "href", buildHref(rootPath + "/catalog", java.util.Map.of("shelfId", String.valueOf(shelf.getId()))), + "type", "application/opds+json;profile=acquisition" + ))); + } + } + } + root.put("navigation", navigation); + return mapper.writeValueAsString(root); + } catch (Exception e) { + log.error("Failed generating OPDS v2 shelves navigation", e); + throw new RuntimeException("Failed generating OPDS v2 shelves navigation", e); + } + } + private List getAllowedBooks(String queryParam) { OpdsUserDetails opdsUserDetails = authenticationService.getOpdsUser(); OpdsUser opdsUser = opdsUserDetails.getOpdsUser(); @@ -92,7 +355,12 @@ public class OpdsService { private String extractVersionFromAcceptHeader(HttpServletRequest request) { var acceptHeader = request.getHeader("Accept"); - return (acceptHeader != null && acceptHeader.contains("version=2.0")) ? "2.0" : "1.2"; + if (acceptHeader == null) return "1.2"; + // Accept either explicit version or generic OPDS 2 media type + if (acceptHeader.contains("version=2.0") || acceptHeader.contains("application/opds+json")) { + return "2.0"; + } + return "1.2"; } private String generateOpdsV1SearchDescription() { @@ -189,14 +457,148 @@ public class OpdsService { } } - private String generateOpdsV2Feed(List books) { - // Placeholder for OPDS v2.0 feed implementation (similar structure as v1) - return "OPDS v2.0 Feed is under construction"; + private String generateOpdsV2Feed(List content, long total, String basePath, java.util.Map queryParams, int page, int size) { + try { + ObjectMapper mapper = new ObjectMapper(); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + // Root collection + var root = new java.util.LinkedHashMap(); + + // metadata with pagination + var meta = new java.util.LinkedHashMap(); + meta.put("title", "Booklore Catalog"); + if (page < 1) page = 1; + if (size < 1) size = 1; + if (size > 200) size = 200; + meta.put("itemsPerPage", size); + meta.put("currentPage", page); + meta.put("numberOfItems", total); + root.put("metadata", meta); + + // links + var links = new java.util.ArrayList>(); + links.add(java.util.Map.of( + "rel", "self", + "href", buildHref(basePath, mergeQuery(queryParams, java.util.Map.of("page", String.valueOf(page), "size", String.valueOf(size)))), + "type", "application/opds+json;profile=acquisition" + )); + links.add(java.util.Map.of( + "rel", "start", + "href", "/api/v2/opds", + "type", "application/opds+json;profile=navigation" + )); + links.add(java.util.Map.of( + "rel", "search", + "href", "/api/v2/opds/search.opds", + "type", "application/opensearchdescription+xml" + )); + if ((page - 1) > 0) { + links.add(java.util.Map.of( + "rel", "previous", + "href", buildHref(basePath, mergeQuery(queryParams, java.util.Map.of("page", String.valueOf(page - 1), "size", String.valueOf(size)))), + "type", "application/opds+json;profile=acquisition" + )); + } + if ((long) page * size < total) { + links.add(java.util.Map.of( + "rel", "next", + "href", buildHref(basePath, mergeQuery(queryParams, java.util.Map.of("page", String.valueOf(page + 1), "size", String.valueOf(size)))), + "type", "application/opds+json;profile=acquisition" + )); + } + root.put("links", links); + + // publications + var pubs = new java.util.ArrayList>(); + for (Book book : content) { + pubs.add(toPublicationMap(book)); + } + root.put("publications", pubs); + + return mapper.writeValueAsString(root); + } catch (Exception e) { + log.error("Failed generating OPDS v2 feed", e); + throw new RuntimeException("Failed generating OPDS v2 feed", e); + } + } + + public String generateOpdsV2Publication(HttpServletRequest request, long bookId) { + List allowed = getAllowedBooks(null); + Book target = allowed.stream().filter(b -> b.getId() != null && b.getId() == bookId).findFirst() + .orElseThrow(() -> new AccessDeniedException("You are not allowed to access this resource")); + try { + ObjectMapper mapper = new ObjectMapper(); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return mapper.writeValueAsString(toPublicationMap(target)); + } catch (Exception e) { + log.error("Failed generating OPDS v2 publication", e); + throw new RuntimeException("Failed generating OPDS v2 publication", e); + } + } + + private java.util.Map toPublicationMap(Book book) { + String base = "/api/v2/opds"; + var pub = new java.util.LinkedHashMap(); + var pm = new java.util.LinkedHashMap(); + String title = (book.getMetadata() != null ? book.getMetadata().getTitle() : book.getTitle()); + pm.put("title", title != null ? title : "Untitled"); + if (book.getMetadata() != null) { + if (book.getMetadata().getLanguage() != null) { + pm.put("language", book.getMetadata().getLanguage()); + } + if (book.getMetadata().getIsbn13() != null) { + pm.put("identifier", "urn:isbn:" + book.getMetadata().getIsbn13()); + } else if (book.getMetadata().getIsbn10() != null) { + pm.put("identifier", "urn:isbn:" + book.getMetadata().getIsbn10()); + } + if (book.getMetadata().getAuthors() != null && !book.getMetadata().getAuthors().isEmpty()) { + var authors = book.getMetadata().getAuthors().stream() + .map(a -> java.util.Map.of("name", a)) + .collect(java.util.stream.Collectors.toList()); + pm.put("author", authors); + } + if (book.getMetadata().getDescription() != null) { + pm.put("description", book.getMetadata().getDescription()); + } + } + pub.put("metadata", pm); + + var plinks = new java.util.ArrayList>(); + String type = "application/" + fileMimeType(book); + plinks.add(new java.util.LinkedHashMap<>(java.util.Map.of( + "rel", "http://opds-spec.org/acquisition/open-access", + "href", base + "/" + book.getId() + "/download", + "type", type + ))); + plinks.add(new java.util.LinkedHashMap<>(java.util.Map.of( + "rel", "self", + "href", base + "/publications/" + book.getId(), + "type", "application/opds-publication+json" + ))); + pub.put("links", plinks); + + if (book.getMetadata() != null && book.getMetadata().getCoverUpdatedOn() != null) { + var images = new java.util.ArrayList>(); + String coverHref = base + "/" + book.getId() + "/cover?" + book.getMetadata().getCoverUpdatedOn(); + images.add(java.util.Map.of("href", coverHref, "type", "image/jpeg")); + pub.put("images", images); + } + return pub; } private String generateOpdsV2SearchDescription() { - // Placeholder for OPDS v2.0 feed implementation (similar structure as v1) - return "OPDS v2.0 Feed is under construction"; + return """ + + + Booklore catalog (OPDS 2) + Search the Booklore ebook catalog. + + en-us + UTF-8 + UTF-8 + + """; } @@ -227,4 +629,107 @@ public class OpdsService { private String extractVersionFromRequest(HttpServletRequest request) { return (request.getRequestURI() != null && request.getRequestURI().startsWith("/api/v2/opds")) ? "v2" : "v1"; } + + private String urlEncode(String value) { + try { + return java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8); + } catch (Exception e) { + return value; + } + } + + private int parseIntParam(HttpServletRequest request, String name, int defaultValue) { + try { + String v = request.getParameter(name); + if (v == null || v.isBlank()) return defaultValue; + return Integer.parseInt(v); + } catch (Exception e) { + return defaultValue; + } + } + + private String buildHref(String basePath, java.util.Map params) { + if (params == null || params.isEmpty()) return basePath; + String query = params.entrySet().stream() + .map(e -> urlEncode(e.getKey()) + "=" + urlEncode(e.getValue())) + .collect(java.util.stream.Collectors.joining("&")); + return basePath + (query.isEmpty() ? "" : ("?" + query)); + } + + private java.util.Map mergeQuery(java.util.Map base, java.util.Map extra) { + var map = new java.util.LinkedHashMap(); + if (base != null) map.putAll(base); + if (extra != null) map.putAll(extra); + return map; + } + + private Long parseLongParam(HttpServletRequest request, String name, Long defaultValue) { + try { + String v = request.getParameter(name); + if (v == null || v.isBlank()) return defaultValue; + return Long.parseLong(v); + } catch (Exception e) { + return defaultValue; + } + } + + private org.springframework.data.domain.Page getAllowedBooksPage(String queryParam, Long libraryId, Long shelfId, int page, int size) { + OpdsUserDetails opdsUserDetails = authenticationService.getOpdsUser(); + OpdsUser opdsUser = opdsUserDetails.getOpdsUser(); + + if (opdsUser != null) { + if (shelfId != null) { + return bookQueryService.getAllBooksByShelfPage(shelfId, true, page, size); + } + if (libraryId != null) { + return bookQueryService.getAllBooksByLibraryIdsPage(java.util.Set.of(libraryId), true, page, size); + } + if (queryParam != null && !queryParam.isBlank()) { + return bookQueryService.searchBooksByMetadataPage(queryParam, page, size); + } + return bookQueryService.getAllBooksPage(true, page, size); + } + + OpdsUserV2 opdsUserV2 = opdsUserDetails.getOpdsUserV2(); + BookLoreUserEntity entity = userRepository.findById(opdsUserV2.getUserId()) + .orElseThrow(() -> new AccessDeniedException("User not found")); + + if (!entity.getPermissions().isPermissionAccessOpds() && !entity.getPermissions().isPermissionAdmin()) { + throw new AccessDeniedException("You are not allowed to access this resource"); + } + + BookLoreUser user = bookLoreUserTransformer.toDTO(entity); + boolean isAdmin = user.getPermissions().isAdmin(); + java.util.Set libraryIds = user.getAssignedLibraries().stream() + .map(Library::getId) + .collect(java.util.stream.Collectors.toSet()); + + if (shelfId != null) { + var shelf = shelfRepository.findById(shelfId).orElseThrow(() -> new AccessDeniedException("Shelf not found")); + if (!shelf.getUser().getId().equals(user.getId()) && !isAdmin) { + throw new AccessDeniedException("You are not allowed to access this shelf"); + } + return bookQueryService.getAllBooksByShelfPage(shelfId, true, page, size); + } + + if (libraryId != null) { + if (!isAdmin && !libraryIds.contains(libraryId)) { + throw new AccessDeniedException("You are not allowed to access this library"); + } + return (queryParam != null && !queryParam.isBlank()) + ? bookQueryService.searchBooksByMetadataInLibrariesPage(queryParam, java.util.Set.of(libraryId), page, size) + : bookQueryService.getAllBooksByLibraryIdsPage(java.util.Set.of(libraryId), true, page, size); + } + + if (isAdmin) { + return (queryParam != null && !queryParam.isBlank()) + ? bookQueryService.searchBooksByMetadataPage(queryParam, page, size) + : bookQueryService.getAllBooksPage(true, page, size); + } + + return (queryParam != null && !queryParam.isBlank()) + ? bookQueryService.searchBooksByMetadataInLibrariesPage(queryParam, libraryIds, page, size) + : bookQueryService.getAllBooksByLibraryIdsPage(libraryIds, true, page, size); + } + } diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/opds/OpdsServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/opds/OpdsServiceTest.java index d1eaafbe6..8206e1d93 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/opds/OpdsServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/opds/OpdsServiceTest.java @@ -35,6 +35,10 @@ class OpdsServiceTest { @Mock private BookLoreUserTransformer bookLoreUserTransformer; @Mock + private com.adityachandel.booklore.service.library.LibraryService libraryService; + @Mock + private com.adityachandel.booklore.repository.ShelfRepository shelfRepository; + @Mock private HttpServletRequest request; @InjectMocks @@ -85,7 +89,7 @@ class OpdsServiceTest { } @Test - void generateCatalogFeed_opdsV2Admin_callsGetAllBooks_and_returnsV2Placeholder() { + void generateCatalogFeed_opdsV2Admin_callsGetAllBooks_and_returnsV2Json() { OpdsUserDetails details = mock(OpdsUserDetails.class); when(authenticationService.getOpdsUser()).thenReturn(details); when(details.getOpdsUser()).thenReturn(null); @@ -108,19 +112,21 @@ class OpdsServiceTest { // Accept header drives v2 selection; do not stub request.getRequestURI() (unused here) when(request.getHeader("Accept")).thenReturn("application/opds+json;version=2.0"); - // stub the backend call exercised by getAllowedBooks for admin + no query - when(bookQueryService.getAllBooks(true)).thenReturn(List.of()); + // stub the backend call exercised by getAllowedBooksPage for admin + no query + when(bookQueryService.getAllBooksPage(true, 1, 50)).thenReturn(new org.springframework.data.domain.PageImpl<>(List.of())); String feed = service.generateCatalogFeed(request); - assertEquals("OPDS v2.0 Feed is under construction", feed); + assertNotNull(feed); + assertTrue(feed.trim().startsWith("{")); + assertTrue(feed.contains("\"publications\"")); verify(userRepository).findById(9L); - verify(bookQueryService).getAllBooks(true); + verify(bookQueryService).getAllBooksPage(true, 1, 50); } @Test - void generateSearchResults_opdsV2_callsSearch_and_returnsV2Placeholder() { + void generateSearchResults_opdsV2_callsSearch_and_returnsV2Json() { // Setup v2 user (admin) so getAllowedBooks will call searchBooksByMetadata(query) OpdsUserDetails details = mock(OpdsUserDetails.class); when(authenticationService.getOpdsUser()).thenReturn(details); @@ -143,14 +149,16 @@ class OpdsServiceTest { // Accept header drives v2 selection; do not stub request.getRequestURI() when(request.getHeader("Accept")).thenReturn("application/opds+json;version=2.0"); - // getAllowedBooks will invoke searchBooksByMetadata for admin + query - when(bookQueryService.searchBooksByMetadata("query")).thenReturn(List.of(mock(Book.class))); + // getAllowedBooksPage will invoke searchBooksByMetadataPage for admin + query + when(bookQueryService.searchBooksByMetadataPage("query", 1, 50)).thenReturn(new org.springframework.data.domain.PageImpl<>(List.of(mock(Book.class)))); String feed = service.generateSearchResults(request, "query"); - assertEquals("OPDS v2.0 Feed is under construction", feed); + assertNotNull(feed); + assertTrue(feed.trim().startsWith("{")); + assertTrue(feed.contains("\"publications\"")); verify(userRepository).findById(9L); - verify(bookQueryService).searchBooksByMetadata("query"); + verify(bookQueryService).searchBooksByMetadataPage("query", 1, 50); } @Test @@ -208,9 +216,79 @@ class OpdsServiceTest { } @Test - void generateSearchDescription_opdsV2_returnsV2Placeholder() { + void generateSearchDescription_opdsV2_returnsOpenSearchXml() { when(request.getHeader("Accept")).thenReturn("application/opds+json;version=2.0"); String desc = service.generateSearchDescription(request); - assertEquals("OPDS v2.0 Feed is under construction", desc); + assertNotNull(desc); + assertTrue(desc.contains(" Date: Fri, 12 Sep 2025 10:41:18 -0600 Subject: [PATCH 08/10] Fix test --- .../adityachandel/booklore/service/opds/OpdsServiceTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/opds/OpdsServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/opds/OpdsServiceTest.java index 8206e1d93..c394788d5 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/opds/OpdsServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/opds/OpdsServiceTest.java @@ -149,6 +149,9 @@ class OpdsServiceTest { // Accept header drives v2 selection; do not stub request.getRequestURI() when(request.getHeader("Accept")).thenReturn("application/opds+json;version=2.0"); + // Stub the request parameter "q" that generateSearchResults reads + when(request.getParameter("q")).thenReturn("query"); + // getAllowedBooksPage will invoke searchBooksByMetadataPage for admin + query when(bookQueryService.searchBooksByMetadataPage("query", 1, 50)).thenReturn(new org.springframework.data.domain.PageImpl<>(List.of(mock(Book.class)))); From 6ea2eb58bc193e3780011ac43bccae8a9fccb69b Mon Sep 17 00:00:00 2001 From: Aditya Chandel <8075870+adityachandelgit@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:59:36 -0600 Subject: [PATCH 09/10] Add auto-move option on metadata update and simplify file monitoring (#1137) - Introduce option to automatically move books when their metadata is updated - Simplify the file monitoring workflow for improved reliability and clarity --- .../controller/FileUploadController.java | 5 +- .../settings/MetadataPersistenceSettings.java | 1 + .../booklore/repository/BookRepository.java | 6 + .../repository/BookdropFileRepository.java | 1 + .../service/AdditionalFileService.java | 42 +- .../booklore/service/BookQueryService.java | 7 + .../booklore/service/BookService.java | 53 +- .../appsettings/SettingPersistenceHelper.java | 1 + .../service/bookdrop/BookDropService.java | 615 ++++++++------ .../bookdrop/BookdropMonitoringService.java | 3 +- .../service/file/FileMoveService.java | 349 ++------ .../service/file/FileMovingHelper.java | 303 +++++++ .../file/MonitoredFileOperationService.java | 147 ++++ .../service/file/UnifiedFileMoveService.java | 184 ++++ .../fileprocessor/AbstractFileProcessor.java | 1 + .../service/metadata/BookMetadataUpdater.java | 78 +- .../MonitoringProtectionService.java | 124 --- .../MonitoringRegistrationService.java | 81 ++ .../service/monitoring/MonitoringService.java | 155 +--- .../service/upload/FileUploadService.java | 321 +++---- .../booklore/util/FileService.java | 36 +- .../booklore/BookServiceDeleteTests.java | 6 +- .../FileMoveServiceMoveFilesTest.java | 792 ------------------ .../booklore/FileMoveServiceTest.java | 4 +- .../service/AdditionalFileServiceTest.java | 334 +++++--- .../service/BookServiceDeleteBooksTest.java | 275 ------ .../MonitoringProtectionConcurrentTest.java | 276 ------ .../MonitoringProtectionIntegrationTest.java | 100 --- ...MonitoringProtectionRaceConditionTest.java | 232 ----- .../service/bookdrop/BookDropServiceTest.java | 421 ++++++++++ .../service/file/FileMoveServiceTest.java | 362 ++++++++ .../service/file/FileMovingHelperTest.java | 306 +++++++ .../MonitoredFileOperationServiceTest.java | 208 +++++ .../file/UnifiedFileMoveServiceTest.java | 210 +++++ .../MonitoringRegistrationServiceTest.java | 140 ++++ .../monitoring/MonitoringServiceTest.java | 288 +++++++ .../service/upload/FileUploadServiceTest.java | 116 ++- ...uplicate-files-notification.component.scss | 4 +- .../live-notification-box.component.scss | 4 +- .../live-task-event-box.component.scss | 5 +- .../src/app/core/model/app-settings.model.ts | 1 + .../layout-topbar/app.topbar.component.scss | 13 +- .../layout-topbar/app.topbar.component.ts | 4 +- .../file-naming-pattern.component.html | 4 +- ...tadata-persistence-settings-component.html | 29 +- ...tadata-persistence-settings-component.scss | 43 + ...metadata-persistence-settings-component.ts | 1 + 47 files changed, 3749 insertions(+), 2942 deletions(-) create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMovingHelper.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/file/MonitoredFileOperationService.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/file/UnifiedFileMoveService.java delete mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringProtectionService.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringRegistrationService.java delete mode 100644 booklore-api/src/test/java/com/adityachandel/booklore/FileMoveServiceMoveFilesTest.java delete mode 100644 booklore-api/src/test/java/com/adityachandel/booklore/service/BookServiceDeleteBooksTest.java delete mode 100644 booklore-api/src/test/java/com/adityachandel/booklore/service/MonitoringProtectionConcurrentTest.java delete mode 100644 booklore-api/src/test/java/com/adityachandel/booklore/service/MonitoringProtectionIntegrationTest.java delete mode 100644 booklore-api/src/test/java/com/adityachandel/booklore/service/MonitoringProtectionRaceConditionTest.java create mode 100644 booklore-api/src/test/java/com/adityachandel/booklore/service/bookdrop/BookDropServiceTest.java create mode 100644 booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMoveServiceTest.java create mode 100644 booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMovingHelperTest.java create mode 100644 booklore-api/src/test/java/com/adityachandel/booklore/service/file/MonitoredFileOperationServiceTest.java create mode 100644 booklore-api/src/test/java/com/adityachandel/booklore/service/file/UnifiedFileMoveServiceTest.java create mode 100644 booklore-api/src/test/java/com/adityachandel/booklore/service/monitoring/MonitoringRegistrationServiceTest.java create mode 100644 booklore-api/src/test/java/com/adityachandel/booklore/service/monitoring/MonitoringServiceTest.java diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/FileUploadController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/FileUploadController.java index 195427b23..1184e8d7a 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/FileUploadController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/FileUploadController.java @@ -22,11 +22,12 @@ public class FileUploadController { @PreAuthorize("@securityUtil.isAdmin() or @securityUtil.canUpload()") @PostMapping(value = "/upload", consumes = "multipart/form-data") - public ResponseEntity uploadFile(@RequestParam("file") MultipartFile file, @RequestParam("libraryId") long libraryId, @RequestParam("pathId") long pathId) throws IOException { + public ResponseEntity uploadFile(@RequestParam("file") MultipartFile file, @RequestParam("libraryId") long libraryId, @RequestParam("pathId") long pathId) throws IOException { if (file.isEmpty()) { throw new IllegalArgumentException("Uploaded file is missing."); } - return ResponseEntity.ok(fileUploadService.uploadFile(file, libraryId, pathId)); + fileUploadService.uploadFile(file, libraryId, pathId); + return ResponseEntity.noContent().build(); } @PreAuthorize("@securityUtil.isAdmin() or @securityUtil.canUpload()") diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/MetadataPersistenceSettings.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/MetadataPersistenceSettings.java index b1dcae25a..c45862740 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/MetadataPersistenceSettings.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/MetadataPersistenceSettings.java @@ -14,4 +14,5 @@ public class MetadataPersistenceSettings { private boolean convertCbrCb7ToCbz; private boolean backupMetadata; private boolean backupCover; + private boolean moveFilesToLibraryPattern; } 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 df05f3feb..32756c6f8 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 @@ -22,6 +22,8 @@ public interface BookRepository extends JpaRepository, JpaSpec Optional findByCurrentHash(String currentHash); + Optional findByCurrentHashAndDeletedTrue(String currentHash); + @Query("SELECT b.id FROM BookEntity b WHERE b.library.id = :libraryId AND (b.deleted IS NULL OR b.deleted = false)") Set findBookIdsByLibraryId(@Param("libraryId") long libraryId); @@ -47,6 +49,10 @@ public interface BookRepository extends JpaRepository, JpaSpec @Query("SELECT b FROM BookEntity b WHERE b.id IN :bookIds AND (b.deleted IS NULL OR b.deleted = false)") List findAllWithMetadataByIds(@Param("bookIds") Set bookIds); + @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"}) + @Query("SELECT b FROM BookEntity b WHERE b.id IN :bookIds AND (b.deleted IS NULL OR b.deleted = false)") + List findWithMetadataByIdsWithPagination(@Param("bookIds") Set bookIds, Pageable pageable); + @EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath"}) @Query("SELECT b FROM BookEntity b WHERE b.library.id = :libraryId AND (b.deleted IS NULL OR b.deleted = false)") List findAllWithMetadataByLibraryId(@Param("libraryId") Long libraryId); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookdropFileRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookdropFileRepository.java index 940332408..2447414bb 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookdropFileRepository.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookdropFileRepository.java @@ -32,3 +32,4 @@ public interface BookdropFileRepository extends JpaRepository findAllExcludingIdsFlat(@Param("excludedIds") List excludedIds); } + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/AdditionalFileService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/AdditionalFileService.java index 9e30dfa54..2a45ffc19 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/AdditionalFileService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/AdditionalFileService.java @@ -1,15 +1,11 @@ package com.adityachandel.booklore.service; -import com.adityachandel.booklore.exception.ApiError; import com.adityachandel.booklore.mapper.AdditionalFileMapper; import com.adityachandel.booklore.model.dto.AdditionalFile; import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; -import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.enums.AdditionalFileType; import com.adityachandel.booklore.repository.BookAdditionalFileRepository; -import com.adityachandel.booklore.repository.BookRepository; -import com.adityachandel.booklore.service.monitoring.MonitoringProtectionService; -import com.adityachandel.booklore.util.FileUtils; +import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.Resource; @@ -19,19 +15,11 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.time.Instant; -import java.util.HexFormat; import java.util.List; -import java.util.Objects; import java.util.Optional; @Slf4j @@ -41,7 +29,7 @@ public class AdditionalFileService { private final BookAdditionalFileRepository additionalFileRepository; private final AdditionalFileMapper additionalFileMapper; - private final MonitoringProtectionService monitoringProtectionService; + private final MonitoringRegistrationService monitoringRegistrationService; public List getAdditionalFilesByBookId(Long bookId) { List entities = additionalFileRepository.findByBookId(bookId); @@ -61,21 +49,18 @@ public class AdditionalFileService { } BookAdditionalFileEntity file = fileOpt.get(); - - monitoringProtectionService.executeWithProtection(() -> { - try { - // Delete physical file - Files.deleteIfExists(file.getFullFilePath()); - log.info("Deleted additional file: {}", file.getFullFilePath()); - // Delete database record - additionalFileRepository.delete(file); - } catch (IOException e) { - log.warn("Failed to delete physical file: {}", file.getFullFilePath(), e); - // Still delete the database record even if file deletion fails - additionalFileRepository.delete(file); - } - }, "additional file deletion"); + try { + monitoringRegistrationService.unregisterSpecificPath(file.getFullFilePath().getParent()); + + Files.deleteIfExists(file.getFullFilePath()); + log.info("Deleted additional file: {}", file.getFullFilePath()); + + additionalFileRepository.delete(file); + } catch (IOException e) { + log.warn("Failed to delete physical file: {}", file.getFullFilePath(), e); + additionalFileRepository.delete(file); + } } public ResponseEntity downloadAdditionalFile(Long fileId) throws IOException { @@ -98,5 +83,4 @@ public class AdditionalFileService { .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getFileName() + "\"") .body(resource); } - } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookQueryService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookQueryService.java index 8780d2a39..58a995f9b 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookQueryService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookQueryService.java @@ -5,6 +5,8 @@ import com.adityachandel.booklore.model.dto.Book; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.repository.BookRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.data.domain.Page; @@ -114,6 +116,11 @@ public class BookQueryService { return bookRepository.findAllWithMetadataByIds(bookIds); } + public List findWithMetadataByIdsWithPagination(Set bookIds, int offset, int limit) { + Pageable pageable = PageRequest.of(offset / limit, limit); + return bookRepository.findWithMetadataByIdsWithPagination(bookIds, pageable); + } + public List getAllFullBookEntities() { return bookRepository.findAllFullBooks(); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java index ca25f4d82..7213e4225 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/BookService.java @@ -15,7 +15,7 @@ import com.adityachandel.booklore.model.enums.BookFileType; import com.adityachandel.booklore.model.enums.ReadStatus; import com.adityachandel.booklore.model.enums.ResetProgressType; import com.adityachandel.booklore.repository.*; -import com.adityachandel.booklore.service.monitoring.MonitoringProtectionService; +import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; import com.adityachandel.booklore.util.FileService; import com.adityachandel.booklore.util.FileUtils; import lombok.AllArgsConstructor; @@ -59,7 +59,7 @@ public class BookService { private final BookQueryService bookQueryService; private final UserProgressService userProgressService; private final BookDownloadService bookDownloadService; - private final MonitoringProtectionService monitoringProtectionService; + private final MonitoringRegistrationService monitoringRegistrationService; private void setBookProgress(Book book, UserBookProgressEntity progress) { @@ -468,8 +468,7 @@ public class BookService { if (Files.exists(coverPath)) { return new UrlResource(coverPath.toUri()); } else { - Path defaultCover = Paths.get("static/images/missing-cover.jpg"); - return new UrlResource(defaultCover.toUri()); + return new ClassPathResource("static/images/missing-cover.jpg"); } } catch (MalformedURLException e) { throw new RuntimeException("Failed to load book cover for bookId=" + bookId, e); @@ -504,35 +503,33 @@ public class BookService { public ResponseEntity deleteBooks(Set ids) { List books = bookQueryService.findAllWithMetadataByIds(ids); List failedFileDeletions = new ArrayList<>(); + for (BookEntity book : books) { + Path fullFilePath = book.getFullFilePath(); + try { + if (Files.exists(fullFilePath)) { + monitoringRegistrationService.unregisterSpecificPath(fullFilePath.getParent()); + Files.delete(fullFilePath); + log.info("Deleted book file: {}", fullFilePath); - return monitoringProtectionService.executeWithProtection(() -> { - for (BookEntity book : books) { - Path fullFilePath = book.getFullFilePath(); - try { - if (Files.exists(fullFilePath)) { - Files.delete(fullFilePath); - log.info("Deleted book file: {}", fullFilePath); + Set libraryRoots = book.getLibrary().getLibraryPaths().stream() + .map(LibraryPathEntity::getPath) + .map(Paths::get) + .map(Path::normalize) + .collect(Collectors.toSet()); - Set libraryRoots = book.getLibrary().getLibraryPaths().stream() - .map(LibraryPathEntity::getPath) - .map(Paths::get) - .map(Path::normalize) - .collect(Collectors.toSet()); - - deleteEmptyParentDirsUpToLibraryFolders(fullFilePath.getParent(), libraryRoots); - } - } catch (IOException e) { - log.warn("Failed to delete book file: {}", fullFilePath, e); - failedFileDeletions.add(book.getId()); + deleteEmptyParentDirsUpToLibraryFolders(fullFilePath.getParent(), libraryRoots); } + } catch (IOException e) { + log.warn("Failed to delete book file: {}", fullFilePath, e); + failedFileDeletions.add(book.getId()); } + } - bookRepository.deleteAll(books); - BookDeletionResponse response = new BookDeletionResponse(ids, failedFileDeletions); - return failedFileDeletions.isEmpty() - ? ResponseEntity.ok(response) - : ResponseEntity.status(HttpStatus.MULTI_STATUS).body(response); - }, "book deletion"); + bookRepository.deleteAll(books); + BookDeletionResponse response = new BookDeletionResponse(ids, failedFileDeletions); + return failedFileDeletions.isEmpty() + ? ResponseEntity.ok(response) + : ResponseEntity.status(HttpStatus.MULTI_STATUS).body(response); } public void deleteEmptyParentDirsUpToLibraryFolders(Path currentDir, Set libraryRoots) throws IOException { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java index c47664e05..c4af60d6f 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java @@ -186,6 +186,7 @@ public class SettingPersistenceHelper { .convertCbrCb7ToCbz(false) .backupMetadata(false) .backupCover(false) + .moveFilesToLibraryPattern(false) .build(); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookDropService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookDropService.java index b10e02f10..b949cad22 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookDropService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookDropService.java @@ -4,7 +4,6 @@ import com.adityachandel.booklore.config.AppProperties; import com.adityachandel.booklore.exception.ApiError; import com.adityachandel.booklore.mapper.BookdropFileMapper; import com.adityachandel.booklore.model.FileProcessResult; -import com.adityachandel.booklore.model.dto.Book; import com.adityachandel.booklore.model.dto.BookMetadata; import com.adityachandel.booklore.model.dto.BookdropFile; import com.adityachandel.booklore.model.dto.BookdropFileNotification; @@ -23,17 +22,14 @@ import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.repository.BookdropFileRepository; import com.adityachandel.booklore.repository.LibraryRepository; import com.adityachandel.booklore.service.NotificationService; -import com.adityachandel.booklore.service.appsettings.AppSettingService; +import com.adityachandel.booklore.service.file.FileMovingHelper; import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor; import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry; import com.adityachandel.booklore.service.metadata.MetadataRefreshService; -import com.adityachandel.booklore.service.monitoring.MonitoringProtectionService; import com.adityachandel.booklore.util.FileUtils; -import com.adityachandel.booklore.util.PathPatternResolver; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.io.FilenameUtils; import org.springframework.core.io.PathResource; import org.springframework.core.io.Resource; import org.springframework.data.domain.Page; @@ -45,6 +41,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.time.Instant; import java.util.Comparator; import java.util.List; @@ -63,7 +60,6 @@ public class BookDropService { private final BookdropFileRepository bookdropFileRepository; private final LibraryRepository libraryRepository; private final BookRepository bookRepository; - private final MonitoringProtectionService monitoringProtectionService; private final BookdropMonitoringService bookdropMonitoringService; private final NotificationService notificationService; private final MetadataRefreshService metadataRefreshService; @@ -72,7 +68,9 @@ public class BookDropService { private final AppProperties appProperties; private final BookdropFileMapper mapper; private final ObjectMapper objectMapper; - AppSettingService appSettingService; + private final FileMovingHelper fileMovingHelper; + + private static final int CHUNK_SIZE = 100; public BookdropFileNotification getFileNotificationSummary() { long pendingCount = bookdropFileRepository.countByStatus(BookdropFileEntity.Status.PENDING_REVIEW); @@ -88,140 +86,169 @@ public class BookDropService { } } - public BookdropFinalizeResult finalizeImport(BookdropFinalizeRequest request) { - return monitoringProtectionService.executeWithProtection(() -> { - try { - bookdropMonitoringService.pauseMonitoring(); - - BookdropFinalizeResult results = BookdropFinalizeResult.builder() - .processedAt(Instant.now()) - .build(); - Long defaultLibraryId = request.getDefaultLibraryId(); - Long defaultPathId = request.getDefaultPathId(); - - Map metadataById = Optional.ofNullable(request.getFiles()) - .orElse(List.of()) - .stream() - .collect(Collectors.toMap(BookdropFinalizeRequest.BookdropFinalizeFile::getFileId, Function.identity())); - - final int CHUNK_SIZE = 100; - AtomicInteger failedCount = new AtomicInteger(); - AtomicInteger totalFilesProcessed = new AtomicInteger(); - - log.info("Starting finalizeImport: selectAll={}, provided file count={}, defaultLibraryId={}, defaultPathId={}", - request.getSelectAll(), metadataById.size(), defaultLibraryId, defaultPathId); - - if (Boolean.TRUE.equals(request.getSelectAll())) { - List excludedIds = Optional.ofNullable(request.getExcludedIds()).orElse(List.of()); - - List allIds = bookdropFileRepository.findAllExcludingIdsFlat(excludedIds); - log.info("SelectAll: Total files to finalize (after exclusions): {}, Excluded IDs: {}", allIds.size(), excludedIds); - - for (int i = 0; i < allIds.size(); i += CHUNK_SIZE) { - int end = Math.min(i + CHUNK_SIZE, allIds.size()); - List chunk = allIds.subList(i, end); - - log.info("Processing chunk {}/{} ({} files): IDs={}", (i / CHUNK_SIZE + 1), (int) Math.ceil((double) allIds.size() / CHUNK_SIZE), chunk.size(), chunk); - - List chunkFiles = bookdropFileRepository.findAllById(chunk); - Map fileMap = chunkFiles.stream().collect(Collectors.toMap(BookdropFileEntity::getId, Function.identity())); - - for (Long id : chunk) { - BookdropFileEntity file = fileMap.get(id); - if (file == null) { - log.warn("File ID {} missing in DB during finalizeImport chunk processing", id); - failedCount.incrementAndGet(); - totalFilesProcessed.incrementAndGet(); - continue; - } - processFile(file, metadataById.get(id), defaultLibraryId, defaultPathId, results, failedCount); - totalFilesProcessed.incrementAndGet(); - } - } - } else { - List ids = Optional.ofNullable(request.getFiles()) - .orElse(List.of()) - .stream() - .map(BookdropFinalizeRequest.BookdropFinalizeFile::getFileId) - .toList(); - - log.info("Processing {} manually selected files in chunks of {}. File IDs: {}", ids.size(), CHUNK_SIZE, ids); - - for (int i = 0; i < ids.size(); i += CHUNK_SIZE) { - int end = Math.min(i + CHUNK_SIZE, ids.size()); - List chunkIds = ids.subList(i, end); - List chunkFiles = bookdropFileRepository.findAllById(chunkIds); - - log.info("Processing chunk {} of {} ({} files): IDs={}", (i / CHUNK_SIZE + 1), (int) Math.ceil((double) ids.size() / CHUNK_SIZE), chunkFiles.size(), chunkIds); - - Map fileMap = chunkFiles.stream() - .collect(Collectors.toMap(BookdropFileEntity::getId, Function.identity())); - - for (Long id : chunkIds) { - BookdropFileEntity file = fileMap.get(id); - if (file == null) { - log.error("File ID {} not found in DB during finalizeImport chunk processing", id); - failedCount.incrementAndGet(); - totalFilesProcessed.incrementAndGet(); - continue; - } - processFile(file, metadataById.get(id), defaultLibraryId, defaultPathId, results, failedCount); - totalFilesProcessed.incrementAndGet(); - } - } - } - - results.setTotalFiles(totalFilesProcessed.get()); - results.setFailed(failedCount.get()); - results.setSuccessfullyImported(totalFilesProcessed.get() - failedCount.get()); - - log.info("Finalization complete. Success: {}, Failed: {}, Total processed: {}", - results.getSuccessfullyImported(), - results.getFailed(), - results.getTotalFiles()); - - return results; - - } finally { - bookdropMonitoringService.resumeMonitoring(); - } - }, "bookdrop finalize import"); + public Resource getBookdropCover(long bookdropId) { + String coverPath = Paths.get(appProperties.getPathConfig(), "bookdrop_temp", bookdropId + ".jpg").toString(); + File coverFile = new File(coverPath); + if (coverFile.exists() && coverFile.isFile()) { + return new PathResource(coverFile.toPath()); + } else { + return null; + } } - private void processFile( - BookdropFileEntity fileEntity, - BookdropFinalizeRequest.BookdropFinalizeFile fileReq, - Long defaultLibraryId, - Long defaultPathId, - BookdropFinalizeResult results, - AtomicInteger failedCount - ) { + public BookdropFinalizeResult finalizeImport(BookdropFinalizeRequest request) { try { - Long libraryId; - Long pathId; - BookMetadata metadata; + bookdropMonitoringService.pauseMonitoring(); + return processFinalizationRequest(request); + } finally { + bookdropMonitoringService.resumeMonitoring(); + log.info("Bookdrop monitoring resumed"); + } + } - if (fileReq != null) { - libraryId = fileReq.getLibraryId() != null ? fileReq.getLibraryId() : defaultLibraryId; - pathId = fileReq.getPathId() != null ? fileReq.getPathId() : defaultPathId; - metadata = fileReq.getMetadata(); - log.debug("Processing fileId={}, fileName={} with provided metadata, libraryId={}, pathId={}", fileEntity.getId(), fileEntity.getFileName(), libraryId, pathId); - } else { - if (defaultLibraryId == null || defaultPathId == null) { - log.warn("Missing default metadata for fileId={}", fileEntity.getId()); - throw ApiError.GENERIC_BAD_REQUEST.createException("Missing metadata and defaults for fileId=" + fileEntity.getId()); - } + public void discardSelectedFiles(boolean selectAll, List excludedIds, List selectedIds) { + bookdropMonitoringService.pauseMonitoring(); + Path bookdropPath = Path.of(appProperties.getBookdropFolder()); - metadata = fileEntity.getFetchedMetadata() != null - ? objectMapper.readValue(fileEntity.getFetchedMetadata(), BookMetadata.class) - : objectMapper.readValue(fileEntity.getOriginalMetadata(), BookMetadata.class); + AtomicInteger deletedFiles = new AtomicInteger(); + AtomicInteger deletedDirs = new AtomicInteger(); + AtomicInteger deletedCovers = new AtomicInteger(); - libraryId = defaultLibraryId; - pathId = defaultPathId; - log.debug("Processing fileId={}, fileName={} with default metadata, libraryId={}, pathId={}", fileEntity.getId(), fileEntity.getFileName(), libraryId, pathId); + try { + if (!Files.exists(bookdropPath)) { + log.info("Bookdrop folder does not exist: {}", bookdropPath); + return; } - BookdropFileResult result = moveFile(libraryId, pathId, metadata, fileEntity); + List filesToDelete = getFilesToDelete(selectAll, excludedIds, selectedIds); + deleteFilesAndCovers(filesToDelete, deletedFiles, deletedCovers); + deleteEmptyDirectories(bookdropPath, deletedDirs); + + bookdropFileRepository.deleteAllById(filesToDelete.stream().map(BookdropFileEntity::getId).toList()); + log.info("Deleted {} bookdrop DB entries", filesToDelete.size()); + + bookdropNotificationService.sendBookdropFileSummaryNotification(); + log.info("Bookdrop cleanup summary: deleted {} files, {} folders, {} DB entries, {} covers", + deletedFiles.get(), deletedDirs.get(), filesToDelete.size(), deletedCovers.get()); + + } finally { + bookdropMonitoringService.resumeMonitoring(); + log.info("Bookdrop monitoring resumed after cleanup (library monitoring unaffected)"); + } + } + + private BookdropFinalizeResult processFinalizationRequest(BookdropFinalizeRequest request) { + BookdropFinalizeResult results = BookdropFinalizeResult.builder() + .processedAt(Instant.now()) + .build(); + + Long defaultLibraryId = request.getDefaultLibraryId(); + Long defaultPathId = request.getDefaultPathId(); + Map metadataById = getMetadataMap(request); + + AtomicInteger failedCount = new AtomicInteger(); + AtomicInteger totalFilesProcessed = new AtomicInteger(); + + log.info("Starting finalizeImport: selectAll={}, provided file count={}, defaultLibraryId={}, defaultPathId={}", request.getSelectAll(), metadataById.size(), defaultLibraryId, defaultPathId); + + if (Boolean.TRUE.equals(request.getSelectAll())) { + processAllFiles(request, metadataById, defaultLibraryId, defaultPathId, results, failedCount, totalFilesProcessed); + } else { + processSelectedFiles(request, metadataById, defaultLibraryId, defaultPathId, results, failedCount, totalFilesProcessed); + } + + updateFinalResults(results, totalFilesProcessed, failedCount); + return results; + } + + private Map getMetadataMap(BookdropFinalizeRequest request) { + return Optional.ofNullable(request.getFiles()) + .orElse(List.of()) + .stream() + .collect(Collectors.toMap(BookdropFinalizeRequest.BookdropFinalizeFile::getFileId, Function.identity())); + } + + private void processAllFiles(BookdropFinalizeRequest request, + Map metadataById, + Long defaultLibraryId, + Long defaultPathId, + BookdropFinalizeResult results, + AtomicInteger failedCount, + AtomicInteger totalFilesProcessed) { + List excludedIds = Optional.ofNullable(request.getExcludedIds()).orElse(List.of()); + List allIds = bookdropFileRepository.findAllExcludingIdsFlat(excludedIds); + log.info("SelectAll: Total files to finalize (after exclusions): {}, Excluded IDs: {}", allIds.size(), excludedIds); + + processFileChunks(allIds, metadataById, defaultLibraryId, defaultPathId, results, failedCount, totalFilesProcessed); + } + + private void processSelectedFiles(BookdropFinalizeRequest request, + Map metadataById, + Long defaultLibraryId, + Long defaultPathId, + BookdropFinalizeResult results, + AtomicInteger failedCount, + AtomicInteger totalFilesProcessed) { + List ids = Optional.ofNullable(request.getFiles()) + .orElse(List.of()) + .stream() + .map(BookdropFinalizeRequest.BookdropFinalizeFile::getFileId) + .toList(); + + log.info("Processing {} manually selected files in chunks of {}. File IDs: {}", ids.size(), CHUNK_SIZE, ids); + processFileChunks(ids, metadataById, defaultLibraryId, defaultPathId, results, failedCount, totalFilesProcessed); + } + + private void processFileChunks(List ids, + Map metadataById, + Long defaultLibraryId, + Long defaultPathId, + BookdropFinalizeResult results, + AtomicInteger failedCount, + AtomicInteger totalFilesProcessed) { + for (int i = 0; i < ids.size(); i += CHUNK_SIZE) { + int end = Math.min(i + CHUNK_SIZE, ids.size()); + List chunk = ids.subList(i, end); + + log.info("Processing chunk {}/{} ({} files): IDs={}", (i / CHUNK_SIZE + 1), (int) Math.ceil((double) ids.size() / CHUNK_SIZE), chunk.size(), chunk); + + List chunkFiles = bookdropFileRepository.findAllById(chunk); + Map fileMap = chunkFiles.stream().collect(Collectors.toMap(BookdropFileEntity::getId, Function.identity())); + + for (Long id : chunk) { + BookdropFileEntity file = fileMap.get(id); + if (file == null) { + log.warn("File ID {} missing in DB during finalizeImport chunk processing", id); + failedCount.incrementAndGet(); + totalFilesProcessed.incrementAndGet(); + continue; + } + processFile(file, metadataById.get(id), defaultLibraryId, defaultPathId, results, failedCount); + totalFilesProcessed.incrementAndGet(); + } + } + } + + private void updateFinalResults(BookdropFinalizeResult results, AtomicInteger totalFilesProcessed, AtomicInteger failedCount) { + results.setTotalFiles(totalFilesProcessed.get()); + results.setFailed(failedCount.get()); + results.setSuccessfullyImported(totalFilesProcessed.get() - failedCount.get()); + + log.info("Finalization complete. Success: {}, Failed: {}, Total processed: {}", + results.getSuccessfullyImported(), + results.getFailed(), + results.getTotalFiles()); + } + + private void processFile(BookdropFileEntity fileEntity, + BookdropFinalizeRequest.BookdropFinalizeFile fileReq, + Long defaultLibraryId, + Long defaultPathId, + BookdropFinalizeResult results, + AtomicInteger failedCount) { + try { + FileProcessingContext context = prepareFileProcessingContext(fileEntity, fileReq, defaultLibraryId, defaultPathId); + BookdropFileResult result = moveFile(context.libraryId, context.pathId, context.metadata, fileEntity); results.getResults().add(result); if (!result.isSuccess()) { @@ -239,7 +266,38 @@ public class BookDropService { } } - private BookdropFileResult moveFile(long libraryId, long pathId, BookMetadata metadata, BookdropFileEntity bookdropFile) throws Exception { + private FileProcessingContext prepareFileProcessingContext(BookdropFileEntity fileEntity, + BookdropFinalizeRequest.BookdropFinalizeFile fileReq, + Long defaultLibraryId, + Long defaultPathId) throws Exception { + Long libraryId; + Long pathId; + BookMetadata metadata; + + if (fileReq != null) { + libraryId = fileReq.getLibraryId() != null ? fileReq.getLibraryId() : defaultLibraryId; + pathId = fileReq.getPathId() != null ? fileReq.getPathId() : defaultPathId; + metadata = fileReq.getMetadata(); + log.debug("Processing fileId={}, fileName={} with provided metadata, libraryId={}, pathId={}", fileEntity.getId(), fileEntity.getFileName(), libraryId, pathId); + } else { + if (defaultLibraryId == null || defaultPathId == null) { + log.warn("Missing default metadata for fileId={}", fileEntity.getId()); + throw ApiError.GENERIC_BAD_REQUEST.createException("Missing metadata and defaults for fileId=" + fileEntity.getId()); + } + + metadata = fileEntity.getFetchedMetadata() != null + ? objectMapper.readValue(fileEntity.getFetchedMetadata(), BookMetadata.class) + : objectMapper.readValue(fileEntity.getOriginalMetadata(), BookMetadata.class); + + libraryId = defaultLibraryId; + pathId = defaultPathId; + log.debug("Processing fileId={}, fileName={} with default metadata, libraryId={}, pathId={}", fileEntity.getId(), fileEntity.getFileName(), libraryId, pathId); + } + + return new FileProcessingContext(libraryId, pathId, metadata); + } + + private BookdropFileResult moveFile(long libraryId, long pathId, BookMetadata metadata, BookdropFileEntity bookdropFile) { LibraryEntity library = libraryRepository.findById(libraryId) .orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId)); @@ -248,22 +306,12 @@ public class BookDropService { .findFirst() .orElseThrow(() -> ApiError.INVALID_LIBRARY_PATH.createException(libraryId)); - String filePattern = library.getFileNamingPattern(); - if (filePattern == null || filePattern.isBlank()) { - filePattern = appSettingService.getAppSettings().getUploadPattern(); - } - - if (filePattern.endsWith("/") || filePattern.endsWith("\\")) { - filePattern += "{currentFilename}"; - } - - String relativePath = PathPatternResolver.resolvePattern(metadata, filePattern, FilenameUtils.getName(bookdropFile.getFilePath())); + String filePattern = fileMovingHelper.getFileNamingPattern(library); Path source = Path.of(bookdropFile.getFilePath()); - Path target = Paths.get(path.getPath(), relativePath); + Path target = fileMovingHelper.generateNewFilePath(path.getPath(), metadata, filePattern, bookdropFile.getFilePath()); File targetFile = target.toFile(); - log.debug("Preparing to move file id={}, name={}, source={}, target={}, library={}, path={}", - bookdropFile.getId(), bookdropFile.getFileName(), source, target, library.getName(), path.getPath()); + log.debug("Preparing to move file id={}, name={}, source={}, target={}, library={}, path={}", bookdropFile.getId(), bookdropFile.getFileName(), source, target, library.getName(), path.getPath()); if (!Files.exists(source)) { bookdropFileRepository.deleteById(bookdropFile.getId()); @@ -277,64 +325,93 @@ public class BookDropService { return failureResult(targetFile.getName(), "File already exists in the library '" + library.getName() + "'"); } - return monitoringProtectionService.executeWithProtection(() -> { - try { - Files.createDirectories(target.getParent()); - Files.move(source, target); - - log.info("Moved file id={}, name={} from '{}' to '{}'", bookdropFile.getId(), bookdropFile.getFileName(), source, target); - - FileProcessResult fileProcessResult = processFile(targetFile.getName(), library, path, targetFile, - BookFileExtension.fromFileName(bookdropFile.getFileName()) - .orElseThrow(() -> ApiError.INVALID_FILE_FORMAT.createException("Unsupported file extension")) - .getType()); - - BookEntity bookEntity = bookRepository.findById(fileProcessResult.getBook().getId()) - .orElseThrow(() -> ApiError.FILE_NOT_FOUND.createException("Book ID missing after import")); - - notificationService.sendMessage(Topic.BOOK_ADD, fileProcessResult.getStatus()); - metadataRefreshService.updateBookMetadata(bookEntity, metadata, metadata.getThumbnailUrl() != null, false); - bookdropFileRepository.deleteById(bookdropFile.getId()); - bookdropNotificationService.sendBookdropFileSummaryNotification(); - - File cachedCover = Paths.get(appProperties.getPathConfig(), "bookdrop_temp", bookdropFile.getId() + ".jpg").toFile(); - if (cachedCover.exists()) { - boolean deleted = cachedCover.delete(); - log.debug("Deleted cached cover image for bookdropId={}: {}", bookdropFile.getId(), deleted); - } - - log.info("File import completed: id={}, name={}, library={}, path={}", bookdropFile.getId(), targetFile.getName(), library.getName(), path.getPath()); - - return BookdropFileResult.builder() - .fileName(targetFile.getName()) - .message("File successfully imported into the '" + library.getName() + "' library from the Bookdrop folder") - .success(true) - .build(); - - } catch (Exception e) { - log.error("Failed to move file id={}, name={} from '{}' to '{}': {}", bookdropFile.getId(), bookdropFile.getFileName(), source, target, e.getMessage(), e); - try { - if (Files.exists(target)) { - Files.deleteIfExists(target); - log.info("Cleaned up partially created target file: {}", target); - } - } catch (Exception cleanupException) { - log.warn("Failed to cleanup target file after move error: {}", target, cleanupException); - } - return failureResult(bookdropFile.getFileName(), "Failed to move file: " + e.getMessage()); - } - }, "bookdrop file move"); + return performFileMove(bookdropFile, source, target, library, path, metadata); } - private BookdropFileResult failureResult(String fileName, String message) { + private BookdropFileResult performFileMove(BookdropFileEntity bookdropFile, Path source, Path target, + LibraryEntity library, LibraryPathEntity path, BookMetadata metadata) { + Path tempPath = null; + try { + tempPath = Files.createTempFile("bookdrop-finalize-", bookdropFile.getFileName()); + Files.copy(source, tempPath, StandardCopyOption.REPLACE_EXISTING); + + Files.createDirectories(target.getParent()); + Files.move(tempPath, target, StandardCopyOption.REPLACE_EXISTING); + Files.deleteIfExists(source); + + log.info("Moved file id={}, name={} from '{}' to '{}'", bookdropFile.getId(), bookdropFile.getFileName(), source, target); + + return processMovedFile(bookdropFile, target.toFile(), library, path, metadata); + + } catch (Exception e) { + log.error("Failed to move file id={}, name={} from '{}' to '{}': {}", bookdropFile.getId(), bookdropFile.getFileName(), source, target, e.getMessage(), e); + cleanupFailedMove(target); + return failureResult(bookdropFile.getFileName(), "Failed to move file: " + e.getMessage()); + } finally { + cleanupTempFile(tempPath); + } + } + + private BookdropFileResult processMovedFile(BookdropFileEntity bookdropFile, + File targetFile, + LibraryEntity library, + LibraryPathEntity path, + BookMetadata metadata) throws Exception { + FileProcessResult fileProcessResult = processFileInLibrary(targetFile.getName(), library, path, targetFile, + BookFileExtension.fromFileName(bookdropFile.getFileName()) + .orElseThrow(() -> ApiError.INVALID_FILE_FORMAT.createException("Unsupported file extension")) + .getType()); + + BookEntity bookEntity = bookRepository.findById(fileProcessResult.getBook().getId()) + .orElseThrow(() -> ApiError.FILE_NOT_FOUND.createException("Book ID missing after import")); + + notificationService.sendMessage(Topic.BOOK_ADD, fileProcessResult.getBook()); + metadataRefreshService.updateBookMetadata(bookEntity, metadata, metadata.getThumbnailUrl() != null, false); + + cleanupBookdropData(bookdropFile); + + log.info("File import completed: id={}, name={}, library={}, path={}", bookdropFile.getId(), targetFile.getName(), library.getName(), path.getPath()); + return BookdropFileResult.builder() - .fileName(fileName) - .message(message) - .success(false) + .fileName(targetFile.getName()) + .message("File successfully imported into the '" + library.getName() + "' library from the Bookdrop folder") + .success(true) .build(); } - private FileProcessResult processFile(String fileName, LibraryEntity library, LibraryPathEntity path, File file, BookFileType type) { + private void cleanupBookdropData(BookdropFileEntity bookdropFile) { + bookdropFileRepository.deleteById(bookdropFile.getId()); + bookdropNotificationService.sendBookdropFileSummaryNotification(); + + File cachedCover = Paths.get(appProperties.getPathConfig(), "bookdrop_temp", bookdropFile.getId() + ".jpg").toFile(); + if (cachedCover.exists()) { + boolean deleted = cachedCover.delete(); + log.debug("Deleted cached cover image for bookdropId={}: {}", bookdropFile.getId(), deleted); + } + } + + private void cleanupFailedMove(Path target) { + try { + if (Files.exists(target)) { + Files.deleteIfExists(target); + log.info("Cleaned up partially created target file: {}", target); + } + } catch (Exception cleanupException) { + log.warn("Failed to cleanup target file after move error: {}", target, cleanupException); + } + } + + private void cleanupTempFile(Path tempPath) { + if (tempPath != null) { + try { + Files.deleteIfExists(tempPath); + } catch (Exception e) { + log.warn("Failed to cleanup temp file: {}", tempPath, e); + } + } + } + + private FileProcessResult processFileInLibrary(String fileName, LibraryEntity library, LibraryPathEntity path, File file, BookFileType type) { LibraryFile libraryFile = LibraryFile.builder() .libraryEntity(library) .libraryPathEntity(path) @@ -347,87 +424,67 @@ public class BookDropService { return processor.processFile(libraryFile); } - public void discardSelectedFiles(boolean selectAll, List excludedIds, List selectedIds) { - bookdropMonitoringService.pauseMonitoring(); - Path bookdropPath = Path.of(appProperties.getBookdropFolder()); - - AtomicInteger deletedFiles = new AtomicInteger(); - AtomicInteger deletedDirs = new AtomicInteger(); - AtomicInteger deletedCovers = new AtomicInteger(); - - try { - if (!Files.exists(bookdropPath)) { - log.info("Bookdrop folder does not exist: {}", bookdropPath); - return; - } - - List filesToDelete; - if (selectAll) { - filesToDelete = bookdropFileRepository.findAll().stream() - .filter(f -> excludedIds == null || !excludedIds.contains(f.getId())) - .toList(); - log.info("Discarding all files except excluded IDs: {}", excludedIds); - } else { - filesToDelete = bookdropFileRepository.findAllById(selectedIds == null ? List.of() : selectedIds); - log.info("Discarding selected files: {}", selectedIds); - } - - for (BookdropFileEntity entity : filesToDelete) { - try { - Path filePath = Path.of(entity.getFilePath()); - if (Files.exists(filePath) && Files.isRegularFile(filePath) && Files.deleteIfExists(filePath)) { - deletedFiles.incrementAndGet(); - log.debug("Deleted file from disk: id={}, path={}", entity.getId(), filePath); - } - Path coverPath = Paths.get(appProperties.getPathConfig(), "bookdrop_temp", entity.getId() + ".jpg"); - if (Files.exists(coverPath) && Files.deleteIfExists(coverPath)) { - deletedCovers.incrementAndGet(); - log.debug("Deleted cover image: id={}, path={}", entity.getId(), coverPath); - } - } catch (IOException e) { - log.warn("Failed to delete file or cover for bookdropId={}: {}", entity.getId(), e.getMessage()); - } - } - - bookdropFileRepository.deleteAllById(filesToDelete.stream().map(BookdropFileEntity::getId).toList()); - log.info("Deleted {} bookdrop DB entries", filesToDelete.size()); - - try (Stream paths = Files.walk(bookdropPath)) { - paths.sorted(Comparator.reverseOrder()) - .filter(p -> !p.equals(bookdropPath) && Files.isDirectory(p)) - .forEach(p -> { - try (Stream subPaths = Files.list(p)) { - if (subPaths.findAny().isEmpty()) { - Files.deleteIfExists(p); - deletedDirs.incrementAndGet(); - log.debug("Deleted empty directory: {}", p); - } - } catch (IOException e) { - log.warn("Failed to delete folder: {}", p, e); - } - }); - } catch (IOException e) { - log.warn("Failed to scan bookdrop folder for empty directories", e); - } - - bookdropNotificationService.sendBookdropFileSummaryNotification(); - log.info("Bookdrop cleanup summary: deleted {} files, {} folders, {} DB entries, {} covers", - deletedFiles.get(), deletedDirs.get(), filesToDelete.size(), deletedCovers.get()); - - } finally { - bookdropMonitoringService.resumeMonitoring(); - log.info("Bookdrop monitoring resumed after cleanup"); - } - } - - public Resource getBookdropCover(long bookdropId) { - String coverPath = Paths.get(appProperties.getPathConfig(), "bookdrop_temp", bookdropId + ".jpg").toString(); - File coverFile = new File(coverPath); - if (coverFile.exists() && coverFile.isFile()) { - return new PathResource(coverFile.toPath()); + private List getFilesToDelete(boolean selectAll, List excludedIds, List selectedIds) { + if (selectAll) { + List filesToDelete = bookdropFileRepository.findAll().stream() + .filter(f -> excludedIds == null || !excludedIds.contains(f.getId())) + .toList(); + log.info("Discarding all files except excluded IDs: {}", excludedIds); + return filesToDelete; } else { - return null; + List filesToDelete = bookdropFileRepository.findAllById(selectedIds == null ? List.of() : selectedIds); + log.info("Discarding selected files: {}", selectedIds); + return filesToDelete; } } -} + private void deleteFilesAndCovers(List filesToDelete, AtomicInteger deletedFiles, AtomicInteger deletedCovers) { + for (BookdropFileEntity entity : filesToDelete) { + try { + Path filePath = Path.of(entity.getFilePath()); + if (Files.exists(filePath) && Files.isRegularFile(filePath) && Files.deleteIfExists(filePath)) { + deletedFiles.incrementAndGet(); + log.debug("Deleted file from disk: id={}, path={}", entity.getId(), filePath); + } + Path coverPath = Paths.get(appProperties.getPathConfig(), "bookdrop_temp", entity.getId() + ".jpg"); + if (Files.exists(coverPath) && Files.deleteIfExists(coverPath)) { + deletedCovers.incrementAndGet(); + log.debug("Deleted cover image: id={}, path={}", entity.getId(), coverPath); + } + } catch (IOException e) { + log.warn("Failed to delete file or cover for bookdropId={}: {}", entity.getId(), e.getMessage()); + } + } + } + + private void deleteEmptyDirectories(Path bookdropPath, AtomicInteger deletedDirs) { + try (Stream paths = Files.walk(bookdropPath)) { + paths.sorted(Comparator.reverseOrder()) + .filter(p -> !p.equals(bookdropPath) && Files.isDirectory(p)) + .forEach(p -> { + try (Stream subPaths = Files.list(p)) { + if (subPaths.findAny().isEmpty()) { + Files.deleteIfExists(p); + deletedDirs.incrementAndGet(); + log.debug("Deleted empty directory: {}", p); + } + } catch (IOException e) { + log.warn("Failed to delete folder: {}", p, e); + } + }); + } catch (IOException e) { + log.warn("Failed to scan bookdrop folder for empty directories", e); + } + } + + private BookdropFileResult failureResult(String fileName, String message) { + return BookdropFileResult.builder() + .fileName(fileName) + .message(message) + .success(false) + .build(); + } + + private record FileProcessingContext(Long libraryId, Long pathId, BookMetadata metadata) { + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropMonitoringService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropMonitoringService.java index 52fd2c6cf..667203a24 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropMonitoringService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropMonitoringService.java @@ -93,8 +93,7 @@ public class BookdropMonitoringService { try { watchKey = bookdrop.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, - StandardWatchEventKinds.ENTRY_DELETE, - StandardWatchEventKinds.ENTRY_MODIFY); + StandardWatchEventKinds.ENTRY_DELETE); paused = false; log.info("Bookdrop monitoring resumed."); } catch (IOException e) { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMoveService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMoveService.java index d3cd3350b..80fa8e616 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMoveService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMoveService.java @@ -3,172 +3,86 @@ package com.adityachandel.booklore.service.file; import com.adityachandel.booklore.mapper.BookMapper; import com.adityachandel.booklore.model.dto.Book; import com.adityachandel.booklore.model.dto.request.FileMoveRequest; -import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; import com.adityachandel.booklore.model.entity.BookEntity; -import com.adityachandel.booklore.model.entity.LibraryEntity; -import com.adityachandel.booklore.model.entity.LibraryPathEntity; -import com.adityachandel.booklore.model.entity.BookMetadataEntity; import com.adityachandel.booklore.model.websocket.Topic; -import com.adityachandel.booklore.repository.BookAdditionalFileRepository; import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.service.BookQueryService; import com.adityachandel.booklore.service.NotificationService; -import com.adityachandel.booklore.service.appsettings.AppSettingService; -import com.adityachandel.booklore.service.library.LibraryService; -import com.adityachandel.booklore.service.monitoring.MonitoringProtectionService; import com.adityachandel.booklore.util.PathPatternResolver; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import java.io.File; -import java.io.IOException; -import java.nio.file.*; -import java.util.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; @Slf4j @Service @AllArgsConstructor public class FileMoveService { + private static final int BATCH_SIZE = 100; + private final BookQueryService bookQueryService; private final BookRepository bookRepository; - private final BookAdditionalFileRepository bookAdditionalFileRepository; private final BookMapper bookMapper; private final NotificationService notificationService; - private final LibraryService libraryService; - private final MonitoringProtectionService monitoringProtectionService; - private final AppSettingService appSettingService; + private final UnifiedFileMoveService unifiedFileMoveService; public void moveFiles(FileMoveRequest request) { Set bookIds = request.getBookIds(); - List books = bookQueryService.findAllWithMetadataByIds(bookIds); - String defaultPattern = appSettingService.getAppSettings().getUploadPattern(); + log.info("Moving {} books in batches of {}", bookIds.size(), BATCH_SIZE); - log.info("Starting file move for {} books", books.size()); + List allUpdatedBooks = new ArrayList<>(); + int totalProcessed = 0; + int offset = 0; - Set libraryIds = new HashSet<>(); + while (offset < bookIds.size()) { + log.info("Processing batch {}/{}", (offset / BATCH_SIZE) + 1, (bookIds.size() + BATCH_SIZE - 1) / BATCH_SIZE); + + List batchBooks = bookQueryService.findWithMetadataByIdsWithPagination(bookIds, offset, BATCH_SIZE); + + if (batchBooks.isEmpty()) { + log.info("No more books at offset {}", offset); + break; + } + + List batchUpdatedBooks = processBookChunk(batchBooks); + allUpdatedBooks.addAll(batchUpdatedBooks); + + totalProcessed += batchBooks.size(); + offset += BATCH_SIZE; + + log.info("Processed {}/{} books", totalProcessed, bookIds.size()); + } + + log.info("Move completed: {} books processed, {} updated", totalProcessed, allUpdatedBooks.size()); + sendUpdateNotifications(allUpdatedBooks); + } + + public String generatePathFromPattern(BookEntity book, String pattern) { + return PathPatternResolver.resolvePattern(book, pattern); + } + + private List processBookChunk(List books) { List updatedBooks = new ArrayList<>(); - monitoringProtectionService.executeWithProtection(() -> { - for (BookEntity book : books) { - processBookMove(book, defaultPattern, updatedBooks, libraryIds); + unifiedFileMoveService.moveBatchBookFiles(books, new UnifiedFileMoveService.BatchMoveCallback() { + @Override + public void onBookMoved(BookEntity book) { + bookRepository.save(book); + updatedBooks.add(bookMapper.toBook(book)); } - log.info("Completed file move for {} books.", books.size()); - sendUpdateNotifications(updatedBooks); - }, "file move operations"); - - // Trigger library rescan immediately after file operations complete - if (!libraryIds.isEmpty()) { - rescanLibraries(libraryIds); - } - } - - - private void processBookMove(BookEntity book, String defaultPattern, List updatedBooks, Set libraryIds) { - if (book.getMetadata() == null) return; - - String pattern = getFileNamingPattern(book, defaultPattern); - if (pattern == null) return; - - if (!hasRequiredPathComponents(book)) return; - - Path oldFilePath = book.getFullFilePath(); - if (!Files.exists(oldFilePath)) { - log.warn("File does not exist for book id {}: {}", book.getId(), oldFilePath); - return; - } - - log.info("Processing book id {}: '{}'", book.getId(), book.getMetadata().getTitle()); - - Path newFilePath = generateNewFilePath(book, pattern); - if (oldFilePath.equals(newFilePath)) { - log.info("Source and destination paths are identical for book id {}. Skipping.", book.getId()); - return; - } - - try { - moveFileAndUpdateBook(book, oldFilePath, newFilePath, updatedBooks, libraryIds); - - // Move additional files if present - if (book.getAdditionalFiles() != null && !book.getAdditionalFiles().isEmpty()) { - moveAdditionalFiles(book, pattern); + @Override + public void onBookMoveFailed(BookEntity book, Exception error) { + log.error("Move failed for book {}: {}", book.getId(), error.getMessage(), error); + throw new RuntimeException("File move failed for book id " + book.getId(), error); } - } catch (IOException e) { - log.error("Failed to move file for book id {}: {}", book.getId(), e.getMessage(), e); - } - } + }); - private String getFileNamingPattern(BookEntity book, String defaultPattern) { - if (book.getLibraryPath() == null || book.getLibraryPath().getLibrary() == null) { - log.error("Book id {} has no library associated. Skipping.", book.getId()); - return null; - } - LibraryEntity library = book.getLibraryPath().getLibrary(); - String pattern = library.getFileNamingPattern(); - if (pattern == null || pattern.trim().isEmpty()) { - pattern = defaultPattern; - log.info("Using default pattern for library {} as no custom pattern is set", library.getName()); - } - if (pattern == null || pattern.trim().isEmpty()) { - log.error("No file naming pattern available for book id {}. Skipping.", book.getId()); - return null; - } - - return pattern; - } - - private boolean hasRequiredPathComponents(BookEntity book) { - if (book.getLibraryPath() == null || book.getLibraryPath().getPath() == null || - book.getFileSubPath() == null || book.getFileName() == null) { - log.error("Missing required path components for book id {}. Skipping.", book.getId()); - return false; - } - return true; - } - - private Path generateNewFilePath(BookEntity book, String pattern) { - String newRelativePathStr = generatePathFromPattern(book, pattern); - if (newRelativePathStr.startsWith("/") || newRelativePathStr.startsWith("\\")) { - newRelativePathStr = newRelativePathStr.substring(1); - } - - Path libraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize(); - return libraryRoot.resolve(newRelativePathStr).normalize(); - } - - private void moveFileAndUpdateBook(BookEntity book, Path oldFilePath, Path newFilePath, - List updatedBooks, Set libraryIds) throws IOException { - if (newFilePath.getParent() != null) { - Files.createDirectories(newFilePath.getParent()); - } - - log.info("Moving file from {} to {}", oldFilePath, newFilePath); - Files.move(oldFilePath, newFilePath, StandardCopyOption.REPLACE_EXISTING); - - updateBookPaths(book, newFilePath); - bookRepository.save(book); - updatedBooks.add(bookMapper.toBook(book)); - - log.info("Updated book id {} with new path", book.getId()); - - Path libraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize(); - deleteEmptyParentDirsUpToLibraryFolders(oldFilePath.getParent(), Set.of(libraryRoot)); - - if (book.getLibraryPath().getLibrary().getId() != null) { - libraryIds.add(book.getLibraryPath().getLibrary().getId()); - } - } - - private void updateBookPaths(BookEntity book, Path newFilePath) { - String newFileName = newFilePath.getFileName().toString(); - Path libraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize(); - Path newRelativeSubPath = libraryRoot.relativize(newFilePath.getParent()); - String newFileSubPath = newRelativeSubPath.toString().replace('\\', '/'); - - book.setFileSubPath(newFileSubPath); - book.setFileName(newFileName); + return updatedBooks; } private void sendUpdateNotifications(List updatedBooks) { @@ -176,169 +90,4 @@ public class FileMoveService { notificationService.sendMessage(Topic.BOOK_METADATA_BATCH_UPDATE, updatedBooks); } } - - private void rescanLibraries(Set libraryIds) { - for (Long libraryId : libraryIds) { - try { - libraryService.rescanLibrary(libraryId); - log.info("Rescanned library id {} after file move", libraryId); - } catch (Exception e) { - log.error("Failed to rescan library id {}: {}", libraryId, e.getMessage(), e); - } - } - } - - - - public String generatePathFromPattern(BookEntity book, String pattern) { - return PathPatternResolver.resolvePattern(book, pattern); - } - - private void moveAdditionalFiles(BookEntity book, String pattern) throws IOException { - Map fileNameCounter = new HashMap<>(); - - for (BookAdditionalFileEntity additionalFile : book.getAdditionalFiles()) { - Path oldAdditionalFilePath = additionalFile.getFullFilePath(); - if (!Files.exists(oldAdditionalFilePath)) { - log.warn("Additional file does not exist for book id {}: {}", book.getId(), oldAdditionalFilePath); - continue; - } - - String newRelativePathStr = PathPatternResolver.resolvePattern(book.getMetadata(), pattern, additionalFile.getFileName()); - if (newRelativePathStr.startsWith("/") || newRelativePathStr.startsWith("\\")) { - newRelativePathStr = newRelativePathStr.substring(1); - } - - Path libraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize(); - Path newAdditionalFilePath = libraryRoot.resolve(newRelativePathStr).normalize(); - - // Check for filename uniqueness and add index if necessary - newAdditionalFilePath = ensureUniqueFilePath(newAdditionalFilePath, fileNameCounter); - - if (oldAdditionalFilePath.equals(newAdditionalFilePath)) { - log.debug("Source and destination paths are identical for additional file id {}. Skipping.", additionalFile.getId()); - continue; - } - - // Create parent directories if needed - if (newAdditionalFilePath.getParent() != null) { - Files.createDirectories(newAdditionalFilePath.getParent()); - } - - log.info("Moving additional file from {} to {}", oldAdditionalFilePath, newAdditionalFilePath); - Files.move(oldAdditionalFilePath, newAdditionalFilePath, StandardCopyOption.REPLACE_EXISTING); - - // Update additional file paths - updateAdditionalFilePaths(additionalFile, newAdditionalFilePath, libraryRoot); - bookAdditionalFileRepository.save(additionalFile); - - log.info("Updated additional file id {} with new path", additionalFile.getId()); - } - } - - private Path ensureUniqueFilePath(Path filePath, Map fileNameCounter) { - String fileName = filePath.getFileName().toString(); - String baseName = fileName; - String extension = ""; - - int lastDot = fileName.lastIndexOf("."); - if (lastDot >= 0 && lastDot < fileName.length() - 1) { - baseName = fileName.substring(0, lastDot); - extension = fileName.substring(lastDot); - } - - String fileKey = filePath.toString().toLowerCase(); - Integer count = fileNameCounter.get(fileKey); - - if (count == null) { - fileNameCounter.put(fileKey, 1); - return filePath; - } else { - // File name already exists, add index - count++; - fileNameCounter.put(fileKey, count); - String newFileName = baseName + "_" + count + extension; - return filePath.getParent().resolve(newFileName); - } - } - - private void updateAdditionalFilePaths(BookAdditionalFileEntity additionalFile, Path newFilePath, Path libraryRoot) { - String newFileName = newFilePath.getFileName().toString(); - Path newRelativeSubPath = libraryRoot.relativize(newFilePath.getParent()); - String newFileSubPath = newRelativeSubPath.toString().replace('\\', '/'); - - additionalFile.setFileSubPath(newFileSubPath); - additionalFile.setFileName(newFileName); - } - - public void deleteEmptyParentDirsUpToLibraryFolders(Path currentDir, Set libraryRoots) throws IOException { - Set ignoredFilenames = Set.of(".DS_Store", "Thumbs.db"); - currentDir = currentDir.toAbsolutePath().normalize(); - - Set normalizedRoots = new HashSet<>(); - for (Path root : libraryRoots) { - normalizedRoots.add(root.toAbsolutePath().normalize()); - } - - while (currentDir != null) { - if (isLibraryRoot(currentDir, normalizedRoots)) { - log.debug("Reached library root: {}. Stopping cleanup.", currentDir); - break; - } - - File[] files = currentDir.toFile().listFiles(); - if (files == null) { - log.warn("Cannot read directory: {}. Stopping cleanup.", currentDir); - break; - } - - if (hasOnlyIgnoredFiles(files, ignoredFilenames)) { - deleteIgnoredFilesAndDirectory(files, currentDir); - currentDir = currentDir.getParent(); - } else { - log.debug("Directory {} contains important files. Stopping cleanup.", currentDir); - break; - } - } - } - - private boolean isLibraryRoot(Path currentDir, Set normalizedRoots) { - for (Path root : normalizedRoots) { - try { - if (Files.isSameFile(root, currentDir)) { - return true; - } - } catch (IOException e) { - log.warn("Failed to compare paths: {} and {}", root, currentDir); - } - } - return false; - } - - private boolean hasOnlyIgnoredFiles(File[] files, Set ignoredFilenames) { - for (File file : files) { - if (!ignoredFilenames.contains(file.getName())) { - return false; - } - } - return true; - } - - private void deleteIgnoredFilesAndDirectory(File[] files, Path currentDir) { - for (File file : files) { - try { - Files.delete(file.toPath()); - log.info("Deleted ignored file: {}", file.getAbsolutePath()); - } catch (IOException e) { - log.warn("Failed to delete ignored file: {}", file.getAbsolutePath()); - } - } - - try { - Files.delete(currentDir); - log.info("Deleted empty directory: {}", currentDir); - } catch (IOException e) { - log.warn("Failed to delete directory: {}", currentDir, e); - } - } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMovingHelper.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMovingHelper.java new file mode 100644 index 000000000..91084d202 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/FileMovingHelper.java @@ -0,0 +1,303 @@ +package com.adityachandel.booklore.service.file; + +import com.adityachandel.booklore.model.dto.BookMetadata; +import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.LibraryEntity; +import com.adityachandel.booklore.repository.BookAdditionalFileRepository; +import com.adityachandel.booklore.service.appsettings.AppSettingService; +import com.adityachandel.booklore.util.PathPatternResolver; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.FilenameUtils; +import org.springframework.stereotype.Component; + +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.nio.file.StandardCopyOption; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +@Slf4j +@Component +@AllArgsConstructor +public class FileMovingHelper { + + private final BookAdditionalFileRepository bookAdditionalFileRepository; + private final AppSettingService appSettingService; + + /** + * Generates the new file path based on the library's file naming pattern + */ + public Path generateNewFilePath(BookEntity book, String pattern) { + String newRelativePathStr = PathPatternResolver.resolvePattern(book, pattern); + if (newRelativePathStr.startsWith("/") || newRelativePathStr.startsWith("\\")) { + newRelativePathStr = newRelativePathStr.substring(1); + } + + Path libraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize(); + return libraryRoot.resolve(newRelativePathStr).normalize(); + } + + /** + * Generates the new file path based on metadata and file naming pattern + */ + public Path generateNewFilePath(String libraryRootPath, BookMetadata metadata, String pattern, String fileName) { + String relativePath = PathPatternResolver.resolvePattern(metadata, pattern, FilenameUtils.getName(fileName)); + return Paths.get(libraryRootPath, relativePath); + } + + /** + * Gets the file naming pattern for a library, falling back to default if not set, + * and finally to {currentFilename} if no patterns are available + */ + public String getFileNamingPattern(LibraryEntity library) { + String pattern = library.getFileNamingPattern(); + if (pattern == null || pattern.trim().isEmpty()) { + try { + pattern = appSettingService.getAppSettings().getUploadPattern(); + log.debug("Using default pattern for library {} as no custom pattern is set", library.getName()); + } catch (Exception e) { + log.warn("Failed to get default upload pattern for library {}: {}", library.getName(), e.getMessage()); + } + } + if (pattern == null || pattern.trim().isEmpty()) { + pattern = "{currentFilename}"; + log.info("No file naming pattern available for library {}. Using fallback pattern: {currentFilename}", library.getName()); + } + + // Ensure pattern ends with filename placeholder if it ends with separator + if (pattern.endsWith("/") || pattern.endsWith("\\")) { + pattern += "{currentFilename}"; + } + + return pattern; + } + + /** + * Checks if a book has all required path components for file operations + */ + public boolean hasRequiredPathComponents(BookEntity book) { + if (book.getLibraryPath() == null || book.getLibraryPath().getPath() == null || + book.getFileSubPath() == null || book.getFileName() == null) { + log.error("Missing required path components for book id {}. Skipping.", book.getId()); + return false; + } + return true; + } + + /** + * Moves a book file if the current path differs from the expected pattern + */ + public boolean moveBookFileIfNeeded(BookEntity book, String pattern) throws IOException { + Path oldFilePath = book.getFullFilePath(); + Path newFilePath = generateNewFilePath(book, pattern); + + if (oldFilePath.equals(newFilePath)) { + log.debug("Source and destination paths are identical for book id {}. Skipping.", book.getId()); + return false; + } + + moveBookFileAndUpdatePaths(book, oldFilePath, newFilePath); + return true; + } + + /** + * Moves a file from source to target path, creating directories as needed + */ + public void moveFile(Path source, Path target) throws IOException { + if (target.getParent() != null) { + Files.createDirectories(target.getParent()); + } + + log.info("Moving file from {} to {}", source, target); + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + + /** + * Updates book entity paths after a file move + */ + public void updateBookPaths(BookEntity book, Path newFilePath) { + String newFileName = newFilePath.getFileName().toString(); + Path libraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize(); + Path newRelativeSubPath = libraryRoot.relativize(newFilePath.getParent()); + String newFileSubPath = newRelativeSubPath.toString().replace('\\', '/'); + + book.setFileSubPath(newFileSubPath); + book.setFileName(newFileName); + } + + /** + * Moves additional files for a book based on the file naming pattern + */ + public void moveAdditionalFiles(BookEntity book, String pattern) throws IOException { + Map fileNameCounter = new HashMap<>(); + + for (BookAdditionalFileEntity additionalFile : book.getAdditionalFiles()) { + moveAdditionalFile(book, additionalFile, pattern, fileNameCounter); + } + } + + /** + * Deletes empty parent directories up to library roots + */ + public void deleteEmptyParentDirsUpToLibraryFolders(Path currentDir, Set libraryRoots) throws IOException { + Set ignoredFilenames = Set.of(".DS_Store", "Thumbs.db"); + currentDir = currentDir.toAbsolutePath().normalize(); + + Set normalizedRoots = new HashSet<>(); + for (Path root : libraryRoots) { + normalizedRoots.add(root.toAbsolutePath().normalize()); + } + + while (currentDir != null) { + if (isLibraryRoot(currentDir, normalizedRoots)) { + log.debug("Reached library root: {}. Stopping cleanup.", currentDir); + break; + } + + File[] files = currentDir.toFile().listFiles(); + if (files == null) { + log.warn("Cannot read directory: {}. Stopping cleanup.", currentDir); + break; + } + + if (hasOnlyIgnoredFiles(files, ignoredFilenames)) { + deleteIgnoredFilesAndDirectory(files, currentDir); + currentDir = currentDir.getParent(); + } else { + log.debug("Directory {} contains important files. Stopping cleanup.", currentDir); + break; + } + } + } + + private void moveBookFileAndUpdatePaths(BookEntity book, Path oldFilePath, Path newFilePath) throws IOException { + moveFile(oldFilePath, newFilePath); + updateBookPaths(book, newFilePath); + + // Clean up empty directories + try { + Path libraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize(); + deleteEmptyParentDirsUpToLibraryFolders(oldFilePath.getParent(), Set.of(libraryRoot)); + } catch (IOException e) { + log.warn("Failed to clean up empty directories after moving book ID {}: {}", book.getId(), e.getMessage()); + } + } + + private void moveAdditionalFile(BookEntity book, BookAdditionalFileEntity additionalFile, String pattern, Map fileNameCounter) throws IOException { + Path oldAdditionalFilePath = additionalFile.getFullFilePath(); + if (!Files.exists(oldAdditionalFilePath)) { + log.warn("Additional file does not exist for book id {}: {}", book.getId(), oldAdditionalFilePath); + return; + } + + Path newAdditionalFilePath = generateAdditionalFilePath(book, additionalFile, pattern); + newAdditionalFilePath = ensureUniqueFilePath(newAdditionalFilePath, fileNameCounter); + + if (oldAdditionalFilePath.equals(newAdditionalFilePath)) { + log.debug("Source and destination paths are identical for additional file id {}. Skipping.", additionalFile.getId()); + return; + } + + moveFile(oldAdditionalFilePath, newAdditionalFilePath); + + Path libraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize(); + updateAdditionalFilePaths(additionalFile, newAdditionalFilePath, libraryRoot); + bookAdditionalFileRepository.save(additionalFile); + + log.info("Updated additional file id {} with new path", additionalFile.getId()); + } + + private Path generateAdditionalFilePath(BookEntity book, BookAdditionalFileEntity additionalFile, String pattern) { + String newRelativePathStr = PathPatternResolver.resolvePattern(book.getMetadata(), pattern, additionalFile.getFileName()); + // Fall back to the filename when resolver returns null/empty to avoid NPEs in callers/tests + if (newRelativePathStr == null || newRelativePathStr.trim().isEmpty()) { + newRelativePathStr = additionalFile.getFileName() != null ? additionalFile.getFileName() : ""; + } + if (!newRelativePathStr.isEmpty() && (newRelativePathStr.startsWith("/") || newRelativePathStr.startsWith("\\"))) { + newRelativePathStr = newRelativePathStr.substring(1); + } + + Path libraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize(); + return libraryRoot.resolve(newRelativePathStr).normalize(); + } + + private Path ensureUniqueFilePath(Path filePath, Map fileNameCounter) { + String fileName = filePath.getFileName().toString(); + String baseName = fileName; + String extension = ""; + + int lastDot = fileName.lastIndexOf("."); + if (lastDot >= 0 && lastDot < fileName.length() - 1) { + baseName = fileName.substring(0, lastDot); + extension = fileName.substring(lastDot); + } + + String fileKey = filePath.toString().toLowerCase(); + Integer count = fileNameCounter.get(fileKey); + + if (count == null) { + fileNameCounter.put(fileKey, 1); + return filePath; + } else { + count++; + fileNameCounter.put(fileKey, count); + String newFileName = baseName + "_" + count + extension; + return filePath.getParent().resolve(newFileName); + } + } + + private void updateAdditionalFilePaths(BookAdditionalFileEntity additionalFile, Path newFilePath, Path libraryRoot) { + String newFileName = newFilePath.getFileName().toString(); + Path newRelativeSubPath = libraryRoot.relativize(newFilePath.getParent()); + String newFileSubPath = newRelativeSubPath.toString().replace('\\', '/'); + + additionalFile.setFileSubPath(newFileSubPath); + additionalFile.setFileName(newFileName); + } + + private boolean isLibraryRoot(Path currentDir, Set normalizedRoots) { + for (Path root : normalizedRoots) { + try { + if (Files.isSameFile(root, currentDir)) { + return true; + } + } catch (IOException e) { + log.warn("Failed to compare paths: {} and {}", root, currentDir); + } + } + return false; + } + + private boolean hasOnlyIgnoredFiles(File[] files, Set ignoredFilenames) { + for (File file : files) { + if (!ignoredFilenames.contains(file.getName())) { + return false; + } + } + return true; + } + + private void deleteIgnoredFilesAndDirectory(File[] files, Path currentDir) { + for (File file : files) { + try { + Files.delete(file.toPath()); + log.info("Deleted ignored file: {}", file.getAbsolutePath()); + } catch (IOException e) { + log.warn("Failed to delete ignored file: {}", file.getAbsolutePath()); + } + } + try { + Files.delete(currentDir); + log.info("Deleted empty directory: {}", currentDir); + } catch (IOException e) { + log.warn("Failed to delete directory: {}", currentDir, e); + } + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/MonitoredFileOperationService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/MonitoredFileOperationService.java new file mode 100644 index 000000000..9815411ef --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/MonitoredFileOperationService.java @@ -0,0 +1,147 @@ +package com.adityachandel.booklore.service.file; + +import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Stream; + +/** + * Service responsible for executing file operations while temporarily disabling monitoring + * on specific paths to prevent event loops and race conditions during file system operations. + * + * This service provides targeted path protection by unregistering only the directories + * involved in the operation rather than pausing all monitoring, ensuring minimal + * disruption to the monitoring system. + */ +@Slf4j +@Component +@AllArgsConstructor +public class MonitoredFileOperationService { + + private final MonitoringRegistrationService monitoringRegistrationService; + + /** + * Executes a file operation with targeted path protection to prevent monitoring conflicts. + * + * This method temporarily unregisters monitoring for only the specific directories involved + * in the operation (source and target) rather than pausing all monitoring. This approach + * minimizes the impact on the monitoring system while preventing event loops that could + * occur when the monitoring service detects changes from our own file operations. + * + * The process follows these steps: + * 1. Identify source and target directories + * 2. Temporarily unregister monitoring for these specific paths + * 3. Execute the file operation + * 4. Wait for filesystem operations to settle + * 5. Re-register paths and scan for new directory structures + * + * @param sourcePath the source file path for the operation + * @param targetPath the target file path for the operation + * @param libraryId the library ID used for re-registration of monitoring + * @param operation the file operation to execute (supplied as a lambda) + * @param the return type of the operation + */ + public void executeWithMonitoringSuspended(Path sourcePath, Path targetPath, Long libraryId, Supplier operation) { + // Extract parent directories since we monitor directories, not individual files + Path sourceDir = sourcePath.getParent(); + Path targetDir = targetPath.getParent(); + + // Track which paths we unregister so we can restore them later + Set unregisteredPaths = new HashSet<>(); + + try { + // Unregister source directory to prevent detection of file removal events + if (monitoringRegistrationService.isPathMonitored(sourceDir)) { + monitoringRegistrationService.unregisterSpecificPath(sourceDir); + unregisteredPaths.add(sourceDir); + log.debug("Temporarily unregistered source directory to prevent monitoring conflicts: {}", sourceDir); + } + + // Unregister target directory if it's different from source and already exists + // This prevents detection of file creation events during the operation + if (!sourceDir.equals(targetDir) && Files.exists(targetDir) && monitoringRegistrationService.isPathMonitored(targetDir)) { + monitoringRegistrationService.unregisterSpecificPath(targetDir); + unregisteredPaths.add(targetDir); + log.info("Temporarily unregistered target directory to prevent monitoring conflicts: {}", targetDir); + } + + log.debug("Protected {} directory paths from monitoring during file operation", unregisteredPaths.size()); + + // Execute the actual file operation (move, copy, delete, etc.) + T result = operation.get(); + + // Allow filesystem operations to complete and settle before re-enabling monitoring + // This prevents race conditions where monitoring might restart before the operation is fully complete + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Thread interrupted while waiting for filesystem operations to settle"); + } + + } finally { + // Always restore monitoring, even if the operation failed + reregisterPathsAfterMove(unregisteredPaths, libraryId, targetDir); + } + } + + /** + * Restores monitoring registration for paths after a file operation and discovers new directories. + * + * This method handles the cleanup phase by: + * 1. Re-registering previously monitored paths that still exist + * 2. Registering new directory structures created by the operation + * 3. Ensuring comprehensive monitoring coverage after the operation + * + * @param unregisteredPaths the set of paths that were temporarily unregistered + * @param libraryId the library ID to use for new registrations + * @param targetDir the target directory where new structures might have been created + */ + private void reregisterPathsAfterMove(Set unregisteredPaths, Long libraryId, Path targetDir) { + // Restore monitoring for original paths that still exist after the operation + for (Path path : unregisteredPaths) { + if (Files.exists(path) && Files.isDirectory(path)) { + monitoringRegistrationService.registerSpecificPath(path, libraryId); + log.debug("Restored monitoring for existing path: {}", path); + } else { + log.info("Path no longer exists after operation, skipping re-registration: {}", path); + } + } + + // Register monitoring for new directory structures created at the target location + if (Files.exists(targetDir) && Files.isDirectory(targetDir)) { + // Register the target directory itself if it wasn't previously monitored + if (!monitoringRegistrationService.isPathMonitored(targetDir)) { + monitoringRegistrationService.registerSpecificPath(targetDir, libraryId); + log.debug("Registered new target directory for monitoring: {}", targetDir); + } + + // Discover and register any new subdirectories created during the operation + // This ensures complete monitoring coverage for complex directory structures + try (Stream stream = Files.walk(targetDir)) { + stream.filter(Files::isDirectory) + .filter(Files::exists) + .filter(path -> !path.equals(targetDir)) // Skip the parent directory itself + .forEach(path -> { + if (!monitoringRegistrationService.isPathMonitored(path)) { + monitoringRegistrationService.registerSpecificPath(path, libraryId); + log.info("Registered new subdirectory for monitoring: {}", path); + } + }); + } catch (IOException e) { + log.warn("Failed to scan and register new subdirectories at: {}", targetDir, e); + } + } + + log.debug("Completed restoration of monitoring after file operation"); + } +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/file/UnifiedFileMoveService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/UnifiedFileMoveService.java new file mode 100644 index 000000000..a5b89234b --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/file/UnifiedFileMoveService.java @@ -0,0 +1,184 @@ +package com.adityachandel.booklore.service.file; + +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; + +@Slf4j +@Service +@AllArgsConstructor +public class UnifiedFileMoveService { + + private final FileMovingHelper fileMovingHelper; + private final MonitoredFileOperationService monitoredFileOperationService; + private final MonitoringRegistrationService monitoringRegistrationService; + + /** + * Moves a single book file to match the library's file naming pattern. + * Used for metadata updates where one file needs to be moved. + */ + public void moveSingleBookFile(BookEntity bookEntity) { + if (bookEntity.getLibraryPath() == null || bookEntity.getLibraryPath().getLibrary() == null) { + log.debug("Book ID {} has no library associated. Skipping file move.", bookEntity.getId()); + return; + } + + String pattern = fileMovingHelper.getFileNamingPattern(bookEntity.getLibraryPath().getLibrary()); + + if (!fileMovingHelper.hasRequiredPathComponents(bookEntity)) { + log.debug("Missing required path components for book ID {}. Skipping file move.", bookEntity.getId()); + return; + } + + Path currentFilePath = bookEntity.getFullFilePath(); + if (!Files.exists(currentFilePath)) { + log.warn("File does not exist for book ID {}: {}. Skipping file move.", bookEntity.getId(), currentFilePath); + return; + } + + // Check if current path differs from expected pattern + Path expectedFilePath = fileMovingHelper.generateNewFilePath(bookEntity, pattern); + if (currentFilePath.equals(expectedFilePath)) { + log.debug("File for book ID {} is already in the correct location according to library pattern. No move needed.", bookEntity.getId()); + return; + } + + log.info("File for book ID {} needs to be moved from {} to {} to match library pattern", bookEntity.getId(), currentFilePath, expectedFilePath); + + // For single file moves, use targeted path protection + monitoredFileOperationService.executeWithMonitoringSuspended(currentFilePath, expectedFilePath, bookEntity.getLibraryPath().getLibrary().getId(), () -> { + try { + boolean moved = fileMovingHelper.moveBookFileIfNeeded(bookEntity, pattern); + if (moved) { + log.info("Successfully moved file for book ID {} from {} to {} to match library pattern", bookEntity.getId(), currentFilePath, bookEntity.getFullFilePath()); + } + return moved; + } catch (IOException e) { + log.error("Failed to move file for book ID {}: {}", bookEntity.getId(), e.getMessage(), e); + throw new RuntimeException("File move failed", e); + } + }); + } + + /** + * Moves multiple book files in batches with library-level monitoring protection. + * Used for bulk file operations where many files need to be moved. + */ + public void moveBatchBookFiles(List books, BatchMoveCallback callback) { + if (books.isEmpty()) { + log.debug("No books to move"); + return; + } + + Set libraryIds = new HashSet<>(); + Map> libraryToRootsMap = new HashMap<>(); + + // Collect library information for monitoring protection + for (BookEntity book : books) { + if (book.getMetadata() == null) continue; + if (!fileMovingHelper.hasRequiredPathComponents(book)) continue; + + Path oldFilePath = book.getFullFilePath(); + if (!Files.exists(oldFilePath)) continue; + + Long libraryId = book.getLibraryPath().getLibrary().getId(); + Path libraryRoot = Paths.get(book.getLibraryPath().getPath()).toAbsolutePath().normalize(); + + libraryToRootsMap.computeIfAbsent(libraryId, k -> new HashSet<>()).add(libraryRoot); + libraryIds.add(libraryId); + } + + // Unregister libraries for batch operation + unregisterLibrariesBatch(libraryToRootsMap); + + try { + // Process each book + for (BookEntity book : books) { + if (book.getMetadata() == null) continue; + + String pattern = fileMovingHelper.getFileNamingPattern(book.getLibraryPath().getLibrary()); + + if (!fileMovingHelper.hasRequiredPathComponents(book)) continue; + + Path oldFilePath = book.getFullFilePath(); + if (!Files.exists(oldFilePath)) { + log.warn("File not found for book {}: {}", book.getId(), oldFilePath); + continue; + } + + log.debug("Moving book {}: '{}'", book.getId(), book.getMetadata().getTitle()); + + try { + boolean moved = fileMovingHelper.moveBookFileIfNeeded(book, pattern); + if (moved) { + log.debug("Book {} moved successfully", book.getId()); + callback.onBookMoved(book); + } + + // Move additional files if any + if (book.getAdditionalFiles() != null && !book.getAdditionalFiles().isEmpty()) { + fileMovingHelper.moveAdditionalFiles(book, pattern); + } + } catch (IOException e) { + log.error("Move failed for book {}: {}", book.getId(), e.getMessage(), e); + callback.onBookMoveFailed(book, e); + } + } + + // Small delay to let filesystem operations settle + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Interrupted during batch move delay"); + } + + } finally { + // Re-register libraries + registerLibrariesBatch(libraryToRootsMap); + } + } + + private void unregisterLibrariesBatch(Map> libraryToRootsMap) { + log.debug("Unregistering {} libraries for batch move", libraryToRootsMap.size()); + + for (Map.Entry> entry : libraryToRootsMap.entrySet()) { + Long libraryId = entry.getKey(); + monitoringRegistrationService.unregisterLibrary(libraryId); + log.debug("Unregistered library {}", libraryId); + } + } + + private void registerLibrariesBatch(Map> libraryToRootsMap) { + log.debug("Re-registering {} libraries after batch move", libraryToRootsMap.size()); + + for (Map.Entry> entry : libraryToRootsMap.entrySet()) { + Long libraryId = entry.getKey(); + Set libraryRoots = entry.getValue(); + + for (Path libraryRoot : libraryRoots) { + if (Files.exists(libraryRoot) && Files.isDirectory(libraryRoot)) { + monitoringRegistrationService.registerLibraryPaths(libraryId, libraryRoot); + log.debug("Re-registered library {} at {}", libraryId, libraryRoot); + } + } + } + } + + /** + * Callback interface for batch move operations + */ + public interface BatchMoveCallback { + void onBookMoved(BookEntity book); + void onBookMoveFailed(BookEntity book, Exception error); + } +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/AbstractFileProcessor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/AbstractFileProcessor.java index 42139c57d..bac6ce035 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/AbstractFileProcessor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/AbstractFileProcessor.java @@ -104,6 +104,7 @@ public abstract class AbstractFileProcessor implements BookFileProcessor { if (!sameLibraryPath) { entity.setLibraryPath(libraryFile.getLibraryPathEntity()); + entity.setLibrary(libraryFile.getLibraryEntity()); updated = true; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java index 5484c24bb..3b042a77a 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java @@ -13,6 +13,7 @@ import com.adityachandel.booklore.repository.AuthorRepository; import com.adityachandel.booklore.repository.CategoryRepository; import com.adityachandel.booklore.service.FileFingerprint; import com.adityachandel.booklore.service.appsettings.AppSettingService; +import com.adityachandel.booklore.service.file.UnifiedFileMoveService; import com.adityachandel.booklore.service.metadata.backuprestore.MetadataBackupRestore; import com.adityachandel.booklore.service.metadata.backuprestore.MetadataBackupRestoreFactory; import com.adityachandel.booklore.service.metadata.writer.MetadataWriterFactory; @@ -49,6 +50,7 @@ public class BookMetadataUpdater { private final MetadataWriterFactory metadataWriterFactory; private final MetadataBackupRestoreFactory metadataBackupRestoreFactory; private final BookReviewUpdateService bookReviewUpdateService; + private final UnifiedFileMoveService unifiedFileMoveService; @Transactional(propagation = Propagation.REQUIRES_NEW) public void setBookMetadata(BookEntity bookEntity, MetadataUpdateWrapper wrapper, boolean setThumbnail, boolean mergeCategories) { @@ -109,45 +111,55 @@ public class BookMetadataUpdater { log.info("CBX metadata writing disabled for book ID {}", bookId); } else { metadataWriterFactory.getWriter(bookType).ifPresent(writer -> { - try { - String thumbnailUrl = setThumbnail ? newMetadata.getThumbnailUrl() : null; + try { + String thumbnailUrl = setThumbnail ? newMetadata.getThumbnailUrl() : null; - if ((StringUtils.hasText(thumbnailUrl) && isLocalOrPrivateUrl(thumbnailUrl) || Boolean.TRUE.equals(metadata.getCoverLocked()))) { - log.debug("Blocked local/private thumbnail URL: {}", thumbnailUrl); - thumbnailUrl = null; - } - - File file = new File(bookEntity.getFullFilePath().toUri()); - writer.writeMetadataToFile(file, metadata, thumbnailUrl, false, clearFlags); - - String newHash = ""; - - // Special handling: If original file was .cbr or .cb7 and now .cbz exists, update to .cbz - File resultingFile = file; - if (!file.exists()) { - // Replace last extension .cbr or .cb7 (case-insensitive) with .cbz - String cbzName = file.getName().replaceFirst("(?i)\\.(cbr|cb7)$", ".cbz"); - File cbzFile = new File(file.getParentFile(), cbzName); - if (cbzFile.exists()) { - bookEntity.setFileName(cbzName); - resultingFile = cbzFile; + if ((StringUtils.hasText(thumbnailUrl) && isLocalOrPrivateUrl(thumbnailUrl) || Boolean.TRUE.equals(metadata.getCoverLocked()))) { + log.debug("Blocked local/private thumbnail URL: {}", thumbnailUrl); + thumbnailUrl = null; } - bookEntity.setFileSizeKb(resultingFile.length() / 1024); - log.info("Converted to CBZ: {} -> {}", file.getAbsolutePath(), resultingFile.getAbsolutePath()); - newHash = FileFingerprint.generateHash(resultingFile.toPath()); - } else { - newHash = FileFingerprint.generateHash(bookEntity.getFullFilePath()); + + File file = new File(bookEntity.getFullFilePath().toUri()); + writer.writeMetadataToFile(file, metadata, thumbnailUrl, false, clearFlags); + + String newHash; + + // Special handling: If original file was .cbr or .cb7 and now .cbz exists, update to .cbz + File resultingFile = file; + if (!file.exists()) { + // Replace last extension .cbr or .cb7 (case-insensitive) with .cbz + String cbzName = file.getName().replaceFirst("(?i)\\.(cbr|cb7)$", ".cbz"); + File cbzFile = new File(file.getParentFile(), cbzName); + if (cbzFile.exists()) { + bookEntity.setFileName(cbzName); + resultingFile = cbzFile; + } + bookEntity.setFileSizeKb(resultingFile.length() / 1024); + log.info("Converted to CBZ: {} -> {}", file.getAbsolutePath(), resultingFile.getAbsolutePath()); + newHash = FileFingerprint.generateHash(resultingFile.toPath()); + } else { + newHash = FileFingerprint.generateHash(bookEntity.getFullFilePath()); + } + + bookEntity.setCurrentHash(newHash); + } catch (Exception e) { + log.warn("Failed to write metadata for book ID {}: {}", bookId, e.getMessage()); } - - bookEntity.setCurrentHash(newHash); - } catch (Exception e) { - log.warn("Failed to write metadata for book ID {}: {}", bookId, e.getMessage()); - } - }); - } + }); + } + } + + boolean moveFilesToLibraryPattern = settings.isMoveFilesToLibraryPattern(); + if (moveFilesToLibraryPattern) { + try { + unifiedFileMoveService.moveSingleBookFile(bookEntity); + } catch (Exception e) { + log.warn("Failed to move files for book ID {} after metadata update: {}", bookId, e.getMessage()); + } } } + private void updateBasicFields(BookMetadata m, BookMetadataEntity e, MetadataClearFlags clear) { handleFieldUpdate(e.getTitleLocked(), clear.isTitle(), m.getTitle(), v -> e.setTitle(nullIfBlank(v))); handleFieldUpdate(e.getSubtitleLocked(), clear.isSubtitle(), m.getSubtitle(), v -> e.setSubtitle(nullIfBlank(v))); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringProtectionService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringProtectionService.java deleted file mode 100644 index 644f50e27..000000000 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringProtectionService.java +++ /dev/null @@ -1,124 +0,0 @@ -package com.adityachandel.booklore.service.monitoring; - -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.util.function.Supplier; - -/** - * Thread-safe service for managing monitoring protection during file operations. - * - * This service prevents race conditions where file operations are detected as - * "missing files" by the monitoring system, which could lead to data loss. - * - * The service ensures: - * - Thread-safe pause/resume operations - * - Monitoring always resumes even on exceptions - * - Protection against concurrent operations interfering with each other - */ -@Slf4j -@Service -@AllArgsConstructor -public class MonitoringProtectionService { - - private final MonitoringService monitoringService; - - // Synchronization lock to prevent race conditions in pause/resume logic - private static final Object monitoringLock = new Object(); - - /** - * Executes an operation with monitoring protection. - * - * @param operation The operation to execute while monitoring is paused - * @param operationName Name for logging purposes - * @param Return type of the operation - * @return Result of the operation - */ - public T executeWithProtection(Supplier operation, String operationName) { - boolean didPause = pauseMonitoringSafely(); - - try { - log.debug("Executing {} with monitoring protection (paused: {})", operationName, didPause); - return operation.get(); - } finally { - resumeMonitoringSafely(didPause, operationName); - } - } - - /** - * Executes a void operation with monitoring protection. - * - * @param operation The operation to execute while monitoring is paused - * @param operationName Name for logging purposes - */ - public void executeWithProtection(Runnable operation, String operationName) { - executeWithProtection(() -> { - operation.run(); - return null; - }, operationName); - } - - /** - * Thread-safe pause of monitoring service. - * - * @return true if monitoring was paused by this call, false if already paused - */ - private boolean pauseMonitoringSafely() { - synchronized (monitoringLock) { - if (!monitoringService.isPaused()) { - monitoringService.pauseMonitoring(); - log.debug("Monitoring paused for file operations"); - return true; - } - log.debug("Monitoring already paused by another operation"); - return false; - } - } - - /** - * Thread-safe resume of monitoring service with a 5-second delay. - * The delay is critical to prevent race conditions where file operations - * are still settling when monitoring resumes. - * - * @param didPause true if this operation paused monitoring - * @param operationName name of the operation for logging - */ - private void resumeMonitoringSafely(boolean didPause, String operationName) { - if (!didPause) { - log.debug("Monitoring was not paused by {} - no resume needed", operationName); - return; - } - - // Use virtual thread for delayed resume to avoid blocking - Thread.startVirtualThread(() -> { - try { - Thread.sleep(5_000); // Critical 5-second delay for filesystem operations to settle - - synchronized (monitoringLock) { - // Double-check that monitoring is still paused before resuming - if (monitoringService.isPaused()) { - monitoringService.resumeMonitoring(); - log.debug("Monitoring resumed after {} completed with 5s delay", operationName); - } else { - log.warn("Monitoring was already resumed by another thread during {}", operationName); - } - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("Interrupted while delaying resume of monitoring after {}", operationName); - } - }); - } - - /** - * Checks if monitoring is currently paused. - * - * @return true if monitoring is paused - */ - public boolean isMonitoringPaused() { - synchronized (monitoringLock) { - return monitoringService.isPaused(); - } - } -} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringRegistrationService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringRegistrationService.java new file mode 100644 index 000000000..951f54bcf --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringRegistrationService.java @@ -0,0 +1,81 @@ +package com.adityachandel.booklore.service.monitoring; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.nio.file.Files; +import java.nio.file.Path; + +@Slf4j +@Service +@AllArgsConstructor +public class MonitoringRegistrationService { + + private final MonitoringService monitoringService; + + + /** + * Checks if a specific path is currently being monitored. + * + * @param path the path to check + * @return true if the path is monitored + */ + public boolean isPathMonitored(Path path) { + return monitoringService.isPathMonitored(path); + } + + /** + * Unregisters a specific path from monitoring without affecting other paths. + * This is more efficient than pausing all monitoring for single path operations. + * + * @param path the path to unregister + */ + public void unregisterSpecificPath(Path path) { + monitoringService.unregisterPath(path); + } + + /** + * Registers a specific path for monitoring. + * + * @param path the path to register + * @param libraryId the library ID associated with this path + */ + public void registerSpecificPath(Path path, Long libraryId) { + monitoringService.registerPath(path, libraryId); + } + + /** + * Unregisters an entire library from monitoring. + * This is more efficient for batch operations than unregistering individual paths. + * + * @param libraryId the library ID to unregister + */ + public void unregisterLibrary(Long libraryId) { + monitoringService.unregisterLibrary(libraryId); + } + + /** + * Re-registers an entire library for monitoring after batch operations. + * Since MonitoringService.registerLibrary() requires a Library object, + * this method will register individual paths under the library instead. + * + * @param libraryId the library ID to register + * @param libraryRoot the root path of the library + */ + public void registerLibraryPaths(Long libraryId, Path libraryRoot) { + if (!Files.exists(libraryRoot) || !Files.isDirectory(libraryRoot)) { + return; + } + try { + monitoringService.registerPath(libraryRoot, libraryId); + try (var stream = Files.walk(libraryRoot)) { + stream.filter(Files::isDirectory) + .filter(path -> !path.equals(libraryRoot)) + .forEach(path -> monitoringService.registerPath(path, libraryId)); + } + } catch (Exception e) { + log.error("Failed to register library paths for libraryId {} at {}", libraryId, libraryRoot, e); + } + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringService.java index 8c9351baa..606e99bf3 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/monitoring/MonitoringService.java @@ -29,14 +29,11 @@ public class MonitoringService { private final ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor(); private final Set monitoredPaths = ConcurrentHashMap.newKeySet(); + private final Map registeredWatchKeys = new ConcurrentHashMap<>(); private final Map pathToLibraryIdMap = new ConcurrentHashMap<>(); private final Map libraryWatchStatusMap = new ConcurrentHashMap<>(); - private final Map registeredWatchKeys = new ConcurrentHashMap<>(); private final Map> libraryIdToPaths = new ConcurrentHashMap<>(); - private int pauseCount = 0; - private final Object pauseLock = new Object(); - public MonitoringService(LibraryFileEventProcessor libraryFileEventProcessor, WatchService watchService, MonitoringTask monitoringTask) { this.libraryFileEventProcessor = libraryFileEventProcessor; this.watchService = watchService; @@ -60,60 +57,6 @@ public class MonitoringService { } } - public synchronized void pauseMonitoring() { - pauseCount++; - if (pauseCount == 1) { - int count = 0; - for (Path path : new HashSet<>(monitoredPaths)) { - unregisterPath(path, false); - count++; - } - log.info("Monitoring paused ({} paths unregistered, pauseCount={})", count, pauseCount); - } else { - log.info("Monitoring pause requested (pauseCount={})", pauseCount); - } - } - - public synchronized void resumeMonitoring() { - if (pauseCount == 0) { - log.warn("resumeMonitoring() called but monitoring is not paused"); - return; - } - - pauseCount--; - if (pauseCount == 0) { - libraryIdToPaths.forEach((libraryId, rootPaths) -> { - for (Path rootPath : rootPaths) { - if (Files.exists(rootPath)) { - try (Stream stream = Files.walk(rootPath)) { - stream.filter(Files::isDirectory).forEach(path -> { - if (Files.exists(path)) { - registerPath(path, libraryId); - } - }); - } catch (IOException e) { - log.warn("Failed to walk path during resume: {}", rootPath, e); - } - } else { - log.debug("Skipping registration of non-existent path during resume: {}", rootPath); - } - } - }); - - synchronized (pauseLock) { - pauseLock.notifyAll(); - } - - log.info("Monitoring resumed"); - } else { - log.info("Monitoring resume requested (pauseCount={}), monitoring still paused", pauseCount); - } - } - - public synchronized boolean isPaused() { - return pauseCount > 0; - } - public void registerLibraries(List libraries) { libraries.forEach(lib -> libraryWatchStatusMap.put(lib.getId(), lib.isWatch())); libraries.stream().filter(Library::isWatch).forEach(this::registerLibrary); @@ -159,19 +102,7 @@ public class MonitoringService { libraryWatchStatusMap.put(libraryId, false); libraryIdToPaths.remove(libraryId); - log.info("Unregistered library {} from monitoring", libraryId); - } - - public void unregisterLibraries(Set libraryIds) { - libraryIds.forEach(this::unregisterLibrary); - } - - public boolean isLibraryWatched(Long libraryId) { - return libraryWatchStatusMap.getOrDefault(libraryId, false); - } - - public boolean isRelevantBookFile(Path path) { - return BookFileExtension.fromFileName(path.getFileName().toString()).isPresent(); + log.debug("Unregistered library {} from monitoring", libraryId); } public synchronized boolean registerPath(Path path, Long libraryId) { @@ -201,11 +132,21 @@ public class MonitoringService { if (key != null) key.cancel(); pathToLibraryIdMap.remove(path); if (logUnregister) { - log.info("Unregistered path: {}", path); + log.debug("Unregistered path: {}", path); } } } + private void unregisterSubPaths(Path deletedPath) { + Set toRemove = monitoredPaths.stream() + .filter(p -> p.startsWith(deletedPath)) + .collect(Collectors.toSet()); + + for (Path path : toRemove) { + unregisterPath(path); + } + } + @EventListener public void handleFileChangeEvent(FileChangeEvent event) { Path fullPath = event.getFilePath(); @@ -220,26 +161,8 @@ public class MonitoringService { boolean isRelevantFile = isRelevantBookFile(fullPath); if (!(isDir || isRelevantFile)) return; - if (isDir && kind == StandardWatchEventKinds.ENTRY_CREATE) { - Long parentLibraryId = pathToLibraryIdMap.get(event.getWatchedFolder()); - if (parentLibraryId != null) { - try (Stream stream = Files.walk(fullPath)) { - stream.filter(Files::isDirectory).forEach(path -> registerPath(path, parentLibraryId)); - } catch (IOException e) { - log.warn("Failed to register nested paths: {}", fullPath, e); - } - } - } - - if (isDir && kind == StandardWatchEventKinds.ENTRY_DELETE) { - unregisterSubPaths(fullPath); - } - - if (!eventQueue.offer(event)) { - log.warn("Event queue full, dropping: {}", fullPath); - } else { - log.debug("Queued: {} [{}]", fullPath, kind.name()); - } + handleDirectoryEvents(event, fullPath, kind, isDir); + queueEvent(event, fullPath, kind); } @EventListener @@ -258,15 +181,8 @@ public class MonitoringService { singleThreadExecutor.submit(() -> { while (!Thread.currentThread().isInterrupted()) { try { - synchronized (pauseLock) { - while (isPaused()) { - pauseLock.wait(); - } - } - FileChangeEvent event = eventQueue.take(); processFileChangeEvent(event); - } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; @@ -284,9 +200,7 @@ public class MonitoringService { if (libraryId != null) { try { - libraryFileEventProcessor.processFile( - event.getEventKind(), libraryId, watchedFolder.toString(), filePath.toString() - ); + libraryFileEventProcessor.processFile(event.getEventKind(), libraryId, watchedFolder.toString(), filePath.toString()); } catch (InvalidDataAccessApiUsageException e) { log.debug("InvalidDataAccessApiUsageException for libraryId={}", libraryId); } @@ -295,13 +209,36 @@ public class MonitoringService { } } - private void unregisterSubPaths(Path deletedPath) { - Set toRemove = monitoredPaths.stream() - .filter(p -> p.startsWith(deletedPath)) - .collect(Collectors.toSet()); + private void handleDirectoryEvents(FileChangeEvent event, Path fullPath, WatchEvent.Kind kind, boolean isDir) { + if (isDir && kind == StandardWatchEventKinds.ENTRY_CREATE) { + Long parentLibraryId = pathToLibraryIdMap.get(event.getWatchedFolder()); + if (parentLibraryId != null) { + try (Stream stream = Files.walk(fullPath)) { + stream.filter(Files::isDirectory).forEach(path -> registerPath(path, parentLibraryId)); + } catch (IOException e) { + log.warn("Failed to register nested paths: {}", fullPath, e); + } + } + } - for (Path path : toRemove) { - unregisterPath(path); + if (isDir && kind == StandardWatchEventKinds.ENTRY_DELETE) { + unregisterSubPaths(fullPath); } } -} \ No newline at end of file + + private void queueEvent(FileChangeEvent event, Path fullPath, WatchEvent.Kind kind) { + if (!eventQueue.offer(event)) { + log.warn("Event queue full, dropping: {}", fullPath); + } else { + log.debug("Queued: {} [{}]", fullPath, kind.name()); + } + } + + public boolean isRelevantBookFile(Path path) { + return BookFileExtension.fromFileName(path.getFileName().toString()).isPresent(); + } + + public boolean isPathMonitored(Path path) { + return monitoredPaths.contains(path.toAbsolutePath().normalize()); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/upload/FileUploadService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/upload/FileUploadService.java index 045ef0484..f172409d4 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/upload/FileUploadService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/upload/FileUploadService.java @@ -3,50 +3,36 @@ package com.adityachandel.booklore.service.upload; import com.adityachandel.booklore.config.AppProperties; import com.adityachandel.booklore.exception.ApiError; import com.adityachandel.booklore.mapper.AdditionalFileMapper; -import com.adityachandel.booklore.model.FileProcessResult; import com.adityachandel.booklore.model.dto.AdditionalFile; import com.adityachandel.booklore.model.dto.Book; import com.adityachandel.booklore.model.dto.BookMetadata; -import com.adityachandel.booklore.model.dto.settings.LibraryFile; import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.entity.LibraryEntity; import com.adityachandel.booklore.model.entity.LibraryPathEntity; import com.adityachandel.booklore.model.enums.AdditionalFileType; import com.adityachandel.booklore.model.enums.BookFileExtension; -import com.adityachandel.booklore.model.enums.BookFileType; -import com.adityachandel.booklore.model.enums.FileProcessStatus; -import com.adityachandel.booklore.model.websocket.Topic; import com.adityachandel.booklore.repository.BookAdditionalFileRepository; import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.repository.LibraryRepository; import com.adityachandel.booklore.service.FileFingerprint; -import com.adityachandel.booklore.service.NotificationService; import com.adityachandel.booklore.service.appsettings.AppSettingService; -import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor; -import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry; +import com.adityachandel.booklore.service.file.FileMovingHelper; import com.adityachandel.booklore.service.metadata.extractor.EpubMetadataExtractor; import com.adityachandel.booklore.service.metadata.extractor.PdfMetadataExtractor; -import com.adityachandel.booklore.service.monitoring.MonitoringService; -import com.adityachandel.booklore.util.FileUtils; import com.adityachandel.booklore.util.PathPatternResolver; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; - -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.IOException; -import java.nio.file.*; -import java.nio.file.attribute.GroupPrincipal; -import java.nio.file.attribute.PosixFileAttributeView; -import java.nio.file.attribute.UserPrincipal; -import java.nio.file.attribute.UserPrincipalLookupService; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.time.Instant; -import java.util.Objects; import java.util.Optional; @RequiredArgsConstructor @@ -54,202 +40,190 @@ import java.util.Optional; @Slf4j public class FileUploadService { + private static final String UPLOAD_TEMP_PREFIX = "upload-"; + private static final String BOOKDROP_TEMP_PREFIX = "bookdrop-"; + private static final long BYTES_TO_KB_DIVISOR = 1024L; + private static final long MB_TO_BYTES_MULTIPLIER = 1024L * 1024L; + private final LibraryRepository libraryRepository; private final BookRepository bookRepository; private final BookAdditionalFileRepository additionalFileRepository; - private final BookFileProcessorRegistry processorRegistry; - private final NotificationService notificationService; private final AppSettingService appSettingService; private final AppProperties appProperties; private final PdfMetadataExtractor pdfMetadataExtractor; private final EpubMetadataExtractor epubMetadataExtractor; private final AdditionalFileMapper additionalFileMapper; - private final MonitoringService monitoringService; + private final FileMovingHelper fileMovingHelper; - @Value("${PUID:${USER_ID:0}}") - private String userId; - - @Value("${PGID:${GROUP_ID:0}}") - private String groupId; - - public Book uploadFile(MultipartFile file, long libraryId, long pathId) throws IOException { + public void uploadFile(MultipartFile file, long libraryId, long pathId) { validateFile(file); - LibraryEntity libraryEntity = libraryRepository.findById(libraryId).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId)); - - LibraryPathEntity libraryPathEntity = libraryEntity.getLibraryPaths() - .stream() - .filter(p -> p.getId() == pathId) - .findFirst() - .orElseThrow(() -> ApiError.INVALID_LIBRARY_PATH.createException(libraryId)); - - Path tempPath = Files.createTempFile("upload-", Objects.requireNonNull(file.getOriginalFilename())); - FileProcessResult result; - - boolean wePaused = false; - if (!monitoringService.isPaused()) { - monitoringService.pauseMonitoring(); - wePaused = true; - } + final LibraryEntity libraryEntity = findLibraryById(libraryId); + final LibraryPathEntity libraryPathEntity = findLibraryPathById(libraryEntity, pathId); + final String originalFileName = getValidatedFileName(file); + Path tempPath = null; try { + tempPath = createTempFile(UPLOAD_TEMP_PREFIX, originalFileName); file.transferTo(tempPath); - setTemporaryFileOwnership(tempPath); - BookFileExtension fileExt = BookFileExtension.fromFileName(file.getOriginalFilename()).orElseThrow(() -> ApiError.INVALID_FILE_FORMAT.createException("Unsupported file extension")); - BookMetadata metadata = extractMetadata(fileExt, tempPath.toFile()); - String uploadPattern = appSettingService.getAppSettings().getUploadPattern(); - if (uploadPattern.endsWith("/") || uploadPattern.endsWith("\\")) { - uploadPattern += "{currentFilename}"; - } - String relativePath = PathPatternResolver.resolvePattern(metadata, uploadPattern, file.getOriginalFilename()); - Path finalPath = Paths.get(libraryPathEntity.getPath(), relativePath); - File finalFile = finalPath.toFile(); + final BookFileExtension fileExtension = getFileExtension(originalFileName); + final BookMetadata metadata = extractMetadata(fileExtension, tempPath.toFile()); + final String uploadPattern = fileMovingHelper.getFileNamingPattern(libraryEntity); - if (finalFile.exists()) { - throw ApiError.FILE_ALREADY_EXISTS.createException(); - } + final String relativePath = PathPatternResolver.resolvePattern(metadata, uploadPattern, originalFileName); + final Path finalPath = Paths.get(libraryPathEntity.getPath(), relativePath); - Files.createDirectories(finalPath.getParent()); - Files.move(tempPath, finalPath); + validateFinalPath(finalPath); + moveFileToFinalLocation(tempPath, finalPath); log.info("File uploaded to final location: {}", finalPath); - result = processFile(finalFile.getName(), libraryEntity, libraryPathEntity, finalFile, fileExt.getType()); - if (result != null && result.getStatus() != FileProcessStatus.DUPLICATE) { - notificationService.sendMessage(Topic.BOOK_ADD, result.getBook()); - } - - return result.getBook(); - } catch (IOException e) { + log.error("Failed to upload file: {}", originalFileName, e); throw ApiError.FILE_READ_ERROR.createException(e.getMessage()); } finally { - Files.deleteIfExists(tempPath); - - if (wePaused) { - Thread.startVirtualThread(() -> { - try { - Thread.sleep(5_000); - monitoringService.resumeMonitoring(); - log.info("Monitoring resumed after 5s delay"); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("Interrupted while delaying resume of monitoring"); - } - }); - } + cleanupTempFile(tempPath); } } @Transactional public AdditionalFile uploadAdditionalFile(Long bookId, MultipartFile file, AdditionalFileType additionalFileType, String description) throws IOException { - Optional bookOpt = bookRepository.findById(bookId); - if (bookOpt.isEmpty()) { - throw new IllegalArgumentException("Book not found with id: " + bookId); - } - - BookEntity book = bookOpt.get(); - String originalFileName = file.getOriginalFilename(); - if (originalFileName == null) { - throw new IllegalArgumentException("File must have a name"); - } - - Path tempPath = Files.createTempFile("upload-", Objects.requireNonNull(file.getOriginalFilename())); - - boolean wePaused = false; - if (!monitoringService.isPaused()) { - monitoringService.pauseMonitoring(); - wePaused = true; - } + final BookEntity book = findBookById(bookId); + final String originalFileName = getValidatedFileName(file); + Path tempPath = null; try { + tempPath = createTempFile(UPLOAD_TEMP_PREFIX, originalFileName); file.transferTo(tempPath); - setTemporaryFileOwnership(tempPath); + final String fileHash = FileFingerprint.generateHash(tempPath); + validateAlternativeFormatDuplicate(additionalFileType, fileHash); - // Check for duplicates by hash, but only for alternative formats - String fileHash = FileFingerprint.generateHash(tempPath); - if (additionalFileType == AdditionalFileType.ALTERNATIVE_FORMAT) { - Optional existingAltFormat = additionalFileRepository.findByAltFormatCurrentHash(fileHash); - if (existingAltFormat.isPresent()) { - throw new IllegalArgumentException("Alternative format file already exists with same content"); - } - } - - // Store file in same directory as the book - Path finalPath = Paths.get(book.getLibraryPath().getPath(), book.getFileSubPath(), originalFileName); - File finalFile = finalPath.toFile(); - - if (finalFile.exists()) { - throw ApiError.FILE_ALREADY_EXISTS.createException(); - } - - Files.createDirectories(finalPath.getParent()); - Files.move(tempPath, finalPath); + final Path finalPath = buildAdditionalFilePath(book, originalFileName); + validateFinalPath(finalPath); + moveFileToFinalLocation(tempPath, finalPath); log.info("Additional file uploaded to final location: {}", finalPath); - // Create entity - BookAdditionalFileEntity entity = BookAdditionalFileEntity.builder() - .book(book) - .fileName(originalFileName) - .fileSubPath(book.getFileSubPath()) - .additionalFileType(additionalFileType) - .fileSizeKb(file.getSize() / 1024) - .initialHash(fileHash) - .currentHash(fileHash) - .description(description) - .addedOn(Instant.now()) - .build(); + final BookAdditionalFileEntity entity = createAdditionalFileEntity(book, originalFileName, additionalFileType, file.getSize(), fileHash, description); + final BookAdditionalFileEntity savedEntity = additionalFileRepository.save(entity); - entity = additionalFileRepository.save(entity); + return additionalFileMapper.toAdditionalFile(savedEntity); - return additionalFileMapper.toAdditionalFile(entity); } catch (IOException e) { + log.error("Failed to upload additional file for book {}: {}", bookId, originalFileName, e); throw ApiError.FILE_READ_ERROR.createException(e.getMessage()); } finally { - Files.deleteIfExists(tempPath); - - if (wePaused) { - Thread.startVirtualThread(() -> { - try { - Thread.sleep(5_000); - monitoringService.resumeMonitoring(); - log.info("Monitoring resumed after 5s delay"); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("Interrupted while delaying resume of monitoring"); - } - }); - } + cleanupTempFile(tempPath); } } public Book uploadFileBookDrop(MultipartFile file) throws IOException { validateFile(file); - Path dropFolder = Paths.get(appProperties.getBookdropFolder()); + final Path dropFolder = Paths.get(appProperties.getBookdropFolder()); Files.createDirectories(dropFolder); - String originalFilename = Objects.requireNonNull(file.getOriginalFilename()); - Path tempPath = Files.createTempFile("bookdrop-", originalFilename); + final String originalFilename = getValidatedFileName(file); + Path tempPath = null; try { + tempPath = createTempFile(BOOKDROP_TEMP_PREFIX, originalFilename); file.transferTo(tempPath); - setTemporaryFileOwnership(tempPath); - Path finalPath = dropFolder.resolve(originalFilename); - - if (Files.exists(finalPath)) { - throw ApiError.FILE_ALREADY_EXISTS.createException(); - } + final Path finalPath = dropFolder.resolve(originalFilename); + validateFinalPath(finalPath); Files.move(tempPath, finalPath); log.info("File moved to book-drop folder: {}", finalPath); return null; + } finally { - Files.deleteIfExists(tempPath); + cleanupTempFile(tempPath); + } + } + + private LibraryEntity findLibraryById(long libraryId) { + return libraryRepository.findById(libraryId) + .orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId)); + } + + private LibraryPathEntity findLibraryPathById(LibraryEntity libraryEntity, long pathId) { + return libraryEntity.getLibraryPaths() + .stream() + .filter(p -> p.getId() == pathId) + .findFirst() + .orElseThrow(() -> ApiError.INVALID_LIBRARY_PATH.createException(libraryEntity.getId())); + } + + private BookEntity findBookById(Long bookId) { + return bookRepository.findById(bookId) + .orElseThrow(() -> new IllegalArgumentException("Book not found with id: " + bookId)); + } + + private String getValidatedFileName(MultipartFile file) { + final String originalFileName = file.getOriginalFilename(); + if (originalFileName == null) { + throw new IllegalArgumentException("File must have a name"); + } + return originalFileName; + } + + private BookFileExtension getFileExtension(String fileName) { + return BookFileExtension.fromFileName(fileName) + .orElseThrow(() -> ApiError.INVALID_FILE_FORMAT.createException("Unsupported file extension")); + } + + private Path createTempFile(String prefix, String fileName) throws IOException { + return Files.createTempFile(prefix, fileName); + } + + private void validateFinalPath(Path finalPath) { + if (Files.exists(finalPath)) { + throw ApiError.FILE_ALREADY_EXISTS.createException(); + } + } + + private void moveFileToFinalLocation(Path sourcePath, Path targetPath) throws IOException { + Files.createDirectories(targetPath.getParent()); + Files.move(sourcePath, targetPath); + } + + private void validateAlternativeFormatDuplicate(AdditionalFileType additionalFileType, String fileHash) { + if (additionalFileType == AdditionalFileType.ALTERNATIVE_FORMAT) { + final Optional existingAltFormat = additionalFileRepository.findByAltFormatCurrentHash(fileHash); + if (existingAltFormat.isPresent()) { + throw new IllegalArgumentException("Alternative format file already exists with same content"); + } + } + } + + private Path buildAdditionalFilePath(BookEntity book, String fileName) { + return Paths.get(book.getLibraryPath().getPath(), book.getFileSubPath(), fileName); + } + + private BookAdditionalFileEntity createAdditionalFileEntity(BookEntity book, String fileName, AdditionalFileType additionalFileType, long fileSize, String fileHash, String description) { + return BookAdditionalFileEntity.builder() + .book(book) + .fileName(fileName) + .fileSubPath(book.getFileSubPath()) + .additionalFileType(additionalFileType) + .fileSizeKb(fileSize / BYTES_TO_KB_DIVISOR) + .initialHash(fileHash) + .currentHash(fileHash) + .description(description) + .addedOn(Instant.now()) + .build(); + } + + private void cleanupTempFile(Path tempPath) { + if (tempPath != null) { + try { + Files.deleteIfExists(tempPath); + } catch (IOException e) { + log.warn("Failed to cleanup temp file: {}", tempPath, e); + } } } @@ -262,41 +236,14 @@ public class FileUploadService { } private void validateFile(MultipartFile file) { - String originalFilename = file.getOriginalFilename(); + final String originalFilename = file.getOriginalFilename(); if (originalFilename == null || BookFileExtension.fromFileName(originalFilename).isEmpty()) { throw ApiError.INVALID_FILE_FORMAT.createException("Unsupported file extension"); } - int maxSizeMb = appSettingService.getAppSettings().getMaxFileUploadSizeInMb(); - if (file.getSize() > maxSizeMb * 1024L * 1024L) { + + final int maxSizeMb = appSettingService.getAppSettings().getMaxFileUploadSizeInMb(); + if (file.getSize() > maxSizeMb * MB_TO_BYTES_MULTIPLIER) { throw ApiError.FILE_TOO_LARGE.createException(maxSizeMb); } } - - private void setTemporaryFileOwnership(Path tempPath) throws IOException { - UserPrincipalLookupService lookupService = FileSystems.getDefault() - .getUserPrincipalLookupService(); - if (!userId.equals("0")) { - UserPrincipal user = lookupService.lookupPrincipalByName(userId); - Files.getFileAttributeView(tempPath, PosixFileAttributeView.class, LinkOption.NOFOLLOW_LINKS).setOwner(user); - } - if (!groupId.equals("0")) { - GroupPrincipal group = lookupService.lookupPrincipalByGroupName(groupId); - Files.getFileAttributeView(tempPath, PosixFileAttributeView.class, LinkOption.NOFOLLOW_LINKS).setGroup(group); - } - } - - private FileProcessResult processFile(String fileName, LibraryEntity libraryEntity, LibraryPathEntity libraryPathEntity, File storageFile, BookFileType type) { - String subPath = FileUtils.getRelativeSubPath(libraryPathEntity.getPath(), storageFile.toPath()); - - LibraryFile libraryFile = LibraryFile.builder() - .libraryEntity(libraryEntity) - .libraryPathEntity(libraryPathEntity) - .fileSubPath(subPath) - .bookFileType(type) - .fileName(fileName) - .build(); - - BookFileProcessor processor = processorRegistry.getProcessorOrThrow(type); - return processor.processFile(libraryFile); - } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java b/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java index 4875f8301..9b60644d5 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/util/FileService.java @@ -208,11 +208,22 @@ public class FileService { if (!folder.exists() && !folder.mkdirs()) { throw new IOException("Failed to create directory: " + folder.getAbsolutePath()); } + BufferedImage rgbImage = new BufferedImage( + coverImage.getWidth(), + coverImage.getHeight(), + BufferedImage.TYPE_INT_RGB + ); + Graphics2D g = rgbImage.createGraphics(); + g.drawImage(coverImage, 0, 0, Color.WHITE, null); + g.dispose(); + File originalFile = new File(folder, COVER_FILENAME); - boolean originalSaved = ImageIO.write(coverImage, IMAGE_FORMAT, originalFile); - BufferedImage thumb = resizeImage(coverImage, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); + boolean originalSaved = ImageIO.write(rgbImage, IMAGE_FORMAT, originalFile); + + BufferedImage thumb = resizeImage(rgbImage, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); File thumbnailFile = new File(folder, THUMBNAIL_FILENAME); boolean thumbnailSaved = ImageIO.write(thumb, IMAGE_FORMAT, thumbnailFile); + return originalSaved && thumbnailSaved; } @@ -336,6 +347,27 @@ public class FileService { log.warn("Skipping file due to missing hash: {}", libraryFile.getFullPath()); return Optional.empty(); } + + // First check for soft-deleted books with the same hash + Optional softDeletedBook = bookRepository.findByCurrentHashAndDeletedTrue(hash); + if (softDeletedBook.isPresent()) { + BookEntity book = softDeletedBook.get(); + log.info("Found soft-deleted book with same hash, undeleting: bookId={} file='{}'", + book.getId(), libraryFile.getFileName()); + + // Undelete the book + book.setDeleted(false); + book.setDeletedAt(null); + + // Update file information + book.setFileName(libraryFile.getFileName()); + book.setFileSubPath(libraryFile.getFileSubPath()); + book.setLibraryPath(libraryFile.getLibraryPathEntity()); + book.setLibrary(libraryFile.getLibraryEntity()); + + return Optional.of(bookMapper.toBook(book)); + } + Optional existingByHash = bookRepository.findByCurrentHash(hash); if (existingByHash.isPresent()) { BookEntity book = existingByHash.get(); diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/BookServiceDeleteTests.java b/booklore-api/src/test/java/com/adityachandel/booklore/BookServiceDeleteTests.java index da827daff..ab0f324f6 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/BookServiceDeleteTests.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/BookServiceDeleteTests.java @@ -7,7 +7,7 @@ import com.adityachandel.booklore.service.BookDownloadService; import com.adityachandel.booklore.service.BookQueryService; import com.adityachandel.booklore.service.BookService; import com.adityachandel.booklore.service.UserProgressService; -import com.adityachandel.booklore.service.monitoring.MonitoringProtectionService; +import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; import com.adityachandel.booklore.util.FileService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -49,7 +49,7 @@ class BookServiceDeleteTests { BookQueryService bookQueryService = Mockito.mock(BookQueryService.class); UserProgressService userProgressService = Mockito.mock(UserProgressService.class); BookDownloadService bookDownloadService = Mockito.mock(BookDownloadService.class); - MonitoringProtectionService monitoringProtectionService = Mockito.mock(MonitoringProtectionService.class); + MonitoringRegistrationService monitoringRegistrationService = Mockito.mock(MonitoringRegistrationService.class); bookService = new BookService( bookRepository, @@ -66,7 +66,7 @@ class BookServiceDeleteTests { bookQueryService, userProgressService, bookDownloadService, - monitoringProtectionService + monitoringRegistrationService ); } diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/FileMoveServiceMoveFilesTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/FileMoveServiceMoveFilesTest.java deleted file mode 100644 index cfe2d7ab1..000000000 --- a/booklore-api/src/test/java/com/adityachandel/booklore/FileMoveServiceMoveFilesTest.java +++ /dev/null @@ -1,792 +0,0 @@ -package com.adityachandel.booklore; - -import com.adityachandel.booklore.mapper.BookMapper; -import com.adityachandel.booklore.model.dto.Book; -import com.adityachandel.booklore.model.dto.request.FileMoveRequest; -import com.adityachandel.booklore.model.dto.settings.AppSettings; -import com.adityachandel.booklore.model.entity.*; -import com.adityachandel.booklore.model.enums.AdditionalFileType; -import com.adityachandel.booklore.model.websocket.Topic; -import com.adityachandel.booklore.repository.BookAdditionalFileRepository; -import com.adityachandel.booklore.repository.BookRepository; -import com.adityachandel.booklore.service.BookQueryService; -import com.adityachandel.booklore.service.NotificationService; -import com.adityachandel.booklore.service.appsettings.AppSettingService; -import com.adityachandel.booklore.service.file.FileMoveService; -import com.adityachandel.booklore.service.library.LibraryService; -import com.adityachandel.booklore.service.monitoring.MonitoringProtectionService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.io.TempDir; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class FileMoveServiceMoveFilesTest { - - @Mock - private BookQueryService bookQueryService; - - @Mock - private BookRepository bookRepository; - - @Mock - private BookAdditionalFileRepository bookAdditionalFileRepository; - - @Mock - private BookMapper bookMapper; - - @Mock - private NotificationService notificationService; - - @Mock - private MonitoringProtectionService monitoringProtectionService; - - @Mock - private AppSettingService appSettingService; - - @Mock - private LibraryService libraryService; - - @InjectMocks - private FileMoveService fileMoveService; - - @TempDir - Path tempLibraryRoot; - - @BeforeEach - void setUp() { - // Configure the mock to actually execute the Runnable for all tests - doAnswer(invocation -> { - Runnable runnable = invocation.getArgument(0); - runnable.run(); - return null; - }).when(monitoringProtectionService).executeWithProtection(any(Runnable.class), eq("file move operations")); - } - - private BookEntity createBookWithFile(Path libraryRoot, String fileSubPath, String fileName) throws IOException { - LibraryEntity library = LibraryEntity.builder() - .id(42L) - .name("Test Library") - .fileNamingPattern(null) - .build(); - - LibraryPathEntity libraryPathEntity = LibraryPathEntity.builder() - .path(libraryRoot.toString()) - .library(library) - .build(); - - BookMetadataEntity metadata = BookMetadataEntity.builder() - .title("Test Book") - .authors(new HashSet<>(List.of(new AuthorEntity(1L, "Author Name", new ArrayList<>()))) - ) - .publishedDate(LocalDate.of(2020, 1, 1)) - .build(); - - BookEntity book = BookEntity.builder() - .id(1L) - .fileName(fileName) - .fileSubPath(fileSubPath) - .metadata(metadata) - .libraryPath(libraryPathEntity) - .build(); - - Path oldFilePath = book.getFullFilePath(); - Files.createDirectories(oldFilePath.getParent()); - Files.createFile(oldFilePath); - - return book; - } - - private BookAdditionalFileEntity createAdditionalFile(BookEntity book, Long id, String fileName, - String fileSubPath, AdditionalFileType type, - boolean createActualFile) throws IOException { - BookAdditionalFileEntity additionalFile = BookAdditionalFileEntity.builder() - .id(id) - .book(book) - .fileName(fileName) - .fileSubPath(fileSubPath) - .additionalFileType(type) - .build(); - - if (createActualFile) { - Path filePath = Paths.get(book.getLibraryPath().getPath()) - .resolve(fileSubPath) - .resolve(fileName); - Files.createDirectories(filePath.getParent()); - Files.createFile(filePath); - } - - return additionalFile; - } - - @Test - void testMoveFiles_skipsNonexistentFile() { - BookMetadataEntity metadata = BookMetadataEntity.builder() - .title("NoFile") - .build(); - - LibraryEntity library = LibraryEntity.builder() - .id(43L) - .name("Test Library") - .fileNamingPattern("Moved/{title}") - .build(); - - LibraryPathEntity libraryPathEntity = LibraryPathEntity.builder() - .path(tempLibraryRoot.toString()) - .library(library) - .build(); - - BookEntity book = BookEntity.builder() - .id(2L) - .fileName("nofile.epub") - .fileSubPath("subfolder") - .metadata(metadata) - .libraryPath(libraryPathEntity) - .build(); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(2L))).thenReturn(List.of(book)); - AppSettings appSettings = new AppSettings(); - appSettings.setUploadPattern("Moved/{title}"); - when(appSettingService.getAppSettings()).thenReturn(appSettings); - - FileMoveRequest request = new FileMoveRequest(); - request.setBookIds(Set.of(2L)); - - fileMoveService.moveFiles(request); - - Path expectedNewPath = tempLibraryRoot.resolve("Moved").resolve("NoFile.epub"); - assertThat(Files.exists(expectedNewPath)) - .withFailMessage("No file should be created for nonexistent source") - .isFalse(); - } - - @Test - void testMoveFiles_skipsBookWithoutLibraryPath() throws IOException { - BookMetadataEntity metadata = BookMetadataEntity.builder() - .title("MissingLibrary") - .build(); - - BookEntity book = BookEntity.builder() - .id(3L) - .fileName("missinglibrary.epub") - .fileSubPath("subfolder") - .metadata(metadata) - .libraryPath(null) - .build(); - - Path fakeOldFile = tempLibraryRoot.resolve("subfolder").resolve("missinglibrary.epub"); - Files.createDirectories(fakeOldFile.getParent()); - Files.createFile(fakeOldFile); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(3L))).thenReturn(List.of(book)); - AppSettings appSettings = new AppSettings(); - appSettings.setUploadPattern("Moved/{title}"); - when(appSettingService.getAppSettings()).thenReturn(appSettings); - - FileMoveRequest request = new FileMoveRequest(); - request.setBookIds(Set.of(3L)); - - fileMoveService.moveFiles(request); - - Path expectedNewPath = tempLibraryRoot.resolve("Moved").resolve("MissingLibrary.epub"); - assertThat(Files.exists(expectedNewPath)) - .withFailMessage("File should not be moved if library path is missing") - .isFalse(); - assertThat(Files.exists(fakeOldFile)) - .withFailMessage("Original file should remain when library path is missing") - .isTrue(); - - Files.deleteIfExists(fakeOldFile); - } - - @Test - void testMoveFiles_skipsBookWithNullFileName() throws IOException { - BookMetadataEntity metadata = BookMetadataEntity.builder() - .title("NullFileName") - .build(); - - LibraryEntity library = LibraryEntity.builder() - .id(46L) - .name("Test Library") - .fileNamingPattern("Moved/{title}") - .build(); - - LibraryPathEntity libraryPathEntity = LibraryPathEntity.builder() - .path(tempLibraryRoot.toString()) - .library(library) - .build(); - - BookEntity book = BookEntity.builder() - .id(10L) - .fileName(null) - .fileSubPath("folder") - .metadata(metadata) - .libraryPath(libraryPathEntity) - .build(); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(10L))).thenReturn(List.of(book)); - AppSettings appSettings = new AppSettings(); - appSettings.setUploadPattern("Moved/{title}"); - when(appSettingService.getAppSettings()).thenReturn(appSettings); - - FileMoveRequest request = new FileMoveRequest(); - request.setBookIds(Set.of(10L)); - - fileMoveService.moveFiles(request); - - Path expectedNewPath = tempLibraryRoot.resolve("Moved").resolve("NullFileName"); - assertThat(Files.exists(expectedNewPath)) - .withFailMessage("File should not be moved if filename is null") - .isFalse(); - } - - @Test - void testMoveFiles_skipsBookWithEmptyFileName() throws IOException { - BookMetadataEntity metadata = BookMetadataEntity.builder() - .title("EmptyFileName") - .build(); - - LibraryEntity library = LibraryEntity.builder() - .id(47L) - .name("Test Library") - .fileNamingPattern("Moved/{title}") - .build(); - - LibraryPathEntity libraryPathEntity = LibraryPathEntity.builder() - .path(tempLibraryRoot.toString()) - .library(library) - .build(); - - BookEntity book = BookEntity.builder() - .id(11L) - .fileName("") - .fileSubPath("folder") - .metadata(metadata) - .libraryPath(libraryPathEntity) - .build(); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(11L))).thenReturn(List.of(book)); - AppSettings appSettings = new AppSettings(); - appSettings.setUploadPattern("Moved/{title}"); - when(appSettingService.getAppSettings()).thenReturn(appSettings); - - FileMoveRequest request = new FileMoveRequest(); - request.setBookIds(Set.of(11L)); - - fileMoveService.moveFiles(request); - - Path expectedNewPath = tempLibraryRoot.resolve("Moved").resolve("EmptyFileName"); - assertThat(Files.exists(expectedNewPath)) - .withFailMessage("File should not be moved if filename is empty") - .isFalse(); - } - - @Test - void testMoveFiles_skipsBookWithNullFileSubPath() throws IOException { - BookMetadataEntity metadata = BookMetadataEntity.builder() - .title("NullSubPath") - .build(); - - LibraryEntity library = LibraryEntity.builder() - .id(48L) - .name("Test Library") - .fileNamingPattern("Moved/{title}") - .build(); - - LibraryPathEntity libraryPathEntity = LibraryPathEntity.builder() - .path(tempLibraryRoot.toString()) - .library(library) - .build(); - - BookEntity book = BookEntity.builder() - .id(12L) - .fileName("file.epub") - .fileSubPath(null) - .metadata(metadata) - .libraryPath(libraryPathEntity) - .build(); - - Path oldFile = tempLibraryRoot.resolve("file.epub"); - Files.createFile(oldFile); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(12L))).thenReturn(List.of(book)); - AppSettings appSettings = new AppSettings(); - appSettings.setUploadPattern("Moved/{title}"); - when(appSettingService.getAppSettings()).thenReturn(appSettings); - - FileMoveRequest request = new FileMoveRequest(); - request.setBookIds(Set.of(12L)); - - fileMoveService.moveFiles(request); - - Path expectedNewPath = tempLibraryRoot.resolve("Moved").resolve("NullSubPath.epub"); - assertThat(Files.exists(expectedNewPath)) - .withFailMessage("File should not be moved if fileSubPath is null") - .isFalse(); - - Files.deleteIfExists(oldFile); - } - - @Test - void testMoveFiles_skipsBookWithNullMetadata() throws IOException { - LibraryEntity library = LibraryEntity.builder() - .id(49L) - .name("Test Library") - .fileNamingPattern("Moved/{title}") - .build(); - - LibraryPathEntity libraryPathEntity = LibraryPathEntity.builder() - .path(tempLibraryRoot.toString()) - .library(library) - .build(); - - BookEntity book = BookEntity.builder() - .id(13L) - .fileName("file.epub") - .fileSubPath("folder") - .metadata(null) - .libraryPath(libraryPathEntity) - .build(); - - Path oldFile = tempLibraryRoot.resolve("folder").resolve("file.epub"); - Files.createDirectories(oldFile.getParent()); - Files.createFile(oldFile); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(13L))).thenReturn(List.of(book)); - AppSettings appSettings = new AppSettings(); - appSettings.setUploadPattern("Moved/{title}"); - when(appSettingService.getAppSettings()).thenReturn(appSettings); - - FileMoveRequest request = new FileMoveRequest(); - request.setBookIds(Set.of(13L)); - - fileMoveService.moveFiles(request); - - Path expectedNewPath = tempLibraryRoot.resolve("Moved").resolve(".epub"); - assertThat(Files.exists(expectedNewPath)) - .withFailMessage("File should not be moved if metadata is null") - .isFalse(); - - Files.deleteIfExists(oldFile); - } - - @Test - void testMoveFiles_successfulMove() throws IOException { - BookEntity book = createBookWithFile(tempLibraryRoot, "sub", "mybook.epub"); - Path oldFilePath = book.getFullFilePath(); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(book.getId()))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("X/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - Book dto = Book.builder().id(book.getId()).build(); - when(bookMapper.toBook(book)).thenReturn(dto); - - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(book.getId())); - fileMoveService.moveFiles(req); - - Path newPath = tempLibraryRoot.resolve("X").resolve("Test Book.epub"); - assertThat(Files.exists(newPath)).isTrue(); - assertThat(Files.exists(oldFilePath)).isFalse(); - - verify(bookRepository).save(book); - verify(notificationService).sendMessage(eq(Topic.BOOK_METADATA_BATCH_UPDATE), anyList()); - - // Verify monitoring protection was applied correctly - verify(monitoringProtectionService).executeWithProtection(any(Runnable.class), eq("file move operations")); - - // Note: Library rescan now happens asynchronously with 8-second delay - // We can't verify it immediately in the test, but the operation is scheduled - } - - @Test - void testMoveFiles_usesDefaultPatternWhenLibraryPatternEmpty() throws IOException { - LibraryEntity lib = LibraryEntity.builder() - .id(99L).name("Lib").fileNamingPattern(" ").build(); - LibraryPathEntity lp = LibraryPathEntity.builder() - .path(tempLibraryRoot.toString()).library(lib).build(); - - BookMetadataEntity meta = BookMetadataEntity.builder() - .title("DFT").build(); - BookEntity book = BookEntity.builder() - .id(55L).fileSubPath("old").fileName("a.epub") - .libraryPath(lp).metadata(meta).build(); - - Path old = book.getFullFilePath(); - Files.createDirectories(old.getParent()); - Files.createFile(old); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(55L))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("DEF/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - Book dto = Book.builder().id(book.getId()).build(); - when(bookMapper.toBook(book)).thenReturn(dto); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(55L)); - fileMoveService.moveFiles(req); - - Path moved = tempLibraryRoot.resolve("DEF").resolve("DFT.epub"); - assertThat(Files.exists(moved)).isTrue(); - verify(bookRepository).save(book); - } - - @Test - void testMoveFiles_deletesEmptyParentDirectories() throws IOException { - LibraryEntity library = LibraryEntity.builder() - .id(100L).name("Lib").fileNamingPattern(null).build(); - LibraryPathEntity lp = LibraryPathEntity.builder() - .path(tempLibraryRoot.toString()).library(library).build(); - BookMetadataEntity meta = BookMetadataEntity.builder() - .title("Nested").build(); - BookEntity book = BookEntity.builder() - .id(101L).fileSubPath("a/b/c").fileName("nested.epub") - .libraryPath(lp).metadata(meta).build(); - Path old = book.getFullFilePath(); - Files.createDirectories(old.getParent()); - Files.createFile(old); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(101L))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("Z/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - when(bookMapper.toBook(book)).thenReturn(Book.builder().id(book.getId()).build()); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(101L)); - fileMoveService.moveFiles(req); - - Path newPath = tempLibraryRoot.resolve("Z").resolve("Nested.epub"); - assertThat(Files.exists(newPath)).isTrue(); - assertThat(Files.notExists(tempLibraryRoot.resolve("a"))).isTrue(); - } - - @Test - void testMoveFiles_overwritesExistingDestination() throws IOException { - LibraryEntity library = LibraryEntity.builder() - .id(102L).name("Lib").fileNamingPattern(null).build(); - LibraryPathEntity lp = LibraryPathEntity.builder() - .path(tempLibraryRoot.toString()).library(library).build(); - BookMetadataEntity meta = BookMetadataEntity.builder() - .title("Overwrite").build(); - BookEntity book = BookEntity.builder() - .id(103L).fileSubPath("sub").fileName("file.epub") - .libraryPath(lp).metadata(meta).build(); - Path old = book.getFullFilePath(); - Files.createDirectories(old.getParent()); - Files.write(old, "SRC".getBytes()); - Path destDir = tempLibraryRoot.resolve("DEST"); - Files.createDirectories(destDir); - Path dest = destDir.resolve("Overwrite.epub"); - Files.write(dest, "OLD".getBytes()); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(103L))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("DEST/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - when(bookMapper.toBook(book)).thenReturn(Book.builder().id(book.getId()).build()); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(103L)); - fileMoveService.moveFiles(req); - - assertThat(Files.exists(dest)).isTrue(); - String content = Files.readString(dest); - assertThat(content).isEqualTo("SRC"); - } - - @Test - void testMoveFiles_usesLibraryPatternWhenSet() throws IOException { - LibraryEntity library = LibraryEntity.builder() - .id(104L).name("Lib").fileNamingPattern("LIBY/{title}").build(); - LibraryPathEntity lp = LibraryPathEntity.builder() - .path(tempLibraryRoot.toString()).library(library).build(); - BookMetadataEntity meta = BookMetadataEntity.builder() - .title("LibTest").build(); - BookEntity book = BookEntity.builder() - .id(105L).fileSubPath("x").fileName("file.epub") - .libraryPath(lp).metadata(meta).build(); - Path old = book.getFullFilePath(); - Files.createDirectories(old.getParent()); - Files.createFile(old); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(105L))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("DEFAULT/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - when(bookMapper.toBook(book)).thenReturn(Book.builder().id(book.getId()).build()); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(105L)); - fileMoveService.moveFiles(req); - - Path newPath = tempLibraryRoot.resolve("LIBY").resolve("LibTest.epub"); - assertThat(Files.exists(newPath)).isTrue(); - } - - @Test - void testMoveFiles_skipsWhenDestinationSameAsSource() throws IOException { - BookEntity book = createBookWithFile(tempLibraryRoot, "sub", "mybook.epub"); - Path oldFilePath = book.getFullFilePath(); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(book.getId()))) - .thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("sub/mybook.epub"); - when(appSettingService.getAppSettings()).thenReturn(settings); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(book.getId())); - fileMoveService.moveFiles(req); - - assertThat(Files.exists(oldFilePath)).isTrue(); - verify(bookRepository, never()).save(any()); - verify(notificationService, never()).sendMessage(any(), anyList()); - verify(libraryService, never()).rescanLibrary(anyLong()); - } - - @Test - void testMoveFiles_executesWithMonitoringProtection() { - when(bookQueryService.findAllWithMetadataByIds(anySet())).thenReturn(List.of()); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("X/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(999L)); - fileMoveService.moveFiles(req); - - verify(monitoringProtectionService).executeWithProtection(any(Runnable.class), eq("file move operations")); - } - - @Test - void testMoveFiles_movesAdditionalFiles() throws IOException { - BookEntity book = createBookWithFile(tempLibraryRoot, "sub", "mybook.epub"); - - // Create additional files - BookAdditionalFileEntity additionalFile1 = createAdditionalFile(book, 1L, "mybook.pdf", "sub", - AdditionalFileType.ALTERNATIVE_FORMAT, true); - BookAdditionalFileEntity additionalFile2 = createAdditionalFile(book, 2L, "mybook_notes.txt", "sub", - AdditionalFileType.SUPPLEMENTARY, true); - - List additionalFiles = List.of(additionalFile1, additionalFile2); - book.setAdditionalFiles(additionalFiles); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(book.getId()))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("X/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - Book dto = Book.builder().id(book.getId()).build(); - when(bookMapper.toBook(book)).thenReturn(dto); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(book.getId())); - fileMoveService.moveFiles(req); - - // Verify main book moved - Path newBookPath = tempLibraryRoot.resolve("X").resolve("Test Book.epub"); - assertThat(Files.exists(newBookPath)).isTrue(); - - // Verify additional files moved - Path newAdditionalPath1 = tempLibraryRoot.resolve("X").resolve("Test Book.pdf"); - Path newAdditionalPath2 = tempLibraryRoot.resolve("X").resolve("Test Book.txt"); - assertThat(Files.exists(newAdditionalPath1)).isTrue(); - assertThat(Files.exists(newAdditionalPath2)).isTrue(); - - // Verify database updated - verify(bookAdditionalFileRepository, times(2)).save(any(BookAdditionalFileEntity.class)); - } - - @Test - void testMoveFiles_handlesUniqueNamesForAdditionalFiles() throws IOException { - BookEntity book = createBookWithFile(tempLibraryRoot, "sub", "mybook.epub"); - - // Create additional files that will result in same name after pattern resolution - BookAdditionalFileEntity additionalFile1 = createAdditionalFile(book, 1L, "version1.pdf", "sub", - AdditionalFileType.ALTERNATIVE_FORMAT, true); - BookAdditionalFileEntity additionalFile2 = createAdditionalFile(book, 2L, "version2.pdf", "sub", - AdditionalFileType.ALTERNATIVE_FORMAT, true); - - List additionalFiles = List.of(additionalFile1, additionalFile2); - book.setAdditionalFiles(additionalFiles); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(book.getId()))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("X/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - Book dto = Book.builder().id(book.getId()).build(); - when(bookMapper.toBook(book)).thenReturn(dto); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(book.getId())); - fileMoveService.moveFiles(req); - - // Verify files moved with unique names - Path newAdditionalPath1 = tempLibraryRoot.resolve("X").resolve("Test Book.pdf"); - Path newAdditionalPath2 = tempLibraryRoot.resolve("X").resolve("Test Book_2.pdf"); - assertThat(Files.exists(newAdditionalPath1)).isTrue(); - assertThat(Files.exists(newAdditionalPath2)).isTrue(); - - verify(bookAdditionalFileRepository, times(2)).save(any(BookAdditionalFileEntity.class)); - } - - @Test - void testMoveFiles_skipsNonexistentAdditionalFiles() throws IOException { - BookEntity book = createBookWithFile(tempLibraryRoot, "sub", "mybook.epub"); - - // Create additional file entity without actual file - BookAdditionalFileEntity additionalFile = createAdditionalFile(book, 1L, "nonexistent.pdf", "sub", - AdditionalFileType.ALTERNATIVE_FORMAT, false); - - book.setAdditionalFiles(List.of(additionalFile)); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(book.getId()))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("X/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - Book dto = Book.builder().id(book.getId()).build(); - when(bookMapper.toBook(book)).thenReturn(dto); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(book.getId())); - fileMoveService.moveFiles(req); - - // Verify main book moved - Path newBookPath = tempLibraryRoot.resolve("X").resolve("Test Book.epub"); - assertThat(Files.exists(newBookPath)).isTrue(); - - // Verify nonexistent additional file not saved - verify(bookAdditionalFileRepository, never()).save(any(BookAdditionalFileEntity.class)); - } - - @Test - void testMoveFiles_handlesEmptyAdditionalFilesList() throws IOException { - BookEntity book = createBookWithFile(tempLibraryRoot, "sub", "mybook.epub"); - book.setAdditionalFiles(new ArrayList<>()); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(book.getId()))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("X/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - Book dto = Book.builder().id(book.getId()).build(); - when(bookMapper.toBook(book)).thenReturn(dto); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(book.getId())); - fileMoveService.moveFiles(req); - - // Verify main book moved - Path newBookPath = tempLibraryRoot.resolve("X").resolve("Test Book.epub"); - assertThat(Files.exists(newBookPath)).isTrue(); - - // Verify no additional file operations - verify(bookAdditionalFileRepository, never()).save(any(BookAdditionalFileEntity.class)); - } - - @Test - void testMoveFiles_handlesNullAdditionalFilesList() throws IOException { - BookEntity book = createBookWithFile(tempLibraryRoot, "sub", "mybook.epub"); - book.setAdditionalFiles(null); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(book.getId()))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("X/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - Book dto = Book.builder().id(book.getId()).build(); - when(bookMapper.toBook(book)).thenReturn(dto); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(book.getId())); - fileMoveService.moveFiles(req); - - // Verify main book moved - Path newBookPath = tempLibraryRoot.resolve("X").resolve("Test Book.epub"); - assertThat(Files.exists(newBookPath)).isTrue(); - - // Verify no additional file operations - verify(bookAdditionalFileRepository, never()).save(any(BookAdditionalFileEntity.class)); - } - - @Test - void testMoveFiles_additionalFileWithDifferentExtensions() throws IOException { - BookEntity book = createBookWithFile(tempLibraryRoot, "sub", "mybook.epub"); - - // Create additional files with different extensions - BookAdditionalFileEntity additionalFile1 = createAdditionalFile(book, 1L, "mybook.pdf", "sub", - AdditionalFileType.ALTERNATIVE_FORMAT, true); - BookAdditionalFileEntity additionalFile2 = createAdditionalFile(book, 2L, "mybook.mobi", "sub", - AdditionalFileType.ALTERNATIVE_FORMAT, true); - BookAdditionalFileEntity additionalFile3 = createAdditionalFile(book, 3L, "cover.jpg", "sub", - AdditionalFileType.SUPPLEMENTARY, true); - - List additionalFiles = List.of(additionalFile1, additionalFile2, additionalFile3); - book.setAdditionalFiles(additionalFiles); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(book.getId()))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("Library/{authors}/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - Book dto = Book.builder().id(book.getId()).build(); - when(bookMapper.toBook(book)).thenReturn(dto); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(book.getId())); - fileMoveService.moveFiles(req); - - // Verify all files moved with correct extensions - Path basePath = tempLibraryRoot.resolve("Library").resolve("Author Name"); - assertThat(Files.exists(basePath.resolve("Test Book.epub"))).isTrue(); - assertThat(Files.exists(basePath.resolve("Test Book.pdf"))).isTrue(); - assertThat(Files.exists(basePath.resolve("Test Book.mobi"))).isTrue(); - assertThat(Files.exists(basePath.resolve("Test Book.jpg"))).isTrue(); - - verify(bookAdditionalFileRepository, times(3)).save(any(BookAdditionalFileEntity.class)); - } - - @Test - void testMoveFiles_skipsSamePathAdditionalFiles() throws IOException { - BookEntity book = createBookWithFile(tempLibraryRoot, "X", "Test Book.epub"); - - // Create additional file that will resolve to same path - BookAdditionalFileEntity additionalFile = createAdditionalFile(book, 1L, "Test Book.pdf", "X", - AdditionalFileType.ALTERNATIVE_FORMAT, true); - - book.setAdditionalFiles(List.of(additionalFile)); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(book.getId()))).thenReturn(List.of(book)); - AppSettings settings = new AppSettings(); - settings.setUploadPattern("X/{title}"); - when(appSettingService.getAppSettings()).thenReturn(settings); - - FileMoveRequest req = new FileMoveRequest(); - req.setBookIds(Set.of(book.getId())); - fileMoveService.moveFiles(req); - - // Verify files still exist at original location - Path additionalFilePath = tempLibraryRoot.resolve("X").resolve("Test Book.pdf"); - assertThat(Files.exists(additionalFilePath)).isTrue(); - - // Verify no save called for additional file (skipped) - verify(bookAdditionalFileRepository, never()).save(any(BookAdditionalFileEntity.class)); - } -} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/FileMoveServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/FileMoveServiceTest.java index b09a3ae84..483fe90a1 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/FileMoveServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/FileMoveServiceTest.java @@ -90,7 +90,7 @@ class FileMoveServiceTest { .isIn( "/Good Omens - Neil Gaiman, Terry Pratchett (1990).mobi", "/Good Omens - Terry Pratchett, Neil Gaiman (1990).mobi" - ); // Set order is non-deterministic + ); } @Test @@ -162,7 +162,7 @@ class FileMoveServiceTest { BookEntity book = BookEntity.builder() .metadata(metadata) - .fileName("") // explicitly empty + .fileName("") .build(); String result = fileMoveService.generatePathFromPattern(book, "/{title}"); diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/AdditionalFileServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/AdditionalFileServiceTest.java index f2c3769a3..14630cb88 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/AdditionalFileServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/AdditionalFileServiceTest.java @@ -1,29 +1,36 @@ package com.adityachandel.booklore.service; import com.adityachandel.booklore.mapper.AdditionalFileMapper; +import com.adityachandel.booklore.model.dto.AdditionalFile; import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.entity.LibraryPathEntity; import com.adityachandel.booklore.model.enums.AdditionalFileType; import com.adityachandel.booklore.repository.BookAdditionalFileRepository; -import com.adityachandel.booklore.service.monitoring.MonitoringProtectionService; +import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Collections; +import java.util.List; import java.util.Optional; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -36,7 +43,7 @@ class AdditionalFileServiceTest { private AdditionalFileMapper additionalFileMapper; @Mock - private MonitoringProtectionService monitoringProtectionService; + private MonitoringRegistrationService monitoringRegistrationService; @InjectMocks private AdditionalFileService additionalFileService; @@ -44,146 +51,243 @@ class AdditionalFileServiceTest { @TempDir Path tempDir; - @BeforeEach - void setUp() { - // Configure mock to execute the Runnable for file operations (lenient for tests that don't use it) - lenient().doAnswer(invocation -> { - Runnable runnable = invocation.getArgument(0); - runnable.run(); - return null; - }).when(monitoringProtectionService).executeWithProtection(any(Runnable.class), eq("additional file deletion")); - } + private BookAdditionalFileEntity fileEntity; + private AdditionalFile additionalFile; + private BookEntity bookEntity; + private LibraryPathEntity libraryPathEntity; - @Test - void deleteAdditionalFile_success() throws IOException { - // Arrange + @BeforeEach + void setUp() throws IOException { Path testFile = tempDir.resolve("test-file.pdf"); Files.createFile(testFile); - LibraryPathEntity libraryPath = new LibraryPathEntity(); - libraryPath.setPath(tempDir.toString()); + libraryPathEntity = new LibraryPathEntity(); + libraryPathEntity.setId(1L); + libraryPathEntity.setPath(tempDir.toString()); - BookEntity book = new BookEntity(); - book.setLibraryPath(libraryPath); + bookEntity = new BookEntity(); + bookEntity.setId(100L); + bookEntity.setLibraryPath(libraryPathEntity); - BookAdditionalFileEntity fileEntity = BookAdditionalFileEntity.builder() - .id(1L) - .fileName("test-file.pdf") - .fileSubPath("") - .additionalFileType(AdditionalFileType.ALTERNATIVE_FORMAT) - .book(book) - .build(); + fileEntity = new BookAdditionalFileEntity(); + fileEntity.setId(1L); + fileEntity.setBook(bookEntity); + fileEntity.setFileName("test-file.pdf"); + fileEntity.setFileSubPath("."); + fileEntity.setAdditionalFileType(AdditionalFileType.ALTERNATIVE_FORMAT); - when(additionalFileRepository.findById(1L)).thenReturn(Optional.of(fileEntity)); - - // Act - additionalFileService.deleteAdditionalFile(1L); - - // Assert - verify(monitoringProtectionService).executeWithProtection(any(Runnable.class), eq("additional file deletion")); - verify(additionalFileRepository).delete(fileEntity); - assertThat(Files.exists(testFile)).isFalse(); + additionalFile = mock(AdditionalFile.class); } @Test - void deleteAdditionalFile_fileNotFound() { - // Arrange - when(additionalFileRepository.findById(1L)).thenReturn(Optional.empty()); + void getAdditionalFilesByBookId_WhenFilesExist_ShouldReturnMappedFiles() { + Long bookId = 100L; + List entities = List.of(fileEntity); + List expectedFiles = List.of(additionalFile); - // Act & Assert - assertThatThrownBy(() -> additionalFileService.deleteAdditionalFile(1L)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Additional file not found with id: 1"); + when(additionalFileRepository.findByBookId(bookId)).thenReturn(entities); + when(additionalFileMapper.toAdditionalFiles(entities)).thenReturn(expectedFiles); - verify(monitoringProtectionService, never()).executeWithProtection(any(Runnable.class), any()); + List result = additionalFileService.getAdditionalFilesByBookId(bookId); + + assertEquals(expectedFiles, result); + verify(additionalFileRepository).findByBookId(bookId); + verify(additionalFileMapper).toAdditionalFiles(entities); } @Test - void deleteAdditionalFile_physicalFileNotExists() { - // Arrange - Path nonExistentFile = tempDir.resolve("non-existent.pdf"); + void getAdditionalFilesByBookId_WhenNoFilesExist_ShouldReturnEmptyList() { + Long bookId = 100L; + List entities = Collections.emptyList(); + List expectedFiles = Collections.emptyList(); - LibraryPathEntity libraryPath = new LibraryPathEntity(); - libraryPath.setPath(tempDir.toString()); + when(additionalFileRepository.findByBookId(bookId)).thenReturn(entities); + when(additionalFileMapper.toAdditionalFiles(entities)).thenReturn(expectedFiles); - BookEntity book = new BookEntity(); - book.setLibraryPath(libraryPath); + List result = additionalFileService.getAdditionalFilesByBookId(bookId); - BookAdditionalFileEntity fileEntity = BookAdditionalFileEntity.builder() - .id(1L) - .fileName("non-existent.pdf") - .fileSubPath("") - .additionalFileType(AdditionalFileType.ALTERNATIVE_FORMAT) - .book(book) - .build(); - - when(additionalFileRepository.findById(1L)).thenReturn(Optional.of(fileEntity)); - - // Act - additionalFileService.deleteAdditionalFile(1L); - - // Assert - verify(monitoringProtectionService).executeWithProtection(any(Runnable.class), eq("additional file deletion")); - verify(additionalFileRepository).delete(fileEntity); + assertTrue(result.isEmpty()); + verify(additionalFileRepository).findByBookId(bookId); + verify(additionalFileMapper).toAdditionalFiles(entities); } @Test - void deleteAdditionalFile_ioExceptionDuringDeletion() throws IOException { - // This test verifies that DB record is still deleted even if file deletion fails - // Arrange - LibraryPathEntity libraryPath = new LibraryPathEntity(); - libraryPath.setPath("/invalid/path"); + void getAdditionalFilesByBookIdAndType_WhenFilesExist_ShouldReturnMappedFiles() { + Long bookId = 100L; + AdditionalFileType type = AdditionalFileType.ALTERNATIVE_FORMAT; + List entities = List.of(fileEntity); + List expectedFiles = List.of(additionalFile); - BookEntity book = new BookEntity(); - book.setLibraryPath(libraryPath); + when(additionalFileRepository.findByBookIdAndAdditionalFileType(bookId, type)).thenReturn(entities); + when(additionalFileMapper.toAdditionalFiles(entities)).thenReturn(expectedFiles); - BookAdditionalFileEntity fileEntity = BookAdditionalFileEntity.builder() - .id(1L) - .fileName("test-file.pdf") - .fileSubPath("") - .additionalFileType(AdditionalFileType.ALTERNATIVE_FORMAT) - .book(book) - .build(); + List result = additionalFileService.getAdditionalFilesByBookIdAndType(bookId, type); - when(additionalFileRepository.findById(1L)).thenReturn(Optional.of(fileEntity)); - - // Act - additionalFileService.deleteAdditionalFile(1L); - - // Assert - DB record should still be deleted even if file deletion fails - verify(monitoringProtectionService).executeWithProtection(any(Runnable.class), eq("additional file deletion")); - verify(additionalFileRepository).delete(fileEntity); + assertEquals(expectedFiles, result); + verify(additionalFileRepository).findByBookIdAndAdditionalFileType(bookId, type); + verify(additionalFileMapper).toAdditionalFiles(entities); } @Test - void deleteAdditionalFile_withMonitoringProtection() { - // Arrange - LibraryPathEntity libraryPath = new LibraryPathEntity(); - libraryPath.setPath(tempDir.toString()); + void getAdditionalFilesByBookIdAndType_WhenNoFilesExist_ShouldReturnEmptyList() { + Long bookId = 100L; + AdditionalFileType type = AdditionalFileType.SUPPLEMENTARY; + List entities = Collections.emptyList(); + List expectedFiles = Collections.emptyList(); - BookEntity book = new BookEntity(); - book.setLibraryPath(libraryPath); + when(additionalFileRepository.findByBookIdAndAdditionalFileType(bookId, type)).thenReturn(entities); + when(additionalFileMapper.toAdditionalFiles(entities)).thenReturn(expectedFiles); - BookAdditionalFileEntity fileEntity = BookAdditionalFileEntity.builder() - .id(1L) - .fileName("test-file.pdf") - .fileSubPath("subfolder") - .additionalFileType(AdditionalFileType.SUPPLEMENTARY) - .book(book) - .build(); + List result = additionalFileService.getAdditionalFilesByBookIdAndType(bookId, type); - when(additionalFileRepository.findById(1L)).thenReturn(Optional.of(fileEntity)); + assertTrue(result.isEmpty()); + verify(additionalFileRepository).findByBookIdAndAdditionalFileType(bookId, type); + verify(additionalFileMapper).toAdditionalFiles(entities); + } - // Act - additionalFileService.deleteAdditionalFile(1L); + @Test + void deleteAdditionalFile_WhenFileNotFound_ShouldThrowException() { + Long fileId = 1L; + when(additionalFileRepository.findById(fileId)).thenReturn(Optional.empty()); - // Assert - Verify monitoring protection was used - verify(monitoringProtectionService).executeWithProtection(any(Runnable.class), eq("additional file deletion")); - - // Verify the operation name is correct for logging/tracking - verify(monitoringProtectionService).executeWithProtection( - any(Runnable.class), - eq("additional file deletion") + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> additionalFileService.deleteAdditionalFile(fileId) ); + + assertEquals("Additional file not found with id: 1", exception.getMessage()); + verify(additionalFileRepository).findById(fileId); + verify(additionalFileRepository, never()).delete(any()); + verify(monitoringRegistrationService, never()).unregisterSpecificPath(any()); } -} \ No newline at end of file + + @Test + void deleteAdditionalFile_WhenFileExists_ShouldDeleteSuccessfully() throws IOException { + Long fileId = 1L; + Path parentPath = fileEntity.getFullFilePath().getParent(); + + when(additionalFileRepository.findById(fileId)).thenReturn(Optional.of(fileEntity)); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.deleteIfExists(fileEntity.getFullFilePath())).thenReturn(true); + + additionalFileService.deleteAdditionalFile(fileId); + + verify(additionalFileRepository).findById(fileId); + verify(monitoringRegistrationService).unregisterSpecificPath(parentPath); + filesMock.verify(() -> Files.deleteIfExists(fileEntity.getFullFilePath())); + verify(additionalFileRepository).delete(fileEntity); + } + } + + @Test + void deleteAdditionalFile_WhenIOExceptionOccurs_ShouldStillDeleteFromRepository() throws IOException { + Long fileId = 1L; + Path parentPath = fileEntity.getFullFilePath().getParent(); + + when(additionalFileRepository.findById(fileId)).thenReturn(Optional.of(fileEntity)); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.deleteIfExists(fileEntity.getFullFilePath())).thenThrow(new IOException("File access error")); + + additionalFileService.deleteAdditionalFile(fileId); + + verify(additionalFileRepository).findById(fileId); + verify(monitoringRegistrationService).unregisterSpecificPath(parentPath); + filesMock.verify(() -> Files.deleteIfExists(fileEntity.getFullFilePath())); + verify(additionalFileRepository).delete(fileEntity); + } + } + + @Test + void deleteAdditionalFile_WhenEntityRelationshipsMissing_ShouldThrowIllegalStateException() { + Long fileId = 1L; + BookAdditionalFileEntity invalidEntity = new BookAdditionalFileEntity(); + invalidEntity.setId(fileId); + + when(additionalFileRepository.findById(fileId)).thenReturn(Optional.of(invalidEntity)); + + assertThrows( + IllegalStateException.class, + () -> additionalFileService.deleteAdditionalFile(fileId) + ); + + verify(additionalFileRepository).findById(fileId); + verify(additionalFileRepository, never()).delete(any()); + verify(monitoringRegistrationService, never()).unregisterSpecificPath(any()); + } + + @Test + void downloadAdditionalFile_WhenFileNotFound_ShouldReturnNotFound() throws IOException { + Long fileId = 1L; + when(additionalFileRepository.findById(fileId)).thenReturn(Optional.empty()); + + ResponseEntity result = additionalFileService.downloadAdditionalFile(fileId); + + assertEquals(HttpStatus.NOT_FOUND, result.getStatusCode()); + assertNull(result.getBody()); + verify(additionalFileRepository).findById(fileId); + } + + @Test + void downloadAdditionalFile_WhenPhysicalFileNotExists_ShouldReturnNotFound() throws IOException { + Long fileId = 1L; + + BookAdditionalFileEntity entityWithNonExistentFile = new BookAdditionalFileEntity(); + entityWithNonExistentFile.setId(fileId); + entityWithNonExistentFile.setBook(bookEntity); + entityWithNonExistentFile.setFileName("non-existent.pdf"); + entityWithNonExistentFile.setFileSubPath("."); + + when(additionalFileRepository.findById(fileId)).thenReturn(Optional.of(entityWithNonExistentFile)); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + Path actualPath = entityWithNonExistentFile.getFullFilePath(); + filesMock.when(() -> Files.exists(actualPath)).thenReturn(false); + + ResponseEntity result = additionalFileService.downloadAdditionalFile(fileId); + + assertEquals(HttpStatus.NOT_FOUND, result.getStatusCode()); + assertNull(result.getBody()); + verify(additionalFileRepository).findById(fileId); + filesMock.verify(() -> Files.exists(actualPath)); + } + } + + @Test + void downloadAdditionalFile_WhenFileExists_ShouldReturnFileResource() throws IOException { + Long fileId = 1L; + when(additionalFileRepository.findById(fileId)).thenReturn(Optional.of(fileEntity)); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.exists(fileEntity.getFullFilePath())).thenReturn(true); + + ResponseEntity result = additionalFileService.downloadAdditionalFile(fileId); + + assertEquals(HttpStatus.OK, result.getStatusCode()); + assertNotNull(result.getBody()); + assertTrue(result.getHeaders().containsKey(HttpHeaders.CONTENT_DISPOSITION)); + assertTrue(result.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION).contains("test-file.pdf")); + assertEquals(MediaType.APPLICATION_OCTET_STREAM, result.getHeaders().getContentType()); + + verify(additionalFileRepository).findById(fileId); + filesMock.verify(() -> Files.exists(fileEntity.getFullFilePath())); + } + } + + @Test + void downloadAdditionalFile_WhenEntityRelationshipsMissing_ShouldThrowIllegalStateException() throws IOException { + Long fileId = 1L; + BookAdditionalFileEntity invalidEntity = new BookAdditionalFileEntity(); + invalidEntity.setId(fileId); + + when(additionalFileRepository.findById(fileId)).thenReturn(Optional.of(invalidEntity)); + + assertThrows( + IllegalStateException.class, + () -> additionalFileService.downloadAdditionalFile(fileId) + ); + + verify(additionalFileRepository).findById(fileId); + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/BookServiceDeleteBooksTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/BookServiceDeleteBooksTest.java deleted file mode 100644 index 127a8dc79..000000000 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/BookServiceDeleteBooksTest.java +++ /dev/null @@ -1,275 +0,0 @@ -package com.adityachandel.booklore.service; - -import com.adityachandel.booklore.config.security.service.AuthenticationService; -import com.adityachandel.booklore.mapper.BookMapper; -import com.adityachandel.booklore.model.dto.response.BookDeletionResponse; -import com.adityachandel.booklore.model.entity.BookEntity; -import com.adityachandel.booklore.model.entity.LibraryEntity; -import com.adityachandel.booklore.model.entity.LibraryPathEntity; -import com.adityachandel.booklore.repository.*; -import com.adityachandel.booklore.service.monitoring.MonitoringProtectionService; -import com.adityachandel.booklore.util.FileService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.io.TempDir; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.ResponseEntity; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Set; -import java.util.function.Supplier; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class BookServiceDeleteBooksTest { - - @Mock private BookRepository bookRepository; - @Mock private PdfViewerPreferencesRepository pdfViewerPreferencesRepository; - @Mock private EpubViewerPreferencesRepository epubViewerPreferencesRepository; - @Mock private CbxViewerPreferencesRepository cbxViewerPreferencesRepository; - @Mock private NewPdfViewerPreferencesRepository newPdfViewerPreferencesRepository; - @Mock private ShelfRepository shelfRepository; - @Mock private FileService fileService; - @Mock private BookMapper bookMapper; - @Mock private UserRepository userRepository; - @Mock private UserBookProgressRepository userBookProgressRepository; - @Mock private AuthenticationService authenticationService; - @Mock private BookQueryService bookQueryService; - @Mock private UserProgressService userProgressService; - @Mock private BookDownloadService bookDownloadService; - @Mock private MonitoringProtectionService monitoringProtectionService; - - private BookService bookService; - - @TempDir - Path tempDir; - - @BeforeEach - void setUp() { - bookService = new BookService( - bookRepository, - pdfViewerPreferencesRepository, - epubViewerPreferencesRepository, - cbxViewerPreferencesRepository, - newPdfViewerPreferencesRepository, - shelfRepository, - fileService, - bookMapper, - userRepository, - userBookProgressRepository, - authenticationService, - bookQueryService, - userProgressService, - bookDownloadService, - monitoringProtectionService - ); - - // Configure mock to execute the Supplier for file operations - when(monitoringProtectionService.executeWithProtection(any(Supplier.class), eq("book deletion"))) - .thenAnswer(invocation -> { - Supplier supplier = invocation.getArgument(0); - return supplier.get(); - }); - } - - private LibraryEntity createTestLibrary() { - LibraryPathEntity libraryPath = new LibraryPathEntity(); - libraryPath.setPath(tempDir.toString()); - - LibraryEntity library = new LibraryEntity(); - library.setId(1L); - library.setName("Test Library"); - library.setLibraryPaths(List.of(libraryPath)); - - return library; - } - - private LibraryPathEntity createTestLibraryPath() { - LibraryPathEntity libraryPath = new LibraryPathEntity(); - libraryPath.setPath(tempDir.toString()); - return libraryPath; - } - - @Test - void deleteBooks_successfulDeletion() throws IOException { - // Arrange - Path testFile1 = tempDir.resolve("book1.epub"); - Path testFile2 = tempDir.resolve("book2.pdf"); - Files.createFile(testFile1); - Files.createFile(testFile2); - - LibraryEntity library = createTestLibrary(); - LibraryPathEntity libraryPath = createTestLibraryPath(); - - BookEntity book1 = BookEntity.builder() - .id(1L) - .fileName("book1.epub") - .fileSubPath("") - .libraryPath(libraryPath) - .library(library) - .build(); - - BookEntity book2 = BookEntity.builder() - .id(2L) - .fileName("book2.pdf") - .fileSubPath("") - .libraryPath(libraryPath) - .library(library) - .build(); - - List books = List.of(book1, book2); - when(bookQueryService.findAllWithMetadataByIds(Set.of(1L, 2L))).thenReturn(books); - - // Act - ResponseEntity response = bookService.deleteBooks(Set.of(1L, 2L)); - - // Assert - assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); - BookDeletionResponse deletionResponse = response.getBody(); - assertThat(deletionResponse).isNotNull(); - assertThat(deletionResponse.getDeleted()).containsExactlyInAnyOrder(1L, 2L); - assertThat(deletionResponse.getFailedFileDeletions()).isEmpty(); - - // Verify monitoring protection was used - verify(monitoringProtectionService).executeWithProtection(any(Supplier.class), eq("book deletion")); - - // Verify books were deleted from database - verify(bookRepository).deleteAll(books); - - // Verify files were deleted - assertThat(Files.exists(testFile1)).isFalse(); - assertThat(Files.exists(testFile2)).isFalse(); - } - - @Test - void deleteBooks_fileNotFound() { - // Arrange - Path nonExistentFile = tempDir.resolve("non-existent.epub"); - - LibraryEntity library = createTestLibrary(); - LibraryPathEntity libraryPath = createTestLibraryPath(); - - BookEntity book = BookEntity.builder() - .id(1L) - .fileName("non-existent.epub") - .fileSubPath("") - .libraryPath(libraryPath) - .library(library) - .build(); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(1L))).thenReturn(List.of(book)); - - // Act - ResponseEntity response = bookService.deleteBooks(Set.of(1L)); - - // Assert - assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); - BookDeletionResponse deletionResponse = response.getBody(); - assertThat(deletionResponse).isNotNull(); - assertThat(deletionResponse.getDeleted()).containsExactly(1L); - - // File doesn't exist, so no deletion failure is recorded - assertThat(deletionResponse.getFailedFileDeletions()).isEmpty(); - - verify(monitoringProtectionService).executeWithProtection(any(Supplier.class), eq("book deletion")); - verify(bookRepository).deleteAll(List.of(book)); - } - - @Test - void deleteBooks_partialFailure() throws IOException { - // Arrange - Path existingFile = tempDir.resolve("existing.epub"); - Files.createFile(existingFile); - - LibraryEntity library = createTestLibrary(); - LibraryPathEntity libraryPath = createTestLibraryPath(); - - BookEntity existingBook = BookEntity.builder() - .id(1L) - .fileName("existing.epub") - .fileSubPath("") - .libraryPath(libraryPath) - .library(library) - .build(); - - BookEntity missingBook = BookEntity.builder() - .id(2L) - .fileName("missing.epub") - .fileSubPath("") - .libraryPath(libraryPath) - .library(library) - .build(); - - List books = List.of(existingBook, missingBook); - when(bookQueryService.findAllWithMetadataByIds(Set.of(1L, 2L))).thenReturn(books); - - // Act - ResponseEntity response = bookService.deleteBooks(Set.of(1L, 2L)); - - // Assert - assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); - BookDeletionResponse deletionResponse = response.getBody(); - assertThat(deletionResponse).isNotNull(); - assertThat(deletionResponse.getDeleted()).containsExactlyInAnyOrder(1L, 2L); - assertThat(deletionResponse.getFailedFileDeletions()).isEmpty(); // Missing file is not considered a failure - - verify(monitoringProtectionService).executeWithProtection(any(Supplier.class), eq("book deletion")); - verify(bookRepository).deleteAll(books); - - // Existing file should be deleted - assertThat(Files.exists(existingFile)).isFalse(); - } - - @Test - void deleteBooks_emptySet() { - // Act - ResponseEntity response = bookService.deleteBooks(Set.of()); - - // Assert - assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); - BookDeletionResponse deletionResponse = response.getBody(); - assertThat(deletionResponse).isNotNull(); - assertThat(deletionResponse.getDeleted()).isEmpty(); - assertThat(deletionResponse.getFailedFileDeletions()).isEmpty(); - - // Should still use monitoring protection even for empty operations - verify(monitoringProtectionService).executeWithProtection(any(Supplier.class), eq("book deletion")); - verify(bookRepository).deleteAll(List.of()); - } - - @Test - void deleteBooks_verifyMonitoringProtectionUsage() { - // Arrange - LibraryEntity library = createTestLibrary(); - LibraryPathEntity libraryPath = createTestLibraryPath(); - - BookEntity book = BookEntity.builder() - .id(1L) - .fileName("test.epub") - .fileSubPath("") - .libraryPath(libraryPath) - .library(library) - .build(); - - when(bookQueryService.findAllWithMetadataByIds(Set.of(1L))).thenReturn(List.of(book)); - - // Act - bookService.deleteBooks(Set.of(1L)); - - // Assert - Verify monitoring protection is called with correct parameters - verify(monitoringProtectionService).executeWithProtection( - any(Supplier.class), - eq("book deletion") - ); - } -} \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/MonitoringProtectionConcurrentTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/MonitoringProtectionConcurrentTest.java deleted file mode 100644 index 97c18c0f9..000000000 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/MonitoringProtectionConcurrentTest.java +++ /dev/null @@ -1,276 +0,0 @@ -package com.adityachandel.booklore.service; - -import com.adityachandel.booklore.service.monitoring.MonitoringProtectionService; -import com.adityachandel.booklore.service.monitoring.MonitoringService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.*; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class MonitoringProtectionConcurrentTest { - - @Mock - private MonitoringService monitoringService; - - private MonitoringProtectionService monitoringProtectionService; - - @BeforeEach - void setUp() { - monitoringProtectionService = new MonitoringProtectionService(monitoringService); - } - - @Test - void concurrentOperations_properSynchronization() throws InterruptedException { - // Arrange - when(monitoringService.isPaused()).thenReturn(false, true, true, true, true); - - AtomicInteger operationCount = new AtomicInteger(0); - AtomicInteger pauseCount = new AtomicInteger(0); - AtomicInteger resumeCount = new AtomicInteger(0); - - // Track pause/resume calls - doAnswer(invocation -> { - pauseCount.incrementAndGet(); - return null; - }).when(monitoringService).pauseMonitoring(); - - doAnswer(invocation -> { - resumeCount.incrementAndGet(); - return null; - }).when(monitoringService).resumeMonitoring(); - - int numberOfThreads = 10; - ExecutorService executor = Executors.newFixedThreadPool(numberOfThreads); - CountDownLatch latch = new CountDownLatch(numberOfThreads); - - // Act - Run multiple concurrent operations - List> futures = new ArrayList<>(); - for (int i = 0; i < numberOfThreads; i++) { - final int operationId = i; - Future future = executor.submit(() -> { - try { - monitoringProtectionService.executeWithProtection(() -> { - operationCount.incrementAndGet(); - // Simulate some work - try { - Thread.sleep(10); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }, "concurrent test operation " + operationId); - } finally { - latch.countDown(); - } - }); - futures.add(future); - } - - // Wait for all operations to complete - boolean completed = latch.await(30, TimeUnit.SECONDS); - executor.shutdown(); - - // Assert - assertThat(completed).isTrue(); - assertThat(operationCount.get()).isEqualTo(numberOfThreads); - - // Verify monitoring was paused at least once (may be fewer due to synchronization) - assertThat(pauseCount.get()).isGreaterThan(0); - - // Wait a bit for resume calls (they happen with delay in virtual threads) - Thread.sleep(6000); - assertThat(resumeCount.get()).isGreaterThan(0); - - // All futures should complete successfully - for (Future future : futures) { - assertThat(future.isDone()).isTrue(); - } - } - - @Test - void concurrentOperations_onlyOnePausesMonitoring() throws InterruptedException { - // Arrange - First call returns false (not paused), subsequent return true (already paused) - when(monitoringService.isPaused()).thenReturn(false).thenReturn(true); - - AtomicInteger pauseCallCount = new AtomicInteger(0); - doAnswer(invocation -> { - pauseCallCount.incrementAndGet(); - return null; - }).when(monitoringService).pauseMonitoring(); - - int numberOfThreads = 5; - ExecutorService executor = Executors.newFixedThreadPool(numberOfThreads); - CountDownLatch startLatch = new CountDownLatch(1); - CountDownLatch finishLatch = new CountDownLatch(numberOfThreads); - - // Act - Start all threads at the same time to maximize contention - for (int i = 0; i < numberOfThreads; i++) { - executor.submit(() -> { - try { - startLatch.await(); // Wait for signal to start - monitoringProtectionService.executeWithProtection(() -> { - // Simulate work - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }, "sync test"); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - finishLatch.countDown(); - } - }); - } - - startLatch.countDown(); // Start all threads - boolean completed = finishLatch.await(10, TimeUnit.SECONDS); - executor.shutdown(); - - // Assert - assertThat(completed).isTrue(); - - // Only one thread should have actually called pauseMonitoring due to synchronization - assertThat(pauseCallCount.get()).isEqualTo(1); - } - - @Test - void concurrentOperations_withExceptions() throws InterruptedException { - // Arrange - when(monitoringService.isPaused()).thenReturn(false, true, true); - - AtomicInteger successCount = new AtomicInteger(0); - AtomicInteger exceptionCount = new AtomicInteger(0); - AtomicInteger resumeCount = new AtomicInteger(0); - - doAnswer(invocation -> { - resumeCount.incrementAndGet(); - return null; - }).when(monitoringService).resumeMonitoring(); - - int numberOfThreads = 8; - ExecutorService executor = Executors.newFixedThreadPool(numberOfThreads); - CountDownLatch latch = new CountDownLatch(numberOfThreads); - - // Act - Half the operations throw exceptions - for (int i = 0; i < numberOfThreads; i++) { - final int operationId = i; - executor.submit(() -> { - try { - monitoringProtectionService.executeWithProtection(() -> { - if (operationId % 2 == 0) { - successCount.incrementAndGet(); - } else { - exceptionCount.incrementAndGet(); - throw new RuntimeException("Test exception " + operationId); - } - }, "exception test " + operationId); - } catch (RuntimeException e) { - // Expected for half the operations - } finally { - latch.countDown(); - } - }); - } - - boolean completed = latch.await(10, TimeUnit.SECONDS); - executor.shutdown(); - - // Assert - assertThat(completed).isTrue(); - assertThat(successCount.get()).isEqualTo(numberOfThreads / 2); - assertThat(exceptionCount.get()).isEqualTo(numberOfThreads / 2); - - // Wait for resume calls (delayed) - Thread.sleep(6000); - - // Even with exceptions, monitoring should still be resumed - assertThat(resumeCount.get()).isGreaterThan(0); - } - - @Test - void concurrentOperations_supplierReturnValues() throws InterruptedException, ExecutionException, TimeoutException { - // Arrange - when(monitoringService.isPaused()).thenReturn(false, true, true, true); - - int numberOfThreads = 6; - ExecutorService executor = Executors.newFixedThreadPool(numberOfThreads); - - // Act - Use Supplier version to test return values - List> futures = new ArrayList<>(); - for (int i = 0; i < numberOfThreads; i++) { - final int operationId = i; - Future future = executor.submit(() -> - monitoringProtectionService.executeWithProtection(() -> { - try { - Thread.sleep(50); // Simulate work - return "Result-" + operationId; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return "Interrupted-" + operationId; - } - }, "supplier test " + operationId) - ); - futures.add(future); - } - - // Assert - List results = new ArrayList<>(); - for (Future future : futures) { - String result = future.get(10, TimeUnit.SECONDS); - results.add(result); - } - - executor.shutdown(); - - assertThat(results).hasSize(numberOfThreads); - for (int i = 0; i < numberOfThreads; i++) { - assertThat(results).contains("Result-" + i); - } - } - - @Test - void stressTest_manyQuickOperations() throws InterruptedException { - // Arrange - when(monitoringService.isPaused()).thenReturn(false).thenReturn(true); - - AtomicInteger completedOperations = new AtomicInteger(0); - int numberOfOperations = 100; - ExecutorService executor = Executors.newFixedThreadPool(20); - CountDownLatch latch = new CountDownLatch(numberOfOperations); - - // Act - Many quick operations to test lock contention - for (int i = 0; i < numberOfOperations; i++) { - executor.submit(() -> { - try { - monitoringProtectionService.executeWithProtection(() -> { - completedOperations.incrementAndGet(); - // Very quick operation - }, "stress test"); - } finally { - latch.countDown(); - } - }); - } - - boolean completed = latch.await(30, TimeUnit.SECONDS); - executor.shutdown(); - - // Assert - assertThat(completed).isTrue(); - assertThat(completedOperations.get()).isEqualTo(numberOfOperations); - - // Should only pause once due to synchronization, even with many operations - verify(monitoringService, times(1)).pauseMonitoring(); - } -} \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/MonitoringProtectionIntegrationTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/MonitoringProtectionIntegrationTest.java deleted file mode 100644 index c7b744ff1..000000000 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/MonitoringProtectionIntegrationTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.adityachandel.booklore.service; - -import com.adityachandel.booklore.service.monitoring.MonitoringService; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import static org.mockito.Mockito.*; - -/** - * Integration test to verify that critical file operations properly use monitoring protection - * to prevent race conditions that can cause data loss. - * - * This addresses the race condition bug where monitoring would detect file operations - * as "missing files" and delete them before the operations completed. - */ -@ExtendWith(MockitoExtension.class) -class MonitoringProtectionIntegrationTest { - - @Mock - private MonitoringService monitoringService; - - @Test - void testMonitoringProtectionPattern_PauseAndResumeSequence() { - // Given - monitoring service that reports not paused initially - when(monitoringService.isPaused()).thenReturn(false); - - // When - simulating the monitoring protection pattern used in file operations - boolean didPause = pauseMonitoringIfNeeded(); - try { - // Critical file operation would happen here - // (simulated - actual file operations tested in integration tests) - } finally { - resumeMonitoringImmediately(didPause); - } - - // Then - verify the correct sequence occurred - verify(monitoringService).isPaused(); // Check current state - verify(monitoringService).pauseMonitoring(); // Pause before operation - verify(monitoringService).resumeMonitoring(); // Resume after operation - } - - @Test - void testMonitoringProtectionPattern_AlreadyPaused() { - // Given - monitoring service that reports already paused - when(monitoringService.isPaused()).thenReturn(true); - - // When - simulating the monitoring protection pattern - boolean didPause = pauseMonitoringIfNeeded(); - try { - // Critical file operation would happen here - } finally { - resumeMonitoringImmediately(didPause); - } - - // Then - verify no additional pause/resume calls were made - verify(monitoringService).isPaused(); // Check current state - verify(monitoringService, never()).pauseMonitoring(); // Should not pause again - verify(monitoringService, never()).resumeMonitoring(); // Should not resume what we didn't pause - } - - @Test - void testMonitoringProtectionPattern_ExceptionHandling() { - // Given - monitoring service that reports not paused initially - when(monitoringService.isPaused()).thenReturn(false); - - // When - simulating the monitoring protection pattern with exception - boolean didPause = pauseMonitoringIfNeeded(); - try { - // Simulate a critical file operation that throws an exception - throw new RuntimeException("File operation failed"); - } catch (RuntimeException e) { - // Exception handling would occur here in real code - } finally { - resumeMonitoringImmediately(didPause); - } - - // Then - verify monitoring was still resumed despite the exception - verify(monitoringService).isPaused(); - verify(monitoringService).pauseMonitoring(); - verify(monitoringService).resumeMonitoring(); // Critical: must resume even on failure - } - - // Helper methods that mirror the actual implementation pattern - - private boolean pauseMonitoringIfNeeded() { - if (!monitoringService.isPaused()) { - monitoringService.pauseMonitoring(); - return true; - } - return false; - } - - private void resumeMonitoringImmediately(boolean didPause) { - if (didPause) { - monitoringService.resumeMonitoring(); - } - } -} \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/MonitoringProtectionRaceConditionTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/MonitoringProtectionRaceConditionTest.java deleted file mode 100644 index 82ae119df..000000000 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/MonitoringProtectionRaceConditionTest.java +++ /dev/null @@ -1,232 +0,0 @@ -package com.adityachandel.booklore.service; - -import com.adityachandel.booklore.service.monitoring.MonitoringProtectionService; -import com.adityachandel.booklore.service.monitoring.MonitoringService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.io.TempDir; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.io.IOException; -import java.nio.file.*; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.*; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -/** - * Integration test that simulates the actual race condition scenario: - * File operations happening while monitoring service detects "missing" files. - * - * This test verifies that MonitoringProtectionService prevents the race condition - * that was causing data loss in the original bug. - */ -@ExtendWith(MockitoExtension.class) -class MonitoringProtectionRaceConditionTest { - - @TempDir - Path tempDir; - - @Mock - private MonitoringService monitoringService; - - private MonitoringProtectionService monitoringProtectionService; - - @BeforeEach - void setUp() { - monitoringProtectionService = new MonitoringProtectionService(monitoringService); - } - - @Test - void raceConditionPrevention_fileMoveDuringMonitoring() throws InterruptedException, IOException, ExecutionException, TimeoutException { - // Arrange - Path sourceFile = tempDir.resolve("source.txt"); - Path targetFile = tempDir.resolve("target.txt"); - Files.write(sourceFile, "test content".getBytes()); - - AtomicBoolean fileOperationCompleted = new AtomicBoolean(false); - AtomicBoolean monitoringDetectedMissingFile = new AtomicBoolean(false); - AtomicBoolean raceConditionOccurred = new AtomicBoolean(false); - - // Configure mock behavior - monitoring is paused during file operations - when(monitoringService.isPaused()).thenAnswer(invocation -> { - // isPaused() should return true when monitoring is paused (during file operations) - // We start unpaused, then get paused during the operation, then unpaused after - return !fileOperationCompleted.get(); // true when operation is running = paused - }); - - // Start aggressive monitoring that looks for the file - ExecutorService monitoringExecutor = Executors.newSingleThreadExecutor(); - Future monitoringTask = monitoringExecutor.submit(() -> { - while (!fileOperationCompleted.get()) { - if (!monitoringService.isPaused() && !Files.exists(sourceFile)) { - monitoringDetectedMissingFile.set(true); - if (!fileOperationCompleted.get()) { - // This would be where the monitoring service deletes the "missing" file - raceConditionOccurred.set(true); - break; - } - } - try { - Thread.sleep(1); // Very aggressive checking - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } - } - }); - - // Act - Perform file operation with protection - monitoringProtectionService.executeWithProtection(() -> { - try { - // Simulate the file operation that was causing issues - Thread.sleep(100); // Simulate some processing time - Files.move(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING); - Thread.sleep(100); // Simulate more processing time - fileOperationCompleted.set(true); - } catch (InterruptedException | IOException e) { - throw new RuntimeException(e); - } - }, "race condition test"); - - // Wait for monitoring task to complete - monitoringTask.get(10, TimeUnit.SECONDS); - monitoringExecutor.shutdown(); - - // Assert - assertThat(Files.exists(targetFile)).isTrue(); - assertThat(Files.exists(sourceFile)).isFalse(); - assertThat(fileOperationCompleted.get()).isTrue(); - - // The critical assertion: monitoring should not have detected a missing file during the operation - assertThat(raceConditionOccurred.get()) - .withFailMessage("Race condition occurred! Monitoring detected missing file during protected operation") - .isFalse(); - } - - @Test - void raceConditionPrevention_multipleConcurrentFileOperations() throws InterruptedException, IOException, ExecutionException, TimeoutException { - // Arrange - when(monitoringService.isPaused()).thenReturn(false).thenReturn(true); - - // Multiple file operations that could interfere with each other - int numberOfOperations = 10; - List sourceFiles = new ArrayList<>(); - List targetFiles = new ArrayList<>(); - - for (int i = 0; i < numberOfOperations; i++) { - Path source = tempDir.resolve("source_" + i + ".txt"); - Path target = tempDir.resolve("target_" + i + ".txt"); - Files.write(source, ("content " + i).getBytes()); - sourceFiles.add(source); - targetFiles.add(target); - } - - AtomicInteger completedOperations = new AtomicInteger(0); - AtomicInteger raceConditionCount = new AtomicInteger(0); - - // Start monitoring that checks for missing files - ExecutorService monitoringExecutor = Executors.newSingleThreadExecutor(); - Future monitoringTask = monitoringExecutor.submit(() -> { - while (completedOperations.get() < numberOfOperations) { - if (!monitoringService.isPaused()) { - // Check for any missing source files - for (int i = 0; i < numberOfOperations; i++) { - Path source = sourceFiles.get(i); - Path target = targetFiles.get(i); - - // If source is gone but target doesn't exist, it's a race condition - if (!Files.exists(source) && !Files.exists(target)) { - raceConditionCount.incrementAndGet(); - } - } - } - try { - Thread.sleep(1); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } - } - }); - - // Act - Perform multiple concurrent file operations - ExecutorService operationExecutor = Executors.newFixedThreadPool(5); - CountDownLatch latch = new CountDownLatch(numberOfOperations); - - for (int i = 0; i < numberOfOperations; i++) { - final int operationIndex = i; - operationExecutor.submit(() -> { - try { - monitoringProtectionService.executeWithProtection(() -> { - try { - Thread.sleep(50); // Simulate processing - Files.move(sourceFiles.get(operationIndex), - targetFiles.get(operationIndex), - StandardCopyOption.REPLACE_EXISTING); - completedOperations.incrementAndGet(); - } catch (InterruptedException | IOException e) { - throw new RuntimeException(e); - } - }, "concurrent operation " + operationIndex); - } finally { - latch.countDown(); - } - }); - } - - // Wait for all operations to complete - boolean allCompleted = latch.await(30, TimeUnit.SECONDS); - monitoringTask.get(5, TimeUnit.SECONDS); - - operationExecutor.shutdown(); - monitoringExecutor.shutdown(); - - // Assert - assertThat(allCompleted).isTrue(); - assertThat(completedOperations.get()).isEqualTo(numberOfOperations); - - // All target files should exist - for (Path target : targetFiles) { - assertThat(Files.exists(target)).isTrue(); - } - - // No source files should exist - for (Path source : sourceFiles) { - assertThat(Files.exists(source)).isFalse(); - } - - // Critical assertion: no race conditions should have occurred - assertThat(raceConditionCount.get()) - .withFailMessage("Race conditions detected during concurrent operations") - .isEqualTo(0); - } - - @Test - void monitoringResumesAfterDelay() throws InterruptedException { - // Arrange - when(monitoringService.isPaused()).thenReturn(false, true); - - // Act - Perform operation with protection - monitoringProtectionService.executeWithProtection(() -> { - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }, "delay test"); - - // Wait for monitoring to resume (should happen after 5 seconds) - Thread.sleep(6000); - - // Assert - Verify monitoring service methods were called - verify(monitoringService).pauseMonitoring(); - verify(monitoringService).resumeMonitoring(); - } -} \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/bookdrop/BookDropServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/bookdrop/BookDropServiceTest.java new file mode 100644 index 000000000..afca34213 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/bookdrop/BookDropServiceTest.java @@ -0,0 +1,421 @@ +package com.adityachandel.booklore.service.bookdrop; + +import com.adityachandel.booklore.config.AppProperties; +import com.adityachandel.booklore.exception.APIException; +import com.adityachandel.booklore.mapper.BookdropFileMapper; +import com.adityachandel.booklore.model.FileProcessResult; +import com.adityachandel.booklore.model.dto.Book; +import com.adityachandel.booklore.model.dto.BookMetadata; +import com.adityachandel.booklore.model.dto.BookdropFile; +import com.adityachandel.booklore.model.dto.BookdropFileNotification; +import com.adityachandel.booklore.model.dto.request.BookdropFinalizeRequest; +import com.adityachandel.booklore.model.dto.response.BookdropFinalizeResult; +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookdropFileEntity; +import com.adityachandel.booklore.model.entity.LibraryEntity; +import com.adityachandel.booklore.model.entity.LibraryPathEntity; +import com.adityachandel.booklore.model.enums.BookFileType; +import com.adityachandel.booklore.repository.BookRepository; +import com.adityachandel.booklore.repository.BookdropFileRepository; +import com.adityachandel.booklore.repository.LibraryRepository; +import com.adityachandel.booklore.service.NotificationService; +import com.adityachandel.booklore.service.file.FileMovingHelper; +import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor; +import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry; +import com.adityachandel.booklore.service.metadata.MetadataRefreshService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Ignore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.Resource; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class BookDropServiceTest { + + @Mock + private BookdropFileRepository bookdropFileRepository; + @Mock + private LibraryRepository libraryRepository; + @Mock + private BookRepository bookRepository; + @Mock + private BookdropMonitoringService bookdropMonitoringService; + @Mock + private NotificationService notificationService; + @Mock + private MetadataRefreshService metadataRefreshService; + @Mock + private BookdropNotificationService bookdropNotificationService; + @Mock + private BookFileProcessorRegistry processorRegistry; + @Mock + private AppProperties appProperties; + @Mock + private BookdropFileMapper mapper; + @Mock + private ObjectMapper objectMapper; + @Mock + private FileMovingHelper fileMovingHelper; + + @InjectMocks + private BookDropService bookDropService; + + @TempDir + Path tempDir; + + private BookdropFileEntity bookdropFileEntity; + private LibraryEntity libraryEntity; + private LibraryPathEntity libraryPathEntity; + private BookdropFile bookdropFile; + + @BeforeEach + void setUp() throws IOException { + libraryPathEntity = new LibraryPathEntity(); + libraryPathEntity.setId(1L); + libraryPathEntity.setPath(tempDir.toString()); + + libraryEntity = new LibraryEntity(); + libraryEntity.setId(1L); + libraryEntity.setName("Test Library"); + libraryEntity.setLibraryPaths(List.of(libraryPathEntity)); + + bookdropFileEntity = new BookdropFileEntity(); + bookdropFileEntity.setId(1L); + bookdropFileEntity.setFileName("test-book.pdf"); + bookdropFileEntity.setFilePath(tempDir.resolve("test-book.pdf").toString()); + bookdropFileEntity.setStatus(BookdropFileEntity.Status.PENDING_REVIEW); + bookdropFileEntity.setOriginalMetadata("{\"title\":\"Test Book\"}"); + bookdropFileEntity.setFetchedMetadata(null); + + bookdropFile = new BookdropFile(); + bookdropFile.setId(1L); + bookdropFile.setFileName("test-book.pdf"); + + Files.createFile(tempDir.resolve("test-book.pdf")); + } + + @Test + void getFileNotificationSummary_ShouldReturnCorrectCounts() { + when(bookdropFileRepository.countByStatus(BookdropFileEntity.Status.PENDING_REVIEW)).thenReturn(5L); + when(bookdropFileRepository.count()).thenReturn(10L); + + BookdropFileNotification result = bookDropService.getFileNotificationSummary(); + + assertEquals(5, result.getPendingCount()); + assertEquals(10, result.getTotalCount()); + assertNotNull(result.getLastUpdatedAt()); + verify(bookdropFileRepository).countByStatus(BookdropFileEntity.Status.PENDING_REVIEW); + verify(bookdropFileRepository).count(); + } + + @Test + void getFilesByStatus_WhenStatusIsPending_ShouldReturnPendingFiles() { + Pageable pageable = PageRequest.of(0, 10); + Page entityPage = new PageImpl<>(List.of(bookdropFileEntity)); + Page expectedPage = new PageImpl<>(List.of(bookdropFile)); + + when(bookdropFileRepository.findAllByStatus(BookdropFileEntity.Status.PENDING_REVIEW, pageable)) + .thenReturn(entityPage); + when(mapper.toDto(bookdropFileEntity)).thenReturn(bookdropFile); + + Page result = bookDropService.getFilesByStatus("pending", pageable); + + assertEquals(1, result.getContent().size()); + assertEquals(bookdropFile, result.getContent().get(0)); + verify(bookdropFileRepository).findAllByStatus(BookdropFileEntity.Status.PENDING_REVIEW, pageable); + verify(mapper).toDto(bookdropFileEntity); + } + + @Test + void getFilesByStatus_WhenStatusIsNotPending_ShouldReturnAllFiles() { + Pageable pageable = PageRequest.of(0, 10); + Page entityPage = new PageImpl<>(List.of(bookdropFileEntity)); + + when(bookdropFileRepository.findAll(pageable)).thenReturn(entityPage); + when(mapper.toDto(bookdropFileEntity)).thenReturn(bookdropFile); + + Page result = bookDropService.getFilesByStatus("all", pageable); + + assertEquals(1, result.getContent().size()); + verify(bookdropFileRepository).findAll(pageable); + verify(mapper).toDto(bookdropFileEntity); + } + + @Test + void getBookdropCover_WhenCoverExists_ShouldReturnResource() throws IOException { + long bookdropId = 1L; + when(appProperties.getPathConfig()).thenReturn(tempDir.toString()); + Path coverPath = tempDir.resolve("bookdrop_temp").resolve("1.jpg"); + Files.createDirectories(coverPath.getParent()); + Files.createFile(coverPath); + + Resource result = bookDropService.getBookdropCover(bookdropId); + + assertNotNull(result); + assertTrue(result.exists()); + } + + @Test + void getBookdropCover_WhenCoverDoesNotExist_ShouldReturnNull() { + long bookdropId = 999L; + when(appProperties.getPathConfig()).thenReturn(tempDir.toString()); + + Resource result = bookDropService.getBookdropCover(bookdropId); + + assertNull(result); + } + + @Test + void finalizeImport_ShouldPauseAndResumeMonitoring() { + BookdropFinalizeRequest request = new BookdropFinalizeRequest(); + request.setSelectAll(false); + request.setFiles(List.of()); + + BookdropFinalizeResult result = bookDropService.finalizeImport(request); + + assertNotNull(result); + assertNotNull(result.getProcessedAt()); + verify(bookdropMonitoringService).pauseMonitoring(); + verify(bookdropMonitoringService).resumeMonitoring(); + } + + @Test + @Disabled + void finalizeImport_WhenSelectAllTrue_ShouldProcessAllFiles() throws Exception { + BookdropFinalizeRequest request = new BookdropFinalizeRequest(); + request.setSelectAll(true); + request.setDefaultLibraryId(1L); + request.setDefaultPathId(1L); + request.setExcludedIds(List.of()); + + BookMetadata metadata = new BookMetadata(); + metadata.setTitle("Test Book"); + + when(bookdropFileRepository.findAllExcludingIdsFlat(any())).thenReturn(List.of(1L)); + when(bookdropFileRepository.findAllById(any())).thenReturn(List.of(bookdropFileEntity)); + when(libraryRepository.findById(1L)).thenReturn(Optional.of(libraryEntity)); + when(objectMapper.readValue(anyString(), eq(BookMetadata.class))).thenReturn(metadata); + when(fileMovingHelper.getFileNamingPattern(libraryEntity)).thenReturn("{title}"); + when(fileMovingHelper.generateNewFilePath(anyString(), any(), anyString(), anyString())) + .thenReturn(tempDir.resolve("moved-book.pdf")); + + BookFileProcessor processor = mock(BookFileProcessor.class); + when(processorRegistry.getProcessorOrThrow(any())).thenReturn(processor); + + Book book = Book.builder() + .id(1L) + .title("Test Book") + .build(); + + FileProcessResult processResult = FileProcessResult.builder() + .book(book) + .build(); + when(processor.processFile(any())).thenReturn(processResult); + + BookEntity bookEntity = new BookEntity(); + bookEntity.setId(1L); + when(bookRepository.findById(1L)).thenReturn(Optional.of(bookEntity)); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.exists(any(Path.class))).thenReturn(true); + filesMock.when(() -> Files.createTempFile(anyString(), anyString())).thenReturn(tempDir.resolve("temp-file")); + filesMock.when(() -> Files.copy(any(Path.class), any(Path.class), any())).thenReturn(1024L); + filesMock.when(() -> Files.createDirectories(any(Path.class))).thenReturn(tempDir); + filesMock.when(() -> Files.move(any(Path.class), any(Path.class), any())).thenReturn(tempDir); + filesMock.when(() -> Files.deleteIfExists(any(Path.class))).thenReturn(true); + + BookdropFinalizeResult result = bookDropService.finalizeImport(request); + + assertNotNull(result); + assertEquals(1, result.getTotalFiles()); + assertEquals(1, result.getSuccessfullyImported()); + assertEquals(0, result.getFailed()); + } + } + + @Test + void finalizeImport_WhenLibraryNotFound_ShouldFail() throws Exception { + BookdropFinalizeRequest request = new BookdropFinalizeRequest(); + request.setSelectAll(true); + request.setDefaultLibraryId(999L); + request.setDefaultPathId(1L); + + when(bookdropFileRepository.findAllExcludingIdsFlat(any())).thenReturn(List.of(1L)); + when(bookdropFileRepository.findAllById(any())).thenReturn(List.of(bookdropFileEntity)); + when(libraryRepository.findById(999L)).thenReturn(Optional.empty()); + + BookMetadata metadata = new BookMetadata(); + when(objectMapper.readValue(anyString(), eq(BookMetadata.class))).thenReturn(metadata); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.exists(any(Path.class))).thenReturn(true); + + BookdropFinalizeResult result = bookDropService.finalizeImport(request); + + assertNotNull(result); + assertEquals(1, result.getTotalFiles()); + assertEquals(0, result.getSuccessfullyImported()); + assertEquals(1, result.getFailed()); + } + } + + @Test + void discardSelectedFiles_WhenSelectAllTrue_ShouldDeleteAllExceptExcluded() throws IOException { + List excludedIds = List.of(2L); + BookdropFileEntity fileToDelete = new BookdropFileEntity(); + fileToDelete.setId(1L); + fileToDelete.setFilePath(tempDir.resolve("file-to-delete.pdf").toString()); + + Files.createFile(tempDir.resolve("file-to-delete.pdf")); + + when(bookdropFileRepository.findAll()).thenReturn(List.of(fileToDelete)); + when(appProperties.getBookdropFolder()).thenReturn(tempDir.toString()); + when(appProperties.getPathConfig()).thenReturn(tempDir.toString()); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.exists(any(Path.class))).thenReturn(true); + filesMock.when(() -> Files.walk(any(Path.class))).thenReturn(java.util.stream.Stream.of(tempDir)); + filesMock.when(() -> Files.isDirectory(any(Path.class))).thenReturn(false); + filesMock.when(() -> Files.isRegularFile(any(Path.class))).thenReturn(true); + filesMock.when(() -> Files.deleteIfExists(any(Path.class))).thenReturn(true); + filesMock.when(() -> Files.list(any(Path.class))).thenReturn(java.util.stream.Stream.empty()); + + bookDropService.discardSelectedFiles(true, excludedIds, null); + + verify(bookdropFileRepository).findAll(); + verify(bookdropFileRepository).deleteAllById(List.of(1L)); + verify(bookdropNotificationService).sendBookdropFileSummaryNotification(); + verify(bookdropMonitoringService).pauseMonitoring(); + verify(bookdropMonitoringService).resumeMonitoring(); + } + } + + @Test + void discardSelectedFiles_WhenSelectAllFalse_ShouldDeleteOnlySelected() throws IOException { + List selectedIds = List.of(1L); + when(bookdropFileRepository.findAllById(selectedIds)).thenReturn(List.of(bookdropFileEntity)); + when(appProperties.getBookdropFolder()).thenReturn(tempDir.toString()); + when(appProperties.getPathConfig()).thenReturn(tempDir.toString()); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.exists(any(Path.class))).thenReturn(true); + filesMock.when(() -> Files.walk(any(Path.class))).thenReturn(java.util.stream.Stream.of(tempDir)); + filesMock.when(() -> Files.isDirectory(any(Path.class))).thenReturn(false); + filesMock.when(() -> Files.isRegularFile(any(Path.class))).thenReturn(true); + filesMock.when(() -> Files.deleteIfExists(any(Path.class))).thenReturn(true); + filesMock.when(() -> Files.list(any(Path.class))).thenReturn(java.util.stream.Stream.empty()); + + bookDropService.discardSelectedFiles(false, null, selectedIds); + + verify(bookdropFileRepository).findAllById(selectedIds); + verify(bookdropFileRepository).deleteAllById(List.of(1L)); + verify(bookdropNotificationService).sendBookdropFileSummaryNotification(); + } + } + + @Test + void discardSelectedFiles_WhenBookdropFolderDoesNotExist_ShouldHandleGracefully() { + when(appProperties.getBookdropFolder()).thenReturn("/non-existent-path"); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.exists(any(Path.class))).thenReturn(false); + + bookDropService.discardSelectedFiles(true, null, null); + + verify(bookdropMonitoringService).pauseMonitoring(); + verify(bookdropMonitoringService).resumeMonitoring(); + } + } + + @Test + void finalizeImport_WhenSourceFileDoesNotExist_ShouldDeleteFromDB() throws Exception { + BookdropFinalizeRequest request = new BookdropFinalizeRequest(); + request.setSelectAll(true); + request.setDefaultLibraryId(1L); + request.setDefaultPathId(1L); + + BookdropFileEntity missingFileEntity = new BookdropFileEntity(); + missingFileEntity.setId(2L); + missingFileEntity.setFileName("missing-file.pdf"); + missingFileEntity.setFilePath("/non-existent/missing-file.pdf"); + missingFileEntity.setOriginalMetadata("{\"title\":\"Missing Book\"}"); + missingFileEntity.setFetchedMetadata(null); + + when(bookdropFileRepository.findAllExcludingIdsFlat(any())).thenReturn(List.of(2L)); + when(bookdropFileRepository.findAllById(any())).thenReturn(List.of(missingFileEntity)); + when(libraryRepository.findById(1L)).thenReturn(Optional.of(libraryEntity)); + + BookMetadata metadata = new BookMetadata(); + metadata.setTitle("Missing Book"); + when(objectMapper.readValue(anyString(), eq(BookMetadata.class))).thenReturn(metadata); + when(fileMovingHelper.getFileNamingPattern(libraryEntity)).thenReturn("{title}"); + when(fileMovingHelper.generateNewFilePath(anyString(), any(), anyString(), anyString())) + .thenReturn(tempDir.resolve("target-file.pdf")); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.exists(Path.of("/non-existent/missing-file.pdf"))).thenReturn(false); + + BookdropFinalizeResult result = bookDropService.finalizeImport(request); + + verify(bookdropFileRepository).deleteById(2L); + verify(bookdropNotificationService).sendBookdropFileSummaryNotification(); + assertNotNull(result); + assertEquals(1, result.getTotalFiles()); + assertEquals(0, result.getSuccessfullyImported()); + assertEquals(1, result.getFailed()); + } + } + + @Test + void finalizeImport_WhenIOExceptionDuringMove_ShouldHandleGracefully() throws Exception { + BookdropFinalizeRequest request = new BookdropFinalizeRequest(); + request.setSelectAll(true); + request.setDefaultLibraryId(1L); + request.setDefaultPathId(1L); + + when(bookdropFileRepository.findAllExcludingIdsFlat(any())).thenReturn(List.of(1L)); + when(bookdropFileRepository.findAllById(any())).thenReturn(List.of(bookdropFileEntity)); + when(libraryRepository.findById(1L)).thenReturn(Optional.of(libraryEntity)); + + BookMetadata metadata = new BookMetadata(); + when(objectMapper.readValue(anyString(), eq(BookMetadata.class))).thenReturn(metadata); + when(fileMovingHelper.getFileNamingPattern(libraryEntity)).thenReturn("{title}"); + when(fileMovingHelper.generateNewFilePath(anyString(), any(), anyString(), anyString())) + .thenReturn(tempDir.resolve("target-file.pdf")); + + try (MockedStatic filesMock = mockStatic(Files.class)) { + filesMock.when(() -> Files.exists(any(Path.class))).thenReturn(true); + filesMock.when(() -> Files.createTempFile(anyString(), anyString())) + .thenThrow(new IOException("Disk full")); + + BookdropFinalizeResult result = bookDropService.finalizeImport(request); + + assertNotNull(result); + assertEquals(1, result.getTotalFiles()); + assertEquals(0, result.getSuccessfullyImported()); + assertEquals(1, result.getFailed()); + } + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMoveServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMoveServiceTest.java new file mode 100644 index 000000000..92ddbb11a --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMoveServiceTest.java @@ -0,0 +1,362 @@ +package com.adityachandel.booklore.service.file; + +import com.adityachandel.booklore.mapper.BookMapper; +import com.adityachandel.booklore.model.dto.Book; +import com.adityachandel.booklore.model.dto.request.FileMoveRequest; +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookMetadataEntity; +import com.adityachandel.booklore.model.websocket.Topic; +import com.adityachandel.booklore.repository.BookRepository; +import com.adityachandel.booklore.service.BookQueryService; +import com.adityachandel.booklore.service.NotificationService; +import com.adityachandel.booklore.util.PathPatternResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class FileMoveServiceTest { + + @Mock + private BookQueryService bookQueryService; + @Mock + private BookRepository bookRepository; + @Mock + private BookMapper bookMapper; + @Mock + private NotificationService notificationService; + @Mock + private UnifiedFileMoveService unifiedFileMoveService; + + @InjectMocks + private FileMoveService fileMoveService; + + private BookEntity bookEntity1; + private BookEntity bookEntity2; + private Book book1; + private Book book2; + + @BeforeEach + void setUp() { + // Setup BookMetadataEntity for book1 + BookMetadataEntity metadata1 = new BookMetadataEntity(); + metadata1.setTitle("Test Book 1"); + + bookEntity1 = new BookEntity(); + bookEntity1.setId(1L); + bookEntity1.setMetadata(metadata1); + metadata1.setBook(bookEntity1); + + // Setup BookMetadataEntity for book2 + BookMetadataEntity metadata2 = new BookMetadataEntity(); + metadata2.setTitle("Test Book 2"); + + bookEntity2 = new BookEntity(); + bookEntity2.setId(2L); + bookEntity2.setMetadata(metadata2); + metadata2.setBook(bookEntity2); + + book1 = mock(Book.class); + book2 = mock(Book.class); + } + + @Test + void moveFiles_WhenSingleBatch_ShouldProcessAllBooks() { + // Given + Set bookIds = Set.of(1L, 2L); + FileMoveRequest request = new FileMoveRequest(); + request.setBookIds(bookIds); + + List batchBooks = List.of(bookEntity1, bookEntity2); + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100)) + .thenReturn(batchBooks); + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 100, 100)) + .thenReturn(List.of()); + + when(bookMapper.toBook(bookEntity1)).thenReturn(book1); + when(bookMapper.toBook(bookEntity2)).thenReturn(book2); + + doAnswer(invocation -> { + UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1); + callback.onBookMoved(bookEntity1); + callback.onBookMoved(bookEntity2); + return null; + }).when(unifiedFileMoveService).moveBatchBookFiles(eq(batchBooks), any()); + + // When + fileMoveService.moveFiles(request); + + // Then + verify(bookQueryService).findWithMetadataByIdsWithPagination(bookIds, 0, 100); + verify(unifiedFileMoveService).moveBatchBookFiles(eq(batchBooks), any()); + verify(bookRepository).save(bookEntity1); + verify(bookRepository).save(bookEntity2); + verify(notificationService).sendMessage(eq(Topic.BOOK_METADATA_BATCH_UPDATE), eq(List.of(book1, book2))); + } + + @Test + void moveFiles_WhenMultipleBatches_ShouldProcessAllBatches() { + // Given - create >100 ids so service iterates multiple batches + Set bookIds = IntStream.rangeClosed(1, 150) + .mapToObj(i -> (long) i) + .collect(Collectors.toSet()); + FileMoveRequest request = new FileMoveRequest(); + request.setBookIds(bookIds); + + // First batch + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100)) + .thenReturn(List.of(bookEntity1)); + // Second batch + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 100, 100)) + .thenReturn(List.of(bookEntity2)); + // Third batch - empty + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 200, 100)) + .thenReturn(List.of()); + + when(book1.getId()).thenReturn(1L); + when(book2.getId()).thenReturn(2L); + when(bookMapper.toBook(bookEntity1)).thenReturn(book1); + when(bookMapper.toBook(bookEntity2)).thenReturn(book2); + + doAnswer(invocation -> { + UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1); + List books = invocation.getArgument(0); + for (BookEntity book : books) { + callback.onBookMoved(book); + } + return null; + }).when(unifiedFileMoveService).moveBatchBookFiles(any(), any()); + + // When + fileMoveService.moveFiles(request); + + // Then + verify(bookQueryService).findWithMetadataByIdsWithPagination(bookIds, 0, 100); + verify(bookQueryService).findWithMetadataByIdsWithPagination(bookIds, 100, 100); + verify(unifiedFileMoveService, times(2)).moveBatchBookFiles(any(), any()); + verify(bookRepository).save(bookEntity1); + verify(bookRepository).save(bookEntity2); + verify(notificationService).sendMessage(eq(Topic.BOOK_METADATA_BATCH_UPDATE), eq(List.of(book1, book2))); + } + + @Test + void moveFiles_WhenNoBooksFound_ShouldNotProcessAnything() { + // Given + Set bookIds = Set.of(1L, 2L); + FileMoveRequest request = new FileMoveRequest(); + request.setBookIds(bookIds); + + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100)) + .thenReturn(List.of()); + + // When + fileMoveService.moveFiles(request); + + // Then + verify(bookQueryService).findWithMetadataByIdsWithPagination(bookIds, 0, 100); + verify(unifiedFileMoveService, never()).moveBatchBookFiles(any(), any()); + verify(bookRepository, never()).save(any()); + verify(notificationService, never()).sendMessage(any(), any()); + } + + @Test + void moveFiles_WhenMoveFailsForSomeBooks_ShouldThrowException() { + // Given + Set bookIds = Set.of(1L, 2L); + FileMoveRequest request = new FileMoveRequest(); + request.setBookIds(bookIds); + + List batchBooks = List.of(bookEntity1, bookEntity2); + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100)) + .thenReturn(batchBooks); + + RuntimeException moveException = new RuntimeException("File move failed"); + doAnswer(invocation -> { + UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1); + callback.onBookMoved(bookEntity1); + callback.onBookMoveFailed(bookEntity2, moveException); + return null; + }).when(unifiedFileMoveService).moveBatchBookFiles(eq(batchBooks), any()); + + when(bookMapper.toBook(bookEntity1)).thenReturn(book1); + + // When & Then + RuntimeException exception = assertThrows(RuntimeException.class, () -> { + fileMoveService.moveFiles(request); + }); + + assertEquals("File move failed for book id 2", exception.getMessage()); + assertEquals(moveException, exception.getCause()); + verify(bookRepository).save(bookEntity1); + verify(bookRepository, never()).save(bookEntity2); + } + + @Test + void moveFiles_WhenEmptyBookIds_ShouldCompleteWithoutProcessing() { + // Given + Set bookIds = Set.of(); + FileMoveRequest request = new FileMoveRequest(); + request.setBookIds(bookIds); + + // When + fileMoveService.moveFiles(request); + + // Then: service should not call pagination when bookIds is empty + verify(bookQueryService, never()).findWithMetadataByIdsWithPagination(anySet(), anyInt(), anyInt()); + verify(unifiedFileMoveService, never()).moveBatchBookFiles(any(), any()); + verify(notificationService, never()).sendMessage(any(), any()); + } + + @Test + void moveFiles_WhenPartialBatch_ShouldProcessCorrectly() { + // Given + Set bookIds = Set.of(1L); + FileMoveRequest request = new FileMoveRequest(); + request.setBookIds(bookIds); + + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100)) + .thenReturn(List.of(bookEntity1)); + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 100, 100)) + .thenReturn(List.of()); + + when(book1.getId()).thenReturn(1L); + when(bookMapper.toBook(bookEntity1)).thenReturn(book1); + + doAnswer(invocation -> { + UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1); + callback.onBookMoved(bookEntity1); + return null; + }).when(unifiedFileMoveService).moveBatchBookFiles(any(), any()); + + // When + fileMoveService.moveFiles(request); + + // Then + verify(bookQueryService).findWithMetadataByIdsWithPagination(bookIds, 0, 100); + verify(unifiedFileMoveService).moveBatchBookFiles(eq(List.of(bookEntity1)), any()); + verify(bookRepository).save(bookEntity1); + verify(notificationService).sendMessage(eq(Topic.BOOK_METADATA_BATCH_UPDATE), eq(List.of(book1))); + } + + @Test + void generatePathFromPattern_ShouldDelegateToPathPatternResolver() { + // Given + String pattern = "{author}/{title}"; + String expectedPath = "John Doe/Test Book"; + + try (MockedStatic mockedResolver = mockStatic(PathPatternResolver.class)) { + mockedResolver.when(() -> PathPatternResolver.resolvePattern(bookEntity1, pattern)) + .thenReturn(expectedPath); + + // When + String result = fileMoveService.generatePathFromPattern(bookEntity1, pattern); + + // Then + assertEquals(expectedPath, result); + mockedResolver.verify(() -> PathPatternResolver.resolvePattern(bookEntity1, pattern)); + } + } + + @Test + void generatePathFromPattern_WithDifferentPatterns_ShouldReturnCorrectPaths() { + // Given + String pattern1 = "{title}"; + String pattern2 = "{author}/{series}/{title}"; + String expectedPath1 = "Test Book 1"; + String expectedPath2 = "Author/Series/Test Book 1"; + + try (MockedStatic mockedResolver = mockStatic(PathPatternResolver.class)) { + mockedResolver.when(() -> PathPatternResolver.resolvePattern(bookEntity1, pattern1)) + .thenReturn(expectedPath1); + mockedResolver.when(() -> PathPatternResolver.resolvePattern(bookEntity1, pattern2)) + .thenReturn(expectedPath2); + + // When + String result1 = fileMoveService.generatePathFromPattern(bookEntity1, pattern1); + String result2 = fileMoveService.generatePathFromPattern(bookEntity1, pattern2); + + // Then + assertEquals(expectedPath1, result1); + assertEquals(expectedPath2, result2); + mockedResolver.verify(() -> PathPatternResolver.resolvePattern(bookEntity1, pattern1)); + mockedResolver.verify(() -> PathPatternResolver.resolvePattern(bookEntity1, pattern2)); + } + } + + @Test + void moveFiles_ShouldSendNotificationWithAllUpdatedBooks() { + // Given + Set bookIds = Set.of(1L, 2L); + FileMoveRequest request = new FileMoveRequest(); + request.setBookIds(bookIds); + + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100)) + .thenReturn(List.of(bookEntity1, bookEntity2)); + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 100, 100)) + .thenReturn(List.of()); + + when(bookMapper.toBook(bookEntity1)).thenReturn(book1); + when(bookMapper.toBook(bookEntity2)).thenReturn(book2); + + doAnswer(invocation -> { + UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1); + callback.onBookMoved(bookEntity1); + callback.onBookMoved(bookEntity2); + return null; + }).when(unifiedFileMoveService).moveBatchBookFiles(any(), any()); + + // When + fileMoveService.moveFiles(request); + + // Then + ArgumentCaptor> booksCaptor = ArgumentCaptor.forClass(List.class); + verify(notificationService).sendMessage(eq(Topic.BOOK_METADATA_BATCH_UPDATE), booksCaptor.capture()); + + List sentBooks = booksCaptor.getValue(); + assertEquals(2, sentBooks.size()); + assertTrue(sentBooks.contains(book1)); + assertTrue(sentBooks.contains(book2)); + } + + @Test + void moveFiles_WhenNoBooksMoved_ShouldNotSendNotification() { + // Given + Set bookIds = Set.of(1L, 2L); + FileMoveRequest request = new FileMoveRequest(); + request.setBookIds(bookIds); + + when(bookQueryService.findWithMetadataByIdsWithPagination(bookIds, 0, 100)) + .thenReturn(List.of(bookEntity1, bookEntity2)); + + RuntimeException moveException = new RuntimeException("All moves failed"); + doAnswer(invocation -> { + UnifiedFileMoveService.BatchMoveCallback callback = invocation.getArgument(1); + callback.onBookMoveFailed(bookEntity1, moveException); + return null; + }).when(unifiedFileMoveService).moveBatchBookFiles(any(), any()); + + // When & Then + assertThrows(RuntimeException.class, () -> { + fileMoveService.moveFiles(request); + }); + + verify(notificationService, never()).sendMessage(any(), any()); + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMovingHelperTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMovingHelperTest.java new file mode 100644 index 000000000..c781666f3 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/FileMovingHelperTest.java @@ -0,0 +1,306 @@ +package com.adityachandel.booklore.service.file; + +import com.adityachandel.booklore.model.dto.BookMetadata; +import com.adityachandel.booklore.model.dto.settings.AppSettings; +import com.adityachandel.booklore.model.entity.*; +import com.adityachandel.booklore.repository.BookAdditionalFileRepository; +import com.adityachandel.booklore.service.appsettings.AppSettingService; +import com.adityachandel.booklore.util.PathPatternResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class FileMovingHelperTest { + + @Mock + BookAdditionalFileRepository additionalFileRepository; + + @Mock + AppSettingService appSettingService; + + @InjectMocks + FileMovingHelper helper; + + @TempDir + Path tmp; + + LibraryEntity library; + LibraryPathEntity libraryPath; + BookEntity book; + + @BeforeEach + void init() { + library = new LibraryEntity(); + library.setId(11L); + library.setName("lib"); + libraryPath = new LibraryPathEntity(); + libraryPath.setId(22L); + libraryPath.setPath(tmp.toString()); + library.setLibraryPaths(List.of(libraryPath)); + + book = new BookEntity(); + book.setId(101L); + BookMetadataEntity metaEntity = new BookMetadataEntity(); + metaEntity.setTitle("Title"); + book.setMetadata(metaEntity); + metaEntity.setBook(book); + book.setLibraryPath(libraryPath); + } + + @Test + void generateNewFilePath_fromBook_usesResolvedPattern() { + try (MockedStatic ms = mockStatic(PathPatternResolver.class)) { + ms.when(() -> PathPatternResolver.resolvePattern(eq(book), eq("{pattern}"))) + .thenReturn("some/sub/path/book.pdf"); + + Path result = helper.generateNewFilePath(book, "{pattern}"); + assertTrue(result.toAbsolutePath().toString().contains("some/sub/path/book.pdf")); + } + } + + @Test + void generateNewFilePath_fromMetadata_usesResolvedPattern() { + BookMetadata metadata = new BookMetadata(); + try (MockedStatic ms = mockStatic(PathPatternResolver.class)) { + ms.when(() -> PathPatternResolver.resolvePattern(eq(metadata), eq("{p}"), eq("orig.pdf"))) + .thenReturn("meta/path/orig.pdf"); + + Path result = helper.generateNewFilePath(tmp.toString(), metadata, "{p}", "orig.pdf"); + assertTrue(result.toString().endsWith("meta/path/orig.pdf")); + } + } + + @Test + void getFileNamingPattern_prefersLibrary_thenApp_thenFallback() { + library.setFileNamingPattern("LIB_PATTERN/{currentFilename}"); + String p1 = helper.getFileNamingPattern(library); + assertEquals("LIB_PATTERN/{currentFilename}", p1); + + library.setFileNamingPattern(null); + AppSettings settings = new AppSettings(); + settings.setUploadPattern("APP_PATTERN/{currentFilename}"); + when(appSettingService.getAppSettings()).thenReturn(settings); + String p2 = helper.getFileNamingPattern(library); + assertEquals("APP_PATTERN/{currentFilename}", p2); + + settings.setUploadPattern(null); + when(appSettingService.getAppSettings()).thenReturn(settings); + String p3 = helper.getFileNamingPattern(library); + assertEquals("{currentFilename}", p3); + } + + @Test + void hasRequiredPathComponents_returnsFalseWhenMissing() { + BookEntity b = new BookEntity(); + b.setId(1L); + b.setFileName("f"); + b.setFileSubPath("s"); + assertFalse(helper.hasRequiredPathComponents(b)); + + book.setFileSubPath("s"); + book.setFileName(null); + assertFalse(helper.hasRequiredPathComponents(book)); + + book.setFileName("file.pdf"); + assertTrue(helper.hasRequiredPathComponents(book)); + } + + @Test + void moveBookFileIfNeeded_noOpWhenPathsEqual() throws IOException { + book.setFileSubPath("same"); + book.setFileName("file.pdf"); + try (MockedStatic ms = mockStatic(PathPatternResolver.class)) { + ms.when(() -> PathPatternResolver.resolvePattern(eq(book), anyString())) + .thenReturn("same/file.pdf"); + + boolean changed = helper.moveBookFileIfNeeded(book, "{p}"); + assertFalse(changed); + } + } + + @Test + void moveBookFileIfNeeded_movesAndUpdatesPaths() throws Exception { + book.setFileSubPath("olddir"); + book.setFileName("file.pdf"); + Path oldDir = tmp.resolve("olddir"); + Files.createDirectories(oldDir); + Path oldFile = oldDir.resolve("file.pdf"); + Files.createFile(oldFile); + + try (MockedStatic ms = mockStatic(PathPatternResolver.class)) { + ms.when(() -> PathPatternResolver.resolvePattern(eq(book), anyString())) + .thenReturn("newdir/file.pdf"); + + boolean moved = helper.moveBookFileIfNeeded(book, "{p}"); + assertTrue(moved); + + Path newFile = tmp.resolve("newdir").resolve("file.pdf"); + assertTrue(Files.exists(newFile)); + assertEquals("newdir", book.getFileSubPath()); + assertEquals("file.pdf", book.getFileName()); + assertFalse(Files.exists(oldFile)); + } + } + + @Test + void moveAdditionalFiles_movesFilesAndSaves() throws Exception { + book.setFileSubPath("."); + book.setFileName("book.pdf"); + BookAdditionalFileEntity add = new BookAdditionalFileEntity(); + add.setId(555L); + add.setFileSubPath("oldsub"); + add.setFileName("add.pdf"); + add.setBook(book); + book.setAdditionalFiles(List.of(add)); + + Path oldDir = tmp.resolve("oldsub"); + Files.createDirectories(oldDir); + Path oldFile = oldDir.resolve("add.pdf"); + Files.createFile(oldFile); + + try (MockedStatic ms = mockStatic(PathPatternResolver.class)) { + ms.when(() -> PathPatternResolver.resolvePattern(eq(book.getMetadata()), anyString(), eq("add.pdf"))) + .thenReturn("additional/newadd.pdf"); + + helper.moveAdditionalFiles(book, "{pattern}"); + + Path newFile = tmp.resolve("additional").resolve("newadd.pdf"); + assertTrue(Files.exists(newFile)); + verify(additionalFileRepository).save(add); + assertEquals("newadd.pdf", add.getFileName()); + } + } + + @Test + void generateNewFilePath_trimsLeadingSeparator() { + try (MockedStatic ms = mockStatic(PathPatternResolver.class)) { + ms.when(() -> PathPatternResolver.resolvePattern(eq(book), eq("{pattern}"))) + .thenReturn("/leading/path/book.pdf"); + + Path result = helper.generateNewFilePath(book, "{pattern}"); + assertTrue(result.toString().endsWith("leading/path/book.pdf")); + assertFalse(result.toString().contains("//")); + } + } + + @Test + void getFileNamingPattern_appendsFilename_whenEndsWithSeparator() { + library.setFileNamingPattern("SOME/PATTERN/"); + String pattern = helper.getFileNamingPattern(library); + assertTrue(pattern.endsWith("{currentFilename}")); + assertEquals("SOME/PATTERN/{currentFilename}", pattern); + } + + @Test + void moveFile_createsParentDirectories_and_movesFile() throws Exception { + Path srcDir = tmp.resolve("srcdir"); + Files.createDirectories(srcDir); + Path src = srcDir.resolve("file.txt"); + Files.writeString(src, "hello"); + + Path target = tmp.resolve("nested").resolve("deep").resolve("file.txt"); + assertFalse(Files.exists(target.getParent())); + + helper.moveFile(src, target); + + assertTrue(Files.exists(target)); + assertFalse(Files.exists(src)); + assertTrue(Files.exists(target.getParent())); + } + + @Test + void deleteEmptyParentDirsUpToLibraryFolders_deletesIgnoredFilesAndDirs() throws Exception { + Path libRoot = tmp.resolve("libroot"); + Path dir1 = libRoot.resolve("dir1"); + Path dir2 = dir1.resolve("dir2"); + Files.createDirectories(dir2); + + Files.writeString(dir2.resolve(".DS_Store"), ""); + Files.writeString(dir1.resolve(".DS_Store"), ""); + + assertTrue(Files.exists(dir2)); + assertTrue(Files.exists(dir1)); + assertTrue(Files.exists(libRoot) || Files.createDirectories(libRoot) != null); + + helper.deleteEmptyParentDirsUpToLibraryFolders(dir2, Set.of(libRoot)); + + assertFalse(Files.exists(dir2)); + assertFalse(Files.exists(dir1)); + assertTrue(Files.exists(libRoot)); + } + + @Test + void deleteEmptyParentDirsUpToLibraryFolders_stopsWhenNonIgnoredPresent() throws Exception { + Path libRoot = tmp.resolve("libroot2"); + Path dir = libRoot.resolve("keepdir"); + Files.createDirectories(dir); + + Files.writeString(dir.resolve("keep.txt"), "keep"); + + helper.deleteEmptyParentDirsUpToLibraryFolders(dir, Set.of(libRoot)); + + assertTrue(Files.exists(dir)); + } + + @Test + void moveAdditionalFiles_handles_duplicate_target_names_and_saves() throws Exception { + book.setFileSubPath("."); + book.setFileName("book.pdf"); + + BookAdditionalFileEntity a1 = new BookAdditionalFileEntity(); + a1.setId(1L); + a1.setFileSubPath("oldsub"); + a1.setFileName("add.pdf"); + a1.setBook(book); + + BookAdditionalFileEntity a2 = new BookAdditionalFileEntity(); + a2.setId(2L); + a2.setFileSubPath("oldsub"); + a2.setFileName("add_2.pdf"); + a2.setBook(book); + + book.setAdditionalFiles(List.of(a1, a2)); + + Path oldDir = tmp.resolve("oldsub"); + Files.createDirectories(oldDir); + Files.writeString(oldDir.resolve("add.pdf"), "one"); + Files.writeString(oldDir.resolve("add_2.pdf"), "two"); + + try (MockedStatic ms = mockStatic(PathPatternResolver.class)) { + ms.when(() -> PathPatternResolver.resolvePattern(eq(book.getMetadata()), anyString(), eq("add.pdf"))) + .thenReturn("additional/add.pdf"); + ms.when(() -> PathPatternResolver.resolvePattern(eq(book.getMetadata()), anyString(), eq("add_2.pdf"))) + .thenReturn("additional/add.pdf"); + + helper.moveAdditionalFiles(book, "{pattern}"); + + Path first = tmp.resolve("additional").resolve("add.pdf"); + Path second = tmp.resolve("additional").resolve("add_2.pdf"); + + assertTrue(Files.exists(first)); + assertTrue(Files.exists(second)); + + verify(additionalFileRepository, atLeastOnce()).save(any(BookAdditionalFileEntity.class)); + + assertTrue(a1.getFileName().equals("add.pdf") || a1.getFileName().equals("add_2.pdf")); + assertTrue(a2.getFileName().equals("add.pdf") || a2.getFileName().equals("add_2.pdf")); + assertNotEquals(a1.getFileName(), a2.getFileName()); + } + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/file/MonitoredFileOperationServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/MonitoredFileOperationServiceTest.java new file mode 100644 index 000000000..50b84ad15 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/MonitoredFileOperationServiceTest.java @@ -0,0 +1,208 @@ +package com.adityachandel.booklore.service.file; + +import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MonitoredFileOperationServiceTest { + + @Mock + MonitoringRegistrationService monitoringRegistrationService; + + @InjectMocks + MonitoredFileOperationService service; + + @TempDir + Path tmp; + + Path sourceDir; + Path targetDir; + Path sourceFile; + Path targetFile; + final Long libraryId = 42L; + + @BeforeEach + void init() throws IOException { + sourceDir = tmp.resolve("source"); + Files.createDirectories(sourceDir); + sourceFile = sourceDir.resolve("file.txt"); + Files.writeString(sourceFile, "data"); + + targetDir = tmp.resolve("target"); + targetFile = targetDir.resolve("file.txt"); + } + + @Test + void executeWithMonitoringSuspended_unregistersAndReregisters_whenDifferentPaths() { + Supplier operation = () -> { + try { + Files.createDirectories(targetDir); + Files.createDirectories(targetDir.resolve("sub")); + Files.writeString(targetFile, "moved"); + } catch (IOException e) { + throw new RuntimeException(e); + } + return "ok"; + }; + + when(monitoringRegistrationService.isPathMonitored(any())) + .thenAnswer(invocation -> { + Path p = invocation.getArgument(0); + return p.equals(sourceDir); + }); + + service.executeWithMonitoringSuspended(sourceFile, targetFile, libraryId, operation); + + verify(monitoringRegistrationService).isPathMonitored(eq(sourceDir)); + verify(monitoringRegistrationService).unregisterSpecificPath(eq(sourceDir)); + verify(monitoringRegistrationService).registerSpecificPath(eq(sourceDir), eq(libraryId)); + verify(monitoringRegistrationService).registerSpecificPath(eq(targetDir), eq(libraryId)); + verify(monitoringRegistrationService).registerSpecificPath(eq(targetDir.resolve("sub")), eq(libraryId)); + } + + @Test + void executeWithMonitoringSuspended_skipsDoubleUnregister_whenSamePaths() { + Path src = sourceDir.resolve("a.txt"); + Path tgt = sourceDir.resolve("b.txt"); + + when(monitoringRegistrationService.isPathMonitored(eq(sourceDir))).thenReturn(true); + + Supplier operation = () -> { + try { + Files.writeString(tgt, "x"); + } catch (IOException e) { + throw new RuntimeException(e); + } + return true; + }; + + service.executeWithMonitoringSuspended(src, tgt, libraryId, operation); + + verify(monitoringRegistrationService).unregisterSpecificPath(eq(sourceDir)); + verify(monitoringRegistrationService, never()).unregisterSpecificPath(eq(targetDir)); + verify(monitoringRegistrationService).registerSpecificPath(eq(sourceDir), eq(libraryId)); + } + + @Test + void executeWithMonitoringSuspended_reRegistersEvenIfOperationThrows() { + when(monitoringRegistrationService.isPathMonitored(eq(sourceDir))).thenReturn(true); + + Supplier operation = () -> { + throw new IllegalStateException("boom"); + }; + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> + service.executeWithMonitoringSuspended(sourceFile, targetFile, libraryId, operation) + ); + + assertEquals("boom", ex.getMessage()); + verify(monitoringRegistrationService).unregisterSpecificPath(eq(sourceDir)); + verify(monitoringRegistrationService).registerSpecificPath(eq(sourceDir), eq(libraryId)); + } + + @Test + void reregister_handlesFilesWalkIOException_gracefully() throws Exception { + Supplier operation = () -> { + try { + Files.createDirectories(targetDir); + Files.writeString(targetFile, "moved"); + } catch (IOException e) { + throw new RuntimeException(e); + } + return "ok"; + }; + + when(monitoringRegistrationService.isPathMonitored(any())) + .thenAnswer(invocation -> false); + + try (MockedStatic filesMock = mockStatic(Files.class, invocation -> invocation.callRealMethod())) { + filesMock.when(() -> Files.exists(any(Path.class))).thenCallRealMethod(); + filesMock.when(() -> Files.isDirectory(any(Path.class))).thenCallRealMethod(); + filesMock.when(() -> Files.walk(eq(targetDir))).thenThrow(new IOException("walk fail")); + + assertDoesNotThrow(() -> + service.executeWithMonitoringSuspended(sourceFile, targetFile, libraryId, operation) + ); + + verify(monitoringRegistrationService).registerSpecificPath(eq(targetDir), eq(libraryId)); + } + } + + @Test + void executeWithMonitoringSuspended_skipsReregister_whenSourceRemovedByOperation() { + when(monitoringRegistrationService.isPathMonitored(eq(sourceDir))).thenReturn(true); + + Supplier operation = () -> { + try { + Files.deleteIfExists(sourceFile); + Files.deleteIfExists(sourceDir); + } catch (IOException e) { + throw new RuntimeException(e); + } + return "done"; + }; + + service.executeWithMonitoringSuspended(sourceFile, targetFile, libraryId, operation); + + verify(monitoringRegistrationService).unregisterSpecificPath(eq(sourceDir)); + verify(monitoringRegistrationService, never()).registerSpecificPath(eq(sourceDir), eq(libraryId)); + } + + @Test + void noUnregisters_whenNothingMonitored() { + Supplier operation = () -> { + try { + Files.createDirectories(targetDir); + Files.writeString(targetFile, "ok"); + } catch (IOException e) { + throw new RuntimeException(e); + } + return "ok"; + }; + + when(monitoringRegistrationService.isPathMonitored(any())).thenReturn(false); + + service.executeWithMonitoringSuspended(sourceFile, targetFile, libraryId, operation); + + verify(monitoringRegistrationService, never()).unregisterSpecificPath(any()); + verify(monitoringRegistrationService).registerSpecificPath(eq(targetDir), eq(libraryId)); + } + + @Test + void targetAlreadyMonitored_doesNotReRegister() throws Exception { + Files.createDirectories(targetDir); + when(monitoringRegistrationService.isPathMonitored(eq(sourceDir))).thenReturn(true); + when(monitoringRegistrationService.isPathMonitored(eq(targetDir))).thenReturn(true); + + Supplier operation = () -> { + try { + Files.writeString(targetFile, "x"); + } catch (IOException e) { + throw new RuntimeException(e); + } + return "done"; + }; + + service.executeWithMonitoringSuspended(sourceFile, targetFile, libraryId, operation); + + verify(monitoringRegistrationService).unregisterSpecificPath(eq(sourceDir)); + verify(monitoringRegistrationService).unregisterSpecificPath(eq(targetDir)); + verify(monitoringRegistrationService).registerSpecificPath(eq(targetDir), eq(libraryId)); + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/file/UnifiedFileMoveServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/UnifiedFileMoveServiceTest.java new file mode 100644 index 000000000..6a3a45fd7 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/file/UnifiedFileMoveServiceTest.java @@ -0,0 +1,210 @@ +package com.adityachandel.booklore.service.file; + +import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookMetadataEntity; +import com.adityachandel.booklore.model.entity.LibraryEntity; +import com.adityachandel.booklore.model.entity.LibraryPathEntity; +import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static java.util.Collections.singletonList; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UnifiedFileMoveServiceTest { + + @Mock + FileMovingHelper fileMovingHelper; + + @Mock + MonitoredFileOperationService monitoredFileOperationService; + + @Mock + MonitoringRegistrationService monitoringRegistrationService; + + @InjectMocks + UnifiedFileMoveService service; + + @TempDir + Path tmp; + + LibraryEntity library; + LibraryPathEntity libraryPath; + + @BeforeEach + void setup() { + library = new LibraryEntity(); + library.setId(10L); + library.setName("lib"); + + libraryPath = new LibraryPathEntity(); + libraryPath.setId(20L); + libraryPath.setPath(tmp.toString()); + libraryPath.setLibrary(library); + + library.setLibraryPaths(singletonList(libraryPath)); + } + + @Test + void moveSingleBookFile_skipsWhenNoLibrary() { + BookEntity book = new BookEntity(); + // no libraryPath set + service.moveSingleBookFile(book); + verifyNoInteractions(monitoredFileOperationService); + verifyNoInteractions(fileMovingHelper); + } + + @Test + void moveSingleBookFile_skipsWhenFileMissing() throws Exception { + BookEntity book = new BookEntity(); + book.setId(1L); + book.setLibraryPath(libraryPath); + book.setFileSubPath("."); + book.setFileName("missing.pdf"); + + when(fileMovingHelper.getFileNamingPattern(any())).thenReturn("{currentFilename}"); + + service.moveSingleBookFile(book); + + verifyNoInteractions(monitoredFileOperationService); + } + + @Test + void moveSingleBookFile_executesMoveWhenNeeded() throws Exception { + BookEntity book = new BookEntity(); + book.setId(2L); + book.setLibraryPath(libraryPath); + book.setFileSubPath("."); + book.setFileName("book.pdf"); + + Path src = tmp.resolve("book.pdf"); + Files.writeString(src, "data"); + + Path expected = tmp.resolve("moved").resolve("book.pdf"); + + when(fileMovingHelper.getFileNamingPattern(library)).thenReturn("{currentFilename}"); + when(fileMovingHelper.generateNewFilePath(eq(book), anyString())).thenReturn(expected); + when(fileMovingHelper.hasRequiredPathComponents(eq(book))).thenReturn(true); + + doAnswer(invocation -> { + java.util.function.Supplier supplier = invocation.getArgument(3); + supplier.get(); + return null; + }).when(monitoredFileOperationService).executeWithMonitoringSuspended(any(), any(), anyLong(), any()); + + when(fileMovingHelper.moveBookFileIfNeeded(eq(book), anyString())).thenAnswer(inv -> { + book.setFileSubPath("moved"); + book.setFileName("book.pdf"); + return true; + }); + + Path actualSrc = book.getFullFilePath(); + + service.moveSingleBookFile(book); + + verify(monitoredFileOperationService).executeWithMonitoringSuspended(eq(actualSrc), eq(expected), eq(10L), any()); + verify(fileMovingHelper).moveBookFileIfNeeded(eq(book), anyString()); + assertEquals("moved", book.getFileSubPath()); + } + + @Test + void moveBatchBookFiles_movesBooks_and_callsCallback_and_reRegistersLibraries() throws Exception { + BookEntity b1 = new BookEntity(); + b1.setId(11L); + b1.setLibraryPath(libraryPath); + b1.setFileSubPath("."); + b1.setFileName("a.pdf"); + BookMetadataEntity m1 = new BookMetadataEntity(); + m1.setTitle("A"); + b1.setMetadata(m1); + m1.setBook(b1); + + BookEntity b2 = new BookEntity(); + b2.setId(12L); + b2.setLibraryPath(libraryPath); + b2.setFileSubPath("."); + b2.setFileName("b.pdf"); + BookMetadataEntity m2 = new BookMetadataEntity(); + m2.setTitle("B"); + b2.setMetadata(m2); + m2.setBook(b2); + + Path p1 = tmp.resolve("a.pdf"); + Path p2 = tmp.resolve("b.pdf"); + Files.writeString(p1, "1"); + Files.writeString(p2, "2"); + + when(fileMovingHelper.getFileNamingPattern(library)).thenReturn("{currentFilename}"); + when(fileMovingHelper.hasRequiredPathComponents(eq(b1))).thenReturn(true); + when(fileMovingHelper.hasRequiredPathComponents(eq(b2))).thenReturn(true); + + when(fileMovingHelper.moveBookFileIfNeeded(eq(b1), anyString())).thenAnswer(inv -> { + b1.setFileSubPath("moved"); + return true; + }); + when(fileMovingHelper.moveBookFileIfNeeded(eq(b2), anyString())).thenReturn(false); + + BookAdditionalFileEntity add = new BookAdditionalFileEntity(); + add.setId(100L); + add.setBook(b1); + add.setFileSubPath("."); + add.setFileName("extra.pdf"); + b1.setAdditionalFiles(List.of(add)); + doNothing().when(fileMovingHelper).moveAdditionalFiles(eq(b1), anyString()); + + UnifiedFileMoveService.BatchMoveCallback cb = mock(UnifiedFileMoveService.BatchMoveCallback.class); + + service.moveBatchBookFiles(List.of(b1, b2), cb); + + verify(monitoringRegistrationService).unregisterLibrary(eq(10L)); + verify(fileMovingHelper).moveBookFileIfNeeded(eq(b1), anyString()); + verify(fileMovingHelper).moveBookFileIfNeeded(eq(b2), anyString()); + verify(cb).onBookMoved(eq(b1)); + verify(cb, never()).onBookMoved(eq(b2)); + verify(fileMovingHelper).moveAdditionalFiles(eq(b1), anyString()); + verify(monitoringRegistrationService).registerLibraryPaths(eq(10L), any()); + } + + @Test + void moveBatchBookFiles_callsOnBookMoveFailed_onIOException() throws Exception { + BookEntity b = new BookEntity(); + b.setId(21L); + b.setLibraryPath(libraryPath); + b.setFileSubPath("."); + b.setFileName("c.pdf"); + BookMetadataEntity m = new BookMetadataEntity(); + m.setTitle("C"); + b.setMetadata(m); + m.setBook(b); + + Path p = tmp.resolve("c.pdf"); + Files.writeString(p, "c"); + + when(fileMovingHelper.getFileNamingPattern(library)).thenReturn("{currentFilename}"); + when(fileMovingHelper.hasRequiredPathComponents(eq(b))).thenReturn(true); + when(fileMovingHelper.moveBookFileIfNeeded(eq(b), anyString())).thenThrow(new IOException("disk")); + + UnifiedFileMoveService.BatchMoveCallback cb = mock(UnifiedFileMoveService.BatchMoveCallback.class); + + service.moveBatchBookFiles(List.of(b), cb); + + verify(cb).onBookMoveFailed(eq(b), any(IOException.class)); + verify(monitoringRegistrationService).unregisterLibrary(eq(10L)); + verify(monitoringRegistrationService).registerLibraryPaths(eq(10L), any()); + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/monitoring/MonitoringRegistrationServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/monitoring/MonitoringRegistrationServiceTest.java new file mode 100644 index 000000000..6d4e30e45 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/monitoring/MonitoringRegistrationServiceTest.java @@ -0,0 +1,140 @@ +package com.adityachandel.booklore.service.monitoring; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MonitoringRegistrationServiceTest { + + @Mock + MonitoringService monitoringService; + + @InjectMocks + MonitoringRegistrationService registrationService; + + @TempDir + Path tmp; + + Path root; + Path sub1; + Path sub2; + + @BeforeEach + void setupFs() throws IOException { + root = tmp.resolve("libroot"); + sub1 = root.resolve("a"); + sub2 = root.resolve("a").resolve("b"); + Files.createDirectories(sub2); + } + + @Test + void isPathMonitored_delegatesToMonitoringService() { + when(monitoringService.isPathMonitored(root)).thenReturn(true); + assertTrue(registrationService.isPathMonitored(root)); + verify(monitoringService).isPathMonitored(root); + } + + @Test + void unregisterSpecificPath_delegates() { + registrationService.unregisterSpecificPath(root); + verify(monitoringService).unregisterPath(root); + } + + @Test + void registerSpecificPath_delegates() { + registrationService.registerSpecificPath(root, 123L); + verify(monitoringService).registerPath(root, 123L); + } + + @Test + void unregisterLibrary_delegates() { + registrationService.unregisterLibrary(99L); + verify(monitoringService).unregisterLibrary(99L); + } + + @Test + void registerLibraryPaths_noopWhenMissingOrNotDirectory() throws IOException { + Path missing = tmp.resolve("does-not-exist"); + registrationService.registerLibraryPaths(7L, missing); + verifyNoInteractions(monitoringService); + + Path file = tmp.resolve("afile.txt"); + Files.writeString(file, "x"); + registrationService.registerLibraryPaths(7L, file); + verifyNoInteractions(monitoringService); + } + + @Test + void registerLibraryPaths_registersRootAndAllSubdirs() { + registrationService.registerLibraryPaths(42L, root); + + verify(monitoringService).registerPath(root, 42L); + + // subdirs a and a/b should be registered as well + verify(monitoringService).registerPath(sub1, 42L); + verify(monitoringService).registerPath(sub2, 42L); + + ArgumentCaptor capt = ArgumentCaptor.forClass(Path.class); + verify(monitoringService, atLeast(3)).registerPath(capt.capture(), eq(42L)); + List registered = capt.getAllValues(); + assertTrue(registered.contains(root)); + assertTrue(registered.contains(sub1)); + assertTrue(registered.contains(sub2)); + } + + @Test + void registerLibraryPaths_handlesMonitoringServiceExceptionGracefully() { + doThrow(new RuntimeException("boom")).when(monitoringService).registerPath(eq(root), eq(55L)); + assertDoesNotThrow(() -> registrationService.registerLibraryPaths(55L, root)); + verify(monitoringService).registerPath(root, 55L); + } + + @Test + void registerLibraryPaths_partialFailureStops() { + doAnswer(invocation -> { + Path p = invocation.getArgument(0); + Long id = invocation.getArgument(1); + if (id != null && id.equals(42L) && p.getFileName() != null && "a".equals(p.getFileName().toString())) { + throw new RuntimeException("fail-sub1"); + } + return null; + }).when(monitoringService).registerPath(any(Path.class), anyLong()); + + registrationService.registerLibraryPaths(42L, root); + + verify(monitoringService).registerPath(root, 42L); + verify(monitoringService).registerPath(argThat(p -> p.getFileName() != null && "a".equals(p.getFileName().toString())), eq(42L)); + verify(monitoringService, never()).registerPath(argThat(p -> p.getFileName() != null && "b".equals(p.getFileName().toString())), eq(42L)); + } + + @Test + void registerLibraryPaths_onlyRegistersDirectories() throws IOException { + Path fileInRoot = root.resolve("file.txt"); + Files.createDirectories(root); + Files.writeString(fileInRoot, "content"); + + registrationService.registerLibraryPaths(100L, root); + + verify(monitoringService).registerPath(root, 100L); + verify(monitoringService).registerPath(sub1, 100L); + verify(monitoringService).registerPath(sub2, 100L); + + verify(monitoringService, never()).registerPath(eq(fileInRoot), anyLong()); + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/monitoring/MonitoringServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/monitoring/MonitoringServiceTest.java new file mode 100644 index 000000000..4bdc09dce --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/monitoring/MonitoringServiceTest.java @@ -0,0 +1,288 @@ +package com.adityachandel.booklore.service.monitoring; + +import com.adityachandel.booklore.model.dto.Library; +import com.adityachandel.booklore.model.dto.LibraryPath; +import com.adityachandel.booklore.service.watcher.LibraryFileEventProcessor; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mockito; + +import java.lang.reflect.Field; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class MonitoringServiceTest { + + @TempDir + Path tmp; + + MonitoringService service; + LibraryFileEventProcessor processor; + MonitoringTask monitoringTask; + WatchService watchService; + + @BeforeEach + void setup() throws Exception { + processor = mock(LibraryFileEventProcessor.class); + monitoringTask = mock(MonitoringTask.class); + watchService = FileSystems.getDefault().newWatchService(); + service = Mockito.spy(new MonitoringService(processor, watchService, monitoringTask)); + } + + @AfterEach + void teardown() throws Exception { + try { + service.stopMonitoring(); + } catch (Exception ignored) {} + try { watchService.close(); } catch (Exception ignored) {} + } + + @Test + void registerLibrary_registersAllDirectoriesUnderLibraryPath() throws Exception { + Path root = tmp.resolve("libroot"); + Path a = root.resolve("a"); + Path b = a.resolve("b"); + Files.createDirectories(b); + + Library lib = mock(Library.class); + LibraryPath lp = mock(LibraryPath.class); + when(lp.getPath()).thenReturn(root.toString()); + when(lib.getPaths()).thenReturn(List.of(lp)); + when(lib.getId()).thenReturn(7L); + when(lib.getName()).thenReturn("my-lib"); + when(lib.isWatch()).thenReturn(true); + + doReturn(true).when(service).registerPath(any(Path.class), eq(7L)); + + service.registerLibrary(lib); + + Files.walk(root).filter(Files::isDirectory).forEach(path -> + verify(service).registerPath(eq(path), eq(7L)) + ); + } + + @Test + void unregisterLibrary_removesRegisteredPathsAndUpdatesMaps() throws Exception { + Path root = tmp.resolve("libroot2"); + Files.createDirectories(root); + + Field pathToLibraryField = MonitoringService.class.getDeclaredField("pathToLibraryIdMap"); + pathToLibraryField.setAccessible(true); + @SuppressWarnings("unchecked") + Map map = (Map) pathToLibraryField.get(service); + map.put(root, 99L); + + Field monitoredPathsField = MonitoringService.class.getDeclaredField("monitoredPaths"); + monitoredPathsField.setAccessible(true); + @SuppressWarnings("unchecked") + Set monitored = (Set) monitoredPathsField.get(service); + monitored.add(root); + + Field registeredKeysField = MonitoringService.class.getDeclaredField("registeredWatchKeys"); + registeredKeysField.setAccessible(true); + @SuppressWarnings("unchecked") + Map keys = (Map) registeredKeysField.get(service); + WatchKey mockKey = mock(WatchKey.class); + keys.put(root, mockKey); + + service.unregisterLibrary(99L); + + assertFalse(monitored.contains(root), "monitoredPaths should no longer contain root"); + assertFalse(map.containsKey(root), "pathToLibraryIdMap should no longer contain root"); + assertFalse(keys.containsKey(root), "registeredWatchKeys should no longer contain root"); + verify(mockKey).cancel(); + } + + @Test + void handleFileChangeEvent_createDirectory_registersNestedPaths() throws Exception { + Path watched = tmp.resolve("watched"); + Files.createDirectories(watched); + Path newDir = watched.resolve("newdir"); + Files.createDirectories(newDir); + + Field pathToLibraryField = MonitoringService.class.getDeclaredField("pathToLibraryIdMap"); + pathToLibraryField.setAccessible(true); + @SuppressWarnings("unchecked") + Map map = (Map) pathToLibraryField.get(service); + map.put(watched, 5L); + + doReturn(true).when(service).registerPath(any(Path.class), eq(5L)); + + FileChangeEvent ev = mock(FileChangeEvent.class); + when(ev.getFilePath()).thenReturn(newDir); + doReturn(StandardWatchEventKinds.ENTRY_CREATE).when(ev).getEventKind(); + when(ev.getWatchedFolder()).thenReturn(watched); + + service.handleFileChangeEvent(ev); + + Files.walk(newDir).filter(Files::isDirectory).forEach(p -> verify(service).registerPath(eq(p), eq(5L))); + } + + @Test + void backgroundProcessor_processesQueuedEvents_and_callsProcessor() throws Exception { + Path watched = tmp.resolve("wf"); + Files.createDirectories(watched); + Path file = watched.resolve("book.pdf"); + Files.writeString(file, "x"); + + Field pathToLibraryField = MonitoringService.class.getDeclaredField("pathToLibraryIdMap"); + pathToLibraryField.setAccessible(true); + @SuppressWarnings("unchecked") + Map map = (Map) pathToLibraryField.get(service); + map.put(watched, 123L); + + java.lang.reflect.Method startMethod = MonitoringService.class.getDeclaredMethod("startProcessingThread"); + startMethod.setAccessible(true); + startMethod.invoke(service); + + FileChangeEvent ev = mock(FileChangeEvent.class); + when(ev.getFilePath()).thenReturn(file); + doReturn(StandardWatchEventKinds.ENTRY_CREATE).when(ev).getEventKind(); + when(ev.getWatchedFolder()).thenReturn(watched); + + service.handleFileChangeEvent(ev); + + verify(processor, timeout(2_000)).processFile(eq(StandardWatchEventKinds.ENTRY_CREATE), eq(123L), eq(watched.toString()), eq(file.toString())); + } + + @Test + void handleWatchKeyInvalidation_removesInvalidPath_and_cancelsKey() throws Exception { + Path invalid = tmp.resolve("inv"); + Files.createDirectories(invalid); + + Field monitoredPathsField = MonitoringService.class.getDeclaredField("monitoredPaths"); + monitoredPathsField.setAccessible(true); + @SuppressWarnings("unchecked") + Set monitored = (Set) monitoredPathsField.get(service); + monitored.add(invalid); + + Field registeredKeysField = MonitoringService.class.getDeclaredField("registeredWatchKeys"); + registeredKeysField.setAccessible(true); + @SuppressWarnings("unchecked") + Map keys = (Map) registeredKeysField.get(service); + WatchKey wk = mock(WatchKey.class); + keys.put(invalid, wk); + + WatchKeyInvalidatedEvent ev = mock(WatchKeyInvalidatedEvent.class); + when(ev.getInvalidPath()).thenReturn(invalid); + + service.handleWatchKeyInvalidation(ev); + + assertFalse(monitored.contains(invalid)); + assertFalse(keys.containsKey(invalid)); + verify(wk).cancel(); + } + + @Test + void isRelevantBookFile_detectsBookExtensions() { + Path pdf = Paths.get("somebook.pdf"); + Path txt = Paths.get("notes.txt"); + + assertTrue(service.isRelevantBookFile(pdf)); + assertFalse(service.isRelevantBookFile(txt)); + } + + @Test + void handleFileChangeEvent_ignoresIrrelevantNonBookFile() throws Exception { + Path watched = tmp.resolve("watched-ignore"); + Files.createDirectories(watched); + Path file = watched.resolve("notes.txt"); + Files.writeString(file, "notes"); + + Field pathToLibraryField = MonitoringService.class.getDeclaredField("pathToLibraryIdMap"); + pathToLibraryField.setAccessible(true); + @SuppressWarnings("unchecked") + Map map = (Map) pathToLibraryField.get(service); + map.put(watched, 11L); + + java.lang.reflect.Method startMethod = MonitoringService.class.getDeclaredMethod("startProcessingThread"); + startMethod.setAccessible(true); + startMethod.invoke(service); + + FileChangeEvent ev = mock(FileChangeEvent.class); + when(ev.getFilePath()).thenReturn(file); + doReturn(StandardWatchEventKinds.ENTRY_CREATE).when(ev).getEventKind(); + when(ev.getWatchedFolder()).thenReturn(watched); + + service.handleFileChangeEvent(ev); + + verify(processor, timeout(500).times(0)).processFile(any(), anyLong(), anyString(), anyString()); + } + + @Test + void handleFileChangeEvent_deleteDirectory_unregistersSubPaths() throws Exception { + Path watched = tmp.resolve("watched-del"); + Path a = watched.resolve("a"); + Path b = a.resolve("b"); + Files.createDirectories(b); + + Field monitoredPathsField = MonitoringService.class.getDeclaredField("monitoredPaths"); + monitoredPathsField.setAccessible(true); + @SuppressWarnings("unchecked") + Set monitored = (Set) monitoredPathsField.get(service); + monitored.add(watched); + monitored.add(a); + monitored.add(b); + + FileChangeEvent ev = mock(FileChangeEvent.class); + when(ev.getFilePath()).thenReturn(a); + doReturn(StandardWatchEventKinds.ENTRY_DELETE).when(ev).getEventKind(); + when(ev.getWatchedFolder()).thenReturn(watched); + + service.handleFileChangeEvent(ev); + + assertFalse(monitored.contains(a)); + assertFalse(monitored.contains(b)); + assertTrue(monitored.contains(watched)); + } + + @Test + void isPathMonitored_handlesNonNormalizedPaths() throws Exception { + Path root = tmp.resolve("libroot-norm"); + Path sub = root.resolve("subdir"); + Files.createDirectories(sub); + + Field monitoredPathsField = MonitoringService.class.getDeclaredField("monitoredPaths"); + monitoredPathsField.setAccessible(true); + @SuppressWarnings("unchecked") + Set monitored = (Set) monitoredPathsField.get(service); + monitored.add(sub.toAbsolutePath().normalize()); + + Path nonNormalized = root.resolve("subdir/../subdir/."); + assertTrue(service.isPathMonitored(nonNormalized)); + } + + @Test + void registerPath_successful_updatesInternalMapsAndSets() throws Exception { + Path dir = tmp.resolve("regdir"); + Files.createDirectories(dir); + + boolean registered = service.registerPath(dir, 55L); + assertTrue(registered); + + Field pathToLibraryField = MonitoringService.class.getDeclaredField("pathToLibraryIdMap"); + pathToLibraryField.setAccessible(true); + @SuppressWarnings("unchecked") + Map map = (Map) pathToLibraryField.get(service); + assertEquals(55L, map.get(dir)); + + Field monitoredPathsField = MonitoringService.class.getDeclaredField("monitoredPaths"); + monitoredPathsField.setAccessible(true); + @SuppressWarnings("unchecked") + Set monitored = (Set) monitoredPathsField.get(service); + assertTrue(monitored.contains(dir)); + + Field keysField = MonitoringService.class.getDeclaredField("registeredWatchKeys"); + keysField.setAccessible(true); + @SuppressWarnings("unchecked") + Map keys = (Map) keysField.get(service); + assertTrue(keys.containsKey(dir)); + } +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/upload/FileUploadServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/upload/FileUploadServiceTest.java index b5d6a9264..0d531042b 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/upload/FileUploadServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/upload/FileUploadServiceTest.java @@ -3,32 +3,29 @@ package com.adityachandel.booklore.service.upload; import com.adityachandel.booklore.config.AppProperties; import com.adityachandel.booklore.exception.APIException; import com.adityachandel.booklore.exception.ApiError; -import com.adityachandel.booklore.mapper.AdditionalFileMapperImpl; -import com.adityachandel.booklore.model.FileProcessResult; -import com.adityachandel.booklore.model.dto.Book; +import com.adityachandel.booklore.mapper.AdditionalFileMapper; +import com.adityachandel.booklore.model.dto.AdditionalFile; import com.adityachandel.booklore.model.dto.settings.AppSettings; +import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity; +import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.entity.LibraryEntity; import com.adityachandel.booklore.model.entity.LibraryPathEntity; -import com.adityachandel.booklore.model.enums.BookFileType; -import com.adityachandel.booklore.model.enums.FileProcessStatus; -import com.adityachandel.booklore.model.websocket.Topic; +import com.adityachandel.booklore.model.enums.AdditionalFileType; import com.adityachandel.booklore.repository.BookAdditionalFileRepository; import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.repository.LibraryRepository; -import com.adityachandel.booklore.service.NotificationService; +import com.adityachandel.booklore.service.FileFingerprint; import com.adityachandel.booklore.service.appsettings.AppSettingService; -import com.adityachandel.booklore.service.fileprocessor.BookFileProcessor; -import com.adityachandel.booklore.service.fileprocessor.BookFileProcessorRegistry; +import com.adityachandel.booklore.service.file.FileMovingHelper; import com.adityachandel.booklore.service.metadata.extractor.EpubMetadataExtractor; import com.adityachandel.booklore.service.metadata.extractor.PdfMetadataExtractor; -import com.adityachandel.booklore.service.monitoring.MonitoringService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.MockitoAnnotations; import org.springframework.mock.web.MockMultipartFile; -import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; @@ -39,7 +36,7 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.mockito.ArgumentMatchers.*; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; class FileUploadServiceTest { @@ -54,17 +51,15 @@ class FileUploadServiceTest { @Mock BookAdditionalFileRepository bookAdditionalFileRepository; @Mock - BookFileProcessorRegistry processorRegistry; - @Mock - NotificationService notificationService; - @Mock AppSettingService appSettingService; @Mock PdfMetadataExtractor pdfMetadataExtractor; @Mock EpubMetadataExtractor epubMetadataExtractor; @Mock - MonitoringService monitoringService; + FileMovingHelper fileMovingHelper; + @Mock + AdditionalFileMapper additionalFileMapper; AppProperties appProperties; FileUploadService service; @@ -80,17 +75,11 @@ class FileUploadServiceTest { settings.setUploadPattern("{currentFilename}"); when(appSettingService.getAppSettings()).thenReturn(settings); - var additionalFilesMapper = new AdditionalFileMapperImpl(); - service = new FileUploadService( libraryRepository, bookRepository, bookAdditionalFileRepository, - processorRegistry, notificationService, appSettingService, appProperties, pdfMetadataExtractor, - epubMetadataExtractor, additionalFilesMapper, monitoringService + epubMetadataExtractor, additionalFileMapper, fileMovingHelper ); - - ReflectionTestUtils.setField(service, "userId", "0"); - ReflectionTestUtils.setField(service, "groupId", "0"); } @Test @@ -185,6 +174,7 @@ class FileUploadServiceTest { path.setPath(tempDir.toString()); lib.setLibraryPaths(List.of(path)); when(libraryRepository.findById(1L)).thenReturn(Optional.of(lib)); + when(fileMovingHelper.getFileNamingPattern(lib)).thenReturn("{currentFilename}"); assertThatExceptionOfType(APIException.class) .isThrownBy(() -> service.uploadFile(file, 1L, 1L)) @@ -206,22 +196,76 @@ class FileUploadServiceTest { path.setPath(tempDir.toString()); lib.setLibraryPaths(List.of(path)); when(libraryRepository.findById(7L)).thenReturn(Optional.of(lib)); + when(fileMovingHelper.getFileNamingPattern(lib)).thenReturn("{currentFilename}"); - BookFileProcessor proc = mock(BookFileProcessor.class); - FileProcessResult fileProcessResult = FileProcessResult.builder() - .book(Book.builder().build()) - .status(FileProcessStatus.NEW) - .build(); + service.uploadFile(file, 7L, 2L); - when(processorRegistry.getProcessorOrThrow(BookFileType.CBX)).thenReturn(proc); - when(proc.processFile(any())).thenReturn(fileProcessResult); - - Book result = service.uploadFile(file, 7L, 2L); - - assertThat(result).isSameAs(fileProcessResult.getBook()); Path moved = tempDir.resolve("book.cbz"); assertThat(Files.exists(moved)).isTrue(); - verify(notificationService).sendMessage(eq(Topic.BOOK_ADD), same(fileProcessResult.getBook())); verifyNoInteractions(pdfMetadataExtractor, epubMetadataExtractor); } + + @Test + void uploadAdditionalFile_successful_and_saves_entity() throws Exception { + long bookId = 5L; + MockMultipartFile file = new MockMultipartFile("file", "add.pdf", "application/pdf", "payload".getBytes()); + + LibraryPathEntity libPath = new LibraryPathEntity(); + libPath.setId(1L); + libPath.setPath(tempDir.toString()); + BookEntity book = new BookEntity(); + book.setId(bookId); + book.setLibraryPath(libPath); + book.setFileSubPath("."); + + when(bookRepository.findById(bookId)).thenReturn(Optional.of(book)); + + try (MockedStatic fp = mockStatic(FileFingerprint.class)) { + fp.when(() -> FileFingerprint.generateHash(any())).thenReturn("hash-123"); + + when(bookAdditionalFileRepository.findByAltFormatCurrentHash("hash-123")).thenReturn(Optional.empty()); + + when(bookAdditionalFileRepository.save(any(BookAdditionalFileEntity.class))).thenAnswer(inv -> { + BookAdditionalFileEntity e = inv.getArgument(0); + e.setId(99L); + return e; + }); + + AdditionalFile dto = mock(AdditionalFile.class); + when(additionalFileMapper.toAdditionalFile(any(BookAdditionalFileEntity.class))).thenReturn(dto); + + AdditionalFile result = service.uploadAdditionalFile(bookId, file, AdditionalFileType.ALTERNATIVE_FORMAT, "desc"); + + assertThat(result).isEqualTo(dto); + verify(bookAdditionalFileRepository).save(any(BookAdditionalFileEntity.class)); + verify(additionalFileMapper).toAdditionalFile(any(BookAdditionalFileEntity.class)); + } + } + + @Test + void uploadAdditionalFile_duplicate_alternative_format_throws() { + long bookId = 6L; + MockMultipartFile file = new MockMultipartFile("file", "alt.pdf", "application/pdf", "payload".getBytes()); + + LibraryPathEntity libPath = new LibraryPathEntity(); + libPath.setId(2L); + libPath.setPath(tempDir.toString()); + BookEntity book = new BookEntity(); + book.setId(bookId); + book.setLibraryPath(libPath); + book.setFileSubPath("."); + + when(bookRepository.findById(bookId)).thenReturn(Optional.of(book)); + + try (MockedStatic fp = mockStatic(FileFingerprint.class)) { + fp.when(() -> FileFingerprint.generateHash(any())).thenReturn("dup-hash"); + + BookAdditionalFileEntity existing = new BookAdditionalFileEntity(); + existing.setId(1L); + when(bookAdditionalFileRepository.findByAltFormatCurrentHash("dup-hash")).thenReturn(Optional.of(existing)); + + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> service.uploadAdditionalFile(bookId, file, AdditionalFileType.ALTERNATIVE_FORMAT, null)); + } + } } diff --git a/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-notification.component.scss b/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-notification.component.scss index 6c1ca1ece..0ed91cb0c 100644 --- a/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-notification.component.scss +++ b/booklore-ui/src/app/core/component/duplicate-files-notification/duplicate-files-notification.component.scss @@ -4,5 +4,7 @@ } .live-border { - border: 0.5px solid var(--primary-color); + background: var(--card-background); + border: 1px solid var(--primary-color); + border-radius: 0.5rem; } diff --git a/booklore-ui/src/app/core/component/live-notification-box/live-notification-box.component.scss b/booklore-ui/src/app/core/component/live-notification-box/live-notification-box.component.scss index 2636cb2e7..59773448d 100644 --- a/booklore-ui/src/app/core/component/live-notification-box/live-notification-box.component.scss +++ b/booklore-ui/src/app/core/component/live-notification-box/live-notification-box.component.scss @@ -1,3 +1,5 @@ .live-border { - border: 0.5px solid var(--primary-color); + background: var(--card-background); + border: 1px solid var(--primary-color); + border-radius: 0.5rem; } diff --git a/booklore-ui/src/app/core/component/live-task-event-box/live-task-event-box.component.scss b/booklore-ui/src/app/core/component/live-task-event-box/live-task-event-box.component.scss index a434fb8e7..0e766b8e3 100644 --- a/booklore-ui/src/app/core/component/live-task-event-box/live-task-event-box.component.scss +++ b/booklore-ui/src/app/core/component/live-task-event-box/live-task-event-box.component.scss @@ -1,4 +1,7 @@ .live-border { - border: 0.5px solid var(--primary-color); + background: var(--card-background); + border: 1px solid var(--primary-color); + border-radius: 0.5rem; + } diff --git a/booklore-ui/src/app/core/model/app-settings.model.ts b/booklore-ui/src/app/core/model/app-settings.model.ts index 6b1378189..91014b217 100644 --- a/booklore-ui/src/app/core/model/app-settings.model.ts +++ b/booklore-ui/src/app/core/model/app-settings.model.ts @@ -81,6 +81,7 @@ export interface Douban { } export interface MetadataPersistenceSettings { + moveFilesToLibraryPattern: boolean; saveToOriginalFile: boolean; convertCbrCb7ToCbz: boolean; backupMetadata: boolean; diff --git a/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.scss b/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.scss index 4aabd9b01..62d8e96a4 100644 --- a/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.scss +++ b/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.scss @@ -25,12 +25,13 @@ position: absolute; top: -0.4rem; right: -0.4rem; - background-color: var(--red-500); - color: yellowgreen; - padding: 0 8px; - font-size: 1rem; - font-weight: 400; - line-height: 1.2; + background-color: red; + color: white; + border-radius: 50%; + width: 1.2rem; + height: 1.2rem; + font-size: 0.75rem; + font-weight: 600; display: flex; align-items: center; justify-content: center; diff --git a/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.ts b/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.ts index 687092f9d..e7840881b 100644 --- a/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.ts +++ b/booklore-ui/src/app/layout/component/layout-topbar/app.topbar.component.ts @@ -237,8 +237,8 @@ export class AppTopBarComponent implements OnDestroy { get iconColor(): string { if (this.progressHighlight) return 'yellow'; - if (this.showPulse) return 'red'; - if (this.completedTaskCount > 0 || this.hasPendingBookdropFiles || this.hasDuplicateFiles) return 'orange'; + if (this.showPulse) return 'orange'; + if (this.completedTaskCount > 0 || this.hasPendingBookdropFiles || this.hasDuplicateFiles) return 'yellowgreen'; return 'inherit'; } diff --git a/booklore-ui/src/app/settings/file-naming-pattern/file-naming-pattern.component.html b/booklore-ui/src/app/settings/file-naming-pattern/file-naming-pattern.component.html index 56eecf671..687f5e155 100644 --- a/booklore-ui/src/app/settings/file-naming-pattern/file-naming-pattern.component.html +++ b/booklore-ui/src/app/settings/file-naming-pattern/file-naming-pattern.component.html @@ -5,7 +5,7 @@ File Naming Patterns

- Define custom naming patterns for uploaded files and for moving files within your library. Use metadata placeholders to automate organization. + Configure automatic file organization using metadata placeholders. Patterns are applied when uploading files, moving files within your library, and after metadata updates.

@@ -17,7 +17,7 @@ Default File Naming Pattern

- Define the default naming pattern for files. This pattern applies to all libraries unless overridden. + This pattern serves as the fallback when no library-specific override is configured.

diff --git a/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.html b/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.html index a7e4439fc..1ffa82523 100644 --- a/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.html +++ b/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.html @@ -7,6 +7,13 @@
+
+ +
+ Network Storage Notice: These features are designed for local file systems and have not been tested with network storage (NAS/cloud). Functionality cannot be guaranteed on network file systems. +
+
+
@@ -23,7 +30,7 @@
-
+
@@ -40,7 +47,7 @@
-
+
@@ -57,7 +64,7 @@
-
+
@@ -73,5 +80,21 @@

+ +
+
+
+ + + +
+

+ + Automatically move and rename files according to their library's naming pattern when metadata is updated, either through manual editing or auto-fetch operations. +

+
+
diff --git a/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.scss b/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.scss index 62e8d21c3..7152b9ed2 100644 --- a/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.scss +++ b/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.scss @@ -46,9 +46,32 @@ padding-bottom: 0; } + &.setting-item-indented { + margin-left: 1.5rem; + padding-left: 1rem; + border-left: 2px solid var(--p-content-border-color); + position: relative; + + &::before { + content: ''; + position: absolute; + left: -2px; + top: 0; + bottom: 0; + width: 2px; + background: var(--p-primary-color); + opacity: 0.3; + } + } + @media (max-width: 768px) { flex-direction: column; gap: 1rem; + + &.setting-item-indented { + margin-left: 1rem; + padding-left: 0.75rem; + } } } @@ -113,3 +136,23 @@ width: 100%; } } + +.warning-notice { + display: flex; + align-items: flex-start; + gap: 0.5rem; + color: var(--p-red-400); + font-size: 0.875rem; + line-height: 1.5; + + .pi-exclamation-triangle { + color: var(--p-red-500); + margin-top: 0.125rem; + flex-shrink: 0; + } + + strong { + font-weight: 600; + color: var(--p-red-500); + } +} diff --git a/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.ts b/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.ts index 373771823..4ac9be957 100644 --- a/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.ts +++ b/booklore-ui/src/app/settings/metadata-settings-component/metadata-persistence-settings-component/metadata-persistence-settings-component.ts @@ -21,6 +21,7 @@ export class MetadataPersistenceSettingsComponent implements OnInit { metadataPersistence: MetadataPersistenceSettings = { saveToOriginalFile: false, convertCbrCb7ToCbz: false, + moveFilesToLibraryPattern: false, backupMetadata: true, backupCover: true }; From acd71e1f54a29e0ad6227c806340641dcf9702e4 Mon Sep 17 00:00:00 2001 From: Patrick Deuley Date: Fri, 12 Sep 2025 13:10:30 -0500 Subject: [PATCH 10/10] fix: sort direction URL parameter persistence (#1134) - Fix URL parameters not updating when sort direction changes - Prioritize URL parameters over user preferences for sort state - Fix case sensitivity bug in direction parameter comparison - Ensure both sort field and direction persist on page refresh --- .../book-browser/book-browser.component.ts | 34 ++++++++++++++----- .../book-browser/sorting/BookSorter.ts | 12 ------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts b/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts index 42baa0663..96b7ae332 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts +++ b/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts @@ -147,7 +147,9 @@ export class BookBrowserComponent implements OnInit { private sideBarFilter = new SideBarFilter(this.selectedFilter, this.selectedFilterMode); private headerFilter = new HeaderFilter(this.searchTerm$); - protected bookSorter = new BookSorter(selectedSort => this.applySortOption(selectedSort)); + protected bookSorter = new BookSorter( + selectedSort => this.onManualSortChange(selectedSort) + ); @ViewChild(BookTableComponent) bookTableComponent!: BookTableComponent; @@ -215,7 +217,6 @@ export class BookBrowserComponent implements OnInit { ); this.tieredMenuItems = this.bookMenuService.getTieredMenuItems(this.selectedBooks); - // --- NEW: Subscribe to query params + user changes for reactive updates --- combineLatest([ this.activatedRoute.paramMap, this.activatedRoute.queryParamMap, @@ -290,16 +291,17 @@ export class BookBrowserComponent implements OnInit { ? SortDirection.DESCENDING : SortDirection.ASCENDING; - const matchedSort = this.bookSorter.sortOptions.find(opt => opt.field === userSortKey) || this.bookSorter.sortOptions.find(opt => opt.field === sortParam); + const effectiveSortKey = sortParam || userSortKey; + const effectiveSortDir = directionParam + ? (directionParam.toLowerCase() === SORT_DIRECTION.DESCENDING ? SortDirection.DESCENDING : SortDirection.ASCENDING) + : userSortDir; + + const matchedSort = this.bookSorter.sortOptions.find(opt => opt.field === effectiveSortKey); this.bookSorter.selectedSort = matchedSort ? { label: matchedSort.label, field: matchedSort.field, - direction: userSortDir ?? ( - directionParam?.toUpperCase() === SORT_DIRECTION.DESCENDING - ? SortDirection.DESCENDING - : SortDirection.ASCENDING - ) + direction: effectiveSortDir } : { label: 'Added On', field: 'addedOn', @@ -453,6 +455,22 @@ export class BookBrowserComponent implements OnInit { this.seriesCollapseFilter.setCollapsed(value); } + onManualSortChange(sortOption: SortOption): void { + this.applySortOption(sortOption); + + const currentParams = this.activatedRoute.snapshot.queryParams; + const newParams = { + ...currentParams, + sort: sortOption.field, + direction: sortOption.direction === SortDirection.ASCENDING ? SORT_DIRECTION.ASCENDING : SORT_DIRECTION.DESCENDING + }; + + this.router.navigate([], { + queryParams: newParams, + replaceUrl: true + }); + } + applySortOption(sortOption: SortOption): void { if (this.entityType === EntityType.ALL_BOOKS) { this.bookState$ = this.fetchAllBooks(); diff --git a/booklore-ui/src/app/book/components/book-browser/sorting/BookSorter.ts b/booklore-ui/src/app/book/components/book-browser/sorting/BookSorter.ts index ec7b432c6..91588009b 100644 --- a/booklore-ui/src/app/book/components/book-browser/sorting/BookSorter.ts +++ b/booklore-ui/src/app/book/components/book-browser/sorting/BookSorter.ts @@ -1,6 +1,5 @@ import {SortDirection, SortOption} from '../../../model/sort.model'; - export class BookSorter { selectedSort: SortOption | undefined = undefined; @@ -46,17 +45,6 @@ export class BookSorter { this.updateSortOptions(); this.applySortOption(this.selectedSort); - - /*this.router.navigate([], { - queryParams: { - sort: this.selectedSort.field, - direction: this.selectedSort.direction === SortDirection.ASCENDING - ? SORT_DIRECTION.ASCENDING - : SORT_DIRECTION.DESCENDING - }, - queryParamsHandling: 'merge', - replaceUrl: true - });*/ } updateSortOptions() {