Merge pull request #2148 from booklore-app/develop

Merge develop into master for release
This commit is contained in:
ACX 2026-01-04 19:03:17 -07:00 committed by GitHub
commit 0510fb97c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 702 additions and 366 deletions

View File

@ -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

View File

@ -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

View File

@ -1,35 +1,59 @@
# 🚀 Pull Request
## 🚀 Pull Request
### 📝 Description
## 📝 Description
<!-- Provide a clear and concise summary of the changes introduced in this pull request -->
<!-- Reference related issues using "Fixes #123", "Closes #456", or "Relates to #789" -->
### 🛠️ Changes Implemented
## 🛠️ Changes Implemented
<!-- Detail the specific modifications, additions, or removals made in this pull request -->
-
### 🧪 Testing Strategy
## 🧪 Testing Strategy
<!-- Describe the testing methodology used to verify the correctness of these changes -->
<!-- Include testing approach, scenarios covered, and edge cases considered -->
### 📸 Visual Changes _(if applicable)_
## 📸 Visual Changes _(if applicable)_
<!-- Attach screenshots or videos demonstrating UI/UX modifications -->
---
## ⚠️ Required Pre-Submission Checklist
<!-- ⛔ Pull requests will NOT be considered for review unless ALL required items are completed -->
<!-- All items below are MANDATORY prerequisites for submission -->
- [ ] 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)_
<!-- Provide any supplementary information, implementation considerations, or discussion points for reviewers -->

View File

@ -430,6 +430,22 @@ Join community!
<div align="center">
## 🌟 **Sponsors**
### Thank you to our amazing sponsors!
<a href="https://www.pikapods.com/pods?run=booklore">
<img src="https://www.pikapods.com/static/run-button.svg" alt="Run on PikaPods" height="40">
</a>
*Become a sponsor and get your logo here! [Support us on Open Collective](https://opencollective.com/booklore)*
</div>
---
<div align="center">
## ⚖️ **License**
**GNU General Public License v3.0**

View File

@ -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<BookEntity> bookBatch = bookRepository.findBooksForMigrationBatch(offset, batchSize);
if (bookBatch.isEmpty()) break;
List<Long> bookIds = bookBatch.stream().map(BookEntity::getId).toList();
List<BookEntity> 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<BookEntity> 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<BookEntity> 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<BookEntity> 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"
));
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,9 @@
package com.adityachandel.booklore.service.migration;
public interface Migration {
String getKey();
String getDescription();
void execute();
}

View File

@ -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<BookEntity> 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);
}
}

View File

@ -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());
}
}

View File

@ -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());
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}

View File

@ -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<BookEntity> 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);
}
}

View File

@ -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<BookEntity> 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());
}
}

View File

@ -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<BookEntity> 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());
}
}

View File

@ -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<BookEntity> bookBatch = bookRepository.findBooksForMigrationBatch(offset, batchSize);
if (bookBatch.isEmpty()) break;
List<Long> bookIds = bookBatch.stream().map(BookEntity::getId).toList();
List<BookEntity> 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);
}
}

View File

@ -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();
}

View File

@ -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;

View File

@ -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<User[]>(this.userUrl);
}
updateUser(userId: number, updateData: Partial<User>): Observable<User> {
updateUser(userId: number, updateData: UserUpdateRequest): Observable<User> {
return this.http.put<User>(`${this.userUrl}/${userId}`, updateData);
}

View File

@ -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;