diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index b3036b4a4..aa837521c 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -8,14 +8,6 @@ body:
value: |
Please fill out the details below so we can investigate and fix the issue.
- - type: checkboxes
- id: prerequisites
- attributes:
- label: Quick Check
- options:
- - label: I've searched existing issues and this bug hasn't been reported yet
- required: true
-
- type: textarea
id: description
attributes:
@@ -77,3 +69,11 @@ body:
validations:
required: true
+ - type: checkboxes
+ id: prerequisites
+ attributes:
+ label: Before Submitting
+ description: Please confirm you've completed this step
+ options:
+ - label: I've searched existing issues and confirmed this bug hasn't been reported yet
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index 978446bcd..659af2d94 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -8,14 +8,6 @@ body:
value: |
Please share as much detail as you can to help us understand your suggestion.
- - type: checkboxes
- id: prerequisites
- attributes:
- label: Quick Check
- options:
- - label: I've searched existing issues and this feature hasn't been requested yet
- required: true
-
- type: textarea
id: description
attributes:
@@ -57,3 +49,18 @@ body:
- "Just sharing the idea for now"
validations:
required: true
+
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Have You Considered Any Alternatives? (Optional)
+ description: Are there other ways to achieve what you want? Tell us about them
+
+ - type: checkboxes
+ id: prerequisites
+ attributes:
+ label: Before Submitting
+ description: Please confirm you've completed this step
+ options:
+ - label: I've searched existing issues and confirmed this feature hasn't been requested yet
+ required: true
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 5e394a91e..cfeb61041 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,35 +1,59 @@
-# ๐ Pull Request
+## ๐ Pull Request
+
+### ๐ Description
-## ๐ Description
+### ๐ ๏ธ Changes Implemented
-## ๐ ๏ธ Changes Implemented
-
+### ๐งช Testing Strategy
-## ๐งช Testing Strategy
+### ๐ธ Visual Changes _(if applicable)_
-## ๐ธ Visual Changes _(if applicable)_
+---
+
## โ ๏ธ Required Pre-Submission Checklist
-
-
-- [ ] Code adheres to project style guidelines and conventions
-- [ ] Branch synchronized with latest `develop` branch
-- [ ] Automated unit/integration tests added/updated to cover changes
-- [ ] All tests pass locally (`./gradlew test` for backend)
-- [ ] Manual testing completed in local development environment
-- [ ] Flyway migration versioning follows correct sequence _(if database schema modified)_
-- [ ] Documentation pull request submitted to [booklore-docs](https://github.com/booklore-app/booklore-docs) _(required for features or enhancements that introduce user-facing or visual changes)_
+### **Please Read - This Checklist is Mandatory**
+
+> **Important Notice:** We've experienced several production bugs recently due to incomplete pre-submission checks. To maintain code quality and prevent issues from reaching production, we're enforcing stricter adherence to this checklist.
+>
+> **All checkboxes below must be completed before requesting review.** PRs that haven't completed these requirements will be sent back for completion.
+
+#### **Mandatory Requirements** _(please check ALL boxes)_:
+
+- [ ] **Code adheres to project style guidelines and conventions**
+- [ ] **Branch synchronized with latest `develop` branch** _(please resolve any merge conflicts)_
+- [ ] **๐จ CRITICAL: Automated unit/integration tests added/updated to cover changes** _(MANDATORY for ALL backend changes - this is non-negotiable)_
+- [ ] **๐จ CRITICAL: All tests pass locally** _(run `./gradlew test` for backend - NO EXCEPTIONS)_
+- [ ] **๐จ CRITICAL: Manual testing completed in local development environment** _(verify your changes work AND no existing functionality is broken - test related features thoroughly)_
+- [ ] **Flyway migration versioning follows correct sequence** _(if database schema was modified)_
+- [ ] **Documentation PR submitted to [booklore-docs](https://github.com/booklore-app/booklore-docs)** _(required for features or enhancements that introduce user-facing or visual changes)_
+
+#### **Why This Matters:**
+
+Recent production incidents have been traced back to:
+
+- **Incomplete testing coverage (especially backend)**
+- Merge conflicts not resolved before merge
+- Missing documentation for new features
+
+**Backend changes without tests will not be accepted.** By completing this checklist thoroughly, you're helping maintain the quality and stability of Booklore for all users.
+
+**Note to Reviewers:** Please verify the checklist is complete before beginning your review. If items are unchecked, kindly ask the contributor to complete them first.
+
+---
+
+### ๐ฌ Additional Context _(optional)_
-## ๐ฌ Additional Context _(optional)_
diff --git a/README.md b/README.md
index 572e3c228..5d0910f4f 100644
--- a/README.md
+++ b/README.md
@@ -430,6 +430,22 @@ Join community!
+## ๐ **Sponsors**
+
+### Thank you to our amazing sponsors!
+
+
+
+
+
+*Become a sponsor and get your logo here! [Support us on Open Collective](https://opencollective.com/booklore)*
+
+
+
+---
+
+
+
## โ๏ธ **License**
**GNU General Public License v3.0**
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/AppMigrationService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/AppMigrationService.java
index 9088b1b20..57266f050 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/AppMigrationService.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/AppMigrationService.java
@@ -1,338 +1,37 @@
package com.adityachandel.booklore.service.migration;
-import com.adityachandel.booklore.config.AppProperties;
import com.adityachandel.booklore.model.entity.AppMigrationEntity;
-import com.adityachandel.booklore.model.entity.AppSettingEntity;
-import com.adityachandel.booklore.model.entity.BookEntity;
-import com.adityachandel.booklore.model.entity.BookMetadataEntity;
import com.adityachandel.booklore.repository.AppMigrationRepository;
-import com.adityachandel.booklore.repository.AppSettingsRepository;
-import com.adityachandel.booklore.repository.BookRepository;
-import com.adityachandel.booklore.service.book.BookQueryService;
-import com.adityachandel.booklore.service.file.FileFingerprint;
-import com.adityachandel.booklore.service.metadata.MetadataMatchService;
-import com.adityachandel.booklore.service.InstallationService;
-import com.adityachandel.booklore.util.BookUtils;
-import com.adityachandel.booklore.util.FileService;
-import com.adityachandel.booklore.util.FileUtils;
-import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.transaction.Transactional;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
-import org.springframework.core.io.Resource;
-import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Service;
-import javax.imageio.ImageIO;
-import java.awt.image.BufferedImage;
-import java.io.File;
-import java.io.IOException;
-import java.io.UncheckedIOException;
-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.time.LocalDateTime;
-import java.util.Comparator;
-import java.util.List;
@Slf4j
@AllArgsConstructor
@Service
public class AppMigrationService {
- private static final String INSTALLATION_ID_KEY = "installation_id";
-
private AppMigrationRepository migrationRepository;
- private AppSettingsRepository appSettingsRepository;
- private BookRepository bookRepository;
- private BookQueryService bookQueryService;
- private MetadataMatchService metadataMatchService;
- private AppProperties appProperties;
- private FileService fileService;
- private ObjectMapper objectMapper;
- private InstallationService installationService;
+
@Transactional
- public void generateInstallationId() {
- if (migrationRepository.existsById("generateInstallationId")) return;
-
- installationService.getOrCreateInstallation();
-
- migrationRepository.save(new AppMigrationEntity("generateInstallationId", LocalDateTime.now(), "Generate unique installation ID using timestamp and UUID"));
- }
-
- @Transactional
- public void populateSearchTextOnce() {
- if (migrationRepository.existsById("populateSearchText")) return;
-
- int batchSize = 1000;
- int processedCount = 0;
- int offset = 0;
-
- while (true) {
- List bookBatch = bookRepository.findBooksForMigrationBatch(offset, batchSize);
- if (bookBatch.isEmpty()) break;
-
- List bookIds = bookBatch.stream().map(BookEntity::getId).toList();
- List books = bookRepository.findBooksWithMetadataAndAuthors(bookIds);
-
- for (BookEntity book : books) {
- BookMetadataEntity m = book.getMetadata();
- if (m != null) {
- try {
- m.setSearchText(BookUtils.buildSearchText(m));
- } catch (Exception ex) {
- log.warn("Failed to build search text for book {}: {}", book.getId(), ex.getMessage());
- }
- }
- }
-
- bookRepository.saveAll(books);
- processedCount += books.size();
- offset += batchSize;
-
- log.info("Migration progress: {} books processed", processedCount);
-
- if (bookBatch.size() < batchSize) break;
- }
-
- log.info("Migration 'populateSearchText' completed. Total books processed: {}", processedCount);
- migrationRepository.save(new AppMigrationEntity(
- "populateSearchText",
- LocalDateTime.now(),
- "Populate search_text column for all books"
- ));
- }
-
- @Transactional
- public void populateMissingFileSizesOnce() {
- if (migrationRepository.existsById("populateFileSizes")) {
+ public void executeMigration(Migration migration) {
+ if (migrationRepository.existsById(migration.getKey())) {
+ log.debug("Migration '{}' already executed, skipping", migration.getKey());
return;
}
-
- List books = bookRepository.findAllWithMetadataByFileSizeKbIsNull();
- for (BookEntity book : books) {
- Long sizeInKb = FileUtils.getFileSizeInKb(book);
- if (sizeInKb != null) {
- book.setFileSizeKb(sizeInKb);
- }
- }
- bookRepository.saveAll(books);
-
- log.info("Starting migration 'populateFileSizes' for {} books.", books.size());
- AppMigrationEntity migration = new AppMigrationEntity();
- migration.setKey("populateFileSizes");
- migration.setExecutedAt(LocalDateTime.now());
- migration.setDescription("Populate file size for existing books");
- migrationRepository.save(migration);
- log.info("Migration 'populateFileSizes' executed successfully.");
- }
-
- @Transactional
- public void populateMetadataScoresOnce() {
- if (migrationRepository.existsById("populateMetadataScores_v2")) return;
-
- List books = bookQueryService.getAllFullBookEntities();
- for (BookEntity book : books) {
- Float score = metadataMatchService.calculateMatchScore(book);
- book.setMetadataMatchScore(score);
- }
- bookRepository.saveAll(books);
-
- log.info("Migration 'populateMetadataScores_v2' applied to {} books.", books.size());
- migrationRepository.save(new AppMigrationEntity("populateMetadataScores_v2", LocalDateTime.now(), "Calculate and store metadata match score for all books"));
- }
-
- @Transactional
- public void populateFileHashesOnce() {
- if (migrationRepository.existsById("populateFileHashesV2")) return;
-
- List books = bookRepository.findAll();
- int updated = 0;
-
- for (BookEntity book : books) {
- Path path = book.getFullFilePath();
- if (path == null || !Files.exists(path)) {
- log.warn("Skipping hashing for book ID {} โ file not found at path: {}", book.getId(), path);
- continue;
- }
-
- try {
- String hash = FileFingerprint.generateHash(path);
- if (book.getInitialHash() == null) {
- book.setInitialHash(hash);
- }
- book.setCurrentHash(hash);
- updated++;
- } catch (Exception e) {
- log.error("Failed to compute hash for file: {}", path, e);
- }
- }
-
- bookRepository.saveAll(books);
-
- log.info("Migration 'populateFileHashesV2' applied to {} books.", updated);
- migrationRepository.save(new AppMigrationEntity(
- "populateFileHashesV2",
- LocalDateTime.now(),
- "Calculate and store initialHash and currentHash for all books"
- ));
- }
-
- @Transactional
- public void populateCoversAndResizeThumbnails() {
- if (migrationRepository.existsById("populateCoversAndResizeThumbnails")) return;
-
- long start = System.nanoTime();
- log.info("Starting migration: populateCoversAndResizeThumbnails");
-
- String dataFolder = appProperties.getPathConfig();
- Path thumbsDir = Paths.get(dataFolder, "thumbs");
- Path imagesDir = Paths.get(dataFolder, "images");
-
try {
- if (Files.exists(thumbsDir)) {
- try (var stream = Files.walk(thumbsDir)) {
- stream.filter(Files::isRegularFile)
- .forEach(path -> {
- BufferedImage originalImage = null;
- BufferedImage resized = null;
- try {
- // Load original image
- originalImage = ImageIO.read(path.toFile());
- if (originalImage == null) {
- log.warn("Skipping non-image file: {}", path);
- return;
- }
+ migration.execute();
+ AppMigrationEntity entity = new AppMigrationEntity(migration.getKey(), LocalDateTime.now(), migration.getDescription());
+ migrationRepository.save(entity);
- // Extract bookId from folder structure
- Path relative = thumbsDir.relativize(path); // e.g., "11/f.jpg"
- String bookId = relative.getParent().toString(); // "11"
-
- Path bookDir = imagesDir.resolve(bookId);
- Files.createDirectories(bookDir);
-
- // Copy original to cover.jpg
- Path coverFile = bookDir.resolve("cover.jpg");
- ImageIO.write(originalImage, "jpg", coverFile.toFile());
-
- // Resize and save thumbnail.jpg
- resized = FileService.resizeImage(originalImage, 250, 350);
- Path thumbnailFile = bookDir.resolve("thumbnail.jpg");
- ImageIO.write(resized, "jpg", thumbnailFile.toFile());
-
- log.debug("Processed book {}: cover={} thumbnail={}", bookId, coverFile, thumbnailFile);
- } catch (IOException e) {
- log.error("Error processing file {}", path, e);
- throw new UncheckedIOException(e);
- } finally {
- if (originalImage != null) {
- originalImage.flush();
- }
- if (resized != null) {
- resized.flush();
- }
- }
- });
- }
-
- // Delete old thumbs directory
- log.info("Deleting old thumbs directory: {}", thumbsDir);
- try (var stream = Files.walk(thumbsDir)) {
- stream.sorted(Comparator.reverseOrder())
- .map(Path::toFile)
- .forEach(File::delete);
- }
- }
- } catch (IOException e) {
- log.error("Error during migration populateCoversAndResizeThumbnails", e);
- throw new UncheckedIOException(e);
- }
-
- migrationRepository.save(new AppMigrationEntity(
- "populateCoversAndResizeThumbnails",
- LocalDateTime.now(),
- "Copy thumbnails to images/{bookId}/cover.jpg and create resized 250x350 images as thumbnail.jpg"
- ));
-
- long elapsedMs = (System.nanoTime() - start) / 1_000_000;
- log.info("Completed migration: populateCoversAndResizeThumbnails in {} ms", elapsedMs);
- }
-
- @Transactional
- public void moveIconsToDataFolder() {
- if (migrationRepository.existsById("moveIconsToDataFolder")) return;
-
- long start = System.nanoTime();
- log.info("Starting migration: moveIconsToDataFolder");
-
- try {
- String targetFolder = fileService.getIconsSvgFolder();
- Path targetDir = Paths.get(targetFolder);
- Files.createDirectories(targetDir);
-
- PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
- Resource[] resources = resolver.getResources("classpath:static/images/icons/svg/*.svg");
-
- int copiedCount = 0;
- for (Resource resource : resources) {
- String filename = resource.getFilename();
- if (filename == null) continue;
-
- Path targetFile = targetDir.resolve(filename);
-
- try (var inputStream = resource.getInputStream()) {
- Files.copy(inputStream, targetFile, StandardCopyOption.REPLACE_EXISTING);
- copiedCount++;
- log.debug("Copied icon: {} to {}", filename, targetFile);
- } catch (IOException e) {
- log.error("Failed to copy icon: {}", filename, e);
- }
- }
-
- log.info("Copied {} SVG icons from resources to data folder", copiedCount);
-
- migrationRepository.save(new AppMigrationEntity(
- "moveIconsToDataFolder",
- LocalDateTime.now(),
- "Move SVG icons from resources/static/images/icons/svg to data/icons/svg"
- ));
-
- long elapsedMs = (System.nanoTime() - start) / 1_000_000;
- log.info("Completed migration: moveIconsToDataFolder in {} ms", elapsedMs);
- } catch (IOException e) {
- log.error("Error during migration moveIconsToDataFolder", e);
- throw new UncheckedIOException(e);
+ log.info("Migration '{}' completed successfully", migration.getKey());
+ } catch (Exception e) {
+ log.error("Migration '{}' failed", migration.getKey(), e);
+ throw e;
}
}
-
- @Transactional
- public void migrateInstallationIdToJson() {
- if (migrationRepository.existsById("migrateInstallationIdToJson")) return;
-
- AppSettingEntity setting = appSettingsRepository.findByName(INSTALLATION_ID_KEY);
-
- if (setting != null) {
- String value = setting.getVal();
- try {
- objectMapper.readTree(value);
- log.info("Installation ID is already in JSON format, skipping migration");
- } catch (Exception e) {
- Instant now = Instant.now();
- String json = String.format("{\"id\":\"%s\",\"date\":\"%s\"}", value, now);
- setting.setVal(json);
- appSettingsRepository.save(setting);
- log.info("Migrated installation ID to JSON format with current date");
- }
- }
-
- migrationRepository.save(new AppMigrationEntity(
- "migrateInstallationIdToJson",
- LocalDateTime.now(),
- "Migrate existing installation_id from plain string to JSON format with date"
- ));
- }
-
}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/AppMigrationStartup.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/AppMigrationStartup.java
index b4a419d6f..85a62e05d 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/AppMigrationStartup.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/AppMigrationStartup.java
@@ -1,5 +1,6 @@
package com.adityachandel.booklore.service.migration;
+import com.adityachandel.booklore.service.migration.migrations.*;
import lombok.AllArgsConstructor;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
@@ -10,16 +11,26 @@ import org.springframework.stereotype.Component;
public class AppMigrationStartup {
private final AppMigrationService appMigrationService;
+ private final GenerateInstallationIdMigration generateInstallationIdMigration;
+ private final MigrateInstallationIdToJsonMigration migrateInstallationIdToJsonMigration;
+ private final PopulateMissingFileSizesMigration populateMissingFileSizesMigration;
+ private final PopulateMetadataScoresMigration populateMetadataScoresMigration;
+ private final PopulateFileHashesMigration populateFileHashesMigration;
+ private final PopulateCoversAndResizeThumbnailsMigration populateCoversAndResizeThumbnailsMigration;
+ private final PopulateSearchTextMigration populateSearchTextMigration;
+ private final MoveIconsToDataFolderMigration moveIconsToDataFolderMigration;
+ private final GenerateCoverHashMigration generateCoverHashMigration;
@EventListener(ApplicationReadyEvent.class)
public void runMigrationsOnce() {
- appMigrationService.generateInstallationId();
- appMigrationService.migrateInstallationIdToJson();
- appMigrationService.populateMissingFileSizesOnce();
- appMigrationService.populateMetadataScoresOnce();
- appMigrationService.populateFileHashesOnce();
- appMigrationService.populateCoversAndResizeThumbnails();
- appMigrationService.populateSearchTextOnce();
- appMigrationService.moveIconsToDataFolder();
+ appMigrationService.executeMigration(generateInstallationIdMigration);
+ appMigrationService.executeMigration(migrateInstallationIdToJsonMigration);
+ appMigrationService.executeMigration(populateMissingFileSizesMigration);
+ appMigrationService.executeMigration(populateMetadataScoresMigration);
+ appMigrationService.executeMigration(populateFileHashesMigration);
+ appMigrationService.executeMigration(populateCoversAndResizeThumbnailsMigration);
+ appMigrationService.executeMigration(populateSearchTextMigration);
+ appMigrationService.executeMigration(moveIconsToDataFolderMigration);
+ appMigrationService.executeMigration(generateCoverHashMigration);
}
}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/Migration.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/Migration.java
new file mode 100644
index 000000000..4d18a7336
--- /dev/null
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/Migration.java
@@ -0,0 +1,9 @@
+package com.adityachandel.booklore.service.migration;
+
+public interface Migration {
+ String getKey();
+
+ String getDescription();
+
+ void execute();
+}
\ No newline at end of file
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/GenerateCoverHashMigration.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/GenerateCoverHashMigration.java
new file mode 100644
index 000000000..5887e8e3f
--- /dev/null
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/GenerateCoverHashMigration.java
@@ -0,0 +1,60 @@
+package com.adityachandel.booklore.service.migration.migrations;
+
+import com.adityachandel.booklore.model.entity.BookEntity;
+import com.adityachandel.booklore.repository.BookRepository;
+import com.adityachandel.booklore.service.migration.Migration;
+import com.adityachandel.booklore.util.BookCoverUtils;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class GenerateCoverHashMigration implements Migration {
+
+ private final BookRepository bookRepository;
+
+ @Override
+ public String getKey() {
+ return "generateCoverHash";
+ }
+
+ @Override
+ public String getDescription() {
+ return "Generate unique cover hash for all books using BookCoverUtils";
+ }
+
+ @Override
+ public void execute() {
+ log.info("Starting migration: {}", getKey());
+
+ int batchSize = 1000;
+ int processedCount = 0;
+ int offset = 0;
+
+ while (true) {
+ List bookBatch = bookRepository.findBooksForMigrationBatch(offset, batchSize);
+ if (bookBatch.isEmpty()) break;
+
+ for (BookEntity book : bookBatch) {
+ if (book.getBookCoverHash() == null) {
+ book.setBookCoverHash(BookCoverUtils.generateCoverHash());
+ }
+ }
+
+ bookRepository.saveAll(bookBatch);
+ processedCount += bookBatch.size();
+ offset += batchSize;
+
+ log.info("Migration progress: {} books processed", processedCount);
+
+ if (bookBatch.size() < batchSize) break;
+ }
+
+ log.info("Completed migration '{}'. Total books processed: {}", getKey(), processedCount);
+ }
+}
+
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/GenerateInstallationIdMigration.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/GenerateInstallationIdMigration.java
new file mode 100644
index 000000000..e6f7bee0e
--- /dev/null
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/GenerateInstallationIdMigration.java
@@ -0,0 +1,33 @@
+package com.adityachandel.booklore.service.migration.migrations;
+
+import com.adityachandel.booklore.service.InstallationService;
+import com.adityachandel.booklore.service.migration.Migration;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class GenerateInstallationIdMigration implements Migration {
+
+ private final InstallationService installationService;
+
+ @Override
+ public String getKey() {
+ return "generateInstallationId";
+ }
+
+ @Override
+ public String getDescription() {
+ return "Generate unique installation ID using timestamp and UUID";
+ }
+
+ @Override
+ public void execute() {
+ log.info("Executing migration: {}", getKey());
+ installationService.getOrCreateInstallation();
+ log.info("Completed migration: {}", getKey());
+ }
+}
+
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/MigrateInstallationIdToJsonMigration.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/MigrateInstallationIdToJsonMigration.java
new file mode 100644
index 000000000..b0357867c
--- /dev/null
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/MigrateInstallationIdToJsonMigration.java
@@ -0,0 +1,56 @@
+package com.adityachandel.booklore.service.migration.migrations;
+
+import com.adityachandel.booklore.model.entity.AppSettingEntity;
+import com.adityachandel.booklore.repository.AppSettingsRepository;
+import com.adityachandel.booklore.service.migration.Migration;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.time.Instant;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class MigrateInstallationIdToJsonMigration implements Migration {
+
+ private static final String INSTALLATION_ID_KEY = "installation_id";
+
+ private final AppSettingsRepository appSettingsRepository;
+ private final ObjectMapper objectMapper;
+
+ @Override
+ public String getKey() {
+ return "migrateInstallationIdToJson";
+ }
+
+ @Override
+ public String getDescription() {
+ return "Migrate existing installation_id from plain string to JSON format with date";
+ }
+
+ @Override
+ public void execute() {
+ log.info("Executing migration: {}", getKey());
+
+ AppSettingEntity setting = appSettingsRepository.findByName(INSTALLATION_ID_KEY);
+
+ if (setting != null) {
+ String value = setting.getVal();
+ try {
+ objectMapper.readTree(value);
+ log.info("Installation ID is already in JSON format, skipping migration");
+ } catch (Exception e) {
+ Instant now = Instant.now();
+ String json = String.format("{\"id\":\"%s\",\"date\":\"%s\"}", value, now);
+ setting.setVal(json);
+ appSettingsRepository.save(setting);
+ log.info("Migrated installation ID to JSON format with current date");
+ }
+ }
+
+ log.info("Completed migration: {}", getKey());
+ }
+}
+
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/MoveIconsToDataFolderMigration.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/MoveIconsToDataFolderMigration.java
new file mode 100644
index 000000000..5e9503c9d
--- /dev/null
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/MoveIconsToDataFolderMigration.java
@@ -0,0 +1,74 @@
+package com.adityachandel.booklore.service.migration.migrations;
+
+import com.adityachandel.booklore.service.migration.Migration;
+import com.adityachandel.booklore.util.FileService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class MoveIconsToDataFolderMigration implements Migration {
+
+ private final FileService fileService;
+
+ @Override
+ public String getKey() {
+ return "moveIconsToDataFolder";
+ }
+
+ @Override
+ public String getDescription() {
+ return "Move SVG icons from resources/static/images/icons/svg to data/icons/svg";
+ }
+
+ @Override
+ public void execute() {
+ long start = System.nanoTime();
+ log.info("Starting migration: {}", getKey());
+
+ try {
+ String targetFolder = fileService.getIconsSvgFolder();
+ Path targetDir = Paths.get(targetFolder);
+ Files.createDirectories(targetDir);
+
+ PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
+ Resource[] resources = resolver.getResources("classpath:static/images/icons/svg/*.svg");
+
+ int copiedCount = 0;
+ for (Resource resource : resources) {
+ String filename = resource.getFilename();
+ if (filename == null) continue;
+
+ Path targetFile = targetDir.resolve(filename);
+
+ try (var inputStream = resource.getInputStream()) {
+ Files.copy(inputStream, targetFile, StandardCopyOption.REPLACE_EXISTING);
+ copiedCount++;
+ log.debug("Copied icon: {} to {}", filename, targetFile);
+ } catch (IOException e) {
+ log.error("Failed to copy icon: {}", filename, e);
+ }
+ }
+
+ log.info("Copied {} SVG icons from resources to data folder", copiedCount);
+
+ long elapsedMs = (System.nanoTime() - start) / 1_000_000;
+ log.info("Completed migration: {} in {} ms", getKey(), elapsedMs);
+ } catch (IOException e) {
+ log.error("Error during migration {}", getKey(), e);
+ throw new UncheckedIOException(e);
+ }
+ }
+}
+
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateCoversAndResizeThumbnailsMigration.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateCoversAndResizeThumbnailsMigration.java
new file mode 100644
index 000000000..d20ff274f
--- /dev/null
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateCoversAndResizeThumbnailsMigration.java
@@ -0,0 +1,109 @@
+package com.adityachandel.booklore.service.migration.migrations;
+
+import com.adityachandel.booklore.config.AppProperties;
+import com.adityachandel.booklore.service.migration.Migration;
+import com.adityachandel.booklore.util.FileService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Comparator;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PopulateCoversAndResizeThumbnailsMigration implements Migration {
+
+ private final AppProperties appProperties;
+
+ @Override
+ public String getKey() {
+ return "populateCoversAndResizeThumbnails";
+ }
+
+ @Override
+ public String getDescription() {
+ return "Copy thumbnails to images/{bookId}/cover.jpg and create resized 250x350 images as thumbnail.jpg";
+ }
+
+ @Override
+ public void execute() {
+ long start = System.nanoTime();
+ log.info("Starting migration: {}", getKey());
+
+ String dataFolder = appProperties.getPathConfig();
+ Path thumbsDir = Paths.get(dataFolder, "thumbs");
+ Path imagesDir = Paths.get(dataFolder, "images");
+
+ try {
+ if (Files.exists(thumbsDir)) {
+ try (var stream = Files.walk(thumbsDir)) {
+ stream.filter(Files::isRegularFile)
+ .forEach(path -> {
+ BufferedImage originalImage = null;
+ BufferedImage resized = null;
+ try {
+ // Load original image
+ originalImage = ImageIO.read(path.toFile());
+ if (originalImage == null) {
+ log.warn("Skipping non-image file: {}", path);
+ return;
+ }
+
+ // Extract bookId from folder structure
+ Path relative = thumbsDir.relativize(path); // e.g., "11/f.jpg"
+ String bookId = relative.getParent().toString(); // "11"
+
+ Path bookDir = imagesDir.resolve(bookId);
+ Files.createDirectories(bookDir);
+
+ // Copy original to cover.jpg
+ Path coverFile = bookDir.resolve("cover.jpg");
+ ImageIO.write(originalImage, "jpg", coverFile.toFile());
+
+ // Resize and save thumbnail.jpg
+ resized = FileService.resizeImage(originalImage, 250, 350);
+ Path thumbnailFile = bookDir.resolve("thumbnail.jpg");
+ ImageIO.write(resized, "jpg", thumbnailFile.toFile());
+
+ log.debug("Processed book {}: cover={} thumbnail={}", bookId, coverFile, thumbnailFile);
+ } catch (IOException e) {
+ log.error("Error processing file {}", path, e);
+ throw new UncheckedIOException(e);
+ } finally {
+ if (originalImage != null) {
+ originalImage.flush();
+ }
+ if (resized != null) {
+ resized.flush();
+ }
+ }
+ });
+ }
+
+ // Delete old thumbs directory
+ log.info("Deleting old thumbs directory: {}", thumbsDir);
+ try (var stream = Files.walk(thumbsDir)) {
+ stream.sorted(Comparator.reverseOrder())
+ .map(Path::toFile)
+ .forEach(File::delete);
+ }
+ }
+ } catch (IOException e) {
+ log.error("Error during migration {}", getKey(), e);
+ throw new UncheckedIOException(e);
+ }
+
+ long elapsedMs = (System.nanoTime() - start) / 1_000_000;
+ log.info("Completed migration: {} in {} ms", getKey(), elapsedMs);
+ }
+}
+
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateFileHashesMigration.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateFileHashesMigration.java
new file mode 100644
index 000000000..372efde49
--- /dev/null
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateFileHashesMigration.java
@@ -0,0 +1,63 @@
+package com.adityachandel.booklore.service.migration.migrations;
+
+import com.adityachandel.booklore.model.entity.BookEntity;
+import com.adityachandel.booklore.repository.BookRepository;
+import com.adityachandel.booklore.service.file.FileFingerprint;
+import com.adityachandel.booklore.service.migration.Migration;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PopulateFileHashesMigration implements Migration {
+
+ private final BookRepository bookRepository;
+
+ @Override
+ public String getKey() {
+ return "populateFileHashesV2";
+ }
+
+ @Override
+ public String getDescription() {
+ return "Calculate and store initialHash and currentHash for all books";
+ }
+
+ @Override
+ public void execute() {
+ log.info("Starting migration: {}", getKey());
+
+ List books = bookRepository.findAll();
+ int updated = 0;
+
+ for (BookEntity book : books) {
+ Path path = book.getFullFilePath();
+ if (path == null || !Files.exists(path)) {
+ log.warn("Skipping hashing for book ID {} โ file not found at path: {}", book.getId(), path);
+ continue;
+ }
+
+ try {
+ String hash = FileFingerprint.generateHash(path);
+ if (book.getInitialHash() == null) {
+ book.setInitialHash(hash);
+ }
+ book.setCurrentHash(hash);
+ updated++;
+ } catch (Exception e) {
+ log.error("Failed to compute hash for file: {}", path, e);
+ }
+ }
+
+ bookRepository.saveAll(books);
+
+ log.info("Migration '{}' applied to {} books.", getKey(), updated);
+ }
+}
+
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateMetadataScoresMigration.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateMetadataScoresMigration.java
new file mode 100644
index 000000000..4e5ac65cb
--- /dev/null
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateMetadataScoresMigration.java
@@ -0,0 +1,48 @@
+package com.adityachandel.booklore.service.migration.migrations;
+
+import com.adityachandel.booklore.model.entity.BookEntity;
+import com.adityachandel.booklore.repository.BookRepository;
+import com.adityachandel.booklore.service.book.BookQueryService;
+import com.adityachandel.booklore.service.metadata.MetadataMatchService;
+import com.adityachandel.booklore.service.migration.Migration;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PopulateMetadataScoresMigration implements Migration {
+
+ private final BookRepository bookRepository;
+ private final BookQueryService bookQueryService;
+ private final MetadataMatchService metadataMatchService;
+
+ @Override
+ public String getKey() {
+ return "populateMetadataScores_v2";
+ }
+
+ @Override
+ public String getDescription() {
+ return "Calculate and store metadata match score for all books";
+ }
+
+ @Override
+ public void execute() {
+ log.info("Starting migration: {}", getKey());
+
+ List books = bookQueryService.getAllFullBookEntities();
+
+ for (BookEntity book : books) {
+ Float score = metadataMatchService.calculateMatchScore(book);
+ book.setMetadataMatchScore(score);
+ }
+
+ bookRepository.saveAll(books);
+
+ log.info("Migration '{}' applied to {} books.", getKey(), books.size());
+ }
+}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateMissingFileSizesMigration.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateMissingFileSizesMigration.java
new file mode 100644
index 000000000..dbe38ff63
--- /dev/null
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateMissingFileSizesMigration.java
@@ -0,0 +1,48 @@
+package com.adityachandel.booklore.service.migration.migrations;
+
+import com.adityachandel.booklore.model.entity.BookEntity;
+import com.adityachandel.booklore.repository.BookRepository;
+import com.adityachandel.booklore.service.migration.Migration;
+import com.adityachandel.booklore.util.FileUtils;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PopulateMissingFileSizesMigration implements Migration {
+
+ private final BookRepository bookRepository;
+
+ @Override
+ public String getKey() {
+ return "populateFileSizes";
+ }
+
+ @Override
+ public String getDescription() {
+ return "Populate file size for existing books";
+ }
+
+ @Override
+ public void execute() {
+ log.info("Starting migration: {} for books.", getKey());
+
+ List books = bookRepository.findAllWithMetadataByFileSizeKbIsNull();
+
+ for (BookEntity book : books) {
+ Long sizeInKb = FileUtils.getFileSizeInKb(book);
+ if (sizeInKb != null) {
+ book.setFileSizeKb(sizeInKb);
+ }
+ }
+
+ bookRepository.saveAll(books);
+
+ log.info("Migration '{}' executed successfully for {} books.", getKey(), books.size());
+ }
+}
+
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateSearchTextMigration.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateSearchTextMigration.java
new file mode 100644
index 000000000..916fe0e69
--- /dev/null
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateSearchTextMigration.java
@@ -0,0 +1,69 @@
+package com.adityachandel.booklore.service.migration.migrations;
+
+import com.adityachandel.booklore.model.entity.BookEntity;
+import com.adityachandel.booklore.model.entity.BookMetadataEntity;
+import com.adityachandel.booklore.repository.BookRepository;
+import com.adityachandel.booklore.service.migration.Migration;
+import com.adityachandel.booklore.util.BookUtils;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PopulateSearchTextMigration implements Migration {
+
+ private final BookRepository bookRepository;
+
+ @Override
+ public String getKey() {
+ return "populateSearchText";
+ }
+
+ @Override
+ public String getDescription() {
+ return "Populate search_text column for all books";
+ }
+
+ @Override
+ public void execute() {
+ log.info("Starting migration: {}", getKey());
+
+ int batchSize = 1000;
+ int processedCount = 0;
+ int offset = 0;
+
+ while (true) {
+ List bookBatch = bookRepository.findBooksForMigrationBatch(offset, batchSize);
+ if (bookBatch.isEmpty()) break;
+
+ List bookIds = bookBatch.stream().map(BookEntity::getId).toList();
+ List books = bookRepository.findBooksWithMetadataAndAuthors(bookIds);
+
+ for (BookEntity book : books) {
+ BookMetadataEntity m = book.getMetadata();
+ if (m != null) {
+ try {
+ m.setSearchText(BookUtils.buildSearchText(m));
+ } catch (Exception ex) {
+ log.warn("Failed to build search text for book {}: {}", book.getId(), ex.getMessage());
+ }
+ }
+ }
+
+ bookRepository.saveAll(books);
+ processedCount += books.size();
+ offset += batchSize;
+
+ log.info("Migration progress: {} books processed", processedCount);
+
+ if (bookBatch.size() < batchSize) break;
+ }
+
+ log.info("Completed migration '{}'. Total books processed: {}", getKey(), processedCount);
+ }
+}
+
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/util/BookCoverUtils.java b/booklore-api/src/main/java/com/adityachandel/booklore/util/BookCoverUtils.java
index d09b7ef7e..78925f421 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/util/BookCoverUtils.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/util/BookCoverUtils.java
@@ -2,21 +2,19 @@ package com.adityachandel.booklore.util;
import lombok.experimental.UtilityClass;
-import java.time.Instant;
-import java.util.Random;
+import java.security.SecureRandom;
@UtilityClass
public class BookCoverUtils {
private static final String HASH_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ private static final SecureRandom RANDOM = new SecureRandom();
public static String generateCoverHash() {
- long timestamp = Instant.now().toEpochMilli();
- Random random = new Random(timestamp);
StringBuilder hash = new StringBuilder(13);
hash.append("BL-");
for (int i = 0; i < 13; i++) {
- hash.append(HASH_CHARS.charAt(random.nextInt(HASH_CHARS.length())));
+ hash.append(HASH_CHARS.charAt(RANDOM.nextInt(HASH_CHARS.length())));
}
return hash.toString();
}
diff --git a/booklore-ui/src/app/features/settings/user-management/user-management.component.ts b/booklore-ui/src/app/features/settings/user-management/user-management.component.ts
index 39bf517e1..ffdc85153 100644
--- a/booklore-ui/src/app/features/settings/user-management/user-management.component.ts
+++ b/booklore-ui/src/app/features/settings/user-management/user-management.component.ts
@@ -4,7 +4,7 @@ import {Button, ButtonDirective} from 'primeng/button';
import {DynamicDialogRef} from 'primeng/dynamicdialog';
import {TableModule} from 'primeng/table';
import {LowerCasePipe, TitleCasePipe} from '@angular/common';
-import {User, UserService} from './user.service';
+import {User, UserService, UserUpdateRequest} from './user.service';
interface UserWithEditing extends User {
isEditing?: boolean;
@@ -135,13 +135,14 @@ export class UserManagementComponent implements OnInit, OnDestroy {
saveUser(user: UserWithEditing) {
user.selectedLibraryIds = [...this.editingLibraryIds];
+ const updateRequest: UserUpdateRequest = {
+ name: user.name,
+ email: user.email,
+ permissions: user.permissions,
+ assignedLibraries: user.selectedLibraryIds || [],
+ };
this.userService
- .updateUser(user.id, {
- name: user.name,
- email: user.email,
- permissions: user.permissions,
- assignedLibraries: this.allLibraries.filter(lib => lib.id && user.selectedLibraryIds?.includes(lib.id)),
- })
+ .updateUser(user.id, updateRequest)
.subscribe({
next: () => {
user.isEditing = false;
diff --git a/booklore-ui/src/app/features/settings/user-management/user.service.ts b/booklore-ui/src/app/features/settings/user-management/user.service.ts
index 5cfe9f3a8..e8d31124a 100644
--- a/booklore-ui/src/app/features/settings/user-management/user.service.ts
+++ b/booklore-ui/src/app/features/settings/user-management/user.service.ts
@@ -191,6 +191,13 @@ export interface UserState {
error: string | null;
}
+export interface UserUpdateRequest {
+ name?: string;
+ email?: string;
+ permissions?: User['permissions'];
+ assignedLibraries?: number[];
+}
+
@Injectable({
providedIn: 'root'
})
@@ -277,7 +284,7 @@ export class UserService {
return this.http.get(this.userUrl);
}
- updateUser(userId: number, updateData: Partial): Observable {
+ updateUser(userId: number, updateData: UserUpdateRequest): Observable {
return this.http.put(`${this.userUrl}/${userId}`, updateData);
}
diff --git a/booklore-ui/src/app/features/settings/user-profile-dialog/user-profile-dialog.component.ts b/booklore-ui/src/app/features/settings/user-profile-dialog/user-profile-dialog.component.ts
index 2c65108d1..8d94cbdc3 100644
--- a/booklore-ui/src/app/features/settings/user-profile-dialog/user-profile-dialog.component.ts
+++ b/booklore-ui/src/app/features/settings/user-profile-dialog/user-profile-dialog.component.ts
@@ -3,7 +3,7 @@ import {Button} from 'primeng/button';
import {AbstractControl, FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, ValidationErrors, ValidatorFn, Validators} from '@angular/forms';
import {InputText} from 'primeng/inputtext';
import {Password} from 'primeng/password';
-import {User, UserService} from '../user-management/user.service';
+import {User, UserService, UserUpdateRequest} from '../user-management/user.service';
import {MessageService} from 'primeng/api';
import {Subject} from 'rxjs';
import {filter, takeUntil} from 'rxjs/operators';
@@ -105,7 +105,11 @@ export class UserProfileDialogComponent implements OnInit, OnDestroy {
return;
}
- this.userService.updateUser(this.currentUser.id, this.editUserData).subscribe({
+ const updateRequest: UserUpdateRequest = {
+ name: this.editUserData.name,
+ email: this.editUserData.email,
+ };
+ this.userService.updateUser(this.currentUser.id, updateRequest).subscribe({
next: () => {
this.messageService.add({severity: 'success', summary: 'Success', detail: 'Profile updated successfully'});
this.isEditing = false;