Merge branch 'develop' into feat/change-to-nonroot

This commit is contained in:
ACX 2026-01-05 23:04:37 -07:00 committed by GitHub
commit f6c5a4f055
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
451 changed files with 24516 additions and 4088 deletions

View File

@ -8,14 +8,6 @@ body:
value: |
Please fill out the details below so we can investigate and fix the issue.
- type: checkboxes
id: prerequisites
attributes:
label: Quick Check
options:
- label: I've searched existing issues and this bug hasn't been reported yet
required: true
- type: textarea
id: description
attributes:
@ -77,3 +69,11 @@ body:
validations:
required: true
- type: checkboxes
id: prerequisites
attributes:
label: Before Submitting
description: Please confirm you've completed this step
options:
- label: I've searched existing issues and confirmed this bug hasn't been reported yet
required: true

View File

@ -8,14 +8,6 @@ body:
value: |
Please share as much detail as you can to help us understand your suggestion.
- type: checkboxes
id: prerequisites
attributes:
label: Quick Check
options:
- label: I've searched existing issues and this feature hasn't been requested yet
required: true
- type: textarea
id: description
attributes:
@ -57,3 +49,18 @@ body:
- "Just sharing the idea for now"
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Have You Considered Any Alternatives? (Optional)
description: Are there other ways to achieve what you want? Tell us about them
- type: checkboxes
id: prerequisites
attributes:
label: Before Submitting
description: Please confirm you've completed this step
options:
- label: I've searched existing issues and confirmed this feature hasn't been requested yet
required: true

View File

@ -1,35 +1,59 @@
# 🚀 Pull Request
## 🚀 Pull Request
### 📝 Description
## 📝 Description
<!-- Provide a clear and concise summary of the changes introduced in this pull request -->
<!-- Reference related issues using "Fixes #123", "Closes #456", or "Relates to #789" -->
### 🛠️ Changes Implemented
## 🛠️ Changes Implemented
<!-- Detail the specific modifications, additions, or removals made in this pull request -->
-
### 🧪 Testing Strategy
## 🧪 Testing Strategy
<!-- Describe the testing methodology used to verify the correctness of these changes -->
<!-- Include testing approach, scenarios covered, and edge cases considered -->
### 📸 Visual Changes _(if applicable)_
## 📸 Visual Changes _(if applicable)_
<!-- Attach screenshots or videos demonstrating UI/UX modifications -->
---
## ⚠️ Required Pre-Submission Checklist
<!-- ⛔ Pull requests will NOT be considered for review unless ALL required items are completed -->
<!-- All items below are MANDATORY prerequisites for submission -->
- [ ] Code adheres to project style guidelines and conventions
- [ ] Branch synchronized with latest `develop` branch
- [ ] Automated unit/integration tests added/updated to cover changes
- [ ] All tests pass locally (`./gradlew test` for backend)
- [ ] Manual testing completed in local development environment
- [ ] Flyway migration versioning follows correct sequence _(if database schema modified)_
- [ ] Documentation pull request submitted to [booklore-docs](https://github.com/booklore-app/booklore-docs) _(required for features or enhancements that introduce user-facing or visual changes)_
### **Please Read - This Checklist is Mandatory**
> **Important Notice:** We've experienced several production bugs recently due to incomplete pre-submission checks. To maintain code quality and prevent issues from reaching production, we're enforcing stricter adherence to this checklist.
>
> **All checkboxes below must be completed before requesting review.** PRs that haven't completed these requirements will be sent back for completion.
#### **Mandatory Requirements** _(please check ALL boxes)_:
- [ ] **Code adheres to project style guidelines and conventions**
- [ ] **Branch synchronized with latest `develop` branch** _(please resolve any merge conflicts)_
- [ ] **🚨 CRITICAL: Automated unit tests added/updated to cover changes** _(MANDATORY for ALL Spring Boot backend and Angular frontend changes - this is non-negotiable)_
- [ ] **🚨 CRITICAL: All tests pass locally** _(run `./gradlew test` for Spring Boot backend, and `ng test` for Angular frontend - NO EXCEPTIONS)_
- [ ] **🚨 CRITICAL: Manual testing completed in local development environment** _(verify your changes work AND no existing functionality is broken - test related features thoroughly)_
- [ ] **Flyway migration versioning follows correct sequence** _(if database schema was modified)_
- [ ] **Documentation PR submitted to [booklore-docs](https://github.com/booklore-app/booklore-docs)** _(required for features or enhancements that introduce user-facing or visual changes)_
#### **Why This Matters:**
Recent production incidents have been traced back to:
- **Incomplete testing coverage (especially backend)**
- Merge conflicts not resolved before merge
- Missing documentation for new features
**Backend changes without tests will not be accepted.** By completing this checklist thoroughly, you're helping maintain the quality and stability of Booklore for all users.
**Note to Reviewers:** Please verify the checklist is complete before beginning your review. If items are unchecked, kindly ask the contributor to complete them first.
---
### 💬 Additional Context _(optional)_
## 💬 Additional Context _(optional)_
<!-- Provide any supplementary information, implementation considerations, or discussion points for reviewers -->

View File

@ -20,31 +20,20 @@ jobs:
base_ref: 'origin/develop'
head_ref: 'HEAD'
build-and-push:
backend-tests:
name: Backend Tests
needs: [ migration-check ]
if: needs.migration-check.result == 'success' || needs.migration-check.result == 'skipped'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
checks: write
pull-requests: write
steps:
- name: Checkout Repository
uses: actions/checkout@v6
with:
fetch-depth: 0
# ----------------------------------------
# Environment setup
# ----------------------------------------
- name: Set Up QEMU for Multi-Arch Builds
uses: docker/setup-qemu-action@v3
- name: Set Up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set Up JDK 21
uses: actions/setup-java@v5
@ -53,9 +42,6 @@ jobs:
distribution: 'temurin'
cache: gradle
# ----------------------------------------
# Backend tests
# ----------------------------------------
- name: Execute Backend Tests
id: backend_tests
working-directory: ./booklore-api
@ -75,7 +61,7 @@ jobs:
uses: actions/upload-artifact@v6
if: always()
with:
name: test-reports
name: backend-test-reports
path: |
booklore-api/build/reports/tests/
booklore-api/build/test-results/
@ -87,6 +73,86 @@ jobs:
echo "❌ Backend tests failed"
exit 1
frontend-tests:
name: Frontend Tests
needs: [ migration-check ]
if: needs.migration-check.result == 'success' || needs.migration-check.result == 'skipped'
runs-on: ubuntu-latest
permissions:
contents: read
checks: write
pull-requests: write
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Set Up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: booklore-ui/package-lock.json
- name: Install Frontend Dependencies
working-directory: ./booklore-ui
run: npm ci --force
- name: Execute Frontend Tests
id: frontend_tests
working-directory: ./booklore-ui
run: |
echo "Running frontend tests..."
npx ng test
continue-on-error: true
- name: Publish Frontend Test Results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: booklore-ui/test-results/vitest-results.xml
check_name: Frontend Test Results
- name: Upload Frontend Test Reports
uses: actions/upload-artifact@v6
if: always()
with:
name: frontend-test-reports
path: |
booklore-ui/test-results/vitest-results.xml
retention-days: 30
- name: Validate Frontend Test Results
if: steps.frontend_tests.outcome == 'failure'
run: |
echo "❌ Frontend tests failed"
exit 1
build-and-push:
name: Build and Push Container
needs: [ backend-tests, frontend-tests ]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout Repository
uses: actions/checkout@v6
with:
fetch-depth: 0
# ----------------------------------------
# Environment setup
# ----------------------------------------
- name: Set Up QEMU for Multi-Arch Builds
uses: docker/setup-qemu-action@v3
- name: Set Up Docker Buildx
uses: docker/setup-buildx-action@v3
# ----------------------------------------
# Image tagging
# ----------------------------------------

View File

@ -6,12 +6,27 @@ on:
- 'master'
jobs:
get-base-ref:
name: Get Base Ref
runs-on: ubuntu-latest
outputs:
base_ref: ${{ steps.get_base.outputs.base_ref }}
steps:
- name: Checkout Repository
uses: actions/checkout@v6
with:
fetch-depth: 2
- name: Get Base Ref
id: get_base
run: echo "base_ref=$(git rev-parse HEAD~1)" >> $GITHUB_OUTPUT
migration-check:
name: Flyway Migration Check on Master
needs: [ get-base-ref ]
uses: ./.github/workflows/migrations-check.yml
with:
base_ref: 'HEAD~1'
head_ref: 'HEAD'
base_ref: ${{ needs.get-base-ref.outputs.base_ref }}
head_ref: ${{ github.sha }}
build-and-release:
needs: [ migration-check ]
@ -54,6 +69,21 @@ jobs:
distribution: 'temurin'
cache: 'gradle'
- name: Set Up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: booklore-ui/package-lock.json
- name: Install Frontend Dependencies
working-directory: ./booklore-ui
run: npm ci --force
- name: Execute Frontend Tests
working-directory: ./booklore-ui
run: npm run test:ci
- name: Retrieve Latest Master Version Tag
id: get_version
run: |
@ -151,4 +181,3 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
gh release edit ${{ env.new_tag }} --draft=true

View File

@ -64,6 +64,7 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ inputs.base_ref }}
fetch-depth: 0
- name: Apply Migrations from Base Branch
run: |
@ -80,6 +81,7 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ inputs.head_ref }}
fetch-depth: 0
- name: Apply Migrations from Head Branch
run: |
@ -94,4 +96,3 @@ jobs:
- name: Confirm Flyway Dry Run Success
run: echo "✅ Flyway migration preview successful. Migrations can be applied cleanly."

1
.gitignore vendored
View File

@ -42,5 +42,6 @@ out/
local/
### Dev config, books, and data ###
booklore-ui/test-results/
booklore-api/src/main/resources/application-local.yaml
/shared/

View File

