From 8d9c7e5e992e95a87583ad8cdc55addf65dceed9 Mon Sep 17 00:00:00 2001 From: acx10 Date: Sun, 4 Jan 2026 14:17:48 -0700 Subject: [PATCH 1/4] Add PikaPods as a project sponsor --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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! + + + Run on PikaPods + + +*Become a sponsor and get your logo here! [Support us on Open Collective](https://opencollective.com/booklore)* + +
+ +--- + +
+ ## โš–๏ธ **License** **GNU General Public License v3.0** From 0fe883c9251dfe1f7c83753393f3c201bb4f798b Mon Sep 17 00:00:00 2001 From: ACX <8075870+acx10@users.noreply.github.com> Date: Sun, 4 Jan 2026 17:39:25 -0700 Subject: [PATCH 2/4] Fix: User update fails when libraries are assigned (#2144) Co-authored-by: acx10 --- .../user-management/user-management.component.ts | 15 ++++++++------- .../settings/user-management/user.service.ts | 9 ++++++++- .../user-profile-dialog.component.ts | 8 ++++++-- 3 files changed, 22 insertions(+), 10 deletions(-) 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; From 2a89b19706064421c486c548354ca4148ad0f86a Mon Sep 17 00:00:00 2001 From: ACX <8075870+acx10@users.noreply.github.com> Date: Sun, 4 Jan 2026 18:13:57 -0700 Subject: [PATCH 3/4] Update pull request, feature request, and bug report templates (#2145) Co-authored-by: acx10 --- .github/ISSUE_TEMPLATE/bug_report.yml | 16 +++---- .github/ISSUE_TEMPLATE/feature_request.yml | 23 +++++---- .github/pull_request_template.md | 54 ++++++++++++++++------ 3 files changed, 62 insertions(+), 31 deletions(-) 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)_ From c3af19a804557c5e4f85112ee016a3d8a684c01b Mon Sep 17 00:00:00 2001 From: ACX <8075870+acx10@users.noreply.github.com> Date: Sun, 4 Jan 2026 19:01:54 -0700 Subject: [PATCH 4/4] Fix: Kobo sync missing book covers (v1.16.4) (#2147) Co-authored-by: acx10 --- .../migration/AppMigrationService.java | 323 +----------------- .../migration/AppMigrationStartup.java | 27 +- .../booklore/service/migration/Migration.java | 9 + .../GenerateCoverHashMigration.java | 60 ++++ .../GenerateInstallationIdMigration.java | 33 ++ .../MigrateInstallationIdToJsonMigration.java | 56 +++ .../MoveIconsToDataFolderMigration.java | 74 ++++ ...ateCoversAndResizeThumbnailsMigration.java | 109 ++++++ .../PopulateFileHashesMigration.java | 63 ++++ .../PopulateMetadataScoresMigration.java | 48 +++ .../PopulateMissingFileSizesMigration.java | 48 +++ .../PopulateSearchTextMigration.java | 69 ++++ .../booklore/util/BookCoverUtils.java | 8 +- 13 files changed, 602 insertions(+), 325 deletions(-) create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/migration/Migration.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/GenerateCoverHashMigration.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/GenerateInstallationIdMigration.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/MigrateInstallationIdToJsonMigration.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/MoveIconsToDataFolderMigration.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateCoversAndResizeThumbnailsMigration.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateFileHashesMigration.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateMetadataScoresMigration.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateMissingFileSizesMigration.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/migration/migrations/PopulateSearchTextMigration.java 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(); }