mirror of
https://github.com/adityachandelgit/BookLore.git
synced 2026-01-09 06:21:08 +08:00
Merge branch 'develop' into feat/change-to-nonroot
This commit is contained in:
commit
f6c5a4f055
16
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
16
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@ -8,14 +8,6 @@ body:
|
||||
value: |
|
||||
Please fill out the details below so we can investigate and fix the issue.
|
||||
|
||||
- type: checkboxes
|
||||
id: prerequisites
|
||||
attributes:
|
||||
label: Quick Check
|
||||
options:
|
||||
- label: I've searched existing issues and this bug hasn't been reported yet
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
@ -77,3 +69,11 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: prerequisites
|
||||
attributes:
|
||||
label: Before Submitting
|
||||
description: Please confirm you've completed this step
|
||||
options:
|
||||
- label: I've searched existing issues and confirmed this bug hasn't been reported yet
|
||||
required: true
|
||||
|
||||
23
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
23
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@ -8,14 +8,6 @@ body:
|
||||
value: |
|
||||
Please share as much detail as you can to help us understand your suggestion.
|
||||
|
||||
- type: checkboxes
|
||||
id: prerequisites
|
||||
attributes:
|
||||
label: Quick Check
|
||||
options:
|
||||
- label: I've searched existing issues and this feature hasn't been requested yet
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
@ -57,3 +49,18 @@ body:
|
||||
- "Just sharing the idea for now"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Have You Considered Any Alternatives? (Optional)
|
||||
description: Are there other ways to achieve what you want? Tell us about them
|
||||
|
||||
- type: checkboxes
|
||||
id: prerequisites
|
||||
attributes:
|
||||
label: Before Submitting
|
||||
description: Please confirm you've completed this step
|
||||
options:
|
||||
- label: I've searched existing issues and confirmed this feature hasn't been requested yet
|
||||
required: true
|
||||
|
||||
54
.github/pull_request_template.md
vendored
54
.github/pull_request_template.md
vendored
@ -1,35 +1,59 @@
|
||||
# 🚀 Pull Request
|
||||
## 🚀 Pull Request
|
||||
|
||||
### 📝 Description
|
||||
|
||||
## 📝 Description
|
||||
<!-- Provide a clear and concise summary of the changes introduced in this pull request -->
|
||||
<!-- Reference related issues using "Fixes #123", "Closes #456", or "Relates to #789" -->
|
||||
|
||||
### 🛠️ Changes Implemented
|
||||
|
||||
## 🛠️ Changes Implemented
|
||||
<!-- Detail the specific modifications, additions, or removals made in this pull request -->
|
||||
-
|
||||
|
||||
### 🧪 Testing Strategy
|
||||
|
||||
## 🧪 Testing Strategy
|
||||
<!-- Describe the testing methodology used to verify the correctness of these changes -->
|
||||
<!-- Include testing approach, scenarios covered, and edge cases considered -->
|
||||
|
||||
### 📸 Visual Changes _(if applicable)_
|
||||
|
||||
## 📸 Visual Changes _(if applicable)_
|
||||
<!-- Attach screenshots or videos demonstrating UI/UX modifications -->
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Required Pre-Submission Checklist
|
||||
<!-- ⛔ Pull requests will NOT be considered for review unless ALL required items are completed -->
|
||||
<!-- All items below are MANDATORY prerequisites for submission -->
|
||||
- [ ] Code adheres to project style guidelines and conventions
|
||||
- [ ] Branch synchronized with latest `develop` branch
|
||||
- [ ] Automated unit/integration tests added/updated to cover changes
|
||||
- [ ] All tests pass locally (`./gradlew test` for backend)
|
||||
- [ ] Manual testing completed in local development environment
|
||||
- [ ] Flyway migration versioning follows correct sequence _(if database schema modified)_
|
||||
- [ ] Documentation pull request submitted to [booklore-docs](https://github.com/booklore-app/booklore-docs) _(required for features or enhancements that introduce user-facing or visual changes)_
|
||||
|
||||
### **Please Read - This Checklist is Mandatory**
|
||||
|
||||
> **Important Notice:** We've experienced several production bugs recently due to incomplete pre-submission checks. To maintain code quality and prevent issues from reaching production, we're enforcing stricter adherence to this checklist.
|
||||
>
|
||||
> **All checkboxes below must be completed before requesting review.** PRs that haven't completed these requirements will be sent back for completion.
|
||||
|
||||
#### **Mandatory Requirements** _(please check ALL boxes)_:
|
||||
|
||||
- [ ] **Code adheres to project style guidelines and conventions**
|
||||
- [ ] **Branch synchronized with latest `develop` branch** _(please resolve any merge conflicts)_
|
||||
- [ ] **🚨 CRITICAL: Automated unit 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 -->
|
||||
|
||||
100
.github/workflows/develop-pipeline.yml
vendored
100
.github/workflows/develop-pipeline.yml
vendored
@ -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
|
||||
# ----------------------------------------
|
||||
|
||||
35
.github/workflows/master-pipeline.yml
vendored
35
.github/workflows/master-pipeline.yml
vendored
@ -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
|
||||
|
||||
|
||||
3
.github/workflows/migrations-check.yml
vendored
3
.github/workflows/migrations-check.yml
vendored
@ -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
1
.gitignore
vendored
@ -42,5 +42,6 @@ out/
|
||||
local/
|
||||
|
||||
### Dev config, books, and data ###
|
||||
booklore-ui/test-results/
|
||||
booklore-api/src/main/resources/application-local.yaml
|
||||
/shared/
|
||||
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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" \
|
||||
|
||||
42
README.md
42
README.md
@ -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>
|
||||
|
||||
[](https://github.com/adityachandelgit/BookLore/releases)
|
||||
[](https://github.com/booklore-app/booklore/releases)
|
||||
[](LICENSE)
|
||||
[](https://github.com/adityachandelgit/BookLore/stargazers)
|
||||
[](https://github.com/booklore-app/booklore/stargazers)
|
||||
[](https://hub.docker.com/r/booklore/booklore)
|
||||
|
||||
[](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!
|
||||
|
||||
[](https://github.com/adityachandelgit/BookLore)
|
||||
[](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
|
||||
|
||||
[](https://booklore-app.github.io/booklore-docs/docs/getting-started/)
|
||||
[](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?
|
||||
|
||||
[](https://github.com/booklore-app/booklore/issues)
|
||||
[](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?
|
||||
|
||||
[](https://github.com/booklore-app/booklore/issues/new?template=feature_request.md)
|
||||
[](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! 🙏
|
||||
|
||||
[](https://github.com/adityachandelgit/BookLore/graphs/contributors)
|
||||
[](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**
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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());
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -14,4 +14,5 @@ public class EpubViewerPreferences {
|
||||
private Integer fontSize;
|
||||
private Float letterSpacing;
|
||||
private Float lineHeight;
|
||||
private Long customFontId;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -56,6 +56,8 @@ public enum RuleField {
|
||||
@JsonProperty("moods")
|
||||
MOODS,
|
||||
@JsonProperty("tags")
|
||||
TAGS
|
||||
TAGS,
|
||||
@JsonProperty("genre")
|
||||
GENRE
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,8 +4,6 @@ import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -44,4 +44,7 @@ public class EpubViewerPreferencesEntity {
|
||||
|
||||
@Column(name = "spread")
|
||||
private String spread;
|
||||
|
||||
@Column(name = "custom_font_id")
|
||||
private Long customFontId;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -3,8 +3,6 @@ package com.adityachandel.booklore.model.entity;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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> {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
@ -15,4 +15,8 @@ public interface KoboUserSettingsRepository extends JpaRepository<KoboUserSettin
|
||||
Optional<KoboUserSettingsEntity> findByToken(String token);
|
||||
|
||||
List<KoboUserSettingsEntity> findByAutoAddToShelfTrueAndSyncEnabledTrue();
|
||||
|
||||
long countByHardcoverSyncEnabledTrue();
|
||||
|
||||
long countByAutoAddToShelfTrue();
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
});
|
||||
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user