@ -197,6 +197,24 @@ curl http://localhost:8080/actuator/health
Always run tests before submitting a pull request to ensure your changes don't break existing functionality.
### Frontend Tests (Angular + Vitest)
Booklore uses [Vitest](https://vitest.dev/) for fast, modern frontend testing in the Angular app.
```bash
cd booklore-ui
# Run all frontend tests
ng test
# Run tests with coverage report
ng test --coverage
```
- The coverage report will be generated in the `coverage/` directory.
- You can open `coverage/index.html` in your browser to view detailed coverage metrics.
- All new features and bug fixes should include relevant unit tests.
### Backend Tests
```bash
@ -393,7 +411,7 @@ Instances of unacceptable behavior may result in temporary or permanent ban from
**Need help or want to discuss ideas?**
- 💬 **Discord**: [Join our server](https://discord.gg/Ee5hd458Uz)
- 🐛 **Issues**: [GitHub Issues](https://github.com/adityachandelgit/BookLore/issues)
- 🐛 **Issues**: [GitHub Issues](https://github.com/booklore-app/booklore/issues)
---
@ -409,9 +427,9 @@ By contributing, you agree that your contributions will be licensed under the sa
Not sure where to start? Check out:
- Issues labeled [`good first issue`](https://github.com/adityachandelgit/BookLore/labels/good%20first%20issue)
- Issues labeled [`help wanted`](https://github.com/adityachandelgit/BookLore/labels/help%20wanted)
- Our [project roadmap](https://github.com/adityachandelgit/BookLore/projects)
- Issues labeled [`good first issue`](https://github.com/booklore-app/booklore/labels/good%20first%20issue)
- Issues labeled [`help wanted`](https://github.com/booklore-app/booklore/labels/help%20wanted)
- Our [project roadmap](https://github.com/booklore-app/booklore/projects)
---

View File

@ -44,7 +44,7 @@ LABEL org.opencontainers.image.title="BookLore" \
org.opencontainers.image.description="BookLore: A self-hosted, multi-user digital library with smart shelves, auto metadata, Kobo & KOReader sync, BookDrop imports, OPDS support, and a built-in reader for EPUB, PDF, and comics." \
org.opencontainers.image.source="https://github.com/booklore-app/booklore" \
org.opencontainers.image.url="https://github.com/booklore-app/booklore" \
org.opencontainers.image.documentation="https://booklore-app.github.io/booklore-docs/docs/getting-started" \
org.opencontainers.image.documentation="https://booklore.org/docs/getting-started" \
org.opencontainers.image.version=$APP_VERSION \
org.opencontainers.image.revision=$APP_REVISION \
org.opencontainers.image.licenses="GPL-3.0" \

View File

@ -4,13 +4,15 @@
### *Your Personal Library, Beautifully Organized*
**🌐 Official Website: [https://booklore.org](https://booklore.org/)**
<p align="center">
<img src="assets/demo.gif" alt="BookLore Demo" width="800px" style="border-radius: 10px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);" />
</p>
[![Release](https://img.shields.io/github/v/release/adityachandelgit/BookLore?color=4c6ef5&style=for-the-badge&logo=github)](https://github.com/adityachandelgit/BookLore/releases)
[![Release](https://img.shields.io/github/v/release/adityachandelgit/BookLore?color=4c6ef5&style=for-the-badge&logo=github)](https://github.com/booklore-app/booklore/releases)
[![License](https://img.shields.io/github/license/adityachandelgit/BookLore?color=fab005&style=for-the-badge)](LICENSE)
[![Stars](https://img.shields.io/github/stars/adityachandelgit/BookLore?style=for-the-badge&color=ffd43b)](https://github.com/adityachandelgit/BookLore/stargazers)
[![Stars](https://img.shields.io/github/stars/adityachandelgit/BookLore?style=for-the-badge&color=ffd43b)](https://github.com/booklore-app/booklore/stargazers)
[![Docker Pulls](https://img.shields.io/docker/pulls/booklore/booklore?color=2496ED&style=for-the-badge&logo=docker&logoColor=white)](https://hub.docker.com/r/booklore/booklore)
[![Discord](https://img.shields.io/badge/Join_Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/Ee5hd458Uz)
@ -20,7 +22,7 @@
**BookLore** is a powerful, self-hosted web application designed to organize and manage your personal book collection with elegance and ease. Build your dream library with an intuitive interface, robust metadata management, and seamless multi-user support.
[🚀 Get Started](#-getting-started-with-booklore) • [📖 Documentation](https://booklore-app.github.io/booklore-docs/) • [🎮 Try Demo](#-live-demo-explore-booklore-in-action) • [💬 Community](https://discord.gg/Ee5hd458Uz)
[🚀 Get Started](#-getting-started-with-booklore) • [📖 Documentation](https://booklore.org/docs/getting-started) • [🎮 Try Demo](#-live-demo-explore-booklore-in-action) • [💬 Community](https://discord.gg/Ee5hd458Uz)
</div>
@ -99,7 +101,7 @@ Your support helps BookLore grow and improve! 🌱
Give us a star to show your support and help others discover BookLore!
[![Star this repo](https://img.shields.io/github/stars/adityachandelgit/BookLore?style=social)](https://github.com/adityachandelgit/BookLore)
[![Star this repo](https://img.shields.io/github/stars/adityachandelgit/BookLore?style=social)](https://github.com/booklore-app/booklore)
</td>
<td align="center" width="33%">
@ -140,7 +142,7 @@ Experience BookLore's features in a live environment before deploying your own i
| 🌐 Demo URL | 👤 Username | 🔑 Password |
|----------------------------------------------------|-------------|--------------------|
| **[demo.booklore.dev](https://demo.booklore.dev)** | `booklore` | `9HC20PGGfitvWaZ1` |
| **[demo.booklore.org](https://demo.booklore.org)** | `booklore` | `9HC20PGGfitvWaZ1` |
> ⚠️ **Note:** Demo account has standard user permissions only.
> Admin features (user management, library setup) require a self-hosted instance.
@ -163,7 +165,7 @@ Experience BookLore's features in a live environment before deploying your own i
Guides for installation, setup, features, and more
[![Read the Docs](https://img.shields.io/badge/📖_Read_the_Docs-4c6ef5?style=for-the-badge)](https://booklore-app.github.io/booklore-docs/docs/getting-started/)
[![Read the Docs](https://img.shields.io/badge/📖_Read_the_Docs-4c6ef5?style=for-the-badge)](https://booklore.org/docs/getting-started)
*Contribute to the docs at: [booklore-docs](https://github.com/booklore-app/booklore-docs)*
@ -254,6 +256,12 @@ services:
- ./data:/app/data
- ./books:/books
- ./bookdrop:/bookdrop
healthcheck:
test: wget -q -O - http://localhost:${BOOKLORE_PORT}/api/v1/healthcheck
interval: 60s
retries: 5
start_period: 60s
timeout: 10s
restart: unless-stopped
mariadb:
@ -343,7 +351,7 @@ services:
Found an issue?
[![Open Issue](https://img.shields.io/badge/Report-ff6b6b?style=for-the-badge)](https://github.com/booklore-app/booklore/issues)
[![Open Issue](https://img.shields.io/badge/Report-ff6b6b?style=for-the-badge)](https://github.com/booklore-app/booklore/issues/new?template=bug_report.yml)
</td>
<td align="center">
@ -352,7 +360,7 @@ Found an issue?
Have an idea?
[![Request Feature](https://img.shields.io/badge/Suggest-4ecdc4?style=for-the-badge)](https://github.com/booklore-app/booklore/issues/new?template=feature_request.md)
[![Request Feature](https://img.shields.io/badge/Suggest-4ecdc4?style=for-the-badge)](https://github.com/booklore-app/booklore/issues/new?template=feature_request.yml)
</td>
<td align="center">
@ -409,7 +417,7 @@ Join community!
### Thanks to all our amazing contributors! 🙏
[![Contributors](https://contrib.rocks/image?repo=adityachandelgit/BookLore)](https://github.com/adityachandelgit/BookLore/graphs/contributors)
[![Contributors](https://contrib.rocks/image?repo=adityachandelgit/BookLore)](https://github.com/booklore-app/booklore/graphs/contributors)
**Want to see your face here?** [Start contributing today!](CONTRIBUTING.md)
@ -419,6 +427,22 @@ Join community!
<div align="center">
## 🌟 **Sponsors**
### Thank you to our amazing sponsors!
<a href="https://www.pikapods.com/pods?run=booklore">
<img src="https://www.pikapods.com/static/run-button.svg" alt="Run on PikaPods" height="40">
</a>
*Become a sponsor and get your logo here! [Support us on Open Collective](https://opencollective.com/booklore)*
</div>
---
<div align="center">
## ⚖️ **License**
**GNU General Public License v3.0**

View File

@ -52,6 +52,7 @@ dependencies {
// --- Book & Image Processing ---
implementation 'org.apache.pdfbox:pdfbox:3.0.6'
implementation 'org.apache.pdfbox:pdfbox-io:3.0.6'
implementation 'org.apache.pdfbox:xmpbox:3.0.6'
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
implementation 'com.github.jai-imageio:jai-imageio-core:1.4.0'

View File

@ -16,6 +16,7 @@ public class AppProperties {
private RemoteAuth remoteAuth;
private Swagger swagger = new Swagger();
private Boolean forceDisableOidc = false;
private Telemetry telemetry = new Telemetry();
@Getter
@Setter
@ -35,4 +36,10 @@ public class AppProperties {
public static class Swagger {
private boolean enabled = true;
}
@Getter
@Setter
public static class Telemetry {
private String baseUrl = "https://telemetry.booklore.org";
}
}

View File

@ -12,14 +12,15 @@ public class BookParserConfig {
@Bean
public Map<MetadataProvider, BookParser> parserMap(GoogleParser googleParser, AmazonBookParser amazonBookParser,
GoodReadsParser goodReadsParser, HardcoverParser hardcoverParser, ComicvineBookParser comicvineBookParser, DoubanBookParser doubanBookParser) {
GoodReadsParser goodReadsParser, HardcoverParser hardcoverParser, ComicvineBookParser comicvineBookParser, DoubanBookParser doubanBookParser, LubimyCzytacParser lubimyczytacParser) {
return Map.of(
MetadataProvider.Amazon, amazonBookParser,
MetadataProvider.GoodReads, goodReadsParser,
MetadataProvider.Google, googleParser,
MetadataProvider.Hardcover, hardcoverParser,
MetadataProvider.Comicvine, comicvineBookParser,
MetadataProvider.Douban, doubanBookParser
MetadataProvider.Douban, doubanBookParser,
MetadataProvider.Lubimyczytac, lubimyczytacParser
);
}
}

View File

@ -0,0 +1,16 @@
package com.adityachandel.booklore.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
@Configuration
public class RestClientConfig {
@Bean
public RestClient restClient() {
return RestClient.builder()
.build();
}
}

View File

@ -2,6 +2,7 @@ package com.adityachandel.booklore.config.security;
import com.adityachandel.booklore.config.AppProperties;
import com.adityachandel.booklore.config.security.filter.CoverJwtFilter;
import com.adityachandel.booklore.config.security.filter.CustomFontJwtFilter;
import com.adityachandel.booklore.config.security.filter.DualJwtAuthenticationFilter;
import com.adityachandel.booklore.config.security.filter.KoboAuthFilter;
import com.adityachandel.booklore.config.security.filter.KoreaderAuthFilter;
@ -50,7 +51,8 @@ public class SecurityConfig {
"/kobo/**", // Kobo API requests (auth handled in KoboAuthFilter)
"/api/v1/auth/**", // Login and token refresh endpoints (must remain public)
"/api/v1/public-settings", // Public endpoint for checking OIDC or other app settings
"/api/v1/setup/**" // Setup wizard endpoints (must remain accessible before initial setup)
"/api/v1/setup/**", // Setup wizard endpoints (must remain accessible before initial setup)
"/api/v1/healthcheck/**" // Healthcheck endpoints (must remain accessible for Docker healthchecks)
};
private static final String[] COMMON_UNAUTHENTICATED_ENDPOINTS = {
@ -132,6 +134,21 @@ public class SecurityConfig {
@Bean
@Order(5)
public SecurityFilterChain customFontSecurityChain(HttpSecurity http, CustomFontJwtFilter customFontJwtFilter) throws Exception {
http
.securityMatcher("/api/v1/custom-fonts/*/file")
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll()
)
.addFilterBefore(customFontJwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
@Order(6)
public SecurityFilterChain jwtApiSecurityChain(HttpSecurity http) throws Exception {
List<String> publicEndpoints = new ArrayList<>(Arrays.asList(COMMON_PUBLIC_ENDPOINTS));
if (appProperties.getSwagger().isEnabled()) {

View File

@ -65,6 +65,21 @@ public class SecurityUtil {
return user != null && user.getPermissions().isCanEditMetadata();
}
public boolean canBulkEditMetadata() {
var user = getCurrentUser();
return user != null && user.getPermissions().isCanBulkEditMetadata();
}
public boolean canBulkLockUnlockMetadata() {
var user = getCurrentUser();
return user != null && user.getPermissions().isCanBulkLockUnlockMetadata();
}
public boolean canBulkRegenerateCover() {
var user = getCurrentUser();
return user != null && user.getPermissions().isCanBulkRegenerateCover();
}
public boolean canEmailBook() {
var user = getCurrentUser();
return user != null && user.getPermissions().isCanEmailBook();

View File

@ -0,0 +1,93 @@
package com.adityachandel.booklore.config.security.filter;
import com.adityachandel.booklore.config.security.JwtUtils;
import com.adityachandel.booklore.config.security.service.DynamicOidcJwtProcessor;
import com.adityachandel.booklore.config.security.userdetails.UserAuthenticationDetails;
import com.adityachandel.booklore.mapper.custom.BookLoreUserTransformer;
import com.adityachandel.booklore.model.dto.BookLoreUser;
import com.adityachandel.booklore.model.dto.settings.OidcProviderDetails;
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
import com.adityachandel.booklore.repository.UserRepository;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.time.Instant;
@AllArgsConstructor
public abstract class AbstractQueryParameterJwtFilter extends OncePerRequestFilter {
protected final JwtUtils jwtUtils;
protected final UserRepository userRepository;
protected final BookLoreUserTransformer bookLoreUserTransformer;
protected final AppSettingService appSettingService;
protected final DynamicOidcJwtProcessor dynamicOidcJwtProcessor;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// Extract token from query parameter (not from Authorization header)
String token = request.getParameter("token");
if (token == null || token.isEmpty()) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing authentication token");
return;
}
try {
if (jwtUtils.validateToken(token)) {
authenticateLocalUser(token, request);
} else if (appSettingService.getAppSettings().isOidcEnabled()) {
authenticateOidcUser(token, request);
} else {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token");
return;
}
} catch (Exception ex) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authentication failed: " + ex.getMessage());
return;
}
chain.doFilter(request, response);
}
protected void authenticateLocalUser(String token, HttpServletRequest request) {
Long userId = jwtUtils.extractUserId(token);
BookLoreUserEntity entity = userRepository.findById(userId)
.orElseThrow(() -> new UsernameNotFoundException("User not found with ID: " + userId));
BookLoreUser user = bookLoreUserTransformer.toDTO(entity);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(user, null, null);
authentication.setDetails(new UserAuthenticationDetails(request, user.getId()));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
protected void authenticateOidcUser(String token, HttpServletRequest request) throws Exception {
var processor = dynamicOidcJwtProcessor.getProcessor();
var claimsSet = processor.process(token, null);
if (claimsSet.getExpirationTime() == null ||
claimsSet.getExpirationTime().toInstant().isBefore(Instant.now())) {
throw new RuntimeException("OIDC token expired or invalid");
}
OidcProviderDetails providerDetails = appSettingService.getAppSettings().getOidcProviderDetails();
OidcProviderDetails.ClaimMapping claimMapping = providerDetails.getClaimMapping();
String username = claimsSet.getStringClaim(claimMapping.getUsername());
BookLoreUserEntity entity = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("OIDC user not found: " + username));
BookLoreUser user = bookLoreUserTransformer.toDTO(entity);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(user, null, null);
authentication.setDetails(new UserAuthenticationDetails(request, user.getId()));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}

View File

@ -2,92 +2,27 @@ package com.adityachandel.booklore.config.security.filter;
import com.adityachandel.booklore.config.security.JwtUtils;
import com.adityachandel.booklore.config.security.service.DynamicOidcJwtProcessor;
import com.adityachandel.booklore.config.security.userdetails.UserAuthenticationDetails;
import com.adityachandel.booklore.mapper.custom.BookLoreUserTransformer;
import com.adityachandel.booklore.model.dto.BookLoreUser;
import com.adityachandel.booklore.model.dto.settings.OidcProviderDetails;
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
import com.adityachandel.booklore.repository.UserRepository;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.time.Instant;
@AllArgsConstructor
@Component
public class CoverJwtFilter extends OncePerRequestFilter {
public class CoverJwtFilter extends AbstractQueryParameterJwtFilter {
private final JwtUtils jwtUtils;
private final UserRepository userRepository;
private final BookLoreUserTransformer bookLoreUserTransformer;
private final AppSettingService appSettingService;
private final DynamicOidcJwtProcessor dynamicOidcJwtProcessor;
public CoverJwtFilter(
JwtUtils jwtUtils,
UserRepository userRepository,
BookLoreUserTransformer bookLoreUserTransformer,
AppSettingService appSettingService,
DynamicOidcJwtProcessor dynamicOidcJwtProcessor) {
super(jwtUtils, userRepository, bookLoreUserTransformer, appSettingService, dynamicOidcJwtProcessor);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return !path.startsWith("/api/v1/media/");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
String token = request.getParameter("token");
if (token == null || token.isEmpty()) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing authentication token");
return;
}
try {
if (jwtUtils.validateToken(token)) {
authenticateLocalUser(token, request);
} else if (appSettingService.getAppSettings().isOidcEnabled()) {
authenticateOidcUser(token, request);
} else {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token");
return;
}
} catch (Exception ex) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authentication failed: " + ex.getMessage());
return;
}
chain.doFilter(request, response);
}
private void authenticateLocalUser(String token, HttpServletRequest request) {
Long userId = jwtUtils.extractUserId(token);
BookLoreUserEntity entity = userRepository.findById(userId).orElseThrow(() -> new UsernameNotFoundException("User not found with ID: " + userId));
BookLoreUser user = bookLoreUserTransformer.toDTO(entity);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user, null, null);
authentication.setDetails(new UserAuthenticationDetails(request, user.getId()));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
private void authenticateOidcUser(String token, HttpServletRequest request) throws Exception {
var processor = dynamicOidcJwtProcessor.getProcessor();
var claimsSet = processor.process(token, null);
if (claimsSet.getExpirationTime() == null || claimsSet.getExpirationTime().toInstant().isBefore(Instant.now())) {
throw new RuntimeException("OIDC token expired or invalid");
}
OidcProviderDetails providerDetails = appSettingService.getAppSettings().getOidcProviderDetails();
OidcProviderDetails.ClaimMapping claimMapping = providerDetails.getClaimMapping();
String username = claimsSet.getStringClaim(claimMapping.getUsername());
BookLoreUserEntity entity = userRepository.findByUsername(username).orElseThrow(() -> new UsernameNotFoundException("OIDC user not found: " + username));
BookLoreUser user = bookLoreUserTransformer.toDTO(entity);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user, null, null);
authentication.setDetails(new UserAuthenticationDetails(request, user.getId()));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}

View File

@ -0,0 +1,29 @@
package com.adityachandel.booklore.config.security.filter;
import com.adityachandel.booklore.config.security.JwtUtils;
import com.adityachandel.booklore.config.security.service.DynamicOidcJwtProcessor;
import com.adityachandel.booklore.mapper.custom.BookLoreUserTransformer;
import com.adityachandel.booklore.repository.UserRepository;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Component;
@Component
public class CustomFontJwtFilter extends AbstractQueryParameterJwtFilter {
public CustomFontJwtFilter(
JwtUtils jwtUtils,
UserRepository userRepository,
BookLoreUserTransformer bookLoreUserTransformer,
AppSettingService appSettingService,
DynamicOidcJwtProcessor dynamicOidcJwtProcessor) {
super(jwtUtils, userRepository, bookLoreUserTransformer, appSettingService, dynamicOidcJwtProcessor);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
// Only filter requests to custom font file endpoints (e.g., /api/v1/custom-fonts/123/file)
return !(path.startsWith("/api/v1/custom-fonts/") && path.endsWith("/file"));
}
}

View File

@ -2,7 +2,6 @@ package com.adityachandel.booklore.config.security.interceptor;
import com.adityachandel.booklore.config.security.JwtUtils;
import com.adityachandel.booklore.config.security.service.DynamicOidcJwtProcessor;
import com.adityachandel.booklore.mapper.custom.BookLoreUserTransformer;
import com.adityachandel.booklore.model.dto.settings.OidcProviderDetails;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import com.nimbusds.jwt.JWTClaimsSet;
@ -18,10 +17,8 @@ import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
@Slf4j

View File

@ -9,6 +9,7 @@ import com.adityachandel.booklore.model.dto.request.UserLoginRequest;
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
import com.adityachandel.booklore.model.entity.RefreshTokenEntity;
import com.adityachandel.booklore.model.enums.ProvisioningMethod;
import com.adityachandel.booklore.model.enums.UserPermission;
import com.adityachandel.booklore.repository.RefreshTokenRepository;
import com.adityachandel.booklore.repository.UserRepository;
import com.adityachandel.booklore.service.user.DefaultSettingInitializer;
@ -60,16 +61,9 @@ public class AuthenticationService {
private BookLoreUser createSystemUser() {
BookLoreUser.UserPermissions permissions = new BookLoreUser.UserPermissions();
permissions.setAdmin(true);
permissions.setCanUpload(true);
permissions.setCanDownload(true);
permissions.setCanEditMetadata(true);
permissions.setCanManageLibrary(true);
permissions.setCanSyncKoReader(true);
permissions.setCanSyncKobo(true);
permissions.setCanEmailBook(true);
permissions.setCanDeleteBook(true);
permissions.setCanAccessOpds(true);
for (UserPermission permission : UserPermission.values()) {
permission.setInDto(permissions, true);
}
return BookLoreUser.builder()
.id(-1L)

View File

@ -10,8 +10,11 @@ import com.adityachandel.booklore.model.dto.request.ReadProgressRequest;
import com.adityachandel.booklore.model.dto.request.ReadStatusUpdateRequest;
import com.adityachandel.booklore.model.dto.request.ShelvesAssignmentRequest;
import com.adityachandel.booklore.model.dto.response.BookDeletionResponse;
import com.adityachandel.booklore.model.dto.response.BookStatusUpdateResponse;
import com.adityachandel.booklore.model.dto.response.PersonalRatingUpdateResponse;
import com.adityachandel.booklore.model.enums.ResetProgressType;
import com.adityachandel.booklore.service.book.BookService;
import com.adityachandel.booklore.service.book.BookUpdateService;
import com.adityachandel.booklore.service.metadata.BookMetadataService;
import com.adityachandel.booklore.service.recommender.BookRecommendationService;
import io.swagger.v3.oas.annotations.Operation;
@ -40,6 +43,7 @@ import java.util.Set;
public class BookController {
private final BookService bookService;
private final BookUpdateService bookUpdateService;
private final BookRecommendationService bookRecommendationService;
private final BookMetadataService bookMetadataService;
@ -54,8 +58,8 @@ public class BookController {
@Operation(summary = "Get a book by ID", description = "Retrieve details of a specific book by its ID.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Book details returned successfully"),
@ApiResponse(responseCode = "404", description = "Book not found")
@ApiResponse(responseCode = "200", description = "Book details returned successfully"),
@ApiResponse(responseCode = "404", description = "Book not found")
})
@GetMapping("/{bookId}")
@CheckBookAccess(bookIdParam = "bookId")
@ -67,8 +71,8 @@ public class BookController {
@Operation(summary = "Delete books", description = "Delete one or more books by their IDs. Requires admin or delete permission.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Books deleted successfully"),
@ApiResponse(responseCode = "403", description = "Forbidden")
@ApiResponse(responseCode = "200", description = "Books deleted successfully"),
@ApiResponse(responseCode = "403", description = "Forbidden")
})
@PreAuthorize("@securityUtil.canDeleteBook() or @securityUtil.isAdmin()")
@DeleteMapping
@ -105,8 +109,8 @@ public class BookController {
@Operation(summary = "Download book", description = "Download the book file. Requires download permission or admin.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Book downloaded successfully"),
@ApiResponse(responseCode = "403", description = "Forbidden")
@ApiResponse(responseCode = "200", description = "Book downloaded successfully"),
@ApiResponse(responseCode = "403", description = "Forbidden")
})
@GetMapping("/{bookId}/download")
@PreAuthorize("@securityUtil.canDownload() or @securityUtil.isAdmin()")
@ -165,50 +169,46 @@ public class BookController {
@Operation(summary = "Update read status", description = "Update the read status for one or more books.")
@ApiResponse(responseCode = "200", description = "Read status updated successfully")
@PutMapping("/read-status")
public ResponseEntity<List<Book>> updateReadStatus(
@Parameter(description = "Read status update request") @RequestBody @Valid ReadStatusUpdateRequest request) {
List<Book> updatedBooks = bookService.updateReadStatus(request.ids(), request.status());
return ResponseEntity.ok(updatedBooks);
@PostMapping("/status")
public List<BookStatusUpdateResponse> updateReadStatus(@RequestBody @Valid ReadStatusUpdateRequest request) {
return bookService.updateReadStatus(request.getBookIds(), request.getStatus());
}
@Operation(summary = "Reset reading progress", description = "Reset the reading progress for one or more books.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Progress reset successfully"),
@ApiResponse(responseCode = "400", description = "No book IDs provided")
@ApiResponse(responseCode = "200", description = "Progress reset successfully"),
@ApiResponse(responseCode = "400", description = "No book IDs provided")
})
@PostMapping("/reset-progress")
public ResponseEntity<List<Book>> resetProgress(
public ResponseEntity<List<BookStatusUpdateResponse>> resetProgress(
@Parameter(description = "List of book IDs to reset progress for") @RequestBody List<Long> bookIds,
@Parameter(description = "Type of progress reset") @RequestParam ResetProgressType type) {
if (bookIds == null || bookIds.isEmpty()) {
throw ApiError.GENERIC_BAD_REQUEST.createException("No book IDs provided");
}
List<Book> updatedBooks = bookService.resetProgress(bookIds, type);
return ResponseEntity.ok(updatedBooks);
return ResponseEntity.ok(bookUpdateService.resetProgress(bookIds, type));
}
@Operation(summary = "Update personal rating", description = "Update the personal rating for one or more books.")
@ApiResponse(responseCode = "200", description = "Personal rating updated successfully")
@PutMapping("/personal-rating")
public ResponseEntity<List<Book>> updatePersonalRating(
public ResponseEntity<List<PersonalRatingUpdateResponse>> updatePersonalRating(
@Parameter(description = "Personal rating update request") @RequestBody @Valid PersonalRatingUpdateRequest request) {
List<Book> updatedBooks = bookService.updatePersonalRating(request.ids(), request.rating());
return ResponseEntity.ok(updatedBooks);
return ResponseEntity.ok(bookUpdateService.updatePersonalRating(request.ids(), request.rating()));
}
@Operation(summary = "Reset personal rating", description = "Reset the personal rating for one or more books.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Personal rating reset successfully"),
@ApiResponse(responseCode = "400", description = "No book IDs provided")
@ApiResponse(responseCode = "200", description = "Personal rating reset successfully"),
@ApiResponse(responseCode = "400", description = "No book IDs provided")
})
@PostMapping("/reset-personal-rating")
public ResponseEntity<List<Book>> resetPersonalRating(
public ResponseEntity<List<PersonalRatingUpdateResponse>> resetPersonalRating(
@Parameter(description = "List of book IDs to reset personal rating for") @RequestBody List<Long> bookIds) {
if (bookIds == null || bookIds.isEmpty()) {
throw ApiError.GENERIC_BAD_REQUEST.createException("No book IDs provided");
}
List<Book> updatedBooks = bookService.resetPersonalRating(bookIds);
List<PersonalRatingUpdateResponse> updatedBooks = bookUpdateService.resetPersonalRating(bookIds);
return ResponseEntity.ok(updatedBooks);
}
}

View File

@ -107,6 +107,10 @@ public class BookMediaController {
? MediaType.IMAGE_PNG
: MediaType.IMAGE_JPEG;
if (filename == null) {
return ResponseEntity.badRequest().build();
}
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
String fallbackFilename = NON_ASCII_PATTERN.matcher(filename).replaceAll("_");
String contentDisposition = String.format("inline; filename=\"%s\"; filename*=UTF-8''%s",

View File

@ -12,7 +12,6 @@ import com.adityachandel.booklore.model.dto.response.BookdropPatternExtractResul
import com.adityachandel.booklore.service.bookdrop.BookDropService;
import com.adityachandel.booklore.service.bookdrop.BookdropBulkEditService;
import com.adityachandel.booklore.service.bookdrop.BookdropMonitoringService;
import com.adityachandel.booklore.service.monitoring.MonitoringService;
import com.adityachandel.booklore.service.bookdrop.FilenamePatternExtractor;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;

View File

@ -0,0 +1,82 @@
package com.adityachandel.booklore.controller;
import com.adityachandel.booklore.config.security.service.AuthenticationService;
import com.adityachandel.booklore.model.dto.BookLoreUser;
import com.adityachandel.booklore.model.dto.CustomFontDto;
import com.adityachandel.booklore.service.customfont.CustomFontService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@Tag(name = "Custom Fonts", description = "Endpoints for managing custom fonts for EPUB reader")
@RestController
@RequestMapping("/api/v1/custom-fonts")
@RequiredArgsConstructor
public class CustomFontController {
private final CustomFontService customFontService;
private final AuthenticationService authenticationService;
@Operation(summary = "Upload a custom font", description = "Upload a custom font file (.ttf, .otf, .woff, .woff2) for the authenticated user")
@ApiResponse(responseCode = "200", description = "Font uploaded successfully")
@ApiResponse(responseCode = "400", description = "Invalid file or quota exceeded")
@PostMapping("/upload")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<CustomFontDto> uploadFont(
@Parameter(description = "Font file (.ttf, .otf, .woff, .woff2)") @RequestParam("file") MultipartFile file,
@Parameter(description = "Font display name") @RequestParam(value = "fontName", required = false) String fontName) {
BookLoreUser user = authenticationService.getAuthenticatedUser();
CustomFontDto fontDto = customFontService.uploadFont(file, fontName, user.getId());
return ResponseEntity.ok(fontDto);
}
@Operation(summary = "Get all user's custom fonts", description = "Retrieve all custom fonts for the authenticated user")
@ApiResponse(responseCode = "200", description = "Fonts retrieved successfully")
@GetMapping
@PreAuthorize("isAuthenticated()")
public ResponseEntity<List<CustomFontDto>> getUserFonts() {
BookLoreUser user = authenticationService.getAuthenticatedUser();
List<CustomFontDto> fonts = customFontService.getUserFonts(user.getId());
return ResponseEntity.ok(fonts);
}
@Operation(summary = "Delete a custom font", description = "Delete a custom font file and database record")
@ApiResponse(responseCode = "200", description = "Font deleted successfully")
@ApiResponse(responseCode = "404", description = "Font not found or access denied")
@DeleteMapping("/{fontId}")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Void> deleteFont(@PathVariable Long fontId) {
BookLoreUser user = authenticationService.getAuthenticatedUser();
customFontService.deleteFont(fontId, user.getId());
return ResponseEntity.ok().build();
}
@Operation(summary = "Get font file", description = "Retrieve the font file for use in the browser")
@ApiResponse(responseCode = "200", description = "Font file retrieved successfully")
@ApiResponse(responseCode = "404", description = "Font not found or access denied")
@GetMapping("/{fontId}/file")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Resource> getFontFile(@PathVariable Long fontId) {
BookLoreUser user = authenticationService.getAuthenticatedUser();
Resource resource = customFontService.getFontFile(fontId, user.getId());
// Get font format to set correct content type
var format = customFontService.getFontFormat(fontId, user.getId());
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(format.getMimeType()))
.header(HttpHeaders.CONTENT_DISPOSITION, "inline")
.body(resource);
}
}

View File

@ -0,0 +1,21 @@
package com.adityachandel.booklore.controller;
import com.adityachandel.booklore.model.dto.response.SuccessResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/healthcheck")
@Tag(name = "Healthcheck", description = "Endpoints for checking the healch of the application")
public class HealthcheckController {
@Operation(summary = "Get a ping response", description = "Check if the application is responding to requests")
@ApiResponse(responseCode = "200", description = "Health status returned successfully")
@GetMapping
public ResponseEntity<?> getPing() {
return ResponseEntity.ok(new SuccessResponse<>(200, "Pong"));
}
}

View File

@ -6,7 +6,7 @@ import com.adityachandel.booklore.model.dto.kobo.KoboAuthentication;
import com.adityachandel.booklore.model.dto.kobo.KoboReadingStateWrapper;
import com.adityachandel.booklore.model.dto.kobo.KoboResources;
import com.adityachandel.booklore.model.dto.kobo.KoboTestResponse;
import com.adityachandel.booklore.service.*;
import com.adityachandel.booklore.service.ShelfService;
import com.adityachandel.booklore.service.book.BookDownloadService;
import com.adityachandel.booklore.service.book.BookService;
import com.adityachandel.booklore.service.kobo.*;
@ -27,7 +27,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
@ -71,36 +70,58 @@ public class KoboController {
return koboLibrarySyncService.syncLibrary(user, token);
}
@Operation(summary = "Get book thumbnail", description = "Retrieve the thumbnail image for a book.")
@Operation(summary = "Get book thumbnail (versioned)", description = "Retrieve the thumbnail image for a local book with cache-busting version.")
@ApiResponse(responseCode = "200", description = "Thumbnail returned successfully")
@GetMapping("/v1/books/{imageId}/{version}/thumbnail/{width}/{height}/false/image.jpg")
public ResponseEntity<Resource> getVersionedThumbnail(
@Parameter(description = "Book ID") @PathVariable String imageId,
@Parameter(description = "Cover version (timestamp)") @PathVariable String version,
@Parameter(description = "Width of the thumbnail") @PathVariable int width,
@Parameter(description = "Height of the thumbnail") @PathVariable int height) {
return koboThumbnailService.getThumbnail(imageId);
}
@Operation(summary = "Get book thumbnail", description = "Retrieve the thumbnail image for a Kobo store book.")
@ApiResponse(responseCode = "200", description = "Thumbnail returned successfully")
@GetMapping("/v1/books/{imageId}/thumbnail/{width}/{height}/false/image.jpg")
public ResponseEntity<Resource> getThumbnail(
@Parameter(description = "Image ID") @PathVariable String imageId,
@Parameter(description = "Width of the thumbnail") @PathVariable int width,
@Parameter(description = "Height of the thumbnail") @PathVariable int height) {
if (StringUtils.isNumeric(imageId)) {
return koboThumbnailService.getThumbnail(Long.valueOf(imageId));
if (imageId.startsWith("BL-")) {
return koboThumbnailService.getThumbnail(imageId);
} else {
String cdnUrl = String.format("https://cdn.kobo.com/book-images/%s/%d/%d/image.jpg", imageId, width, height);
String cdnUrl = String.format("https://cdn.kobo.com/book-images/%s/%d/%d/false/image.jpg", imageId, width, height);
return koboServerProxy.proxyExternalUrl(cdnUrl);
}
}
@Operation(summary = "Get greyscale book thumbnail", description = "Retrieve a greyscale thumbnail image for a book.")
@Operation(summary = "Get greyscale book thumbnail (versioned)", description = "Retrieve a greyscale thumbnail for a local book with cache-busting version.")
@ApiResponse(responseCode = "200", description = "Greyscale thumbnail returned successfully")
@GetMapping("/v1/books/{bookId}/thumbnail/{width}/{height}/{quality}/{isGreyscale}/image.jpg")
public ResponseEntity<Resource> getGreyThumbnail(
@Parameter(description = "Book ID") @PathVariable String bookId,
@GetMapping("/v1/books/{imageId}/{version}/thumbnail/{width}/{height}/{quality}/{isGreyscale}/image.jpg")
public ResponseEntity<Resource> getVersionedGreyThumbnail(
@Parameter(description = "Book ID") @PathVariable String imageId,
@Parameter(description = "Cover version (timestamp)") @PathVariable String version,
@Parameter(description = "Width of the thumbnail") @PathVariable int width,
@Parameter(description = "Height of the thumbnail") @PathVariable int height,
@Parameter(description = "Quality of the thumbnail") @PathVariable int quality,
@Parameter(description = "Is greyscale") @PathVariable boolean isGreyscale) {
return koboThumbnailService.getThumbnail(imageId);
}
if (StringUtils.isNumeric(bookId)) {
return koboThumbnailService.getThumbnail(Long.valueOf(bookId));
@Operation(summary = "Get greyscale book thumbnail", description = "Retrieve a greyscale thumbnail image for a Kobo store book.")
@ApiResponse(responseCode = "200", description = "Greyscale thumbnail returned successfully")
@GetMapping("/v1/books/{imageId}/thumbnail/{width}/{height}/{quality}/{isGreyscale}/image.jpg")
public ResponseEntity<Resource> getGreyThumbnail(
@Parameter(description = "Image ID") @PathVariable String imageId,
@Parameter(description = "Width of the thumbnail") @PathVariable int width,
@Parameter(description = "Height of the thumbnail") @PathVariable int height,
@Parameter(description = "Quality of the thumbnail") @PathVariable int quality,
@Parameter(description = "Is greyscale") @PathVariable boolean isGreyscale) {
if (imageId.startsWith("BL-")) {
return koboThumbnailService.getThumbnail(imageId);
} else {
String cdnUrl = String.format("https://cdn.kobo.com/book-images/%s/%d/%d/%d/%b/image.jpg", bookId, width, height, quality, isGreyscale);
String cdnUrl = String.format("https://cdn.kobo.com/book-images/%s/%d/%d/%d/%b/image.jpg", imageId, width, height, quality, isGreyscale);
return koboServerProxy.proxyExternalUrl(cdnUrl);
}
}
@ -108,16 +129,14 @@ public class KoboController {
@Operation(summary = "Authenticate Kobo device", description = "Authenticate a Kobo device.")
@ApiResponse(responseCode = "200", description = "Device authenticated successfully")
@PostMapping("/v1/auth/device")
public ResponseEntity<KoboAuthentication> authenticateDevice(
@Parameter(description = "Authentication request body") @RequestBody JsonNode body) {
public ResponseEntity<KoboAuthentication> authenticateDevice(@Parameter(description = "Authentication request body") @RequestBody JsonNode body) {
return koboDeviceAuthService.authenticateDevice(body);
}
@Operation(summary = "Get book metadata", description = "Retrieve metadata for a book in the Kobo library.")
@ApiResponse(responseCode = "200", description = "Metadata returned successfully")
@GetMapping("/v1/library/{bookId}/metadata")
public ResponseEntity<?> getBookMetadata(
@Parameter(description = "Book ID") @PathVariable String bookId) {
public ResponseEntity<?> getBookMetadata(@Parameter(description = "Book ID") @PathVariable String bookId) {
if (StringUtils.isNumeric(bookId)) {
return ResponseEntity.ok(List.of(koboEntitlementService.getMetadataForBook(Long.parseLong(bookId), token)));
} else {
@ -128,8 +147,7 @@ public class KoboController {
@Operation(summary = "Get reading state", description = "Retrieve the reading state for a book.")
@ApiResponse(responseCode = "200", description = "Reading state returned successfully")
@GetMapping("/v1/library/{bookId}/state")
public ResponseEntity<?> getState(
@Parameter(description = "Book ID") @PathVariable String bookId) {
public ResponseEntity<?> getState(@Parameter(description = "Book ID") @PathVariable String bookId) {
if (StringUtils.isNumeric(bookId)) {
return ResponseEntity.ok(koboReadingStateService.getReadingState(bookId));
} else {
@ -153,8 +171,7 @@ public class KoboController {
@Operation(summary = "Get Kobo test analytics", description = "Get test analytics for Kobo.")
@ApiResponse(responseCode = "200", description = "Test analytics returned successfully")
@PostMapping("/v1/analytics/gettests")
public ResponseEntity<?> getTests(
@Parameter(description = "Test analytics request body") @RequestBody Object body) {
public ResponseEntity<?> getTests(@Parameter(description = "Test analytics request body") @RequestBody Object body) {
return ResponseEntity.ok(KoboTestResponse.builder()
.result("Success")
.testKey(RandomStringUtils.secure().nextAlphanumeric(24))
@ -164,8 +181,7 @@ public class KoboController {
@Operation(summary = "Download Kobo book", description = "Download a book from the Kobo library.")
@ApiResponse(responseCode = "200", description = "Book downloaded successfully")
@GetMapping("/v1/books/{bookId}/download")
public void downloadBook(
@Parameter(description = "Book ID") @PathVariable String bookId, HttpServletResponse response) {
public void downloadBook(@Parameter(description = "Book ID") @PathVariable String bookId, HttpServletResponse response) {
if (StringUtils.isNumeric(bookId)) {
bookDownloadService.downloadKoboBook(Long.parseLong(bookId), response);
} else {
@ -176,8 +192,7 @@ public class KoboController {
@Operation(summary = "Delete book from Kobo library", description = "Delete a book from the user's Kobo library.")
@ApiResponse(responseCode = "200", description = "Book deleted successfully")
@DeleteMapping("/v1/library/{bookId}")
public ResponseEntity<?> deleteBookFromLibrary(
@Parameter(description = "Book ID") @PathVariable String bookId) {
public ResponseEntity<?> deleteBookFromLibrary(@Parameter(description = "Book ID") @PathVariable String bookId) {
if (StringUtils.isNumeric(bookId)) {
Shelf userKoboShelf = shelfService.getUserKoboShelf();
if (userKoboShelf != null) {

View File

@ -83,7 +83,7 @@ public class MetadataController {
@Operation(summary = "Bulk edit book metadata", description = "Bulk update metadata for multiple books. Requires metadata edit permission or admin.")
@ApiResponse(responseCode = "204", description = "Bulk metadata updated successfully")
@PutMapping("/bulk-edit-metadata")
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
@PreAuthorize("@securityUtil.canBulkEditMetadata() or @securityUtil.isAdmin()")
public ResponseEntity<Void> bulkEditMetadata(
@Parameter(description = "Bulk metadata update request") @RequestBody BulkMetadataUpdateRequest bulkMetadataUpdateRequest) {
boolean mergeCategories = bulkMetadataUpdateRequest.isMergeCategories();
@ -120,7 +120,7 @@ public class MetadataController {
@Operation(summary = "Toggle all metadata locks", description = "Toggle all metadata locks for books. Requires metadata edit permission or admin.")
@ApiResponse(responseCode = "200", description = "Metadata locks toggled successfully")
@PutMapping("/metadata/toggle-all-lock")
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
@PreAuthorize("@securityUtil.canBulkLockUnlockMetadata() or @securityUtil.isAdmin()")
public ResponseEntity<List<BookMetadata>> toggleAllMetadata(
@Parameter(description = "Toggle all lock request") @RequestBody ToggleAllLockRequest request) {
return ResponseEntity.ok(bookMetadataService.toggleAllLock(request));
@ -139,7 +139,7 @@ public class MetadataController {
@Operation(summary = "Regenerate all covers", description = "Regenerate covers for all books. Requires metadata edit permission or admin.")
@ApiResponse(responseCode = "204", description = "Covers regenerated successfully")
@PostMapping("/regenerate-covers")
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
@PreAuthorize("@securityUtil.canBulkRegenerateCover() or @securityUtil.isAdmin()")
public void regenerateCovers() {
bookMetadataService.regenerateCovers();
}
@ -154,10 +154,20 @@ public class MetadataController {
bookMetadataService.regenerateCover(bookId);
}
@Operation(summary = "Generate custom cover for a book", description = "Generate a custom cover for a specific book based on its metadata. Requires metadata edit permission or admin.")
@ApiResponse(responseCode = "204", description = "Custom cover generated successfully")
@PostMapping("/{bookId}/generate-custom-cover")
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
@CheckBookAccess(bookIdParam = "bookId")
public void generateCustomCover(
@Parameter(description = "ID of the book") @PathVariable Long bookId) {
bookMetadataService.generateCustomCover(bookId);
}
@Operation(summary = "Regenerate covers for selected books", description = "Regenerate covers for a list of books. Requires metadata edit permission or admin.")
@ApiResponse(responseCode = "204", description = "Cover regeneration started successfully")
@PostMapping("/bulk-regenerate-covers")
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
@PreAuthorize("@securityUtil.canBulkRegenerateCover() or @securityUtil.isAdmin()")
public ResponseEntity<Void> regenerateCoversForBooks(
@Parameter(description = "List of book IDs") @Validated @RequestBody BulkBookIdsRequest request) {
bookMetadataService.regenerateCoversForBooks(request.getBookIds());
@ -167,7 +177,7 @@ public class MetadataController {
@Operation(summary = "Upload cover image for multiple books", description = "Upload a cover image to apply to multiple books. Requires metadata edit permission or admin.")
@ApiResponse(responseCode = "204", description = "Cover upload started successfully")
@PostMapping("/bulk-upload-cover")
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
@PreAuthorize("@securityUtil.canBulkEditMetadata() or @securityUtil.isAdmin()")
public ResponseEntity<Void> bulkUploadCover(
@Parameter(description = "Cover image file") @RequestParam("file") MultipartFile file,
@Parameter(description = "Comma-separated book IDs") @RequestParam("bookIds") @jakarta.validation.constraints.NotEmpty java.util.Set<Long> bookIds) {
@ -195,7 +205,7 @@ public class MetadataController {
@Operation(summary = "Consolidate metadata", description = "Merge metadata values. Requires metadata edit permission or admin.")
@ApiResponse(responseCode = "204", description = "Metadata consolidated successfully")
@PostMapping("/metadata/manage/consolidate")
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
@PreAuthorize("@securityUtil.canBulkEditMetadata() or @securityUtil.isAdmin()")
public ResponseEntity<Void> mergeMetadata(
@Parameter(description = "Merge metadata request") @Validated @RequestBody MergeMetadataRequest request) {
metadataManagementService.consolidateMetadata(request.getMetadataType(), request.getTargetValues(), request.getValuesToMerge());
@ -205,7 +215,7 @@ public class MetadataController {
@Operation(summary = "Delete metadata values", description = "Delete metadata values. Requires metadata edit permission or admin.")
@ApiResponse(responseCode = "204", description = "Metadata deleted successfully")
@PostMapping("/metadata/manage/delete")
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
@PreAuthorize("@securityUtil.canBulkEditMetadata() or @securityUtil.isAdmin()")
public ResponseEntity<Void> deleteMetadata(
@Parameter(description = "Delete metadata request") @Validated @RequestBody DeleteMetadataRequest request) {
metadataManagementService.deleteMetadata(request.getMetadataType(), request.getValuesToDelete());

View File

@ -3,7 +3,6 @@ package com.adityachandel.booklore.controller;
import com.adityachandel.booklore.config.security.service.AuthenticationService;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.exception.APIException;
import com.adityachandel.booklore.mapper.custom.BookLoreUserTransformer;
import com.adityachandel.booklore.model.dto.settings.OidcAutoProvisionDetails;
import com.adityachandel.booklore.model.dto.settings.OidcProviderDetails;
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;

View File

@ -5,9 +5,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;

View File

@ -10,8 +10,6 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/v1/public-settings")
@RequiredArgsConstructor

View File

@ -37,7 +37,6 @@ public class TaskController {
}
@PostMapping("/start")
@PreAuthorize("@securityUtil.canAccessTaskManager() or @securityUtil.isAdmin()")
public ResponseEntity<TaskCreateResponse> startTask(@RequestBody TaskCreateRequest request) {
TaskCreateResponse response = service.runAsUser(request);
if (response.getStatus() == TaskStatus.ACCEPTED) {

View File

@ -34,16 +34,15 @@ public class UserStatsController {
@Operation(summary = "Get reading session timeline for a week", description = "Returns reading sessions grouped by book for calendar timeline view")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Timeline data retrieved successfully"),
@ApiResponse(responseCode = "400", description = "Invalid week, month, or year"),
@ApiResponse(responseCode = "400", description = "Invalid week or year"),
@ApiResponse(responseCode = "401", description = "Unauthorized")
})
@GetMapping("/timeline")
@PreAuthorize("@securityUtil.canAccessUserStats() or @securityUtil.isAdmin()")
public ResponseEntity<List<ReadingSessionTimelineResponse>> getTimelineForWeek(
@RequestParam int year,
@RequestParam int month,
@RequestParam int week) {
List<ReadingSessionTimelineResponse> timelineData = readingSessionService.getSessionTimelineForWeek(year, month, week);
List<ReadingSessionTimelineResponse> timelineData = readingSessionService.getSessionTimelineForWeek(year, week);
return ResponseEntity.ok(timelineData);
}
@ -111,4 +110,3 @@ public class UserStatsController {
return ResponseEntity.ok(timeline);
}
}

View File

@ -9,7 +9,6 @@ import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.Set;
@Converter
@ -44,8 +43,8 @@ public class BookRecommendationIdsListConverter implements AttributeConverter<Se
try {
return objectMapper.readValue(json, SET_TYPE_REF);
} catch (Exception e) {
log.error("Failed to convert JSON string to BookRecommendation set: {}", json, e);
throw new RuntimeException("Error converting JSON to BookRecommendation list", e);
log.error("Corrupted similar_books_json found in database. Returning empty set. JSON: {}", json, e);
return Set.of();
}
}
}

View File

@ -0,0 +1,140 @@
package com.adityachandel.booklore.crons;
import com.adityachandel.booklore.config.AppProperties;
import com.adityachandel.booklore.model.dto.BookloreTelemetry;
import com.adityachandel.booklore.model.dto.InstallationPing;
import com.adityachandel.booklore.model.dto.settings.AppSettings;
import com.adityachandel.booklore.service.TelemetryService;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import jakarta.annotation.PostConstruct;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.concurrent.TimeUnit;
@Service
@AllArgsConstructor
@Slf4j
public class CronService {
private static final String LAST_TELEMETRY_KEY = "last_telemetry_sent";
private static final String LAST_PING_KEY = "last_ping_sent";
private static final String LAST_PING_APP_VERSION_KEY = "last_ping_app_version";
private static final long INTERVAL_HOURS = 24;
private final AppProperties appProperties;
private final TelemetryService telemetryService;
private final RestClient restClient;
private final AppSettingService appSettingService;
@PostConstruct
public void initScheduledTasks() {
checkAndRunTelemetry();
checkAndRunPing();
}
@Scheduled(fixedDelay = 24, timeUnit = TimeUnit.HOURS, initialDelay = 24)
public void sendTelemetryData() {
AppSettings settings = appSettingService.getAppSettings();
if (settings != null && settings.isTelemetryEnabled()) {
String url = appProperties.getTelemetry().getBaseUrl() + "/api/v1/ingest";
BookloreTelemetry telemetry = telemetryService.collectTelemetry();
if (postData(url, telemetry)) {
appSettingService.saveSetting(LAST_TELEMETRY_KEY, Instant.now().toString());
}
}
}
@Scheduled(fixedDelay = 24, timeUnit = TimeUnit.HOURS, initialDelay = 12)
public void sendPing() {
String url = appProperties.getTelemetry().getBaseUrl() + "/api/v1/heartbeat";
InstallationPing ping = telemetryService.getInstallationPing();
if (ping != null && postData(url, ping)) {
appSettingService.saveSetting(LAST_PING_KEY, Instant.now().toString());
appSettingService.saveSetting(LAST_PING_APP_VERSION_KEY, ping.getAppVersion());
}
}
protected boolean postData(String url, Object body) {
try {
restClient.post()
.uri(url)
.body(body)
.retrieve()
.body(String.class);
return true;
} catch (Exception ex) {
log.debug("POST request to URL: {}, Message: {}", url, ex.getMessage());
return false;
}
}
private void checkAndRunTelemetry() {
AppSettings settings = appSettingService.getAppSettings();
if (settings == null || !settings.isTelemetryEnabled()) {
return;
}
String lastRunStr = appSettingService.getSettingValue(LAST_TELEMETRY_KEY);
if (shouldRunTask(lastRunStr)) {
log.info("Running stats on startup (last run: {})", lastRunStr);
sendTelemetryData();
}
}
private void checkAndRunPing() {
String lastRunStr = appSettingService.getSettingValue(LAST_PING_KEY);
if (hasAppVersionChanged()) {
log.info("App version changed, sending immediate ping");
sendPing();
return;
}
if (shouldRunTask(lastRunStr)) {
log.info("Running ping on startup (last run: {})", lastRunStr);
sendPing();
}
}
/**
* Determines if a task should run immediately on startup.
* Returns false for new installations (no last run recorded) to follow normal schedule.
* Returns true if more than INTERVAL_HOURS have passed since the last run,
* preventing data gaps when the server restarts close to scheduled execution time.
* <p>
* Example: Telemetry normally runs at 2:00 AM daily. If the server restarts at 1:55 AM,
* the scheduled task would reset and not run until 2:00 AM the next day (48 hours later).
* This method checks if 24+ hours have passed since the last run and executes immediately
* on startup if needed, ensuring data is sent at 1:55 AM instead of waiting another 24 hours.
*/
private boolean shouldRunTask(String lastRunStr) {
if (lastRunStr == null || lastRunStr.isEmpty()) {
return false;
}
try {
Instant lastRun = Instant.parse(lastRunStr);
Instant threshold = Instant.now().minus(INTERVAL_HOURS, ChronoUnit.HOURS);
return lastRun.isBefore(threshold);
} catch (Exception e) {
log.warn("Failed to parse last run timestamp: {}", e.getMessage());
return false;
}
}
/**
* Checks if the app version has changed since the last ping.
* Returns true if this is an established installation with a version change.
*/
private boolean hasAppVersionChanged() {
String lastPingVersion = appSettingService.getSettingValue(LAST_PING_APP_VERSION_KEY);
InstallationPing ping = telemetryService.getInstallationPing();
String currentVersion = ping != null ? ping.getAppVersion() : null;
if (lastPingVersion == null || lastPingVersion.isEmpty() || currentVersion == null) {
return false;
}
return !lastPingVersion.equals(currentVersion);
}
}

View File

@ -1,5 +1,6 @@
package com.adityachandel.booklore.exception;
import com.adityachandel.booklore.model.enums.UserPermission;
import lombok.Getter;
import org.springframework.http.HttpStatus;
@ -57,7 +58,8 @@ public enum ApiError {
TASK_NOT_FOUND(HttpStatus.NOT_FOUND, "Scheduled task not found: %s"),
TASK_ALREADY_RUNNING(HttpStatus.CONFLICT, "Task is already running: %s"),
ICON_ALREADY_EXISTS(HttpStatus.CONFLICT, "SVG icon with name '%s' already exists"),
DEMO_USER_PASSWORD_CHANGE_NOT_ALLOWED(HttpStatus.FORBIDDEN, "Demo user password change not allowed.");
DEMO_USER_PASSWORD_CHANGE_NOT_ALLOWED(HttpStatus.FORBIDDEN, "Demo user password change not allowed."),
PERMISSION_DENIED(HttpStatus.FORBIDDEN, "Permission denied: %s");
private final HttpStatus status;
private final String message;
@ -71,4 +73,9 @@ public enum ApiError {
String formattedMessage = (details.length > 0) ? String.format(message, details) : message;
return new APIException(formattedMessage, this.status);
}
public APIException createException(UserPermission permission) {
String formattedMessage = String.format(message, permission.getDescription());
return new APIException(formattedMessage, this.status);
}
}

View File

@ -93,6 +93,27 @@ public class GlobalExceptionHandler {
return new ResponseEntity<>(errorResponse, HttpStatus.OK);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleIllegalArgumentException(IllegalArgumentException ex) {
log.warn("Illegal argument: {}", ex.getMessage());
ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.value(), ex.getMessage());
return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<ErrorResponse> handleIllegalStateException(IllegalStateException ex) {
log.error("Illegal state: {}", ex.getMessage(), ex);
ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.value(), ex.getMessage());
return new ResponseEntity<>(errorResponse, HttpStatus.CONFLICT);
}
@ExceptionHandler(UnsupportedOperationException.class)
public ResponseEntity<ErrorResponse> handleUnsupportedOperationException(UnsupportedOperationException ex) {
log.error("Unsupported operation: {}", ex.getMessage(), ex);
ErrorResponse errorResponse = new ErrorResponse(HttpStatus.NOT_IMPLEMENTED.value(), ex.getMessage());
return new ResponseEntity<>(errorResponse, HttpStatus.NOT_IMPLEMENTED);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
ErrorResponse errorResponse = new ErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR.value(), ex.getMessage());

View File

@ -2,7 +2,6 @@ package com.adityachandel.booklore.mapper;
import com.adityachandel.booklore.model.dto.AdditionalFile;
import com.adityachandel.booklore.model.entity.BookAdditionalFileEntity;
import com.adityachandel.booklore.util.FileUtils;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Named;

View File

@ -4,7 +4,6 @@ import com.adityachandel.booklore.model.dto.BookReview;
import com.adityachandel.booklore.model.entity.BookReviewEntity;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
@Mapper(componentModel = "spring")
public interface BookReviewMapper {

View File

@ -0,0 +1,11 @@
package com.adityachandel.booklore.mapper;
import com.adityachandel.booklore.model.dto.CustomFontDto;
import com.adityachandel.booklore.model.entity.CustomFontEntity;
import org.mapstruct.Mapper;
@Mapper(componentModel = "spring")
public interface CustomFontMapper {
CustomFontDto toDto(CustomFontEntity entity);
}

View File

@ -6,6 +6,7 @@ import com.adityachandel.booklore.model.dto.settings.SidebarSortOption;
import com.adityachandel.booklore.model.dto.settings.UserSettingKey;
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
import com.adityachandel.booklore.model.entity.UserSettingEntity;
import com.adityachandel.booklore.model.enums.UserPermission;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AllArgsConstructor;
@ -25,24 +26,7 @@ public class BookLoreUserTransformer {
public BookLoreUser toDTO(BookLoreUserEntity userEntity) {
BookLoreUser.UserPermissions permissions = new BookLoreUser.UserPermissions();
permissions.setAdmin(userEntity.getPermissions().isPermissionAdmin());
permissions.setCanUpload(userEntity.getPermissions().isPermissionUpload());
permissions.setCanDownload(userEntity.getPermissions().isPermissionDownload());
permissions.setCanEditMetadata(userEntity.getPermissions().isPermissionEditMetadata());
permissions.setCanEmailBook(userEntity.getPermissions().isPermissionEmailBook());
permissions.setCanDeleteBook(userEntity.getPermissions().isPermissionDeleteBook());
permissions.setCanManageLibrary(userEntity.getPermissions().isPermissionManageLibrary());
permissions.setCanAccessOpds(userEntity.getPermissions().isPermissionAccessOpds());
permissions.setCanSyncKoReader(userEntity.getPermissions().isPermissionSyncKoreader());
permissions.setCanSyncKobo(userEntity.getPermissions().isPermissionSyncKobo());
permissions.setCanManageMetadataConfig(userEntity.getPermissions().isPermissionManageMetadataConfig());
permissions.setCanAccessBookdrop(userEntity.getPermissions().isPermissionAccessBookdrop());
permissions.setCanAccessLibraryStats(userEntity.getPermissions().isPermissionAccessLibraryStats());
permissions.setCanAccessUserStats(userEntity.getPermissions().isPermissionAccessUserStats());
permissions.setCanAccessTaskManager(userEntity.getPermissions().isPermissionAccessTaskManager());
permissions.setCanManageGlobalPreferences(userEntity.getPermissions().isPermissionManageGlobalPreferences());
permissions.setCanManageIcons(userEntity.getPermissions().isPermissionManageIcons());
permissions.setDemoUser(userEntity.getPermissions().isPermissionDemoUser());
UserPermission.copyFromEntityToDto(userEntity.getPermissions(), permissions);
BookLoreUser bookLoreUser = new BookLoreUser();
bookLoreUser.setId(userEntity.getId());

View File

@ -29,6 +29,8 @@ public class MetadataClearFlags {
private boolean goodreadsReviewCount;
private boolean hardcoverRating;
private boolean hardcoverReviewCount;
private boolean lubimyczytacId;
private boolean lubimyczytacRating;
private boolean authors;
private boolean categories;
private boolean moods;

View File

@ -2,6 +2,7 @@ package com.adityachandel.booklore.model.dto;
import com.adityachandel.booklore.model.dto.settings.SidebarSortOption;
import com.adityachandel.booklore.model.enums.*;
import com.fasterxml.jackson.annotation.JsonAlias;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@ -44,6 +45,15 @@ public class BookLoreUser {
private boolean canManageGlobalPreferences;
private boolean canManageIcons;
private boolean isDemoUser;
private boolean canBulkAutoFetchMetadata;
private boolean canBulkCustomFetchMetadata;
private boolean canBulkEditMetadata;
private boolean canBulkRegenerateCover;
private boolean canMoveOrganizeFiles;
private boolean canBulkLockUnlockMetadata;
private boolean canBulkResetBookloreReadProgress;
private boolean canBulkResetKoReaderReadProgress;
private boolean canBulkResetBookReadStatus;
}
@Data
@ -93,6 +103,7 @@ public class BookLoreUser {
private String sortDir;
private String view;
private Float coverSize;
@JsonAlias("seriesCollapse")
private Boolean seriesCollapsed;
}
@ -114,7 +125,8 @@ public class BookLoreUser {
private String sortKey;
private String sortDir;
private String view;
private Boolean seriesCollapse;
@JsonAlias("seriesCollapse")
private Boolean seriesCollapsed;
}
@Data

View File

@ -43,7 +43,9 @@ public class BookMetadata {
private String doubanId;
private Double doubanRating;
private Integer doubanReviewCount;
private Double lubimyczytacRating;
private String googleId;
private String lubimyczytacId;
private Instant coverUpdatedOn;
private Set<String> authors;
private Set<String> categories;
@ -80,6 +82,8 @@ public class BookMetadata {
private Boolean hardcoverReviewCountLocked;
private Boolean doubanRatingLocked;
private Boolean doubanReviewCountLocked;
private Boolean lubimyczytacIdLocked;
private Boolean lubimyczytacRatingLocked;
private Boolean coverLocked;
private Boolean authorsLocked;
private Boolean categoriesLocked;

View File

@ -1,14 +1,12 @@
package com.adityachandel.booklore.model.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.*;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode
public class BookRecommendationLite {
private long b; // bookId
private double s; // similarityScore

View File

@ -0,0 +1,100 @@
package com.adityachandel.booklore.model.dto;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
import java.util.Map;
@Builder
@Setter
@Getter
public class BookloreTelemetry {
private int telemetryVersion;
private String installationId;
private String installationDate;
private String appVersion;
private int totalLibraries;
private long totalBooks;
private long totalAdditionalBookFiles;
private long totalAuthors;
private long totalBookNotes;
private long totalBookmarks;
private int totalShelves;
private int totalMagicShelves;
private int totalCategories;
private int totalTags;
private int totalMoods;
private int totalKoreaderUsers;
private UserStatistics userStatistics;
private MetadataStatistics metadataStatistics;
private OpdsStatistics opdsStatistics;
private KoboStatistics koboStatistics;
private EmailStatistics emailStatistics;
private BookStatistics bookStatistics;
private List<LibraryStatistics> libraryStatisticsList;
@Builder
@Getter
public static class UserStatistics {
private int totalUsers;
private int totalLocalUsers;
private int totalOidcUsers;
private boolean oidcEnabled;
}
@Builder
@Getter
public static class MetadataStatistics {
private String[] enabledMetadataProviders;
private String[] enabledReviewMetadataProviders;
private boolean saveMetadataToFile;
private boolean moveFileViaPattern;
private boolean autoBookSearchEnabled;
private boolean similarBookRecommendationsEnabled;
private boolean metadataDownloadOnBookdropEnabled;
}
@Builder
@Getter
public static class OpdsStatistics {
private boolean opdsEnabled;
private int totalOpdsUsers;
}
@Builder
@Getter
public static class KoboStatistics {
private int totalKoboUsers;
private int totalHardcoverSyncEnabled;
private int totalAutoAddToShelf;
private boolean convertToKepubEnabled;
}
@Builder
@Getter
public static class EmailStatistics {
private int totalEmailProviders;
private int totalEmailRecipients;
}
@Builder
@Getter
public static class BookStatistics {
private long totalBooks;
private Map<String, Long> bookCountByType;
}
@Builder
@Getter
public static class LibraryStatistics {
private long bookCount;
private int totalLibraryPaths;
private boolean watchEnabled;
private String iconType;
private String scanMode;
}
}

View File

@ -0,0 +1,22 @@
package com.adityachandel.booklore.model.dto;
import com.adityachandel.booklore.model.enums.FontFormat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CustomFontDto {
private Long id;
private String fontName;
private String originalFileName;
private FontFormat format;
private Long fileSize;
private LocalDateTime uploadedAt;
}

View File

@ -5,12 +5,6 @@ import lombok.*;
import java.time.LocalDate;
import java.util.Set;
import lombok.*;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Set;
@Getter
@Setter
@NoArgsConstructor

View File

@ -14,4 +14,5 @@ public class EpubViewerPreferences {
private Integer fontSize;
private Float letterSpacing;
private Float lineHeight;
private Long customFontId;
}

View File

@ -0,0 +1,16 @@
package com.adityachandel.booklore.model.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Installation {
private String id;
private Instant date;
}

View File

@ -0,0 +1,17 @@
package com.adityachandel.booklore.model.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Builder;
import lombok.Getter;
import java.time.Instant;
@Builder
@Getter
public class InstallationPing {
private int pingVersion;
private String appVersion;
private String installationId;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", timezone = "UTC")
private Instant installationDate;
}

View File

@ -56,6 +56,8 @@ public enum RuleField {
@JsonProperty("moods")
MOODS,
@JsonProperty("tags")
TAGS
TAGS,
@JsonProperty("genre")
GENRE
}

View File

@ -28,6 +28,15 @@ public class UserCreateRequest {
private boolean permissionAccessTaskManager;
private boolean permissionManageGlobalPreferences;
private boolean permissionManageIcons;
private boolean permissionBulkAutoFetchMetadata;
private boolean permissionBulkCustomFetchMetadata;
private boolean permissionBulkEditMetadata;
private boolean permissionBulkRegenerateCover;
private boolean permissionMoveOrganizeFiles;
private boolean permissionBulkLockUnlockMetadata;
private boolean permissionBulkResetBookloreReadProgress;
private boolean permissionBulkResetKoReaderReadProgress;
private boolean permissionBulkResetBookReadStatus;
private Set<Long> selectedLibraries;
}

View File

@ -19,7 +19,8 @@ public class BookEntitlement {
private ActivePeriod activePeriod;
@JsonProperty("IsRemoved")
private boolean isRemoved;
@Builder.Default
private Boolean removed = false;
private String status;
@ -31,7 +32,7 @@ public class BookEntitlement {
@JsonProperty("IsHiddenFromArchive")
@Builder.Default
private boolean isHiddenFromArchive = false;
private boolean hiddenFromArchive = false;
private String id;
private String created;
@ -39,7 +40,7 @@ public class BookEntitlement {
@JsonProperty("IsLocked")
@Builder.Default
private boolean isLocked = false;
private boolean locked = false;
@Builder.Default
private String originCategory = "Imported";

View File

@ -0,0 +1,19 @@
package com.adityachandel.booklore.model.dto.kobo;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ChangedProductMetadata implements Entitlement {
private BookEntitlementContainer changedProductMetadata;
}

View File

@ -18,7 +18,6 @@ import java.util.Map;
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KoboBookMetadata {
private String crossRevisionId;
private String revisionId;
@ -29,13 +28,13 @@ public class KoboBookMetadata {
private String language = "en";
private String isbn;
private String genre;
private String genre = "00000000-0000-0000-0000-000000000001";
private String slug;
private String coverImageId;
@JsonProperty("IsSocialEnabled")
@Builder.Default
private boolean isSocialEnabled = false;
private boolean socialEnabled = true;
private String workId;
@ -44,14 +43,14 @@ public class KoboBookMetadata {
@JsonProperty("IsPreOrder")
@Builder.Default
private boolean isPreOrder = false;
private boolean preOrder = false;
@Builder.Default
private List<ContributorRole> contributorRoles = new ArrayList<>();
@JsonProperty("IsInternetArchive")
@Builder.Default
private boolean isInternetArchive = false;
private boolean internetArchive = false;
private String entitlementId;
private String title;
@ -81,7 +80,7 @@ public class KoboBookMetadata {
@JsonProperty("IsEligibleForKoboLove")
@Builder.Default
private boolean isEligibleForKoboLove = false;
private boolean eligibleForKoboLove = false;
@Builder.Default
private Map<String, String> phoneticPronunciations = Map.of();

View File

@ -19,9 +19,11 @@ public class MetadataRefreshOptions {
private boolean mergeCategories;
private Boolean reviewBeforeApply;
@NotNull(message = "Field options cannot be null")
private FieldOptions fieldOptions;
@Builder.Default
private FieldOptions fieldOptions = new FieldOptions();
@NotNull(message = "Enabled fields cannot be null")
private EnabledFields enabledFields;
@Builder.Default
private EnabledFields enabledFields = new EnabledFields();
@Getter
@Setter

View File

@ -1,6 +1,16 @@
package com.adityachandel.booklore.model.dto.request;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
public record ReadStatusUpdateRequest(List<Long> ids, String status) {
@Data
public class ReadStatusUpdateRequest {
@NotEmpty(message = "Book IDs cannot be empty")
private List<Long> bookIds;
@NotNull(message = "Status cannot be null")
private String status;
}

View File

@ -30,5 +30,14 @@ public class UserUpdateRequest {
private boolean canAccessTaskManager;
private boolean canManageGlobalPreferences;
private boolean canManageIcons;
private boolean canBulkAutoFetchMetadata;
private boolean canBulkCustomFetchMetadata;
private boolean canBulkEditMetadata;
private boolean canBulkRegenerateCover;
private boolean canMoveOrganizeFiles;
private boolean canBulkLockUnlockMetadata;
private boolean canBulkResetBookloreReadProgress;
private boolean canBulkResetKoReaderReadProgress;
private boolean canBulkResetBookReadStatus;
}
}

View File

@ -0,0 +1,21 @@
package com.adityachandel.booklore.model.dto.response;
import com.adityachandel.booklore.model.enums.ReadStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BookStatusUpdateResponse {
private Long bookId;
private ReadStatus readStatus;
private Instant readStatusModifiedTime;
private Instant dateFinished;
}

View File

@ -0,0 +1,16 @@
package com.adityachandel.booklore.model.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PersonalRatingUpdateResponse {
private Long bookId;
private Integer personalRating;
}

View File

@ -7,7 +7,7 @@ import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.List;
@Data
@ -27,8 +27,8 @@ public class TasksHistoryResponse {
private TaskStatus status;
private Integer progressPercentage;
private String message;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime completedAt;
private Instant createdAt;
private Instant updatedAt;
private Instant completedAt;
}
}

View File

@ -33,6 +33,7 @@ public enum AppSettingKey {
CBX_CACHE_SIZE_IN_MB ("cbx_cache_size_in_mb", false, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_GLOBAL_PREFERENCES)),
PDF_CACHE_SIZE_IN_MB ("pdf_cache_size_in_mb", false, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_GLOBAL_PREFERENCES)),
MAX_FILE_UPLOAD_SIZE_IN_MB ("max_file_upload_size_in_mb", false, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_GLOBAL_PREFERENCES)),
TELEMETRY_ENABLED ("telemetryEnabled", false, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_GLOBAL_PREFERENCES)),
// No specific permissions required
SIDEBAR_LIBRARY_SORTING ("sidebar_library_sorting", true, false, List.of()),

View File

@ -25,6 +25,7 @@ public class AppSettings {
private boolean remoteAuthEnabled;
private boolean metadataDownloadOnBookdrop;
private boolean oidcEnabled;
private boolean telemetryEnabled;
private OidcProviderDetails oidcProviderDetails;
private OidcAutoProvisionDetails oidcAutoProvisionDetails;
private MetadataProviderSettings metadataProviderSettings;

View File

@ -1,5 +1,6 @@
package com.adityachandel.booklore.model.dto.settings;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
@ -10,6 +11,8 @@ public class MetadataProviderSettings {
private Hardcover hardcover;
private Comicvine comicvine;
private Douban douban;
@JsonProperty("lubimyczytac")
private Lubimyczytac lubimyczytac;
@Data
public static class Amazon {
@ -45,4 +48,9 @@ public class MetadataProviderSettings {
public static class Douban {
private boolean enabled;
}
@Data
public static class Lubimyczytac {
private boolean enabled;
}
}

View File

@ -4,8 +4,6 @@ import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
@Entity
@Getter

View File

@ -43,6 +43,12 @@ public class BookEntity {
@OneToOne(mappedBy = "book", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private BookMetadataEntity metadata;
@Column(name = "metadata_updated_at")
private Instant metadataUpdatedAt;
@Column(name = "metadata_for_write_updated_at")
private Instant metadataForWriteUpdatedAt;
@ManyToOne
@JoinColumn(name = "library_id", nullable = false)
private LibraryEntity library;
@ -60,6 +66,9 @@ public class BookEntity {
@Column(name = "current_hash", length = 128)
private String currentHash;
@Column(name = "book_cover_hash", length = 20)
private String bookCoverHash;
@Column(name = "deleted")
@Builder.Default
private Boolean deleted = Boolean.FALSE;

View File

@ -106,6 +106,11 @@ public class BookMetadataEntity {
@Column(name = "comicvine_id", length = 100)
private String comicvineId;
@Column(name = "lubimyczytac_id", length = 100)
private String lubimyczytacId;
@Column(name = "lubimyczytac_rating")
private Double lubimyczytacRating;
@Column(name = "title_locked")
@Builder.Default
@ -223,6 +228,14 @@ public class BookMetadataEntity {
@Builder.Default
private Boolean comicvineIdLocked = Boolean.FALSE;
@Column(name = "lubimyczytac_id_locked")
@Builder.Default
private Boolean lubimyczytacIdLocked = Boolean.FALSE;
@Column(name = "lubimyczytac_rating_locked")
@Builder.Default
private Boolean lubimyczytacRatingLocked = Boolean.FALSE;
@Column(name = "reviews_locked")
@Builder.Default
private Boolean reviewsLocked = Boolean.FALSE;
@ -313,11 +326,13 @@ public class BookMetadataEntity {
this.goodreadsReviewCountLocked = lock;
this.hardcoverRatingLocked = lock;
this.hardcoverReviewCountLocked = lock;
this.lubimyczytacRatingLocked = lock;
this.comicvineIdLocked = lock;
this.goodreadsIdLocked = lock;
this.hardcoverIdLocked = lock;
this.hardcoverBookIdLocked = lock;
this.googleIdLocked = lock;
this.lubimyczytacIdLocked = lock;
this.reviewsLocked = lock;
}
@ -346,11 +361,13 @@ public class BookMetadataEntity {
&& Boolean.TRUE.equals(this.goodreadsReviewCountLocked)
&& Boolean.TRUE.equals(this.hardcoverRatingLocked)
&& Boolean.TRUE.equals(this.hardcoverReviewCountLocked)
&& Boolean.TRUE.equals(this.lubimyczytacRatingLocked)
&& Boolean.TRUE.equals(this.goodreadsIdLocked)
&& Boolean.TRUE.equals(this.comicvineIdLocked)
&& Boolean.TRUE.equals(this.hardcoverIdLocked)
&& Boolean.TRUE.equals(this.hardcoverBookIdLocked)
&& Boolean.TRUE.equals(this.googleIdLocked)
&& Boolean.TRUE.equals(this.lubimyczytacIdLocked)
&& Boolean.TRUE.equals(this.reviewsLocked)
;
}

View File

@ -0,0 +1,44 @@
package com.adityachandel.booklore.model.entity;
import com.adityachandel.booklore.model.enums.FontFormat;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "custom_font")
public class CustomFontEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private BookLoreUserEntity user;
@Column(name = "font_name", nullable = false)
private String fontName;
@Column(name = "file_name", nullable = false, unique = true)
private String fileName;
@Column(name = "original_file_name", nullable = false)
private String originalFileName;
@Enumerated(EnumType.STRING)
@Column(name = "format", nullable = false)
private FontFormat format;
@Column(name = "file_size", nullable = false)
private Long fileSize;
@Column(name = "uploaded_at", nullable = false)
private LocalDateTime uploadedAt;
}

View File

@ -44,4 +44,7 @@ public class EpubViewerPreferencesEntity {
@Column(name = "spread")
private String spread;
@Column(name = "custom_font_id")
private Long customFontId;
}

View File

@ -4,6 +4,8 @@ package com.adityachandel.booklore.model.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.Instant;
@Entity
@Table(name = "kobo_library_snapshot_book")
@Getter
@ -24,6 +26,12 @@ public class KoboSnapshotBookEntity {
@Column(name = "book_id", nullable = false)
private Long bookId;
@Column(name = "file_hash")
private String fileHash;
@Column(name = "metadata_updated_at")
private Instant metadataUpdatedAt;
@Column(nullable = false)
@Builder.Default
private boolean synced = false;

View File

@ -3,8 +3,6 @@ package com.adityachandel.booklore.model.entity;
import jakarta.persistence.*;
import lombok.*;
import java.util.List;
@Entity
@Getter
@Setter

View File

@ -90,4 +90,40 @@ public class UserPermissionsEntity {
@Column(name = "permission_demo_user", nullable = false)
@Builder.Default
private boolean permissionDemoUser = false;
@Column(name = "permission_bulk_auto_fetch_metadata", nullable = false)
@Builder.Default
private boolean permissionBulkAutoFetchMetadata = false;
@Column(name = "permission_bulk_custom_fetch_metadata", nullable = false)
@Builder.Default
private boolean permissionBulkCustomFetchMetadata = false;
@Column(name = "permission_bulk_edit_metadata", nullable = false)
@Builder.Default
private boolean permissionBulkEditMetadata = false;
@Column(name = "permission_bulk_regenerate_cover", nullable = false)
@Builder.Default
private boolean permissionBulkRegenerateCover = false;
@Column(name = "permission_move_organize_files", nullable = false)
@Builder.Default
private boolean permissionMoveOrganizeFiles = false;
@Column(name = "permission_bulk_lock_unlock_metadata", nullable = false)
@Builder.Default
private boolean permissionBulkLockUnlockMetadata = false;
@Column(name = "permission_bulk_reset_booklore_read_progress", nullable = false)
@Builder.Default
private boolean permissionBulkResetBookloreReadProgress = false;
@Column(name = "permission_bulk_reset_koreader_read_progress", nullable = false)
@Builder.Default
private boolean permissionBulkResetKoReaderReadProgress = false;
@Column(name = "permission_bulk_reset_book_read_status", nullable = false)
@Builder.Default
private boolean permissionBulkResetBookReadStatus = false;
}

View File

@ -0,0 +1,54 @@
package com.adityachandel.booklore.model.enums;
import lombok.Getter;
@Getter
public enum FontFormat {
TTF("font/ttf", ".ttf"),
OTF("font/otf", ".otf"),
WOFF("font/woff", ".woff"),
WOFF2("font/woff2", ".woff2");
private final String mimeType;
private final String extension;
FontFormat(String mimeType, String extension) {
this.mimeType = mimeType;
this.extension = extension;
}
public static FontFormat fromExtension(String extension) {
String normalizedExt = extension.toLowerCase();
if (!normalizedExt.startsWith(".")) {
normalizedExt = "." + normalizedExt;
}
for (FontFormat format : values()) {
if (format.extension.equals(normalizedExt)) {
return format;
}
}
throw new IllegalArgumentException("Unsupported font format: " + extension);
}
public static FontFormat fromMimeType(String mimeType) {
for (FontFormat format : values()) {
if (format.mimeType.equals(mimeType)) {
return format;
}
}
throw new IllegalArgumentException("Unsupported MIME type: " + mimeType);
}
public static boolean isSupportedExtension(String extension) {
String normalizedExt = extension.toLowerCase();
if (!normalizedExt.startsWith(".")) {
normalizedExt = "." + normalizedExt;
}
for (FontFormat format : values()) {
if (format.extension.equals(normalizedExt)) {
return true;
}
}
return false;
}
}

View File

@ -1,5 +1,5 @@
package com.adityachandel.booklore.model.enums;
public enum MetadataProvider {
Amazon, GoodReads, Google, Hardcover, Comicvine, Douban
Amazon, GoodReads, Google, Hardcover, Comicvine, Douban, Lubimyczytac
}

View File

@ -0,0 +1,282 @@
package com.adityachandel.booklore.model.enums;
import com.adityachandel.booklore.model.dto.BookLoreUser;
import com.adityachandel.booklore.model.dto.request.UserUpdateRequest;
import com.adityachandel.booklore.model.entity.UserPermissionsEntity;
import lombok.Getter;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.function.Predicate;
@Getter
public enum UserPermission {
IS_ADMIN("Admin access",
BookLoreUser.UserPermissions::isAdmin,
BookLoreUser.UserPermissions::setAdmin,
UserUpdateRequest.Permissions::isAdmin,
UserPermissionsEntity::isPermissionAdmin,
UserPermissionsEntity::setPermissionAdmin
),
CAN_UPLOAD(
"Upload books",
BookLoreUser.UserPermissions::isCanUpload,
BookLoreUser.UserPermissions::setCanUpload,
UserUpdateRequest.Permissions::isCanUpload,
UserPermissionsEntity::isPermissionUpload,
UserPermissionsEntity::setPermissionUpload
),
CAN_DOWNLOAD(
"Download books",
BookLoreUser.UserPermissions::isCanDownload,
BookLoreUser.UserPermissions::setCanDownload,
UserUpdateRequest.Permissions::isCanDownload,
UserPermissionsEntity::isPermissionDownload,
UserPermissionsEntity::setPermissionDownload
),
CAN_EDIT_METADATA(
"Edit metadata",
BookLoreUser.UserPermissions::isCanEditMetadata,
BookLoreUser.UserPermissions::setCanEditMetadata,
UserUpdateRequest.Permissions::isCanEditMetadata,
UserPermissionsEntity::isPermissionEditMetadata,
UserPermissionsEntity::setPermissionEditMetadata
),
CAN_MANAGE_LIBRARY(
"Manage library",
BookLoreUser.UserPermissions::isCanManageLibrary,
BookLoreUser.UserPermissions::setCanManageLibrary,
UserUpdateRequest.Permissions::isCanManageLibrary,
UserPermissionsEntity::isPermissionManageLibrary,
UserPermissionsEntity::setPermissionManageLibrary
),
CAN_SYNC_KOREADER(
"Sync KoReader",
BookLoreUser.UserPermissions::isCanSyncKoReader,
BookLoreUser.UserPermissions::setCanSyncKoReader,
UserUpdateRequest.Permissions::isCanSyncKoReader,
UserPermissionsEntity::isPermissionSyncKoreader,
UserPermissionsEntity::setPermissionSyncKoreader
),
CAN_SYNC_KOBO(
"Sync Kobo",
BookLoreUser.UserPermissions::isCanSyncKobo,
BookLoreUser.UserPermissions::setCanSyncKobo,
UserUpdateRequest.Permissions::isCanSyncKobo,
UserPermissionsEntity::isPermissionSyncKobo,
UserPermissionsEntity::setPermissionSyncKobo
),
CAN_EMAIL_BOOK(
"Email books",
BookLoreUser.UserPermissions::isCanEmailBook,
BookLoreUser.UserPermissions::setCanEmailBook,
UserUpdateRequest.Permissions::isCanEmailBook,
UserPermissionsEntity::isPermissionEmailBook,
UserPermissionsEntity::setPermissionEmailBook
),
CAN_DELETE_BOOK(
"Delete books",
BookLoreUser.UserPermissions::isCanDeleteBook,
BookLoreUser.UserPermissions::setCanDeleteBook,
UserUpdateRequest.Permissions::isCanDeleteBook,
UserPermissionsEntity::isPermissionDeleteBook,
UserPermissionsEntity::setPermissionDeleteBook
),
CAN_ACCESS_OPDS(
"Access OPDS",
BookLoreUser.UserPermissions::isCanAccessOpds,
BookLoreUser.UserPermissions::setCanAccessOpds,
UserUpdateRequest.Permissions::isCanAccessOpds,
UserPermissionsEntity::isPermissionAccessOpds,
UserPermissionsEntity::setPermissionAccessOpds
),
CAN_MANAGE_METADATA_CONFIG(
"Manage metadata config",
BookLoreUser.UserPermissions::isCanManageMetadataConfig,
BookLoreUser.UserPermissions::setCanManageMetadataConfig,
UserUpdateRequest.Permissions::isCanManageMetadataConfig,
UserPermissionsEntity::isPermissionManageMetadataConfig,
UserPermissionsEntity::setPermissionManageMetadataConfig
),
CAN_ACCESS_BOOKDROP(
"Access bookdrop",
BookLoreUser.UserPermissions::isCanAccessBookdrop,
BookLoreUser.UserPermissions::setCanAccessBookdrop,
UserUpdateRequest.Permissions::isCanAccessBookdrop,
UserPermissionsEntity::isPermissionAccessBookdrop,
UserPermissionsEntity::setPermissionAccessBookdrop
),
CAN_ACCESS_LIBRARY_STATS(
"Access library stats",
BookLoreUser.UserPermissions::isCanAccessLibraryStats,
BookLoreUser.UserPermissions::setCanAccessLibraryStats,
UserUpdateRequest.Permissions::isCanAccessLibraryStats,
UserPermissionsEntity::isPermissionAccessLibraryStats,
UserPermissionsEntity::setPermissionAccessLibraryStats
),
CAN_ACCESS_USER_STATS(
"Access user stats",
BookLoreUser.UserPermissions::isCanAccessUserStats,
BookLoreUser.UserPermissions::setCanAccessUserStats,
UserUpdateRequest.Permissions::isCanAccessUserStats,
UserPermissionsEntity::isPermissionAccessUserStats,
UserPermissionsEntity::setPermissionAccessUserStats
),
CAN_ACCESS_TASK_MANAGER(
"Access task manager",
BookLoreUser.UserPermissions::isCanAccessTaskManager,
BookLoreUser.UserPermissions::setCanAccessTaskManager,
UserUpdateRequest.Permissions::isCanAccessTaskManager,
UserPermissionsEntity::isPermissionAccessTaskManager,
UserPermissionsEntity::setPermissionAccessTaskManager
),
CAN_MANAGE_GLOBAL_PREFERENCES(
"Manage global preferences",
BookLoreUser.UserPermissions::isCanManageGlobalPreferences,
BookLoreUser.UserPermissions::setCanManageGlobalPreferences,
UserUpdateRequest.Permissions::isCanManageGlobalPreferences,
UserPermissionsEntity::isPermissionManageGlobalPreferences,
UserPermissionsEntity::setPermissionManageGlobalPreferences
),
CAN_MANAGE_ICONS(
"Manage icons",
BookLoreUser.UserPermissions::isCanManageIcons,
BookLoreUser.UserPermissions::setCanManageIcons,
UserUpdateRequest.Permissions::isCanManageIcons,
UserPermissionsEntity::isPermissionManageIcons,
UserPermissionsEntity::setPermissionManageIcons
),
CAN_BULK_AUTO_FETCH_METADATA(
"Bulk auto fetch metadata",
BookLoreUser.UserPermissions::isCanBulkAutoFetchMetadata,
BookLoreUser.UserPermissions::setCanBulkAutoFetchMetadata,
UserUpdateRequest.Permissions::isCanBulkAutoFetchMetadata,
UserPermissionsEntity::isPermissionBulkAutoFetchMetadata,
UserPermissionsEntity::setPermissionBulkAutoFetchMetadata
),
CAN_BULK_CUSTOM_FETCH_METADATA(
"Bulk custom fetch metadata",
BookLoreUser.UserPermissions::isCanBulkCustomFetchMetadata,
BookLoreUser.UserPermissions::setCanBulkCustomFetchMetadata,
UserUpdateRequest.Permissions::isCanBulkCustomFetchMetadata,
UserPermissionsEntity::isPermissionBulkCustomFetchMetadata,
UserPermissionsEntity::setPermissionBulkCustomFetchMetadata
),
CAN_BULK_EDIT_METADATA(
"Bulk edit metadata",
BookLoreUser.UserPermissions::isCanBulkEditMetadata,
BookLoreUser.UserPermissions::setCanBulkEditMetadata,
UserUpdateRequest.Permissions::isCanBulkEditMetadata,
UserPermissionsEntity::isPermissionBulkEditMetadata,
UserPermissionsEntity::setPermissionBulkEditMetadata
),
CAN_BULK_REGENERATE_COVER(
"Bulk regenerate cover",
BookLoreUser.UserPermissions::isCanBulkRegenerateCover,
BookLoreUser.UserPermissions::setCanBulkRegenerateCover,
UserUpdateRequest.Permissions::isCanBulkRegenerateCover,
UserPermissionsEntity::isPermissionBulkRegenerateCover,
UserPermissionsEntity::setPermissionBulkRegenerateCover
),
CAN_MOVE_ORGANIZE_FILES(
"Move/organize files",
BookLoreUser.UserPermissions::isCanMoveOrganizeFiles,
BookLoreUser.UserPermissions::setCanMoveOrganizeFiles,
UserUpdateRequest.Permissions::isCanMoveOrganizeFiles,
UserPermissionsEntity::isPermissionMoveOrganizeFiles,
UserPermissionsEntity::setPermissionMoveOrganizeFiles
),
CAN_BULK_LOCK_UNLOCK_METADATA(
"Bulk lock/unlock metadata",
BookLoreUser.UserPermissions::isCanBulkLockUnlockMetadata,
BookLoreUser.UserPermissions::setCanBulkLockUnlockMetadata,
UserUpdateRequest.Permissions::isCanBulkLockUnlockMetadata,
UserPermissionsEntity::isPermissionBulkLockUnlockMetadata,
UserPermissionsEntity::setPermissionBulkLockUnlockMetadata
),
CAN_BULK_RESET_BOOKLORE_READ_PROGRESS(
"Bulk reset Booklore read progress",
BookLoreUser.UserPermissions::isCanBulkResetBookloreReadProgress,
BookLoreUser.UserPermissions::setCanBulkResetBookloreReadProgress,
UserUpdateRequest.Permissions::isCanBulkResetBookloreReadProgress,
UserPermissionsEntity::isPermissionBulkResetBookloreReadProgress,
UserPermissionsEntity::setPermissionBulkResetBookloreReadProgress
),
CAN_BULK_RESET_KOREADER_READ_PROGRESS(
"Bulk reset KoReader read progress",
BookLoreUser.UserPermissions::isCanBulkResetKoReaderReadProgress,
BookLoreUser.UserPermissions::setCanBulkResetKoReaderReadProgress,
UserUpdateRequest.Permissions::isCanBulkResetKoReaderReadProgress,
UserPermissionsEntity::isPermissionBulkResetKoReaderReadProgress,
UserPermissionsEntity::setPermissionBulkResetKoReaderReadProgress
),
CAN_BULK_RESET_BOOK_READ_STATUS(
"Bulk reset book read status",
BookLoreUser.UserPermissions::isCanBulkResetBookReadStatus,
BookLoreUser.UserPermissions::setCanBulkResetBookReadStatus,
UserUpdateRequest.Permissions::isCanBulkResetBookReadStatus,
UserPermissionsEntity::isPermissionBulkResetBookReadStatus,
UserPermissionsEntity::setPermissionBulkResetBookReadStatus
);
private final String description;
private final Predicate<BookLoreUser.UserPermissions> dtoGetter;
private final BiConsumer<BookLoreUser.UserPermissions, Boolean> dtoSetter;
private final Function<UserUpdateRequest.Permissions, Boolean> requestGetter;
private final Predicate<UserPermissionsEntity> entityGetter;
private final BiConsumer<UserPermissionsEntity, Boolean> entitySetter;
UserPermission(
String description,
Predicate<BookLoreUser.UserPermissions> dtoGetter,
BiConsumer<BookLoreUser.UserPermissions, Boolean> dtoSetter,
Function<UserUpdateRequest.Permissions, Boolean> requestGetter,
Predicate<UserPermissionsEntity> entityGetter,
BiConsumer<UserPermissionsEntity, Boolean> entitySetter
) {
this.description = description;
this.dtoGetter = dtoGetter;
this.dtoSetter = dtoSetter;
this.requestGetter = requestGetter;
this.entityGetter = entityGetter;
this.entitySetter = entitySetter;
}
public boolean isGranted(BookLoreUser.UserPermissions permissions) {
return permissions != null && dtoGetter.test(permissions);
}
public void setInDto(BookLoreUser.UserPermissions dto, boolean value) {
if (dto != null) {
dtoSetter.accept(dto, value);
}
}
public boolean getFromEntity(UserPermissionsEntity entity) {
return entity != null && entityGetter.test(entity);
}
public void setInEntity(UserPermissionsEntity entity, boolean value) {
if (entity != null) {
entitySetter.accept(entity, value);
}
}
public boolean getFromRequest(UserUpdateRequest.Permissions request) {
return request != null && requestGetter.apply(request);
}
public static void copyFromEntityToDto(UserPermissionsEntity source, BookLoreUser.UserPermissions target) {
if (source == null || target == null) return;
for (UserPermission permission : values()) {
permission.setInDto(target, permission.getFromEntity(source));
}
}
public static void copyFromRequestToEntity(UserUpdateRequest.Permissions source, UserPermissionsEntity target) {
if (source == null || target == null) return;
for (UserPermission permission : values()) {
permission.setInEntity(target, permission.getFromRequest(source));
}
}
}

View File

@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@Repository
public interface AuthorRepository extends JpaRepository<AuthorEntity, Long> {

View File

@ -2,8 +2,8 @@ package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.LibraryPathEntity;
import com.adityachandel.booklore.model.enums.BookFileType;
import jakarta.transaction.Transactional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
@ -21,6 +21,8 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
Optional<BookEntity> findByCurrentHash(String currentHash);
Optional<BookEntity> findByBookCoverHash(String bookCoverHash);
@Query("SELECT b.id FROM BookEntity b WHERE b.library.id = :libraryId AND (b.deleted IS NULL OR b.deleted = false)")
Set<Long> findBookIdsByLibraryId(@Param("libraryId") long libraryId);
@ -89,22 +91,6 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
""")
List<BookEntity> findBooksWithMetadataAndAuthors(@Param("bookIds") List<Long> bookIds);
@Query(value = """
SELECT DISTINCT b FROM BookEntity b
LEFT JOIN b.metadata m
WHERE (b.deleted IS NULL OR b.deleted = false) AND (
m.searchText LIKE CONCAT('%', :text, '%')
)
""",
countQuery = """
SELECT COUNT(DISTINCT b.id) FROM BookEntity b
LEFT JOIN b.metadata m
WHERE (b.deleted IS NULL OR b.deleted = false) AND (
m.searchText LIKE CONCAT('%', :text, '%')
)
""")
Page<BookEntity> searchByMetadata(@Param("text") String text, Pageable pageable);
@Modifying
@Transactional
@Query("DELETE FROM BookEntity b WHERE b.deleted IS TRUE")
@ -135,17 +121,26 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
@Param("libraryPath") LibraryPathEntity libraryPath);
@Query(value = """
SELECT *
FROM book
WHERE library_id = :libraryId
AND library_path_id = :libraryPathId
AND file_sub_path = :fileSubPath
AND file_name = :fileName
LIMIT 1
""", nativeQuery = true)
SELECT *
FROM book
WHERE library_id = :libraryId
AND library_path_id = :libraryPathId
AND file_sub_path = :fileSubPath
AND file_name = :fileName
LIMIT 1
""", nativeQuery = true)
Optional<BookEntity> findByLibraryIdAndLibraryPathIdAndFileSubPathAndFileName(
@Param("libraryId") Long libraryId,
@Param("libraryPathId") Long libraryPathId,
@Param("fileSubPath") String fileSubPath,
@Param("fileName") String fileName);
@Query("SELECT COUNT(b.id) FROM BookEntity b WHERE b.id IN :bookIds AND (b.deleted IS NULL OR b.deleted = false)")
long countByIdIn(@Param("bookIds") List<Long> bookIds);
@Query("SELECT COUNT(b) FROM BookEntity b WHERE b.bookType = :type AND (b.deleted IS NULL OR b.deleted = false)")
long countByBookType(@Param("type") BookFileType type);
@Query("SELECT COUNT(b) FROM BookEntity b WHERE b.library.id = :libraryId AND (b.deleted IS NULL OR b.deleted = false)")
long countByLibraryId(@Param("libraryId") Long libraryId);
}

View File

@ -5,9 +5,6 @@ import com.adityachandel.booklore.model.entity.BookShelfMapping;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Set;
@Repository
public interface BookShelfMappingRepository extends JpaRepository<BookShelfMapping, BookShelfKey> {
}

View File

@ -1,7 +1,6 @@
package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.CbxViewerPreferencesEntity;
import com.adityachandel.booklore.model.entity.PdfViewerPreferencesEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

View File

@ -0,0 +1,19 @@
package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.CustomFontEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface CustomFontRepository extends JpaRepository<CustomFontEntity, Long> {
List<CustomFontEntity> findByUserId(Long userId);
int countByUserId(Long userId);
Optional<CustomFontEntity> findByIdAndUserId(Long id, Long userId);
}

View File

@ -22,15 +22,15 @@ public interface KoboSnapshotBookRepository extends JpaRepository<KoboSnapshotBo
void markBooksSynced(@Param("snapshotId") String snapshotId, @Param("bookIds") List<Long> bookIds);
@Query("""
SELECT curr
FROM KoboSnapshotBookEntity curr
WHERE curr.snapshot.id = :currSnapshotId
AND curr.bookId IN (
SELECT prev.bookId
FROM KoboSnapshotBookEntity prev
WHERE prev.snapshot.id = :prevSnapshotId
)
""")
SELECT curr
FROM KoboSnapshotBookEntity curr
WHERE curr.snapshot.id = :currSnapshotId
AND curr.bookId IN (
SELECT prev.bookId
FROM KoboSnapshotBookEntity prev
WHERE prev.snapshot.id = :prevSnapshotId
)
""")
List<KoboSnapshotBookEntity> findExistingBooksBetweenSnapshots(
@Param("prevSnapshotId") String prevSnapshotId,
@Param("currSnapshotId") String currSnapshotId
@ -55,23 +55,59 @@ public interface KoboSnapshotBookRepository extends JpaRepository<KoboSnapshotBo
);
@Query("""
SELECT prev
FROM KoboSnapshotBookEntity prev
WHERE prev.snapshot.id = :prevSnapshotId
AND prev.bookId NOT IN (
SELECT curr.bookId
FROM KoboSnapshotBookEntity curr
WHERE curr.snapshot.id = :currSnapshotId
)
AND prev.bookId NOT IN (
SELECT p.bookIdSynced
FROM KoboDeletedBookProgressEntity p
WHERE p.snapshotId = :currSnapshotId
)
""")
SELECT prev
FROM KoboSnapshotBookEntity prev
WHERE prev.snapshot.id = :prevSnapshotId
AND prev.bookId NOT IN (
SELECT curr.bookId
FROM KoboSnapshotBookEntity curr
WHERE curr.snapshot.id = :currSnapshotId
)
AND prev.bookId NOT IN (
SELECT p.bookIdSynced
FROM KoboDeletedBookProgressEntity p
WHERE p.snapshotId = :currSnapshotId
)
""")
Page<KoboSnapshotBookEntity> findRemovedBooks(
@Param("prevSnapshotId") String prevSnapshotId,
@Param("currSnapshotId") String currSnapshotId,
Pageable pageable
);
@Query("""
SELECT curr
FROM KoboSnapshotBookEntity curr
JOIN KoboSnapshotBookEntity prev
ON curr.bookId = prev.bookId
WHERE curr.snapshot.id = :currSnapshotId
AND prev.snapshot.id = :prevSnapshotId
AND curr.fileHash = prev.fileHash
AND (curr.metadataUpdatedAt = prev.metadataUpdatedAt OR (curr.metadataUpdatedAt IS NULL AND prev.metadataUpdatedAt IS NULL))
""")
List<KoboSnapshotBookEntity> findUnchangedBooksBetweenSnapshots(
@Param("prevSnapshotId") String prevSnapshotId,
@Param("currSnapshotId") String currSnapshotId
);
@Query("""
SELECT curr
FROM KoboSnapshotBookEntity curr
JOIN KoboSnapshotBookEntity prev
ON curr.bookId = prev.bookId
WHERE curr.snapshot.id = :currSnapshotId
AND prev.snapshot.id = :prevSnapshotId
AND curr.synced = false
AND (
curr.fileHash <> prev.fileHash
OR (curr.metadataUpdatedAt <> prev.metadataUpdatedAt AND curr.metadataUpdatedAt IS NOT NULL AND prev.metadataUpdatedAt IS NOT NULL)
OR (curr.metadataUpdatedAt IS NOT NULL AND prev.metadataUpdatedAt IS NULL)
)
""")
Page<KoboSnapshotBookEntity> findChangedBooks(
@Param("prevSnapshotId") String prevSnapshotId,
@Param("currSnapshotId") String currSnapshotId,
Pageable pageable
);
}

View File

@ -15,4 +15,8 @@ public interface KoboUserSettingsRepository extends JpaRepository<KoboUserSettin
Optional<KoboUserSettingsEntity> findByToken(String token);
List<KoboUserSettingsEntity> findByAutoAddToShelfTrueAndSyncEnabledTrue();
long countByHardcoverSyncEnabledTrue();
long countByAutoAddToShelfTrue();
}

View File

@ -3,8 +3,6 @@ package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.LibraryEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;

View File

@ -1,6 +1,5 @@
package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.CbxViewerPreferencesEntity;
import com.adityachandel.booklore.model.entity.NewPdfViewerPreferencesEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

View File

@ -9,6 +9,7 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.List;
@Repository
@ -25,7 +26,7 @@ public interface ReadingSessionRepository extends JpaRepository<ReadingSessionEn
List<ReadingSessionCountDto> findSessionCountsByUserAndYear(@Param("userId") Long userId, @Param("year") int year);
@Query("""
SELECT
SELECT
b.id as bookId,
b.metadata.title as bookTitle,
rs.bookType as bookFileType,
@ -36,20 +37,17 @@ public interface ReadingSessionRepository extends JpaRepository<ReadingSessionEn
FROM ReadingSessionEntity rs
JOIN rs.book b
WHERE rs.user.id = :userId
AND YEAR(rs.startTime) = :year
AND MONTH(rs.startTime) = :month
AND WEEK(rs.startTime) = :week
AND rs.startTime >= :startOfWeek AND rs.startTime < :endOfWeek
GROUP BY b.id, b.metadata.title, rs.bookType
ORDER BY MIN(rs.startTime)
""")
List<ReadingSessionTimelineDto> findSessionTimelineByUserAndWeek(
@Param("userId") Long userId,
@Param("year") int year,
@Param("month") int month,
@Param("week") int week);
@Param("startOfWeek") Instant startOfWeek,
@Param("endOfWeek") Instant endOfWeek);
@Query("""
SELECT
SELECT
CAST(rs.createdAt AS LocalDate) as date,
AVG(rs.progressDelta / (rs.durationSeconds / 60.0)) as avgProgressPerMinute,
COUNT(rs) as totalSessions
@ -64,7 +62,7 @@ public interface ReadingSessionRepository extends JpaRepository<ReadingSessionEn
List<ReadingSpeedDto> findReadingSpeedByUserAndYear(@Param("userId") Long userId, @Param("year") int year);
@Query("""
SELECT
SELECT
HOUR(rs.startTime) as hourOfDay,
COUNT(rs) as sessionCount,
SUM(rs.durationSeconds) as totalDurationSeconds
@ -81,7 +79,7 @@ public interface ReadingSessionRepository extends JpaRepository<ReadingSessionEn
@Param("month") Integer month);
@Query("""
SELECT
SELECT
DAYOFWEEK(rs.startTime) as dayOfWeek,
COUNT(rs) as sessionCount,
SUM(rs.durationSeconds) as totalDurationSeconds
@ -98,7 +96,7 @@ public interface ReadingSessionRepository extends JpaRepository<ReadingSessionEn
@Param("month") Integer month);
@Query("""
SELECT
SELECT
c.name as genre,
COUNT(DISTINCT b.id) as bookCount,
COUNT(rs) as totalSessions,

View File

@ -1,14 +1,11 @@
package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.ShelfEntity;
import jakarta.validation.constraints.NotBlank;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@Repository
public interface ShelfRepository extends JpaRepository<ShelfEntity, Long> {

View File

@ -3,6 +3,7 @@ package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.dto.CompletionTimelineDto;
import com.adityachandel.booklore.model.entity.UserBookProgressEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@ -43,7 +44,7 @@ public interface UserBookProgressRepository extends JpaRepository<UserBookProgre
);
@Query("""
SELECT
SELECT
YEAR(COALESCE(ubp.dateFinished, ubp.readStatusModifiedTime, ubp.lastReadTime)) as year,
MONTH(COALESCE(ubp.dateFinished, ubp.readStatusModifiedTime, ubp.lastReadTime)) as month,
ubp.readStatus as readStatus,
@ -60,4 +61,81 @@ public interface UserBookProgressRepository extends JpaRepository<UserBookProgre
ORDER BY year DESC, month DESC
""")
List<CompletionTimelineDto> findCompletionTimelineByUser(@Param("userId") Long userId, @Param("year") int year);
@Modifying
@Query("""
UPDATE UserBookProgressEntity ubp
SET ubp.readStatus = :readStatus,
ubp.readStatusModifiedTime = :modifiedTime,
ubp.dateFinished = :dateFinished
WHERE ubp.user.id = :userId
AND ubp.book.id IN :bookIds
""")
int bulkUpdateReadStatus(
@Param("userId") Long userId,
@Param("bookIds") List<Long> bookIds,
@Param("readStatus") com.adityachandel.booklore.model.enums.ReadStatus readStatus,
@Param("modifiedTime") java.time.Instant modifiedTime,
@Param("dateFinished") java.time.Instant dateFinished
);
@Query("""
SELECT ubp.book.id FROM UserBookProgressEntity ubp
WHERE ubp.user.id = :userId
AND ubp.book.id IN :bookIds
""")
Set<Long> findExistingProgressBookIds(@Param("userId") Long userId, @Param("bookIds") Set<Long> bookIds);
@Modifying
@Query("""
UPDATE UserBookProgressEntity ubp
SET ubp.readStatus = NULL,
ubp.readStatusModifiedTime = :modifiedTime,
ubp.lastReadTime = NULL,
ubp.dateFinished = NULL,
ubp.pdfProgress = NULL,
ubp.pdfProgressPercent = NULL,
ubp.epubProgress = NULL,
ubp.epubProgressPercent = NULL,
ubp.cbxProgress = NULL,
ubp.cbxProgressPercent = NULL
WHERE ubp.user.id = :userId
AND ubp.book.id IN :bookIds
""")
int bulkResetBookloreProgress(@Param("userId") Long userId, @Param("bookIds") List<Long> bookIds, @Param("modifiedTime") java.time.Instant modifiedTime);
@Modifying
@Query("""
UPDATE UserBookProgressEntity ubp
SET ubp.koreaderProgress = NULL,
ubp.koreaderProgressPercent = NULL,
ubp.koreaderDeviceId = NULL,
ubp.koreaderDevice = NULL,
ubp.koreaderLastSyncTime = NULL
WHERE ubp.user.id = :userId
AND ubp.book.id IN :bookIds
""")
int bulkResetKoreaderProgress(@Param("userId") Long userId, @Param("bookIds") List<Long> bookIds);
@Modifying
@Query("""
UPDATE UserBookProgressEntity ubp
SET ubp.koboProgressPercent = NULL,
ubp.koboLocation = NULL,
ubp.koboLocationType = NULL,
ubp.koboLocationSource = NULL,
ubp.koboProgressReceivedTime = NULL
WHERE ubp.user.id = :userId
AND ubp.book.id IN :bookIds
""")
int bulkResetKoboProgress(@Param("userId") Long userId, @Param("bookIds") List<Long> bookIds);
@Modifying
@Query("""
UPDATE UserBookProgressEntity ubp
SET ubp.personalRating = :rating
WHERE ubp.user.id = :userId
AND ubp.book.id IN :bookIds
""")
int bulkUpdatePersonalRating(@Param("userId") Long userId, @Param("bookIds") List<Long> bookIds, @Param("rating") Integer rating);
}

View File

@ -1,10 +1,11 @@
package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
import com.adityachandel.booklore.model.enums.ProvisioningMethod;
import org.jspecify.annotations.NonNull;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
@ -14,7 +15,8 @@ public interface UserRepository extends JpaRepository<BookLoreUserEntity, Long>
Optional<BookLoreUserEntity> findByEmail(String email);
Optional<BookLoreUserEntity> findById(Long id);
Optional<BookLoreUserEntity> findById(@NonNull Long id);
List<BookLoreUserEntity> findAllByLibraries_Id(Long libraryId);
long countByProvisioningMethod(ProvisioningMethod provisioningMethod);
}

View File

@ -1,12 +0,0 @@
package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.UserSettingEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface UserSettingRepository extends JpaRepository<UserSettingEntity, Long> {
Optional<UserSettingEntity> findByUserIdAndSettingKey(Long userId, String settingKey);
List<UserSettingEntity> findByUserId(Long userId);
}

View File

@ -338,7 +338,8 @@ public class BookRuleEvaluatorService {
private boolean isArrayField(RuleField field) {
return field == RuleField.AUTHORS || field == RuleField.CATEGORIES ||
field == RuleField.MOODS || field == RuleField.TAGS;
field == RuleField.MOODS || field == RuleField.TAGS ||
field == RuleField.GENRE;
}
private Join<?, ?> createArrayFieldJoin(RuleField field, Root<BookEntity> root) {
@ -356,6 +357,7 @@ public class BookRuleEvaluatorService {
case CATEGORIES -> metadataJoin.join("categories", JoinType.INNER);
case MOODS -> metadataJoin.join("moods", JoinType.INNER);
case TAGS -> metadataJoin.join("tags", JoinType.INNER);
case GENRE -> metadataJoin.join("categories", JoinType.INNER);
default -> throw new IllegalArgumentException("Not an array field: " + field);
};
}

View File

@ -296,8 +296,6 @@ public class IconService {
log.warn("Failed to read icon: {}", path.getFileName(), e);
}
});
log.info("Retrieved {} icons for bulk content request", iconMap.size());
return iconMap;
} catch (IOException e) {
log.error("Failed to read icons directory: {}", e.getMessage(), e);

View File

@ -0,0 +1,95 @@
package com.adityachandel.booklore.service;
import com.adityachandel.booklore.model.dto.Installation;
import com.adityachandel.booklore.model.entity.AppSettingEntity;
import com.adityachandel.booklore.repository.AppSettingsRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.UUID;
@Service
@Slf4j
public class InstallationService {
private static final String INSTALLATION_ID_KEY = "installation_id";
private final AppSettingsRepository appSettingsRepository;
private final ObjectMapper objectMapper;
public InstallationService(AppSettingsRepository appSettingsRepository, ObjectMapper objectMapper) {
this.appSettingsRepository = appSettingsRepository;
this.objectMapper = objectMapper.copy();
this.objectMapper.registerModule(new JavaTimeModule());
this.objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}
public Installation getOrCreateInstallation() {
AppSettingEntity setting = appSettingsRepository.findByName(INSTALLATION_ID_KEY);
if (setting == null) {
return createNewInstallation();
}
try {
return objectMapper.readValue(setting.getVal(), Installation.class);
} catch (Exception e) {
log.warn("Failed to parse installation ID, creating new one", e);
return createNewInstallation();
}
}
private Installation createNewInstallation() {
Instant now = Instant.now();
String uuid = UUID.randomUUID().toString();
String combined = now.toString() + "_" + uuid;
String installationId = hashToSha256(combined).substring(0, 24);
Installation installation = new Installation(installationId, now);
saveInstallation(installation);
log.info("Generated new installation ID");
return installation;
}
private void saveInstallation(Installation installation) {
try {
String json = objectMapper.writeValueAsString(installation);
AppSettingEntity setting = appSettingsRepository.findByName(INSTALLATION_ID_KEY);
if (setting == null) {
setting = new AppSettingEntity();
setting.setName(INSTALLATION_ID_KEY);
}
setting.setVal(json);
appSettingsRepository.save(setting);
} catch (Exception e) {
throw new RuntimeException("Failed to save installation ID", e);
}
}
private String hashToSha256(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return hexString.toString();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 algorithm not found", e);
}
}
}

View File

@ -29,6 +29,13 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.temporal.TemporalAdjusters;
import java.time.temporal.WeekFields;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -87,11 +94,16 @@ public class ReadingSessionService {
}
@Transactional(readOnly = true)
public List<ReadingSessionTimelineResponse> getSessionTimelineForWeek(int year, int month, int week) {
public List<ReadingSessionTimelineResponse> getSessionTimelineForWeek(int year, int week) {
BookLoreUser authenticatedUser = authenticationService.getAuthenticatedUser();
Long userId = authenticatedUser.getId();
return readingSessionRepository.findSessionTimelineByUserAndWeek(userId, year, month, week)
LocalDate date = LocalDate.of(year, 1, 1)
.with(WeekFields.of(DayOfWeek.MONDAY, 1).weekOfYear(), week);
LocalDateTime startOfWeek = date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay();
LocalDateTime endOfWeek = date.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).plusDays(1).atStartOfDay();
return readingSessionRepository.findSessionTimelineByUserAndWeek(userId, startOfWeek.atZone(ZoneId.systemDefault()).toInstant(), endOfWeek.atZone(ZoneId.systemDefault()).toInstant())
.stream()
.map(dto -> ReadingSessionTimelineResponse.builder()
.bookId(dto.getBookId())
@ -180,11 +192,11 @@ public class ReadingSessionService {
public List<CompletionTimelineResponse> getCompletionTimeline(int year) {
BookLoreUser authenticatedUser = authenticationService.getAuthenticatedUser();
Long userId = authenticatedUser.getId();
Map<String, Map<ReadStatus, Long>> timelineMap = new HashMap<>();
Map<String, EnumMap<ReadStatus, Long>> timelineMap = new HashMap<>();
userBookProgressRepository.findCompletionTimelineByUser(userId, year).forEach(dto -> {
String key = dto.getYear() + "-" + dto.getMonth();
timelineMap.computeIfAbsent(key, k -> new HashMap<>())
timelineMap.computeIfAbsent(key, k -> new EnumMap<>(ReadStatus.class))
.put(dto.getReadStatus(), dto.getBookCount());
});

View File

@ -0,0 +1,178 @@
package com.adityachandel.booklore.service;
import com.adityachandel.booklore.model.dto.BookloreTelemetry;
import com.adityachandel.booklore.model.dto.Installation;
import com.adityachandel.booklore.model.dto.InstallationPing;
import com.adityachandel.booklore.model.dto.settings.AppSettings;
import com.adityachandel.booklore.model.dto.settings.MetadataProviderSettings;
import com.adityachandel.booklore.model.dto.settings.MetadataPublicReviewsSettings;
import com.adityachandel.booklore.model.entity.LibraryEntity;
import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.model.enums.MetadataProvider;
import com.adityachandel.booklore.model.enums.ProvisioningMethod;
import com.adityachandel.booklore.repository.*;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@AllArgsConstructor
public class TelemetryService {
private final VersionService versionService;
private final LibraryRepository libraryRepository;
private final BookRepository bookRepository;
private final BookMarkRepository bookMarkRepository;
private final BookNoteRepository bookNoteRepository;
private final BookAdditionalFileRepository bookAdditionalFileRepository;
private final AuthorRepository authorRepository;
private final ShelfRepository shelfRepository;
private final MagicShelfRepository magicShelfRepository;
private final CategoryRepository categoryRepository;
private final TagRepository tagRepository;
private final MoodRepository moodRepository;
private final UserRepository userRepository;
private final EmailProviderV2Repository emailProviderV2Repository;
private final EmailRecipientV2Repository emailRecipientV2Repository;
private final AppSettingService appSettingService;
private final KoboUserSettingsRepository koboUserSettingsRepository;
private final KoreaderUserRepository koreaderUserRepository;
private final OpdsUserV2Repository opdsUserV2Repository;
private final InstallationService installationService;
public InstallationPing getInstallationPing() {
Installation installation = installationService.getOrCreateInstallation();
return InstallationPing.builder()
.pingVersion(1)
.appVersion(versionService.appVersion)
.installationId(installation.getId())
.installationDate(installation.getDate())
.build();
}
public BookloreTelemetry collectTelemetry() {
long totalUsers = userRepository.count();
long localUsers = userRepository.countByProvisioningMethod(ProvisioningMethod.LOCAL);
long oidcUsers = userRepository.countByProvisioningMethod(ProvisioningMethod.OIDC);
AppSettings settings = appSettingService.getAppSettings();
BookloreTelemetry.BookStatistics bookStatistics = BookloreTelemetry.BookStatistics.builder()
.totalBooks(bookRepository.count())
.bookCountByType(getBookFileTypeCounts())
.build();
List<BookloreTelemetry.LibraryStatistics> libraryStatisticsList = libraryRepository.findAll().stream()
.map(this::mapLibraryStatistics)
.collect(Collectors.toList());
String[] enabledMetadataProviders = getEnabledMetadataProviders(settings.getMetadataProviderSettings());
String[] enabledReviewMetadataProviders = getEnabledReviewMetadataProviders(settings.getMetadataPublicReviewsSettings());
Installation installation = installationService.getOrCreateInstallation();
return BookloreTelemetry.builder()
.telemetryVersion(2)
.installationId(installation.getId())
.installationDate(installation.getDate() != null ? installation.getDate().toString() : null)
.appVersion(versionService.appVersion)
.totalLibraries((int) libraryRepository.count())
.totalBooks(bookRepository.count())
.totalAdditionalBookFiles(bookAdditionalFileRepository.count())
.totalAuthors(authorRepository.count())
.totalBookmarks(bookMarkRepository.count())
.totalBookNotes(bookNoteRepository.count())
.totalShelves((int) shelfRepository.count())
.totalMagicShelves((int) magicShelfRepository.count())
.totalCategories((int) categoryRepository.count())
.totalTags((int) tagRepository.count())
.totalMoods((int) moodRepository.count())
.totalKoreaderUsers((int) koreaderUserRepository.count())
.userStatistics(BookloreTelemetry.UserStatistics.builder()
.totalUsers((int) totalUsers)
.totalLocalUsers((int) localUsers)
.totalOidcUsers((int) oidcUsers)
.oidcEnabled(oidcUsers > 0)
.build())
.metadataStatistics(BookloreTelemetry.MetadataStatistics.builder()
.enabledMetadataProviders(enabledMetadataProviders)
.enabledReviewMetadataProviders(enabledReviewMetadataProviders)
.saveMetadataToFile(settings.getMetadataPersistenceSettings().isSaveToOriginalFile())
.moveFileViaPattern(settings.getMetadataPersistenceSettings().isMoveFilesToLibraryPattern())
.autoBookSearchEnabled(settings.isAutoBookSearch())
.similarBookRecommendationsEnabled(settings.isSimilarBookRecommendation())
.metadataDownloadOnBookdropEnabled(settings.isMetadataDownloadOnBookdrop())
.build())
.opdsStatistics(BookloreTelemetry.OpdsStatistics.builder()
.opdsEnabled(settings.isOpdsServerEnabled())
.totalOpdsUsers((int) opdsUserV2Repository.count())
.build())
.emailStatistics(BookloreTelemetry.EmailStatistics.builder()
.totalEmailProviders((int) emailProviderV2Repository.count())
.totalEmailRecipients((int) emailRecipientV2Repository.count())
.build())
.koboStatistics(BookloreTelemetry.KoboStatistics.builder()
.convertToKepubEnabled(settings.getKoboSettings().isConvertToKepub())
.totalKoboUsers((int) koboUserSettingsRepository.count())
.totalHardcoverSyncEnabled((int) koboUserSettingsRepository.countByHardcoverSyncEnabledTrue())
.totalAutoAddToShelf((int) koboUserSettingsRepository.countByAutoAddToShelfTrue())
.build())
.bookStatistics(bookStatistics)
.libraryStatisticsList(libraryStatisticsList)
.build();
}
private Map<String, Long> getBookFileTypeCounts() {
Map<String, Long> countByType = new HashMap<>();
for (BookFileType type : BookFileType.values()) {
countByType.put(type.name(), bookRepository.countByBookType(type));
}
return countByType;
}
private BookloreTelemetry.LibraryStatistics mapLibraryStatistics(LibraryEntity lib) {
return BookloreTelemetry.LibraryStatistics.builder()
.totalLibraryPaths(lib.getLibraryPaths() != null ? lib.getLibraryPaths().size() : 0)
.bookCount(bookRepository.countByLibraryId(lib.getId()))
.watchEnabled(lib.isWatch())
.iconType(lib.getIconType() != null ? lib.getIconType().name() : null)
.scanMode(lib.getScanMode() != null ? lib.getScanMode().name() : null)
.build();
}
private String[] getEnabledMetadataProviders(MetadataProviderSettings providers) {
List<String> enabled = new ArrayList<>();
if (providers.getAmazon() != null && providers.getAmazon().isEnabled())
enabled.add(MetadataProvider.Amazon.name());
if (providers.getGoogle() != null && providers.getGoogle().isEnabled())
enabled.add(MetadataProvider.Google.name());
if (providers.getGoodReads() != null && providers.getGoodReads().isEnabled())
enabled.add(MetadataProvider.GoodReads.name());
if (providers.getHardcover() != null && providers.getHardcover().isEnabled())
enabled.add(MetadataProvider.Hardcover.name());
if (providers.getComicvine() != null && providers.getComicvine().isEnabled())
enabled.add(MetadataProvider.Comicvine.name());
if (providers.getDouban() != null && providers.getDouban().isEnabled())
enabled.add(MetadataProvider.Douban.name());
if (providers.getLubimyczytac() != null && providers.getLubimyczytac().isEnabled())
enabled.add(MetadataProvider.Lubimyczytac.name());
return enabled.toArray(new String[0]);
}
private String[] getEnabledReviewMetadataProviders(MetadataPublicReviewsSettings reviewSettings) {
List<String> enabled = new ArrayList<>();
if (reviewSettings.getProviders() != null) {
reviewSettings.getProviders().stream()
.filter(MetadataPublicReviewsSettings.ReviewProviderConfig::isEnabled)
.forEach(cfg -> enabled.add(cfg.getProvider().name()));
}
return enabled.toArray(new String[0]);
}
}

View File

@ -73,7 +73,7 @@ public class AppSettingService {
}
boolean hasPermission = requiredPermissions.stream().anyMatch(permission ->
UserPermissionUtils.hasPermission(user.getPermissions(), permission)
UserPermissionUtils.hasPermission(user.getPermissions(), permission)
);
if (!hasPermission) {
@ -95,7 +95,9 @@ public class AppSettingService {
}
private Map<String, String> getSettingsMap() {
return settingPersistenceHelper.appSettingsRepository.findAll().stream().collect(Collectors.toMap(AppSettingEntity::getName, AppSettingEntity::getVal));
return settingPersistenceHelper.appSettingsRepository.findAll().stream()
.filter(entity -> entity.getName() != null && entity.getVal() != null)
.collect(Collectors.toMap(AppSettingEntity::getName, AppSettingEntity::getVal));
}
private PublicAppSetting buildPublicSetting() {
@ -131,6 +133,7 @@ public class AppSettingService {
builder.uploadPattern(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.UPLOAD_FILE_PATTERN, "{authors}/<{series}/><{seriesIndex}. >{title}< - {authors}>< ({year})>"));
builder.similarBookRecommendation(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.SIMILAR_BOOK_RECOMMENDATION, "true")));
builder.opdsServerEnabled(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.OPDS_SERVER_ENABLED, "false")));
builder.telemetryEnabled(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.TELEMETRY_ENABLED, "true")));
builder.cbxCacheSizeInMb(Integer.parseInt(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.CBX_CACHE_SIZE_IN_MB, "5120")));
builder.pdfCacheSizeInMb(Integer.parseInt(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.PDF_CACHE_SIZE_IN_MB, "5120")));
builder.maxFileUploadSizeInMb(Integer.parseInt(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.MAX_FILE_UPLOAD_SIZE_IN_MB, "100")));
@ -143,4 +146,20 @@ public class AppSettingService {
return builder.build();
}
public String getSettingValue(String key) {
var setting = settingPersistenceHelper.appSettingsRepository.findByName(key);
return setting != null ? setting.getVal() : null;
}
@Transactional
public void saveSetting(String key, String value) {
var setting = settingPersistenceHelper.appSettingsRepository.findByName(key);
if (setting == null) {
setting = new AppSettingEntity();
setting.setName(key);
}
setting.setVal(value);
settingPersistenceHelper.appSettingsRepository.save(setting);
}
}

View File

@ -8,7 +8,6 @@ import com.adityachandel.booklore.service.file.FileFingerprint;
import com.adityachandel.booklore.util.FileUtils;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import java.time.Instant;

View File

@ -12,7 +12,7 @@ import com.adityachandel.booklore.util.FileUtils;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
@ -21,7 +21,6 @@ import org.springframework.stereotype.Service;
import org.springframework.util.FileSystemUtils;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
@ -56,8 +55,8 @@ public class BookDownloadService {
throw ApiError.FAILED_TO_DOWNLOAD_FILE.createException(bookId);
}
InputStream inputStream = new FileInputStream(bookFile);
InputStreamResource resource = new InputStreamResource(inputStream);
// Use FileSystemResource which properly handles file resources and closing
Resource resource = new FileSystemResource(bookFile);
String encodedFilename = URLEncoder.encode(file.getFileName().toString(), StandardCharsets.UTF_8)
.replace("+", "%20");

View File

@ -5,7 +5,6 @@ import com.adityachandel.booklore.model.dto.Book;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.repository.BookRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.*;
import org.springframework.stereotype.Service;
import java.util.List;
@ -33,11 +32,6 @@ public class BookQueryService {
return bookRepository.findAllWithMetadataByIds(bookIds);
}
public List<BookEntity> findWithMetadataByIdsWithPagination(Set<Long> bookIds, int offset, int limit) {
Pageable pageable = PageRequest.of(offset / limit, limit);
return bookRepository.findWithMetadataByIdsWithPagination(bookIds, pageable);
}
public List<BookEntity> getAllFullBookEntities() {
return bookRepository.findAllFullBooks();
}

View File

@ -4,31 +4,29 @@ import com.adityachandel.booklore.config.security.service.AuthenticationService;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.mapper.BookMapper;
import com.adityachandel.booklore.model.dto.*;
import com.adityachandel.booklore.model.dto.progress.CbxProgress;
import com.adityachandel.booklore.model.dto.progress.EpubProgress;
import com.adityachandel.booklore.model.dto.progress.KoProgress;
import com.adityachandel.booklore.model.dto.progress.KoboProgress;
import com.adityachandel.booklore.model.dto.progress.PdfProgress;
import com.adityachandel.booklore.model.dto.progress.*;
import com.adityachandel.booklore.model.dto.request.ReadProgressRequest;
import com.adityachandel.booklore.model.dto.response.BookDeletionResponse;
import com.adityachandel.booklore.model.entity.*;
import com.adityachandel.booklore.model.dto.response.BookStatusUpdateResponse;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.LibraryPathEntity;
import com.adityachandel.booklore.model.entity.UserBookProgressEntity;
import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.model.enums.ReadStatus;
import com.adityachandel.booklore.model.enums.ResetProgressType;
import com.adityachandel.booklore.repository.*;
import com.adityachandel.booklore.service.kobo.KoboReadingStateService;
import com.adityachandel.booklore.service.user.UserProgressService;
import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService;
import com.adityachandel.booklore.service.user.UserProgressService;
import com.adityachandel.booklore.util.FileService;
import com.adityachandel.booklore.util.FileUtils;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.EnumUtils;
import org.springframework.core.io.*;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -39,7 +37,6 @@ import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
@ -53,17 +50,15 @@ public class BookService {
private final EpubViewerPreferencesRepository epubViewerPreferencesRepository;
private final CbxViewerPreferencesRepository cbxViewerPreferencesRepository;
private final NewPdfViewerPreferencesRepository newPdfViewerPreferencesRepository;
private final ShelfRepository shelfRepository;
private final FileService fileService;
private final BookMapper bookMapper;
private final UserRepository userRepository;
private final UserBookProgressRepository userBookProgressRepository;
private final AuthenticationService authenticationService;
private final BookQueryService bookQueryService;
private final UserProgressService userProgressService;
private final BookDownloadService bookDownloadService;
private final MonitoringRegistrationService monitoringRegistrationService;
private final KoboReadingStateService koboReadingStateService;
private final BookUpdateService bookUpdateService;
private void setBookProgress(Book book, UserBookProgressEntity progress) {
@ -248,336 +243,22 @@ public class BookService {
}
public void updateBookViewerSetting(long bookId, BookViewerSettings bookViewerSettings) {
BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
BookLoreUser user = authenticationService.getAuthenticatedUser();
if (bookEntity.getBookType() == BookFileType.PDF) {
if (bookViewerSettings.getPdfSettings() != null) {
PdfViewerPreferencesEntity pdfPrefs = pdfViewerPreferencesRepository
.findByBookIdAndUserId(bookId, user.getId())
.orElseGet(() -> {
PdfViewerPreferencesEntity newPrefs = PdfViewerPreferencesEntity.builder()
.bookId(bookId)
.userId(user.getId())
.build();
return pdfViewerPreferencesRepository.save(newPrefs);
});
PdfViewerPreferences pdfSettings = bookViewerSettings.getPdfSettings();
pdfPrefs.setZoom(pdfSettings.getZoom());
pdfPrefs.setSpread(pdfSettings.getSpread());
pdfViewerPreferencesRepository.save(pdfPrefs);
}
if (bookViewerSettings.getNewPdfSettings() != null) {
NewPdfViewerPreferencesEntity pdfPrefs = newPdfViewerPreferencesRepository.findByBookIdAndUserId(bookId, user.getId())
.orElseGet(() -> {
NewPdfViewerPreferencesEntity entity = NewPdfViewerPreferencesEntity.builder()
.bookId(bookId)
.userId(user.getId())
.build();
return newPdfViewerPreferencesRepository.save(entity);
});
NewPdfViewerPreferences pdfSettings = bookViewerSettings.getNewPdfSettings();
pdfPrefs.setPageSpread(pdfSettings.getPageSpread());
pdfPrefs.setPageViewMode(pdfSettings.getPageViewMode());
newPdfViewerPreferencesRepository.save(pdfPrefs);
}
} else if (bookEntity.getBookType() == BookFileType.EPUB) {
EpubViewerPreferencesEntity epubPrefs = epubViewerPreferencesRepository
.findByBookIdAndUserId(bookId, user.getId())
.orElseGet(() -> {
EpubViewerPreferencesEntity newPrefs = EpubViewerPreferencesEntity.builder()
.bookId(bookId)
.userId(user.getId())
.build();
return epubViewerPreferencesRepository.save(newPrefs);
});
EpubViewerPreferences epubSettings = bookViewerSettings.getEpubSettings();
epubPrefs.setFont(epubSettings.getFont());
epubPrefs.setFontSize(epubSettings.getFontSize());
epubPrefs.setTheme(epubSettings.getTheme());
epubPrefs.setFlow(epubSettings.getFlow());
epubPrefs.setSpread(epubSettings.getSpread());
epubPrefs.setLetterSpacing(epubSettings.getLetterSpacing());
epubPrefs.setLineHeight(epubSettings.getLineHeight());
epubViewerPreferencesRepository.save(epubPrefs);
} else if (bookEntity.getBookType() == BookFileType.CBX) {
CbxViewerPreferencesEntity cbxPrefs = cbxViewerPreferencesRepository
.findByBookIdAndUserId(bookId, user.getId())
.orElseGet(() -> {
CbxViewerPreferencesEntity newPrefs = CbxViewerPreferencesEntity.builder()
.bookId(bookId)
.userId(user.getId())
.build();
return cbxViewerPreferencesRepository.save(newPrefs);
});
CbxViewerPreferences cbxSettings = bookViewerSettings.getCbxSettings();
cbxPrefs.setPageSpread(cbxSettings.getPageSpread());
cbxPrefs.setPageViewMode(cbxSettings.getPageViewMode());
cbxPrefs.setFitMode(cbxSettings.getFitMode());
cbxPrefs.setScrollMode(cbxSettings.getScrollMode());
cbxPrefs.setBackgroundColor(cbxSettings.getBackgroundColor());
cbxViewerPreferencesRepository.save(cbxPrefs);
} else {
throw ApiError.UNSUPPORTED_BOOK_TYPE.createException();
}
bookUpdateService.updateBookViewerSetting(bookId, bookViewerSettings);
}
@Transactional
public void updateReadProgress(ReadProgressRequest request) {
BookEntity book = bookRepository.findById(request.getBookId())
.orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(request.getBookId()));
BookLoreUser user = authenticationService.getAuthenticatedUser();
UserBookProgressEntity progress = userBookProgressRepository
.findByUserIdAndBookId(user.getId(), book.getId())
.orElseGet(UserBookProgressEntity::new);
BookLoreUserEntity userEntity = userRepository.findById(user.getId())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
progress.setUser(userEntity);
progress.setBook(book);
progress.setLastReadTime(Instant.now());
Float percentage = null;
switch (book.getBookType()) {
case EPUB -> {
if (request.getEpubProgress() != null) {
progress.setEpubProgress(request.getEpubProgress().getCfi());
percentage = request.getEpubProgress().getPercentage();
}
}
case PDF -> {
if (request.getPdfProgress() != null) {
progress.setPdfProgress(request.getPdfProgress().getPage());
percentage = request.getPdfProgress().getPercentage();
}
}
case CBX -> {
if (request.getCbxProgress() != null) {
progress.setCbxProgress(request.getCbxProgress().getPage());
percentage = request.getCbxProgress().getPercentage();
}
}
}
if (percentage != null) {
progress.setReadStatus(getStatus(percentage));
setProgressPercent(progress, book.getBookType(), percentage);
}
if (request.getDateFinished() != null) {
progress.setDateFinished(request.getDateFinished());
}
userBookProgressRepository.save(progress);
}
private void setProgressPercent(UserBookProgressEntity progress, BookFileType type, Float percentage) {
switch (type) {
case EPUB -> progress.setEpubProgressPercent(percentage);
case PDF -> progress.setPdfProgressPercent(percentage);
case CBX -> progress.setCbxProgressPercent(percentage);
}
}
private ReadStatus getStatus(Float percentage) {
if (percentage >= 99.5f) return ReadStatus.READ;
if (percentage > 0.5f) return ReadStatus.READING;
return ReadStatus.UNREAD;
bookUpdateService.updateReadProgress(request);
}
@Transactional
public List<Book> updateReadStatus(List<Long> bookIds, String status) {
BookLoreUser user = authenticationService.getAuthenticatedUser();
ReadStatus readStatus = EnumUtils.getEnumIgnoreCase(ReadStatus.class, status);
List<BookEntity> books = bookRepository.findAllById(bookIds);
if (books.size() != bookIds.size()) {
throw ApiError.BOOK_NOT_FOUND.createException("One or more books not found");
}
BookLoreUserEntity userEntity = userRepository.findById(user.getId()).orElseThrow(() -> new UsernameNotFoundException("User not found"));
for (BookEntity book : books) {
UserBookProgressEntity progress = userBookProgressRepository
.findByUserIdAndBookId(user.getId(), book.getId())
.orElse(new UserBookProgressEntity());
progress.setUser(userEntity);
progress.setBook(book);
progress.setReadStatus(readStatus);
progress.setReadStatusModifiedTime(Instant.now());
if (readStatus == ReadStatus.READ) {
progress.setDateFinished(Instant.now());
} else {
progress.setDateFinished(null);
}
userBookProgressRepository.save(progress);
}
return books.stream()
.map(bookEntity -> {
Book book = bookMapper.toBook(bookEntity);
book.setFilePath(FileUtils.getBookFullPath(bookEntity));
UserBookProgressEntity progress = userBookProgressRepository
.findByUserIdAndBookId(user.getId(), bookEntity.getId())
.orElse(null);
this.enrichBookWithProgress(book, progress);
return book;
})
.collect(Collectors.toList());
}
public List<Book> resetProgress(List<Long> bookIds, ResetProgressType type) {
BookLoreUser user = authenticationService.getAuthenticatedUser();
List<Book> updatedBooks = new ArrayList<>();
Optional<BookLoreUserEntity> userEntity = userRepository.findById(user.getId());
for (Long bookId : bookIds) {
BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
UserBookProgressEntity progress = userBookProgressRepository
.findByUserIdAndBookId(user.getId(), bookId)
.orElse(new UserBookProgressEntity());
progress.setBook(bookEntity);
progress.setUser(userEntity.orElseThrow());
if (progress.getReadStatus() != null) {
progress.setReadStatusModifiedTime(Instant.now());
}
progress.setReadStatus(null);
progress.setLastReadTime(null);
progress.setDateFinished(null);
if (type == ResetProgressType.BOOKLORE) {
progress.setPdfProgress(null);
progress.setPdfProgressPercent(null);
progress.setEpubProgress(null);
progress.setEpubProgressPercent(null);
progress.setCbxProgress(null);
progress.setCbxProgressPercent(null);
} else if (type == ResetProgressType.KOREADER) {
progress.setKoreaderProgress(null);
progress.setKoreaderProgressPercent(null);
progress.setKoreaderDeviceId(null);
progress.setKoreaderDevice(null);
progress.setKoreaderLastSyncTime(null);
} else if (type == ResetProgressType.KOBO) {
progress.setKoboProgressPercent(null);
progress.setKoboLocation(null);
progress.setKoboLocationType(null);
progress.setKoboLocationSource(null);
progress.setKoboProgressReceivedTime(null);
koboReadingStateService.deleteReadingState(bookId);
}
userBookProgressRepository.save(progress);
updatedBooks.add(bookMapper.toBook(bookEntity));
}
return updatedBooks;
}
@Transactional
public List<Book> updatePersonalRating(List<Long> bookIds, Integer rating) {
BookLoreUser user = authenticationService.getAuthenticatedUser();
List<BookEntity> books = bookRepository.findAllById(bookIds);
if (books.size() != bookIds.size()) {
throw ApiError.BOOK_NOT_FOUND.createException("One or more books not found");
}
BookLoreUserEntity userEntity = userRepository.findById(user.getId()).orElseThrow(() -> new UsernameNotFoundException("User not found"));
for (BookEntity book : books) {
UserBookProgressEntity progress = userBookProgressRepository
.findByUserIdAndBookId(user.getId(), book.getId())
.orElse(new UserBookProgressEntity());
progress.setUser(userEntity);
progress.setBook(book);
progress.setPersonalRating(rating);
userBookProgressRepository.save(progress);
}
return books.stream()
.map(bookEntity -> {
Book book = bookMapper.toBook(bookEntity);
book.setFilePath(FileUtils.getBookFullPath(bookEntity));
UserBookProgressEntity progress = userBookProgressRepository
.findByUserIdAndBookId(user.getId(), bookEntity.getId())
.orElse(null);
this.enrichBookWithProgress(book, progress);
return book;
})
.collect(Collectors.toList());
}
public List<Book> resetPersonalRating(List<Long> bookIds) {
BookLoreUser user = authenticationService.getAuthenticatedUser();
List<Book> updatedBooks = new ArrayList<>();
Optional<BookLoreUserEntity> userEntity = userRepository.findById(user.getId());
for (Long bookId : bookIds) {
BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
UserBookProgressEntity progress = userBookProgressRepository
.findByUserIdAndBookId(user.getId(), bookId)
.orElse(new UserBookProgressEntity());
progress.setBook(bookEntity);
progress.setUser(userEntity.orElseThrow());
progress.setPersonalRating(null);
userBookProgressRepository.save(progress);
updatedBooks.add(bookMapper.toBook(bookEntity));
}
return updatedBooks;
public List<BookStatusUpdateResponse> updateReadStatus(List<Long> bookIds, String status) {
return bookUpdateService.updateReadStatus(bookIds, status);
}
@Transactional
public List<Book> assignShelvesToBooks(Set<Long> bookIds, Set<Long> shelfIdsToAssign, Set<Long> shelfIdsToUnassign) {
BookLoreUser user = authenticationService.getAuthenticatedUser();
BookLoreUserEntity userEntity = userRepository.findById(user.getId()).orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(user.getId()));
Set<Long> userShelfIds = userEntity.getShelves().stream().map(ShelfEntity::getId).collect(Collectors.toSet());
if (!userShelfIds.containsAll(shelfIdsToAssign)) {
throw ApiError.GENERIC_UNAUTHORIZED.createException("Cannot assign shelves that do not belong to the user.");
}
if (!userShelfIds.containsAll(shelfIdsToUnassign)) {
throw ApiError.GENERIC_UNAUTHORIZED.createException("Cannot unassign shelves that do not belong to the user.");
}
List<BookEntity> bookEntities = bookQueryService.findAllWithMetadataByIds(bookIds);
List<ShelfEntity> shelvesToAssign = shelfRepository.findAllById(shelfIdsToAssign);
for (BookEntity bookEntity : bookEntities) {
bookEntity.getShelves().removeIf(shelf -> shelfIdsToUnassign.contains(shelf.getId()));
bookEntity.getShelves().addAll(shelvesToAssign);
}
bookRepository.saveAll(bookEntities);
Map<Long, UserBookProgressEntity> progressMap = userProgressService.fetchUserProgress(
user.getId(), bookEntities.stream().map(BookEntity::getId).collect(Collectors.toSet()));
return bookEntities.stream().map(bookEntity -> {
Book book = bookMapper.toBook(bookEntity);
book.setShelves(filterShelvesByUserId(book.getShelves(), user.getId()));
book.setFilePath(FileUtils.getBookFullPath(bookEntity));
enrichBookWithProgress(book, progressMap.get(bookEntity.getId()));
return book;
}).collect(Collectors.toList());
return bookUpdateService.assignShelvesToBooks(bookIds, shelfIdsToAssign, shelfIdsToUnassign);
}
public Resource getBookThumbnail(long bookId) {
@ -607,6 +288,11 @@ public class BookService {
}
}
public Resource getBookCover(String coverHash) {
BookEntity bookEntity = bookRepository.findByBookCoverHash(coverHash).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(coverHash));
return getBookCover(bookEntity.getId());
}
public Resource getBackgroundImage() {
try {
BookLoreUser user = authenticationService.getAuthenticatedUser();
@ -639,7 +325,11 @@ public class BookService {
Path fullFilePath = book.getFullFilePath();
try {
if (Files.exists(fullFilePath)) {
monitoringRegistrationService.unregisterSpecificPath(fullFilePath.getParent());
try {
monitoringRegistrationService.unregisterSpecificPath(fullFilePath.getParent());
} catch (Exception ex) {
log.warn("Failed to unregister monitoring for path: {}", fullFilePath.getParent(), ex);
}
Files.delete(fullFilePath);
log.info("Deleted book file: {}", fullFilePath);
@ -654,6 +344,8 @@ public class BookService {
} catch (IOException e) {
log.warn("Failed to delete book file: {}", fullFilePath, e);
failedFileDeletions.add(book.getId());
} finally {
monitoringRegistrationService.registerSpecificPath(fullFilePath.getParent(), book.getLibrary().getId());
}
}
@ -736,4 +428,5 @@ public class BookService {
.filter(shelf -> userId.equals(shelf.getUserId()))
.collect(Collectors.toSet());
}
}
}

Some files were not shown because too many files have changed in this diff Show More