mirror of
https://github.com/adityachandelgit/BookLore.git
synced 2026-01-09 06:21:08 +08:00
Merge pull request #2148 from booklore-app/develop
Merge develop into master for release
This commit is contained in:
commit
0510fb97c3
16
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
16
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@ -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
|
||||
|
||||
23
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
23
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@ -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
|
||||
|
||||
54
.github/pull_request_template.md
vendored
54
.github/pull_request_template.md
vendored
@ -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 -->
|
||||
|
||||
16
README.md
16
README.md
@ -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**
|
||||
|
||||
@ -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"
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
package com.adityachandel.booklore.service.migration;
|
||||
|
||||
public interface Migration {
|
||||
String getKey();
|
||||
|
||||
String getDescription();
|
||||
|
||||
void execute();
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user