Merge branch 'develop' into feature/serve-angular-from-spring-drop-nginx

# Conflicts:
#	booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityConfig.java
This commit is contained in:
acx10
2025-12-19 19:11:28 -07:00
403 changed files with 26398 additions and 11139 deletions

View File

@@ -2,37 +2,31 @@
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
labels: bug
assignees: ''
---
# 🐛 Bug Report for Booklore
# 🐛 Bug Report
Thank you for taking the time to report this bug. Your feedback helps make Booklore better for everyone!
Let's squash this bug together! 🔨
---
## 📝 What happened?
## 📝 Description
<!-- Describe what went wrong -->
## 🔄 Steps to Reproduce
<!-- Provide detailed steps to reproduce the behavior. Be specific about what you clicked, typed, or configured -->
1.
2.
3.
4.
1.
2.
3.
4.
**Result:** <!-- What happened after these steps? -->
## ✅ Expected Behavior
<!-- Describe what should have happened instead -->
## 📸 Screenshots / Error Messages
## 📸 Screenshots / Error Messages _(Optional)_
<!-- Share any screenshots or error messages here (just drag & drop) -->
@@ -44,9 +38,10 @@ ## 💻 Environment
- **Installation:** (e.g., Docker, Unraid, Manual)
- **Storage Type:** (e.g., Local HDD/SSD, Synology NAS, SMB Share, NFS Mount, S3 Bucket)
## 📌 Additional Context
<!-- Add any other relevant information: recent changes, specific books, configuration details, etc. -->
## ✨ Possible Solution _(Optional)_
## 💡 Possible Solution _(Optional)_
<!-- Share any ideas on how to fix this issue -->
## 📌 Additional Context _(Optional)_
<!-- Add any other relevant information: recent changes, specific books, configuration details, etc. -->

View File

@@ -4,14 +4,9 @@
title: ''
labels: ''
assignees: ''
---
# ✨ Feature Request for Booklore
Thank you for contributing to Booklore's development. Your suggestions help shape the future of this project.
---
# ✨ Feature Request
## 📝 Description
<!-- Describe the problem you're facing and the solution you're proposing -->
@@ -36,5 +31,5 @@ ## 🎨 Technical Details _(Optional)_
<!-- Share implementation ideas, alternative solutions, or related features -->
## 📌 Additional Context
## 📌 Additional Context _(Optional)_
<!-- Any other information, research, or context that would be helpful -->

50
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
version: 2
updates:
# Backend Gradle (Java / Spring)
- package-ecosystem: "gradle"
directory: "/booklore-api"
schedule:
interval: "weekly"
day: "friday"
time: "03:00"
timezone: "UTC"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "backend"
commit-message:
prefix: "chore(deps)"
groups:
gradle-dependencies:
patterns:
- "*"
# Frontend npm (Angular)
- package-ecosystem: "npm"
directory: "/booklore-ui"
schedule:
interval: "weekly"
day: "friday"
time: "03:00"
timezone: "UTC"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "frontend"
commit-message:
prefix: "chore(deps)"
groups:
npm-dependencies:
patterns:
- "*"
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
labels:
- "dependencies"
- "ci"
commit-message:
prefix: "chore(deps)"

35
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,35 @@
# 🚀 Pull Request
## 📝 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
<!-- Detail the specific modifications, additions, or removals made in this pull request -->
-
## 🧪 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)_
<!-- 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)_
## 💬 Additional Context _(optional)_
<!-- Provide any supplementary information, implementation considerations, or discussion points for reviewers -->

70
.github/scripts/analyze-changes.sh vendored Normal file
View File

@@ -0,0 +1,70 @@
#!/bin/bash
# Exit immediately if a command exits with a non-zero status.
set -e
# Exit if any command in a pipeline fails, not just the last one.
set -o pipefail
# Define the path where Flyway migration files are stored.
MIGRATION_PATH="booklore-api/src/main/resources/db/migration"
# Get ALL changes: Added (A), Modified (M), Renamed (R), Copied (C), Deleted (D)
# for SQL files in the migration path between the comparison ref and the current HEAD.
# The output is saved to a temporary file for further processing.
git diff --name-status --diff-filter=AMRCD $COMPARE_REF...HEAD -- "$MIGRATION_PATH/V*.sql" > /tmp/all_changes.txt
# The check for no changes is now handled in the workflow.
# If this script runs, it's because changes were detected.
echo "📝 Migration changes detected:"
# Display the detected changes, indented for readability.
cat /tmp/all_changes.txt | sed 's/^/ /'
echo ""
# Check for deleted files
# Grep for lines starting with 'D' (Deleted). The '|| true' prevents the script from exiting if no matches are found.
DELETED=$(grep "^D" /tmp/all_changes.txt || true)
if [ -n "$DELETED" ]; then
echo "❌ ERROR: Deleted migration files detected!"
echo "$DELETED" | sed 's/^/ /'
echo ""
echo "Flyway migrations should NEVER be deleted after being applied."
echo "If you need to revert changes, create a new migration."
exit 1
fi
# Check for renamed files
# Grep for lines starting with 'R' (Renamed).
RENAMED=$(grep "^R" /tmp/all_changes.txt || true)
if [ -n "$RENAMED" ]; then
echo "❌ ERROR: Renamed migration files detected!"
echo "$RENAMED" | sed 's/^/ /'
echo ""
echo "Flyway migrations should NEVER be renamed after being applied."
echo "This will cause issues with migration history tracking."
echo ""
echo "💡 To fix: Revert the rename and create a new migration file instead."
exit 1
fi
# Check for modified files
# Grep for lines starting with 'M' (Modified).
MODIFIED=$(grep "^M" /tmp/all_changes.txt || true)
if [ -n "$MODIFIED" ]; then
echo "❌ ERROR: Modified migration files detected!"
echo "$MODIFIED" | sed 's/^/ /'
echo ""
echo "Flyway migrations should NEVER be modified after being applied."
echo "This will cause checksum validation failures in environments where it has already been applied."
echo ""
echo "💡 To fix: Revert the changes and create a new migration file instead."
exit 1
fi
# Extract ADDED files for conflict checking in a later step.
# We grep for lines starting with 'A' (Added), then use 'cut' to get just the file path.
# 'touch' ensures the file exists even if there are no added files.
grep "^A" /tmp/all_changes.txt | cut -f2- > /tmp/pr_files.txt || touch /tmp/pr_files.txt
# Set a GitHub Actions output variable to indicate that migration changes were found.
# This is used by the workflow to decide whether to run subsequent steps.
echo "has_changes=true" >> $GITHUB_OUTPUT

96
.github/scripts/check-conflicts.sh vendored Normal file
View File

@@ -0,0 +1,96 @@
#!/bin/bash
# Exit immediately if a command exits with a non-zero status.
set -e
# Exit if any command in a pipeline fails, not just the last one.
set -o pipefail
# If there are no new versions to check, exit gracefully.
# This file is created by 'validate-versions.sh'.
# This can happen if a PR has changes, but none are new migration files.
if [ ! -s /tmp/versions_pr_unique.txt ]; then
echo " No new migration versions to check for conflicts."
exit 0
fi
# Define the path where Flyway migration files are stored.
MIGRATION_PATH="booklore-api/src/main/resources/db/migration"
echo "🔍 Fetching migration files from $COMPARE_REF..."
# Get ALL existing migration files from the comparison ref (e.g., 'develop' or a tag).
# 'git ls-tree' lists the contents of a tree object.
# The output is piped to grep to filter for only Flyway SQL files.
# '|| touch' ensures the temp file exists even if no files are found.
git ls-tree -r --name-only $COMPARE_REF -- "$MIGRATION_PATH/" 2>/dev/null | \
grep "V.*\.sql$" > /tmp/base_files.txt || touch /tmp/base_files.txt
# Handle the case where no migration files exist in the base branch.
if [ ! -s /tmp/base_files.txt ]; then
echo "⚠️ No migration files found in $COMPARE_REF"
echo "This might be the first migration or the path has changed."
echo ""
echo "✅ Skipping version conflict check."
PR_COUNT=$(wc -l < /tmp/versions_pr_unique.txt)
echo ""
echo "📊 Migration Summary:"
echo " - Existing migrations in $COMPARE_REF: 0"
echo " - New migrations in this PR: $PR_COUNT"
exit 0
fi
echo "📋 Found $(wc -l < /tmp/base_files.txt) migration files in $COMPARE_REF"
# Extract versions from the base files.
# The loop reads each file path, extracts the version number from the filename,
# and appends it to a temporary file.
> /tmp/versions_base.txt
while IFS= read -r file; do
filename=$(basename "$file")
# sed extracts the version number (e.g., 1.0.0) from a filename like 'V1.0.0__description.sql'.
version=$(echo "$filename" | sed -n 's/^V\([0-9.]*\)__.*/\1/p')
[ -n "$version" ] && echo "$version" >> /tmp/versions_base.txt
done < /tmp/base_files.txt
# Create a file with only unique, sorted version numbers from the base.
sort -u /tmp/versions_base.txt > /tmp/versions_base_unique.txt
BASE_COUNT=$(wc -l < /tmp/versions_base_unique.txt)
echo "📊 Found $BASE_COUNT unique versions in $COMPARE_REF"
# Find conflicts between base versions and versions from NEW PR files.
# 'comm -12' finds lines common to both sorted files.
CONFLICTS=$(comm -12 /tmp/versions_base_unique.txt /tmp/versions_pr_unique.txt)
# If conflicts are found, report them and exit with an error.
if [ -n "$CONFLICTS" ]; then
echo "❌ Version conflicts detected!"
echo ""
echo "The following versions from your new migration files already exist in $COMPARE_REF:"
echo "$CONFLICTS" | sed 's/^/ V/'
echo ""
# Show which files have conflicting versions for easier debugging.
echo "Conflicting files:"
while IFS= read -r version; do
echo " Version V$version exists in:"
grep "V${version}__" /tmp/base_files.txt | xargs -n1 basename | sed 's/^/ BASE: /'
# /tmp/pr_files.txt contains only added files from the PR (from analyze-changes.sh).
grep "V${version}__" /tmp/pr_files.txt | xargs -n1 basename | sed 's/^/ PR: /'
done <<< "$CONFLICTS"
echo ""
echo "💡 To fix: Use a version number that doesn't exist in $COMPARE_REF"
exit 1
fi
echo "✅ No version conflicts detected."
# Get the count of new migrations in the PR.
PR_COUNT=$(wc -l < /tmp/versions_pr_unique.txt)
# Print a final summary.
echo ""
echo "📊 Migration Summary:"
echo " - Existing migrations in $COMPARE_REF: $BASE_COUNT"
echo " - New migrations in this PR: $PR_COUNT"

View File

@@ -0,0 +1,41 @@
#!/bin/bash
# Exit immediately if a command exits with a non-zero status.
set -e
# Exit if any command in a pipeline fails, not just the last one.
set -o pipefail
# The target branch of the pull request (e.g., 'develop', 'master') is passed as the first argument.
TARGET_BRANCH="$1"
echo "🎯 Target branch: $TARGET_BRANCH"
# Handle cases where the target branch is not specified, such as a direct push to a branch.
if [ -z "$TARGET_BRANCH" ]; then
echo "⚠️ No target branch specified (e.g., a direct push event). Defaulting to compare with 'develop'."
TARGET_BRANCH="develop"
fi
# Logic to determine the comparison reference based on the target branch.
if [ "$TARGET_BRANCH" = "master" ]; then
# For PRs to 'master', we compare against the latest git tag.
# This is common for release workflows where 'master' only contains tagged releases.
if ! LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null); then
echo "⚠️ No tags found in repository. Skipping conflict check."
# Set output to signal the workflow to stop.
echo "has_ref=false" >> $GITHUB_OUTPUT
exit 0
fi
echo "📌 Comparing against last tag: $LAST_TAG"
# Set the COMPARE_REF environment variable for subsequent steps in the job.
echo "COMPARE_REF=$LAST_TAG" >> $GITHUB_ENV
else
# For all other cases (PRs to 'develop', other feature branches, or direct pushes),
# we compare against the 'develop' branch.
echo "🔄 Comparing against head of develop branch"
# Ensure the local 'develop' branch is up-to-date with the remote.
git fetch origin develop:develop
# Set the COMPARE_REF to the remote develop branch.
echo "COMPARE_REF=origin/develop" >> $GITHUB_ENV
fi
# Set a GitHub Actions output variable to indicate that a valid comparison ref was found.
echo "has_ref=true" >> $GITHUB_OUTPUT

79
.github/scripts/validate-versions.sh vendored Normal file
View File

@@ -0,0 +1,79 @@
#!/bin/bash
# Exit immediately if a command exits with a non-zero status.
set -e
# Exit if any command in a pipeline fails, not just the last one.
set -o pipefail
# Define the path where Flyway migration files are stored.
MIGRATION_PATH="booklore-api/src/main/resources/db/migration"
# --- Part 1: Check for duplicate versions within the PR branch itself ---
# Get ALL migration files in the current HEAD of the PR branch for an internal duplicate check.
find "$MIGRATION_PATH" -type f -name "V*.sql" > /tmp/all_pr_files.txt
# Check for duplicate versions within the PR branch. This prevents merging a branch
# that contains multiple files with the same version number.
echo "🔎 Checking for duplicate versions in the branch..."
> /tmp/versions_all_pr.txt
# Loop through all found migration files and extract their version numbers.
while IFS= read -r file; do
filename=$(basename "$file")
# sed extracts the version number (e.g., 1.0.0) from a filename like 'V1.0.0__description.sql'.
version=$(echo "$filename" | sed -n 's/^V\([0-9.]*\)__.*/\1/p')
[ -n "$version" ] && echo "$version" >> /tmp/versions_all_pr.txt
done < /tmp/all_pr_files.txt
# 'uniq -d' filters for lines that appear more than once in the sorted list.
sort /tmp/versions_all_pr.txt | uniq -d > /tmp/duplicates_in_pr.txt
# If the duplicates file is not empty, report the error and exit.
if [ -s /tmp/duplicates_in_pr.txt ]; then
echo "❌ Duplicate migration versions found within the branch!"
echo ""
echo "The following versions are duplicated:"
while IFS= read -r version; do
echo " - Version V$version is used by:"
# Show the conflicting files for easy debugging.
grep "V${version}__" /tmp/all_pr_files.txt | xargs -n1 basename | sed 's/^/ /'
done < /tmp/duplicates_in_pr.txt
echo ""
echo "💡 To fix: Ensure all migration files have a unique version number."
exit 1
fi
echo "✅ No duplicate versions found within the branch."
# --- Part 2: Extract versions from NEWLY ADDED files for conflict checking against the base branch ---
# /tmp/pr_files.txt is created by analyze-changes.sh and contains only ADDED files.
# If the file doesn't exist or is empty, there's nothing to check.
if [ ! -f /tmp/pr_files.txt ] || [ ! -s /tmp/pr_files.txt ]; then
echo " No new migration files to check for conflicts."
# Set output to signal the workflow to skip the conflict check step.
echo "has_versions=false" >> $GITHUB_OUTPUT
exit 0
fi
echo "🔎 Extracting versions from new files..."
> /tmp/versions_pr.txt
# Loop through only the NEWLY ADDED files and extract their versions.
while IFS= read -r file; do
filename=$(basename "$file")
version=$(echo "$filename" | sed -n 's/^V\([0-9.]*\)__.*/\1/p')
[ -n "$version" ] && echo "$version" >> /tmp/versions_pr.txt
done < /tmp/pr_files.txt
# If no valid versions were extracted from the new files, exit.
if [ ! -s /tmp/versions_pr.txt ]; then
echo " No versions found in new migration files."
echo "has_versions=false" >> $GITHUB_OUTPUT
exit 0
fi
# Create a sorted, unique list of versions from the new files.
# This file will be used by 'check-conflicts.sh'.
sort -u /tmp/versions_pr.txt > /tmp/versions_pr_unique.txt
# Set output to signal that there are new versions to check for conflicts.
echo "has_versions=true" >> $GITHUB_OUTPUT

View File

@@ -2,11 +2,94 @@ name: Build, Tag, Push, and Release to GitHub Container Registry
on:
push:
branches:
- 'master'
- 'develop'
pull_request:
branches:
- '**'
jobs:
check-for-migrations:
name: Check for DB Migrations
if: github.event_name == 'pull_request' && ((github.base_ref == 'master' && github.head_ref == 'develop') || github.base_ref == 'develop')
runs-on: ubuntu-latest
outputs:
has_migrations: ${{ steps.check_migrations.outputs.has_migrations }}
steps:
- name: Checkout Repository for Diff
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Detect Flyway Migration Changes
id: check_migrations
run: |
# Compare PR head with the target base branch
if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -q "booklore-api/src/main/resources/db/migration/V.*.sql"; then
echo "Migration file changes detected. Proceeding with migration preview."
echo "has_migrations=true" >> $GITHUB_OUTPUT
else
echo "No migration file changes detected. Skipping migration preview."
echo "has_migrations=false" >> $GITHUB_OUTPUT
fi
flyway-migration-preview:
name: Flyway DB Migration Preview
needs: [check-for-migrations]
if: needs.check-for-migrations.outputs.has_migrations == 'true'
runs-on: ubuntu-latest
services:
mariadb:
image: mariadb:10.6
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: booklore_test
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping --silent"
--health-interval=5s
--health-timeout=5s
--health-retries=10
steps:
- name: Checkout Base Branch
uses: actions/checkout@v6
with:
ref: '${{ github.base_ref }}'
- name: Apply Migrations from Base Branch
run: |
echo "Applying migrations from '${{ github.base_ref }}' branch..."
docker run --network host \
-v ${{ github.workspace }}:/flyway/sql \
flyway/flyway:11.19.0-alpine \
-url=jdbc:mariadb://127.0.0.1:3306/booklore_test \
-user=root -password=root \
-locations=filesystem:/flyway/sql/booklore-api/src/main/resources/db/migration \
migrate
- name: Checkout Pull Request Branch
uses: actions/checkout@v6
- name: Apply Migrations from PR Branch
run: |
echo "Applying new migrations from PR branch..."
docker run --network host \
-v ${{ github.workspace }}:/flyway/sql \
flyway/flyway:11.19.0-alpine \
-url=jdbc:mariadb://127.0.0.1:3306/booklore_test \
-user=root -password=root \
-locations=filesystem:/flyway/sql/booklore-api/src/main/resources/db/migration \
migrate
- name: Confirm Flyway Dry Run Success
run: echo "✅ Flyway migration preview successful. Migrations can be applied cleanly."
build-and-push:
needs: [check-for-migrations, flyway-migration-preview]
if: always() && (needs.flyway-migration-preview.result == 'success' || needs.flyway-migration-preview.result == 'skipped')
runs-on: ubuntu-latest
permissions:
@@ -18,44 +101,47 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Log in to Docker Hub
uses: docker/login-action@v2
- name: Authenticate to Docker Hub
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
- name: Authenticate to GitHub Container Registry
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up QEMU for multi-arch builds
- name: Set Up QEMU for Multi-Architecture Builds
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
- name: Set Up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up JDK 21
uses: actions/setup-java@v4
- name: Set Up JDK 21
uses: actions/setup-java@v5
with:
java-version: '21'
distribution: 'temurin'
cache: 'gradle'
- name: Run Backend Tests
- name: Execute Backend Tests
id: backend_tests
working-directory: ./booklore-api
run: |
echo "Running backend tests with testcontainers..."
./gradlew test
./gradlew test --no-daemon --parallel --build-cache
continue-on-error: true
- name: Publish Test Results
- name: Publish Backend Test Results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
@@ -65,8 +151,8 @@ jobs:
report_individual_runs: true
report_suite_logs: 'any'
- name: Upload Test Reports
uses: actions/upload-artifact@v4
- name: Upload Backend Test Reports
uses: actions/upload-artifact@v6
if: always()
with:
name: test-reports
@@ -75,13 +161,13 @@ jobs:
booklore-api/build/test-results/
retention-days: 30
- name: Check Test Results
- name: Validate Backend Test Results
if: steps.backend_tests.outcome == 'failure'
run: |
echo "❌ Backend tests failed! Check the test results above."
exit 1
- name: Get Latest Master Version
- name: Retrieve Latest Master Version Tag
id: get_version
run: |
latest_tag=$(git tag --list "v*" --sort=-v:refname | head -n 1)
@@ -89,7 +175,7 @@ jobs:
echo "latest_tag=$latest_tag" >> $GITHUB_ENV
echo "Latest master tag: $latest_tag"
- name: Determine Version Bump (Only for Master)
- name: Determine Version Bump (Master Only)
if: github.ref == 'refs/heads/master'
id: determine_bump
env:
@@ -107,11 +193,11 @@ jobs:
labels=$(gh pr view "$pr_number" --json labels --jq '.labels[].name' || echo "")
echo "PR labels: $labels"
if echo "$labels" | grep -q 'major'; then
if echo "$labels" | grep -q 'bump:major'; then
bump="major"
elif echo "$labels" | grep -q 'minor'; then
elif echo "$labels" | grep -q 'bump:minor'; then
bump="minor"
elif echo "$labels" | grep -q 'patch'; then
elif echo "$labels" | grep -q 'bump:patch'; then
bump="patch"
else
last_commit_msg=$(git log -1 --pretty=%B)
@@ -161,28 +247,44 @@ jobs:
echo "image_tag=$image_tag" >> $GITHUB_ENV
echo "Image tag: $image_tag"
- name: Build and Push Docker Image
run: |
docker buildx create --use
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg APP_VERSION=${{ env.image_tag }} \
--build-arg APP_REVISION=${{ github.sha }} \
--tag booklore/booklore:${{ env.image_tag }} \
--tag ghcr.io/booklore-app/booklore:${{ env.image_tag }} \
--push .
- name: Build and push Docker image
if: github.event_name == 'push'
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
booklore/booklore:${{ env.image_tag }}
ghcr.io/booklore-app/booklore:${{ env.image_tag }}
build-args: |
APP_VERSION=${{ env.image_tag }}
APP_REVISION=${{ github.sha }}
cache-from: |
type=gha
type=registry,ref=ghcr.io/booklore-app/booklore:buildcache
cache-to: |
type=gha,mode=max
type=registry,ref=ghcr.io/booklore-app/booklore:buildcache,mode=max
- name: Push Latest Tag (Only for Master)
if: github.ref == 'refs/heads/master'
run: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg APP_VERSION=${{ env.new_tag }} \
--tag booklore/booklore:latest \
--tag ghcr.io/booklore-app/booklore:latest \
--push .
- name: Push Latest Tag (Master Only)
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
booklore/booklore:latest
booklore/booklore:${{ env.new_tag }}
ghcr.io/booklore-app/booklore:latest
ghcr.io/booklore-app/booklore:${{ env.new_tag }}
build-args: |
APP_VERSION=${{ env.new_tag }}
APP_REVISION=${{ github.sha }}
cache-from: type=gha
- name: Update Release Draft (Only for Master)
- name: Update GitHub Release Draft (Master Only)
if: github.ref == 'refs/heads/master'
uses: release-drafter/release-drafter@v6
with:
@@ -191,47 +293,9 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Publish Draft Release (Only for Master)
- name: Publish GitHub Draft Release (Master Only)
if: github.ref == 'refs/heads/master'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
gh release edit ${{ env.new_tag }} --draft=false
- name: Notify Discord of New Release
if: false
continue-on-error: true
shell: bash
env:
GH_TOKEN: ${{ github.token }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
NEW_TAG: ${{ env.new_tag }}
run: |
set -euo pipefail
if [ -z "${DISCORD_WEBHOOK_URL:-}" ]; then
echo "DISCORD_WEBHOOK_URL not set, skipping Discord notification."
exit 0
fi
release_json=$(gh release view "$NEW_TAG" --json name,body,url)
release_name=$(jq -r '.name' <<< "$release_json")
release_body=$(jq -r '.body' <<< "$release_json")
release_url=$(jq -r '.url' <<< "$release_json")
clean_body=$(echo "$release_body" | tr -d '\r')
max_length=1800
if [ ${#clean_body} -gt $max_length ]; then
clean_body="${clean_body:0:$((max_length-12))}… [truncated]"
fi
payload=$(jq -n \
--arg title "New Release: $release_name" \
--arg url "$release_url" \
--arg desc "$clean_body" \
'{
content: null,
embeds: [{
title: $title,
url: $url,
description: $desc,
color: 3066993
}]
}')
curl -H "Content-Type: application/json" -d "$payload" "$DISCORD_WEBHOOK_URL"
gh release edit ${{ env.new_tag }} --draft=true

4
.gitignore vendored
View File

@@ -41,4 +41,6 @@ out/
.vscode/
local/
booklore-api/src/main/resources/application-local.yaml
### Dev config, books, and data ###
booklore-api/src/main/resources/application-local.yaml
/shared/

View File

@@ -1,16 +1,17 @@
# Contributing to Booklore
🎉 Thanks for your interest in contributing to **Booklore**, a modern, self-hostable digital library system for books and comics. Whether you're fixing bugs, adding features, improving documentation, or asking questions - your contribution matters!
🎉 **Thank you for your interest in contributing to Booklore!** Whether you're fixing bugs, adding features, improving documentation, or simply asking questions, every contribution helps make Booklore better for everyone.
---
## 📚 Overview
## 📚 What is Booklore?
**Booklore** is a self-hostable platform designed to manage and read books and comics. It includes:
**Booklore** is a modern, self-hostable digital library platform for managing and reading books and comics. It's designed with privacy, flexibility, and ease of use in mind.
- **Frontend**: Angular 20, TypeScript, PrimeNG 19, Tailwind CSS
**Tech Stack:**
- **Frontend**: Angular 20, TypeScript, PrimeNG 19
- **Backend**: Java 21, Spring Boot 3.5
- **Authentication**: Local JWT + optional OIDC (e.g. Authentik)
- **Authentication**: Local JWT + optional OIDC (e.g., Authentik)
- **Database**: MariaDB
- **Deployment**: Docker-compatible, reverse proxy-ready
@@ -20,102 +21,202 @@ ## 📦 Project Structure
```
booklore/
├── booklore-ui/ # Angular frontend
├── booklore-api/ # Spring Boot backend
├── assets/ # Shared assets
├── booklore-ui/ # Angular frontend application
├── booklore-api/ # Spring Boot backend API
├── assets/ # Shared assets (logos, icons, etc.)
├── docker-compose.yml # Production Docker setup
└── dev.docker-compose.yml # Development Docker setup
```
---
## 🚀 Getting Started
1. **Fork the repository** on GitHub
2. **Clone your fork** locally:
### 1. Fork and Clone
First, fork the repository to your GitHub account, then clone it locally:
```bash
git clone https://github.com/adityachandelgit/BookLore.git
# Clone your fork
git clone https://github.com/<your-username>/booklore.git
cd booklore
# Add upstream remote to keep your fork in sync
git remote add upstream https://github.com/booklore-app/booklore.git
```
### 2. Keep Your Fork Updated
Before starting work on a new feature or fix:
```bash
# Fetch latest changes from upstream
git fetch upstream
# Merge upstream changes into your local main branch
git checkout main
git merge upstream/main
# Push updates to your fork
git push origin main
```
---
## 🧱 Local Development Setup
Either run `docker compose -f dev.docker-compose.yml up` or install & run everything Locally (described below).
Booklore offers two development approaches: an all-in-one Docker stack for quick setup, or manual installation for more control.
### Option 1: Docker Development Stack (Recommended for Quick Start)
### 1. Prerequisites
- **Java 21+**
- **Node.js 18+**
- **MariaDB**
- **Docker and Docker Compose**
---
### 2. Frontend Setup
To set up the Angular frontend:
This option sets up everything with a single command:
```bash
cd booklore-ui
npm install
ng serve
docker compose -f dev.docker-compose.yml up
```
The dev server runs at `http://localhost:4200/`.
**What you get:**
- ✅ Frontend dev server at `http://localhost:4200/`
- ✅ Backend API at `http://localhost:8080/`
- ✅ MariaDB at `localhost:3366`
- ✅ Remote Java debugging at `localhost:5005`
> ⚠️ Use `--force` with `npm install` only as a last resort for dependency conflicts.
**Note:** All ports are configurable via environment variables in `dev.docker-compose.yml`:
- `FRONTEND_PORT` (default: 4200)
- `BACKEND_PORT` (default: 8080)
- `DB_PORT` (default: 3366)
- `REMOTE_DEBUG_PORT` (default: 5005)
**Stopping the stack:**
```bash
docker compose -f dev.docker-compose.yml down
```
---
### 3. Backend Setup
### Option 2: Manual Local Development
#### a. Configure `application-dev.yml`
For more control over your development environment, you can run each component separately.
Create or edit `booklore-api/src/main/resources/application-dev.yml`:
#### Prerequisites
Ensure you have the following installed:
- **Java 21+** ([Download](https://adoptium.net/))
- **Node.js 18+** and **npm** ([Download](https://nodejs.org/))
- **MariaDB 10.6+** ([Download](https://mariadb.org/download/))
- **Git** ([Download](https://git-scm.com/))
#### Frontend Setup
```bash
# Navigate to the frontend directory
cd booklore-ui
# Install dependencies
npm install
# Start the development server
ng serve
# Or use npm script
npm start
```
The frontend will be available at `http://localhost:4200/` with hot-reload enabled.
**Common Issues:**
- If you encounter dependency conflicts, try `npm install --legacy-peer-deps`
- Use `--force` only as a last resort
---
#### Backend Setup
##### Step 1: Configure Application Properties
Create a development configuration file at `booklore-api/src/main/resources/application-dev.yml`:
```yaml
app:
path-book: '/path/to/booklore/books' # Directory for book/comic files
path-config: '/path/to/booklore/config' # Directory for thumbnails, metadata, etc.
# Path where books and comics are stored
path-book: '/Users/yourname/booklore-data/books'
# Path for thumbnails, metadata cache, and other config files
path-config: '/Users/yourname/booklore-data/config'
spring:
datasource:
driver-class-name: org.mariadb.jdbc.Driver
url: jdbc:mariadb://localhost:3333/booklore?createDatabaseIfNotExist=true
url: jdbc:mariadb://localhost:3306/booklore?createDatabaseIfNotExist=true
username: root
password: Password123
password: your_secure_password
```
> 🔧 Replace `/path/to/...` with actual local paths
**Important:**
- Replace `/Users/yourname/...` with actual paths on your system
- Create these directories if they don't exist
- Ensure proper read/write permissions
#### b. Run the Backend
**Example paths:**
- **macOS/Linux**: `/Users/yourname/booklore-data/books`
- **Windows**: `C:\Users\yourname\booklore-data\books`
##### Step 2: Set Up the Database
Ensure MariaDB is running and create the database:
```bash
# Connect to MariaDB
mysql -u root -p
# Create database and user (optional)
CREATE DATABASE IF NOT EXISTS booklore;
CREATE USER 'booklore_user'@'localhost' IDENTIFIED BY 'your_secure_password';
GRANT ALL PRIVILEGES ON booklore.* TO 'booklore_user'@'localhost';
FLUSH PRIVILEGES;
EXIT;
```
##### Step 3: Run the Backend
```bash
cd booklore-api
./gradlew bootRun
./gradlew bootRun --args='--spring.profiles.active=dev'
```
The backend API will be available at `http://localhost:8080/`
**Verify it's running:**
```bash
curl http://localhost:8080/actuator/health
```
---
## 🧪 Testing
### Frontend
Always run tests before submitting a pull request to ensure your changes don't break existing functionality.
Run unit tests using:
```bash
cd booklore-ui
ng test
```
### Backend
Run backend tests using:
### Backend Tests
```bash
cd booklore-api
# Run all tests
./gradlew test
# Run tests with detailed output
./gradlew test --info
# Run a specific test class
./gradlew test --tests "com.booklore.api.service.BookServiceTest"
# Generate coverage report
./gradlew test jacocoTestReport
```
**Before creating a PR, always run:**
```bash
./gradlew test
```
@@ -123,75 +224,199 @@ ### Backend
## 🛠️ Contributing Guidelines
### 💡 Bug Reports
### 💡 Reporting Bugs
- Check [existing issues](https://github.com/adityachandelgit/BookLore/issues)
- Include reproduction steps, expected vs. actual behavior, and logs if possible
Found a bug? Help us fix it by providing detailed information:
### 🌟 Feature Requests
1. **Search existing issues** to avoid duplicates
2. **Create a new issue** with the `bug` label
3. **Include the following:**
- Clear, descriptive title (e.g., "Book import fails with PDF files over 100MB")
- Steps to reproduce the issue
- Expected behavior vs. actual behavior
- Screenshots or error logs if applicable
- Your environment (OS, browser, Docker version, etc.)
- Clearly explain the use case and benefit
- Label the issue with `feature`
**Example Bug Report:**
```markdown
**Title:** Book metadata not updating after manual edit
### 🔃 Code Contributions
**Description:**
When I manually edit a book's metadata through the UI and click Save,
the changes appear to save but revert after page refresh.
- Create a feature branch:
**Steps to Reproduce:**
1. Navigate to any book detail page
2. Click "Edit Metadata"
3. Change the title from "Old Title" to "New Title"
4. Click "Save"
5. Refresh the page
```bash
git checkout -b feat/my-feature
**Expected:** Title should remain "New Title"
**Actual:** Title reverts to "Old Title"
**Environment:**
- Browser: Chrome 120
- OS: macOS 14.2
- Booklore Version: 1.2.0
```
- For bug fixes:
---
### 🔃 Submitting Code Changes
#### Branch Naming Convention
Create descriptive branches that clearly indicate the purpose of your changes:
```bash
git checkout -b fix/my-fix
# For new features
git checkout -b feat/add-dark-mode-theme
git checkout -b feat/epub-reader-support
# For bug fixes
git checkout -b fix/book-import-validation
git checkout -b fix/memory-leak-in-scanner
# For documentation
git checkout -b docs/update-installation-guide
# For refactoring
git checkout -b refactor/improve-authentication-flow
```
- Follow code conventions, keep PRs focused and scoped
- Link the relevant issue in your PR
- Test your changes
- Target the `develop` branch when opening PRs
#### Development Workflow
1. **Create a branch** from `develop` (not `main`)
2. **Make your changes** in small, logical commits
3. **Test thoroughly** - run both frontend and backend tests
4. **Update documentation** if your changes affect usage
5. **Run the linter** and fix any issues
6. **Commit with clear messages** following Conventional Commits
7. **Push to your fork**
8. **Open a pull request** targeting the `develop` branch
#### Pull Request Checklist
Before submitting, ensure:
- [ ] Code follows project conventions
- [ ] All tests pass (`./gradlew test` for backend)
- [ ] IntelliJ linter shows no errors
- [ ] Changes are documented (README, inline comments)
- [ ] PR description clearly explains what and why
- [ ] PR is linked to a related issue (if applicable)
- [ ] Branch is up-to-date with `develop`
- [ ] **For big features:** Create a documentation PR at [booklore-docs](https://github.com/booklore-app/booklore-docs) with styling similar to other documentation pages
---
## 🧼 Code Style & Conventions
- **Angular**: Follow the [official style guide](https://angular.io/guide/styleguide)
- **Java**: Use modern features (Java 17+), clean structure
- **Format**: Use linters and Prettier where applicable
- **UI**: Use Tailwind CSS and PrimeNG components consistently
- **Java**: Use modern features (Java 21), clean structure
- **Linter**: Use IntelliJ IDEA's built-in linter for code formatting and style checks
- **UI**: Use SCSS and PrimeNG components consistently
---
## 📝 Commit Message Format
Follow [Conventional Commits](https://www.conventionalcommits.org/):
We follow [Conventional Commits](https://www.conventionalcommits.org/) for clear, standardized commit messages.
Examples:
### Format
- `feat: add column visibility setting to book table`
- `fix: correct metadata locking behavior`
- `docs: improve contributing instructions`
```
<type>(<scope>): <subject>
[optional body]
[optional footer]
```
### Types
- `feat`: New feature
- `fix`: Bug fix
- `docs`: Documentation changes
- `style`: Code style changes (formatting, no logic change)
- `refactor`: Code refactoring
- `test`: Adding or updating tests
- `chore`: Maintenance tasks
- `perf`: Performance improvements
### Examples
```bash
# Feature addition
feat(reader): add keyboard navigation for page turning
# Bug fix
fix(api): resolve memory leak in book scanning service
# Documentation
docs(readme): add troubleshooting section for Docker setup
# Multiple scopes
feat(api,ui): implement book collection management
# Breaking change
feat(auth)!: migrate to OAuth 2.1
BREAKING CHANGE: OAuth 2.0 is no longer supported
```
---
## 🙏 Code of Conduct
Please be respectful, inclusive, and collaborative. Harassment, abuse, or discrimination of any kind will not be tolerated.
We're committed to providing a welcoming and inclusive environment for everyone.
**Our Standards:**
- ✅ Be respectful and considerate
- ✅ Welcome newcomers and help them learn
- ✅ Accept constructive criticism gracefully
- ✅ Focus on what's best for the community
**Unacceptable Behavior:**
- ❌ Harassment, trolling, or discrimination
- ❌ Personal attacks or insults
- ❌ Publishing others' private information
- ❌ Any conduct that would be inappropriate in a professional setting
**Enforcement:**
Instances of unacceptable behavior may result in temporary or permanent ban from the project.
---
## 💬 Community & Support
- Use [GitHub Discussions](https://github.com/adityachandelgit/BookLore/discussions)
- Discord server: https://discord.gg/Ee5hd458Uz
**Need help or want to discuss ideas?**
- 💬 **Discord**: [Join our server](https://discord.gg/Ee5hd458Uz)
- 🐛 **Issues**: [GitHub Issues](https://github.com/adityachandelgit/BookLore/issues)
---
## 📄 License
Booklore is open-source and licensed under the MIT License. See [`LICENSE`](./LICENSE) for details.
Booklore is open-source software licensed under the **GPL-3.0 License**.
By contributing, you agree that your contributions will be licensed under the same license. See the [`LICENSE`](./LICENSE) file for full details.
---
Happy contributing!
## 🎯 What to Work On?
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)
---
## 🎉 Thank You!
Every contribution, no matter how small, makes Booklore better. Thank you for being part of our community!
**Happy Contributing! 📚✨**

View File

@@ -4,11 +4,10 @@ FROM node:22-alpine AS angular-build
WORKDIR /angular-app
COPY ./booklore-ui/package.json ./booklore-ui/package-lock.json ./
RUN npm config set registry http://registry.npmjs.org/ \
&& npm config set fetch-retries 5 \
&& npm config set fetch-retry-mintimeout 20000 \
&& npm config set fetch-retry-maxtimeout 120000 \
&& npm install --force
RUN --mount=type=cache,target=/root/.npm \
npm config set registry http://registry.npmjs.org/ \
&& npm ci --force
COPY ./booklore-ui /angular-app/
RUN npm run build --configuration=production
@@ -18,7 +17,13 @@ FROM gradle:8.14.3-jdk21-alpine AS springboot-build
WORKDIR /springboot-app
# Copy only build files first to cache dependencies
COPY ./booklore-api/build.gradle ./booklore-api/settings.gradle /springboot-app/
# Download dependencies (cached layer)
RUN --mount=type=cache,target=/home/gradle/.gradle \
gradle dependencies --no-daemon
COPY ./booklore-api/src /springboot-app/src
COPY --from=angular-build /angular-app/dist/booklore/browser /springboot-app/src/main/resources/static
@@ -28,7 +33,8 @@ ARG APP_VERSION
RUN apk add --no-cache yq && \
yq eval '.app.version = strenv(APP_VERSION)' -i /springboot-app/src/main/resources/application.yaml
RUN gradle clean build -x test
RUN --mount=type=cache,target=/home/gradle/.gradle \
gradle clean build -x test --no-daemon --parallel
# Stage 3: Final image
FROM eclipse-temurin:21.0.9_10-jre-alpine

View File

@@ -39,7 +39,7 @@ dependencies {
// --- Database & Migration ---
implementation 'org.mariadb.jdbc:mariadb-java-client:3.5.6'
implementation 'org.flywaydb:flyway-mysql:11.18.0'
implementation 'org.flywaydb:flyway-mysql:11.19.0'
// --- Security & Authentication ---
implementation 'io.jsonwebtoken:jjwt-api:0.13.0'
@@ -72,14 +72,15 @@ dependencies {
// --- API Documentation ---
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.14'
implementation 'org.apache.commons:commons-compress:1.28.0'
implementation 'org.apache.commons:commons-text:1.14.0'
implementation 'org.tukaani:xz:1.11' // Required by commons-compress for 7z support
implementation 'org.apache.commons:commons-text:1.15.0'
// --- Template Engine ---
implementation 'org.freemarker:freemarker:2.3.33'
implementation 'org.freemarker:freemarker:2.3.34'
// --- Test Dependencies ---
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.assertj:assertj-core:3.27.3'
testImplementation 'org.assertj:assertj-core:3.27.6'
testImplementation "org.mockito:mockito-inline:5.2.0"
}
@@ -94,3 +95,9 @@ test {
jvmArgs("-XX:+EnableDynamicAgentLoading")
}
bootRun {
def debug = System.getenv('REMOTE_DEBUG_ENABLED')
if (debug.equals("true")) {
jvmArgs("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005")
}
}

View File

Binary file not shown.

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

@@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -114,6 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -171,6 +172,7 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -210,6 +212,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"

View File

@@ -70,10 +70,11 @@ goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell

View File

@@ -27,6 +27,7 @@ public class AppProperties {
private String headerEmail;
private String headerGroups;
private String adminGroup;
private String groupsDelimiter = "\\s+"; // Default to whitespace for backward compatibility
}
@Getter

View File

@@ -12,6 +12,7 @@ import com.adityachandel.booklore.repository.KoboUserSettingsRepository;
import com.adityachandel.booklore.repository.KoreaderUserRepository;
import com.adityachandel.booklore.repository.UserRepository;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -35,8 +36,6 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import jakarta.servlet.http.HttpServletResponse;
@AllArgsConstructor
@EnableMethodSecurity
@Configuration

View File

@@ -131,7 +131,7 @@ public class DualJwtAuthenticationFilter extends OncePerRequestFilter {
} catch (Exception e) {
log.error("OIDC authentication failed", e);
throw ApiError.GENERIC_UNAUTHORIZED.createException("OIDC JWT validation failed");
throw ApiError.GENERIC_UNAUTHORIZED.createException("OIDC JWT validation failed: " + e.getMessage());
}
}

View File

@@ -3,19 +3,25 @@ package com.adityachandel.booklore.config.security.service;
import com.adityachandel.booklore.model.dto.settings.OidcProviderDetails;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.DefaultJWKSetCache;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.jwk.source.RemoteJWKSet;
import com.nimbusds.jose.proc.JWSKeySelector;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jose.util.DefaultResourceRetriever;
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.time.Duration;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@Slf4j
@Component
@@ -42,19 +48,25 @@ public class DynamicOidcJwtProcessor {
throw new IllegalStateException("OIDC issuer URI is not configured in app settings.");
}
String discoveryUri = providerDetails.getIssuerUri() + "/.well-known/openid-configuration";
String discoveryUri = providerDetails.getIssuerUri().replaceAll("/$", "") + "/.well-known/openid-configuration";
log.info("Fetching OIDC discovery document from {}", discoveryUri);
URI jwksUri = fetchJwksUri(discoveryUri);
Duration ttl = Duration.ofHours(6);
Duration refresh = Duration.ofHours(1);
JWKSource<SecurityContext> jwkSource = JWKSourceBuilder.create(jwksUri.toURL())
.cache(ttl.toMillis(), refresh.toMillis())
.build();
JWSKeySelector<SecurityContext> keySelector = new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, jwkSource);
DefaultResourceRetriever resourceRetriever = new DefaultResourceRetriever(10000, 10000);
DefaultJWKSetCache jwkSetCache = new DefaultJWKSetCache(ttl.toMillis(), refresh.toMillis(), TimeUnit.MILLISECONDS);
JWKSource<SecurityContext> jwkSource = new RemoteJWKSet<>(jwksUri.toURL(), resourceRetriever, jwkSetCache);
Set<JWSAlgorithm> jwsAlgs = new HashSet<>();
jwsAlgs.addAll(JWSAlgorithm.Family.RSA);
jwsAlgs.addAll(JWSAlgorithm.Family.EC);
jwsAlgs.addAll(JWSAlgorithm.Family.RSA);
JWSKeySelector<SecurityContext> keySelector = new JWSVerificationKeySelector<>(jwsAlgs, jwkSource);
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
jwtProcessor.setJWSKeySelector(keySelector);
@@ -62,7 +74,14 @@ public class DynamicOidcJwtProcessor {
}
private URI fetchJwksUri(String discoveryUri) throws Exception {
var restClient = org.springframework.web.client.RestClient.create();
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(10000);
factory.setReadTimeout(10000);
var restClient = org.springframework.web.client.RestClient.builder()
.requestFactory(factory)
.build();
var discoveryDoc = restClient.get()
.uri(discoveryUri)
.retrieve()

View File

@@ -46,7 +46,7 @@ public class AdditionalFileController {
@PathVariable Long bookId,
@RequestParam("file") MultipartFile file,
@RequestParam AdditionalFileType additionalFileType,
@RequestParam(required = false) String description) throws IOException {
@RequestParam(required = false) String description) {
AdditionalFile additionalFile = fileUploadService.uploadAdditionalFile(bookId, file, additionalFileType, description);
return ResponseEntity.ok(additionalFile);
}

View File

@@ -5,6 +5,7 @@ import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.model.dto.Book;
import com.adityachandel.booklore.model.dto.BookRecommendation;
import com.adityachandel.booklore.model.dto.BookViewerSettings;
import com.adityachandel.booklore.model.dto.request.PersonalRatingUpdateRequest;
import com.adityachandel.booklore.model.dto.request.ReadProgressRequest;
import com.adityachandel.booklore.model.dto.request.ReadStatusUpdateRequest;
import com.adityachandel.booklore.model.dto.request.ShelvesAssignmentRequest;
@@ -186,4 +187,28 @@ public class BookController {
List<Book> updatedBooks = bookService.resetProgress(bookIds, type);
return ResponseEntity.ok(updatedBooks);
}
@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(
@Parameter(description = "Personal rating update request") @RequestBody @Valid PersonalRatingUpdateRequest request) {
List<Book> updatedBooks = bookService.updatePersonalRating(request.ids(), request.rating());
return ResponseEntity.ok(updatedBooks);
}
@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")
})
@PostMapping("/reset-personal-rating")
public ResponseEntity<List<Book>> 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);
return ResponseEntity.ok(updatedBooks);
}
}

View File

@@ -0,0 +1,49 @@
package com.adityachandel.booklore.controller;
import com.adityachandel.booklore.model.dto.BookMark;
import com.adityachandel.booklore.model.dto.CreateBookMarkRequest;
import com.adityachandel.booklore.service.book.BookMarkService;
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.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/bookmarks")
@Tag(name = "Bookmarks", description = "Endpoints for managing book bookmarks")
public class BookMarkController {
private final BookMarkService bookMarkService;
@Operation(summary = "Get bookmarks for a book", description = "Retrieve all bookmarks for a specific book.")
@ApiResponse(responseCode = "200", description = "Bookmarks returned successfully")
@GetMapping("/book/{bookId}")
public List<BookMark> getBookmarksForBook(
@Parameter(description = "ID of the book") @PathVariable Long bookId) {
return bookMarkService.getBookmarksForBook(bookId);
}
@Operation(summary = "Create a bookmark", description = "Create a new bookmark for a book.")
@ApiResponse(responseCode = "200", description = "Bookmark created successfully")
@PostMapping
public BookMark createBookmark(
@Parameter(description = "Bookmark creation request") @Valid @RequestBody CreateBookMarkRequest request) {
return bookMarkService.createBookmark(request);
}
@Operation(summary = "Delete a bookmark", description = "Delete a specific bookmark by its ID.")
@ApiResponse(responseCode = "204", description = "Bookmark deleted successfully")
@DeleteMapping("/{bookmarkId}")
public ResponseEntity<Void> deleteBookmark(
@Parameter(description = "ID of the bookmark to delete") @PathVariable Long bookmarkId) {
bookMarkService.deleteBookmark(bookmarkId);
return ResponseEntity.noContent().build();
}
}

View File

@@ -4,6 +4,7 @@ import com.adityachandel.booklore.service.book.BookService;
import com.adityachandel.booklore.service.bookdrop.BookDropService;
import com.adityachandel.booklore.service.reader.CbxReaderService;
import com.adityachandel.booklore.service.reader.PdfReaderService;
import com.adityachandel.booklore.service.IconService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
@@ -36,6 +37,7 @@ public class BookMediaController {
private final PdfReaderService pdfReaderService;
private final CbxReaderService cbxReaderService;
private final BookDropService bookDropService;
private final IconService iconService;
@Operation(summary = "Get book thumbnail", description = "Retrieve the thumbnail image for a specific book.")
@ApiResponse(responseCode = "200", description = "Book thumbnail returned successfully")

View File

@@ -2,16 +2,23 @@ package com.adityachandel.booklore.controller;
import com.adityachandel.booklore.model.dto.BookdropFile;
import com.adityachandel.booklore.model.dto.BookdropFileNotification;
import com.adityachandel.booklore.model.dto.request.BookdropBulkEditRequest;
import com.adityachandel.booklore.model.dto.request.BookdropFinalizeRequest;
import com.adityachandel.booklore.model.dto.request.BookdropPatternExtractRequest;
import com.adityachandel.booklore.model.dto.request.BookdropSelectionRequest;
import com.adityachandel.booklore.model.dto.response.BookdropBulkEditResult;
import com.adityachandel.booklore.model.dto.response.BookdropFinalizeResult;
import com.adityachandel.booklore.model.dto.response.BookdropPatternExtractResult;
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;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -26,6 +33,8 @@ public class BookdropFileController {
private final BookDropService bookDropService;
private final BookdropMonitoringService monitoringService;
private final FilenamePatternExtractor filenamePatternExtractor;
private final BookdropBulkEditService bookdropBulkEditService;
@Operation(summary = "Get bookdrop notification summary", description = "Retrieve a summary of bookdrop file notifications.")
@ApiResponse(responseCode = "200", description = "Notification summary returned successfully")
@@ -68,4 +77,22 @@ public class BookdropFileController {
monitoringService.rescanBookdropFolder();
return ResponseEntity.ok().build();
}
@Operation(summary = "Extract metadata from filenames using pattern", description = "Parse filenames of selected files using a pattern to extract metadata fields.")
@ApiResponse(responseCode = "200", description = "Pattern extraction completed")
@PostMapping("/files/extract-pattern")
public ResponseEntity<BookdropPatternExtractResult> extractFromPattern(
@Parameter(description = "Pattern extraction request") @Valid @RequestBody BookdropPatternExtractRequest request) {
BookdropPatternExtractResult result = filenamePatternExtractor.bulkExtract(request);
return ResponseEntity.ok(result);
}
@Operation(summary = "Bulk edit metadata for selected files", description = "Apply metadata changes to multiple selected files at once.")
@ApiResponse(responseCode = "200", description = "Bulk edit completed")
@PostMapping("/files/bulk-edit")
public ResponseEntity<BookdropBulkEditResult> bulkEditMetadata(
@Parameter(description = "Bulk edit request") @Valid @RequestBody BookdropBulkEditRequest request) {
BookdropBulkEditResult result = bookdropBulkEditService.bulkEdit(request);
return ResponseEntity.ok(result);
}
}

View File

@@ -0,0 +1,68 @@
package com.adityachandel.booklore.controller;
import com.adityachandel.booklore.model.dto.request.SvgIconBatchRequest;
import com.adityachandel.booklore.model.dto.request.SvgIconCreateRequest;
import com.adityachandel.booklore.model.dto.response.SvgIconBatchResponse;
import com.adityachandel.booklore.service.IconService;
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.validation.Valid;
import lombok.AllArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@Tag(name = "Icons", description = "Endpoints for managing SVG icons")
@AllArgsConstructor
@RestController
@RequestMapping("/api/v1/icons")
public class IconController {
private final IconService iconService;
@Operation(summary = "Save an SVG icon", description = "Saves an SVG icon to the system.")
@ApiResponse(responseCode = "200", description = "SVG icon saved successfully")
@PostMapping
public ResponseEntity<?> saveSvgIcon(@Valid @RequestBody SvgIconCreateRequest svgIconCreateRequest) {
iconService.saveSvgIcon(svgIconCreateRequest);
return ResponseEntity.ok().build();
}
@Operation(summary = "Save multiple SVG icons", description = "Saves multiple SVG icons to the system in batch.")
@ApiResponse(responseCode = "200", description = "Batch save completed with detailed results")
@PostMapping("/batch")
public ResponseEntity<SvgIconBatchResponse> saveBatchSvgIcons(@Valid @RequestBody SvgIconBatchRequest request) {
SvgIconBatchResponse response = iconService.saveBatchSvgIcons(request.getIcons());
return ResponseEntity.ok(response);
}
@Operation(summary = "Get SVG icon content", description = "Retrieve the SVG content of an icon by its name.")
@ApiResponse(responseCode = "200", description = "SVG icon content retrieved successfully")
@GetMapping("/{svgName}/content")
public ResponseEntity<String> getSvgIconContent(@Parameter(description = "SVG icon name") @PathVariable String svgName) {
String svgContent = iconService.getSvgIcon(svgName);
return ResponseEntity.ok()
.header("Content-Type", "image/svg+xml")
.body(svgContent);
}
@Operation(summary = "Get paginated icon names", description = "Retrieve a paginated list of icon names (default 50 per page).")
@ApiResponse(responseCode = "200", description = "Icon names retrieved successfully")
@GetMapping
public ResponseEntity<Page<String>> getIconNames(
@Parameter(description = "Page number") @RequestParam(defaultValue = "0") int page,
@Parameter(description = "Page size") @RequestParam(defaultValue = "50") int size) {
Page<String> response = iconService.getIconNames(page, size);
return ResponseEntity.ok(response);
}
@Operation(summary = "Delete an SVG icon", description = "Deletes an SVG icon by its name.")
@ApiResponse(responseCode = "200", description = "SVG icon deleted successfully")
@DeleteMapping("/{svgName}")
public ResponseEntity<?> deleteSvgIcon(@Parameter(description = "SVG icon name") @PathVariable String svgName) {
iconService.deleteSvgIcon(svgName);
return ResponseEntity.ok().build();
}
}

View File

@@ -3,7 +3,6 @@ package com.adityachandel.booklore.controller;
import com.adityachandel.booklore.model.dto.KoboSyncSettings;
import com.adityachandel.booklore.service.kobo.KoboSettingsService;
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;
@@ -31,31 +30,19 @@ public class KoboSettingsController {
@Operation(summary = "Create or update Kobo token", description = "Create or update the Kobo sync token for the current user. Requires sync permission or admin.")
@ApiResponse(responseCode = "200", description = "Token created/updated successfully")
@PutMapping
@PutMapping("/token")
@PreAuthorize("@securityUtil.canSyncKobo() or @securityUtil.isAdmin()")
public ResponseEntity<KoboSyncSettings> createOrUpdateToken() {
KoboSyncSettings updated = koboService.createOrUpdateToken();
return ResponseEntity.ok(updated);
}
@Operation(summary = "Toggle Kobo sync", description = "Enable or disable Kobo sync for the current user. Requires sync permission or admin.")
@ApiResponse(responseCode = "204", description = "Sync toggled successfully")
@PutMapping("/sync")
@Operation(summary = "Update Kobo settings", description = "Update Kobo sync settings for the current user. Requires sync permission or admin.")
@ApiResponse(responseCode = "200", description = "Settings updated successfully")
@PutMapping
@PreAuthorize("@securityUtil.canSyncKobo() or @securityUtil.isAdmin()")
public ResponseEntity<Void> toggleSync(
@Parameter(description = "Enable or disable sync") @RequestParam boolean enabled) {
koboService.setSyncEnabled(enabled);
return ResponseEntity.noContent().build();
}
@Operation(summary = "Update progress thresholds", description = "Update the progress thresholds for marking books as reading or finished. Requires sync permission or admin.")
@ApiResponse(responseCode = "200", description = "Thresholds updated successfully")
@PutMapping("/progress-thresholds")
@PreAuthorize("@securityUtil.canSyncKobo() or @securityUtil.isAdmin()")
public ResponseEntity<KoboSyncSettings> updateProgressThresholds(
@Parameter(description = "Progress percentage to mark as reading (0-100)") @RequestParam(required = false) Float readingThreshold,
@Parameter(description = "Progress percentage to mark as finished (0-100)") @RequestParam(required = false) Float finishedThreshold) {
KoboSyncSettings updated = koboService.updateProgressThresholds(readingThreshold, finishedThreshold);
public ResponseEntity<KoboSyncSettings> updateSettings(@RequestBody KoboSyncSettings settings) {
KoboSyncSettings updated = koboService.updateSettings(settings);
return ResponseEntity.ok(updated);
}
}

View File

@@ -13,6 +13,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@@ -35,8 +36,8 @@ public class LibraryController {
@Operation(summary = "Get a library by ID", description = "Retrieve details of a specific library by its ID.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Library details returned successfully"),
@ApiResponse(responseCode = "404", description = "Library not found")
@ApiResponse(responseCode = "200", description = "Library details returned successfully"),
@ApiResponse(responseCode = "404", description = "Library not found")
})
@GetMapping("/{libraryId}")
@CheckLibraryAccess(libraryIdParam = "libraryId")
@@ -50,7 +51,7 @@ public class LibraryController {
@PostMapping
@PreAuthorize("@securityUtil.canManipulateLibrary() or @securityUtil.isAdmin()")
public ResponseEntity<Library> createLibrary(
@Parameter(description = "Library creation request") @RequestBody CreateLibraryRequest request) {
@Parameter(description = "Library creation request") @Validated @RequestBody CreateLibraryRequest request) {
return ResponseEntity.ok(libraryService.createLibrary(request));
}
@@ -60,7 +61,7 @@ public class LibraryController {
@CheckLibraryAccess(libraryIdParam = "libraryId")
@PreAuthorize("@securityUtil.canManipulateLibrary() or @securityUtil.isAdmin()")
public ResponseEntity<Library> updateLibrary(
@Parameter(description = "Library update request") @RequestBody CreateLibraryRequest request,
@Parameter(description = "Library update request") @Validated @RequestBody CreateLibraryRequest request,
@Parameter(description = "ID of the library") @PathVariable Long libraryId) {
return ResponseEntity.ok(libraryService.updateLibrary(request, libraryId));
}

View File

@@ -72,8 +72,8 @@ public class MetadataController {
.updateThumbnail(true)
.mergeCategories(mergeCategories)
.replaceMode(MetadataReplaceMode.REPLACE_ALL)
.mergeMoods(true)
.mergeTags(true)
.mergeMoods(false)
.mergeTags(false)
.build();
bookMetadataUpdater.setBookMetadata(context);
@@ -156,6 +156,27 @@ public class MetadataController {
bookMetadataService.regenerateCover(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()")
public ResponseEntity<Void> regenerateCoversForBooks(
@Parameter(description = "List of book IDs") @Validated @RequestBody BulkBookIdsRequest request) {
bookMetadataService.regenerateCoversForBooks(request.getBookIds());
return ResponseEntity.noContent().build();
}
@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()")
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) {
bookMetadataService.updateCoverImageFromFileForBooks(bookIds, file);
return ResponseEntity.noContent().build();
}
@Operation(summary = "Recalculate metadata match scores", description = "Recalculate match scores for all metadata. Requires admin.")
@ApiResponse(responseCode = "204", description = "Match scores recalculated successfully")
@PostMapping("/metadata/recalculate-match-scores")

View File

@@ -0,0 +1,305 @@
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;
import com.adityachandel.booklore.repository.UserRepository;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import com.adityachandel.booklore.service.user.UserProvisioningService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
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.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.regex.Pattern;
/**
* Controller for handling OIDC authentication for mobile applications.
*
* Mobile apps cannot use the standard web-based OIDC callback because they need
* to receive tokens via a custom URL scheme (e.g., booknexus://callback).
*
* This controller provides endpoints that:
* 1. Accept the OIDC authorization code from the mobile app
* 2. Exchange it for OIDC tokens with the identity provider
* 3. Validate the tokens and provision/authenticate the user
* 4. Return Booklore JWT tokens to the mobile app
*/
@Tag(name = "Mobile OIDC", description = "Endpoints for mobile app OIDC authentication")
@Slf4j
@AllArgsConstructor
@RestController
@RequestMapping("/api/v1/auth/mobile")
public class MobileOidcController {
private static final Pattern TRAILING_SLASHES_PATTERN = Pattern.compile("/+$");
private final AppSettingService appSettingService;
private final UserRepository userRepository;
private final UserProvisioningService userProvisioningService;
private final AuthenticationService authenticationService;
private final BookLoreUserTransformer bookLoreUserTransformer;
private final ObjectMapper objectMapper;
private static final ConcurrentMap<String, Object> userLocks = new ConcurrentHashMap<>();
@Operation(
summary = "Exchange OIDC authorization code for tokens",
description = "Exchanges an OIDC authorization code for Booklore JWT tokens. " +
"The mobile app should call this endpoint after receiving the authorization code " +
"from the OIDC provider. This endpoint will exchange the code for OIDC tokens, " +
"validate them, provision the user if needed, and return Booklore JWT tokens."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Tokens issued successfully"),
@ApiResponse(responseCode = "400", description = "Invalid request or OIDC error"),
@ApiResponse(responseCode = "401", description = "Authentication failed"),
@ApiResponse(responseCode = "403", description = "OIDC is not enabled")
})
@PostMapping("/oidc/callback")
public ResponseEntity<Map<String, String>> handleOidcCallback(
@Parameter(description = "Authorization code from OIDC provider")
@RequestParam("code") String code,
@Parameter(description = "PKCE code verifier used when initiating the auth request")
@RequestParam("code_verifier") String codeVerifier,
@Parameter(description = "Redirect URI that was used in the authorization request")
@RequestParam("redirect_uri") String redirectUri) {
log.info("Mobile OIDC callback received");
// Verify OIDC is enabled
if (!appSettingService.getAppSettings().isOidcEnabled()) {
throw ApiError.FORBIDDEN.createException("OIDC is not enabled on this server");
}
OidcProviderDetails providerDetails = appSettingService.getAppSettings().getOidcProviderDetails();
if (providerDetails == null || providerDetails.getIssuerUri() == null) {
throw ApiError.FORBIDDEN.createException("OIDC is not properly configured");
}
try {
// Discover token endpoint
String tokenEndpoint = discoverTokenEndpoint(providerDetails.getIssuerUri());
// Exchange authorization code for tokens
Map<String, Object> tokenResponse = exchangeCodeForTokens(
tokenEndpoint,
code,
codeVerifier,
redirectUri,
providerDetails.getClientId()
);
// Extract and validate ID token
String idToken = (String) tokenResponse.get("id_token");
if (idToken == null) {
// Some providers may only return access_token, try to use that
idToken = (String) tokenResponse.get("access_token");
}
if (idToken == null) {
throw ApiError.GENERIC_UNAUTHORIZED.createException("No token received from OIDC provider");
}
// Parse the JWT to extract claims
SignedJWT signedJWT = SignedJWT.parse(idToken);
JWTClaimsSet claims = signedJWT.getJWTClaimsSet();
// Extract user information using claim mappings
OidcProviderDetails.ClaimMapping claimMapping = providerDetails.getClaimMapping();
String username = claims.getStringClaim(claimMapping.getUsername());
String email = claims.getStringClaim(claimMapping.getEmail());
String name = claims.getStringClaim(claimMapping.getName());
if (username == null || username.isEmpty()) {
// Fall back to email or subject if username claim is not available
username = email != null ? email : claims.getSubject();
}
log.info("Mobile OIDC: Authenticating user '{}'", username);
// Find or provision user
BookLoreUserEntity userEntity = findOrProvisionUser(username, email, name);
// Generate Booklore JWT tokens
return authenticationService.loginUser(userEntity);
} catch (APIException e) {
throw e;
} catch (Exception e) {
log.error("Mobile OIDC authentication failed", e);
throw ApiError.GENERIC_UNAUTHORIZED.createException("OIDC authentication failed: " + e.getMessage());
}
}
@Operation(
summary = "Mobile OIDC redirect callback",
description = "Alternative endpoint that redirects to a mobile app URL scheme with tokens. " +
"Use this if your mobile app prefers to receive tokens via URL redirect rather than API response."
)
@ApiResponses({
@ApiResponse(responseCode = "302", description = "Redirect to mobile app with tokens"),
@ApiResponse(responseCode = "400", description = "Invalid request or OIDC error"),
@ApiResponse(responseCode = "403", description = "OIDC is not enabled")
})
@GetMapping("/oidc/redirect")
public ResponseEntity<Void> handleOidcRedirect(
@Parameter(description = "Authorization code from OIDC provider")
@RequestParam("code") String code,
@Parameter(description = "PKCE code verifier used when initiating the auth request")
@RequestParam("code_verifier") String codeVerifier,
@Parameter(description = "Redirect URI that was used in the authorization request")
@RequestParam("redirect_uri") String redirectUri,
@Parameter(description = "Mobile app URL scheme to redirect to (e.g., booknexus://callback)")
@RequestParam("app_redirect_uri") String appRedirectUri) {
try {
// Use the callback handler to get tokens
ResponseEntity<Map<String, String>> tokenResponse = handleOidcCallback(code, codeVerifier, redirectUri);
Map<String, String> tokens = tokenResponse.getBody();
if (tokens == null) {
throw ApiError.GENERIC_UNAUTHORIZED.createException("Failed to obtain tokens");
}
// Build redirect URL with tokens as query parameters
StringBuilder redirectUrl = new StringBuilder(appRedirectUri);
redirectUrl.append(appRedirectUri.contains("?") ? "&" : "?");
redirectUrl.append("access_token=").append(URLEncoder.encode(tokens.get("accessToken"), StandardCharsets.UTF_8));
redirectUrl.append("&refresh_token=").append(URLEncoder.encode(tokens.get("refreshToken"), StandardCharsets.UTF_8));
if (tokens.containsKey("isDefaultPassword")) {
redirectUrl.append("&is_default_password=").append(URLEncoder.encode(tokens.get("isDefaultPassword"), StandardCharsets.UTF_8));
}
HttpHeaders headers = new HttpHeaders();
headers.setLocation(URI.create(redirectUrl.toString()));
log.info("Mobile OIDC: Redirecting to app with tokens");
return new ResponseEntity<>(headers, HttpStatus.FOUND);
} catch (APIException e) {
// Redirect to app with error
String errorRedirect = appRedirectUri +
(appRedirectUri.contains("?") ? "&" : "?") +
"error=" + URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8);
HttpHeaders headers = new HttpHeaders();
headers.setLocation(URI.create(errorRedirect));
return new ResponseEntity<>(headers, HttpStatus.FOUND);
}
}
/**
* Discover the token endpoint from the OIDC provider's well-known configuration.
*/
private String discoverTokenEndpoint(String issuerUri) throws Exception {
String discoveryUrl = TRAILING_SLASHES_PATTERN.matcher(issuerUri).replaceAll("") + "/.well-known/openid-configuration";
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<String> response = restTemplate.getForEntity(discoveryUrl, String.class);
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
throw new RuntimeException("Failed to fetch OIDC discovery document");
}
JsonNode discoveryDoc = objectMapper.readTree(response.getBody());
JsonNode tokenEndpointNode = discoveryDoc.get("token_endpoint");
if (tokenEndpointNode == null || tokenEndpointNode.isNull()) {
// Fall back to standard path
return TRAILING_SLASHES_PATTERN.matcher(issuerUri).replaceAll("") + "/protocol/openid-connect/token";
}
return tokenEndpointNode.asText();
}
/**
* Exchange the authorization code for tokens with the OIDC provider.
*/
private Map<String, Object> exchangeCodeForTokens(
String tokenEndpoint,
String code,
String codeVerifier,
String redirectUri,
String clientId) {
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "authorization_code");
body.add("client_id", clientId);
body.add("code", code);
body.add("redirect_uri", redirectUri);
body.add("code_verifier", codeVerifier);
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
try {
ResponseEntity<String> response = restTemplate.postForEntity(tokenEndpoint, request, String.class);
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
throw new RuntimeException("Token exchange failed with status: " + response.getStatusCode());
}
return objectMapper.readValue(response.getBody(), Map.class);
} catch (Exception e) {
log.error("Token exchange failed: {}", e.getMessage());
throw new RuntimeException("Failed to exchange authorization code: " + e.getMessage(), e);
}
}
/**
* Find existing user or provision a new one based on OIDC claims.
*/
private BookLoreUserEntity findOrProvisionUser(String username, String email, String name) {
OidcAutoProvisionDetails provisionDetails = appSettingService.getAppSettings().getOidcAutoProvisionDetails();
boolean autoProvision = provisionDetails != null && provisionDetails.isEnableAutoProvisioning();
return userRepository.findByUsername(username)
.orElseGet(() -> {
if (!autoProvision) {
log.warn("User '{}' not found and auto-provisioning is disabled.", username);
throw ApiError.GENERIC_UNAUTHORIZED.createException("User not found and auto-provisioning is disabled.");
}
Object lock = userLocks.computeIfAbsent(username, k -> new Object());
try {
synchronized (lock) {
return userRepository.findByUsername(username)
.orElseGet(() -> {
log.info("Mobile OIDC: Provisioning new user '{}'", username);
return userProvisioningService.provisionOidcUser(username, email, name, provisionDetails);
});
}
} finally {
userLocks.remove(username);
}
});
}
}

View File

@@ -97,6 +97,26 @@ public class OpdsController {
.body(feed);
}
@Operation(summary = "Get OPDS authors navigation", description = "Retrieve the OPDS authors navigation feed.")
@ApiResponse(responseCode = "200", description = "Authors navigation feed returned successfully")
@GetMapping(value = "/authors", produces = OPDS_CATALOG_MEDIA_TYPE)
public ResponseEntity<String> getAuthorsNavigation(@Parameter(hidden = true) HttpServletRequest request) {
String feed = opdsFeedService.generateAuthorsNavigation(request);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(OPDS_CATALOG_MEDIA_TYPE))
.body(feed);
}
@Operation(summary = "Get OPDS series navigation", description = "Retrieve the OPDS series navigation feed.")
@ApiResponse(responseCode = "200", description = "Series navigation feed returned successfully")
@GetMapping(value = "/series", produces = OPDS_CATALOG_MEDIA_TYPE)
public ResponseEntity<String> getSeriesNavigation(@Parameter(hidden = true) HttpServletRequest request) {
String feed = opdsFeedService.generateSeriesNavigation(request);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(OPDS_CATALOG_MEDIA_TYPE))
.body(feed);
}
@Operation(summary = "Get OPDS catalog feed", description = "Retrieve the OPDS acquisition catalog feed.")
@ApiResponse(responseCode = "200", description = "Catalog feed returned successfully")
@GetMapping(value = "/catalog", produces = OPDS_ACQUISITION_MEDIA_TYPE)

View File

@@ -2,6 +2,7 @@ package com.adityachandel.booklore.controller;
import com.adityachandel.booklore.model.dto.OpdsUserV2;
import com.adityachandel.booklore.model.dto.request.OpdsUserV2CreateRequest;
import com.adityachandel.booklore.model.dto.request.OpdsUserV2UpdateRequest;
import com.adityachandel.booklore.service.opds.OpdsUserV2Service;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -46,4 +47,13 @@ public class OpdsUserV2Controller {
@Parameter(description = "ID of the OPDS user to delete") @PathVariable Long id) {
service.deleteOpdsUser(id);
}
}
@Operation(summary = "Update OPDS user", description = "Update an OPDS user's settings by ID.")
@ApiResponse(responseCode = "200", description = "OPDS user updated successfully")
@PatchMapping("/{id}")
@PreAuthorize("@securityUtil.isAdmin() or @securityUtil.canAccessOpds()")
public OpdsUserV2 updateUser(
@Parameter(description = "ID of the OPDS user to update") @PathVariable Long id,
@Parameter(description = "OPDS user update request") @RequestBody OpdsUserV2UpdateRequest updateRequest) {
return service.updateOpdsUser(id, updateRequest);
}}

View File

@@ -54,7 +54,8 @@ public enum ApiError {
FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "File not found: %s"),
SHELF_CANNOT_BE_DELETED(HttpStatus.FORBIDDEN, "'%s' shelf can't be deleted" ),
TASK_NOT_FOUND(HttpStatus.NOT_FOUND, "Scheduled task not found: %s"),
TASK_ALREADY_RUNNING(HttpStatus.CONFLICT, "Task is already running: %s"),;
TASK_ALREADY_RUNNING(HttpStatus.CONFLICT, "Task is already running: %s"),
ICON_ALREADY_EXISTS(HttpStatus.CONFLICT, "SVG icon with name '%s' already exists");
private final HttpStatus status;
private final String message;

View File

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

View File

@@ -61,14 +61,17 @@ public class BookLoreUserTransformer {
case NEW_PDF_READER_SETTING -> userSettings.setNewPdfReaderSetting(objectMapper.readValue(value, BookLoreUser.UserSettings.NewPdfReaderSetting.class));
case SIDEBAR_LIBRARY_SORTING -> userSettings.setSidebarLibrarySorting(objectMapper.readValue(value, SidebarSortOption.class));
case SIDEBAR_SHELF_SORTING -> userSettings.setSidebarShelfSorting(objectMapper.readValue(value, SidebarSortOption.class));
case SIDEBAR_MAGIC_SHELF_SORTING -> userSettings.setSidebarMagicShelfSorting(objectMapper.readValue(value, SidebarSortOption.class));
case ENTITY_VIEW_PREFERENCES -> userSettings.setEntityViewPreferences(objectMapper.readValue(value, BookLoreUser.UserSettings.EntityViewPreferences.class));
case TABLE_COLUMN_PREFERENCE -> userSettings.setTableColumnPreference(objectMapper.readValue(value, new TypeReference<>() {}));
case DASHBOARD_CONFIG -> userSettings.setDashboardConfig(objectMapper.readValue(value, BookLoreUser.UserSettings.DashboardConfig.class));
}
} else {
switch (settingKey) {
case FILTER_MODE -> userSettings.setFilterMode(value);
case FILTER_SORTING_MODE -> userSettings.setFilterSortingMode(value);
case METADATA_CENTER_VIEW_MODE -> userSettings.setMetadataCenterViewMode(value);
case ENABLE_SERIES_VIEW -> userSettings.setEnableSeriesView(Boolean.parseBoolean(value));
}
}
} catch (IllegalArgumentException e) {

View File

@@ -19,6 +19,7 @@ public class MetadataClearFlags {
private boolean goodreadsId;
private boolean comicvineId;
private boolean hardcoverId;
private boolean hardcoverBookId;
private boolean googleId;
private boolean pageCount;
private boolean language;
@@ -28,7 +29,6 @@ public class MetadataClearFlags {
private boolean goodreadsReviewCount;
private boolean hardcoverRating;
private boolean hardcoverReviewCount;
private boolean personalRating;
private boolean authors;
private boolean categories;
private boolean moods;

View File

@@ -32,6 +32,7 @@ public class Book {
private CbxProgress cbxProgress;
private KoProgress koreaderProgress;
private KoboProgress koboProgress;
private Integer personalRating;
private Set<Shelf> shelves;
private String readStatus;
private Instant dateFinished;

View File

@@ -47,11 +47,14 @@ public class BookLoreUser {
public CbxReaderSetting cbxReaderSetting;
public SidebarSortOption sidebarLibrarySorting;
public SidebarSortOption sidebarShelfSorting;
public SidebarSortOption sidebarMagicShelfSorting;
public EntityViewPreferences entityViewPreferences;
public List<TableColumnPreference> tableColumnPreference;
public String filterMode;
public String filterSortingMode;
public String metadataCenterViewMode;
public boolean koReaderEnabled;
public boolean enableSeriesView;
public DashboardConfig dashboardConfig;
@Data

View File

@@ -0,0 +1,20 @@
package com.adityachandel.booklore.model.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BookMark {
private Long id;
private Long bookId;
private String cfi;
private String title;
private LocalDateTime createdAt;
}

View File

@@ -29,7 +29,6 @@ public class BookMetadata {
private String isbn10;
private Integer pageCount;
private String language;
private Double rating;
private String asin;
private Double amazonRating;
private Integer amazonReviewCount;
@@ -38,12 +37,12 @@ public class BookMetadata {
private Double goodreadsRating;
private Integer goodreadsReviewCount;
private String hardcoverId;
private Integer hardcoverBookId;
private Double hardcoverRating;
private Integer hardcoverReviewCount;
private String doubanId;
private Double doubanRating;
private Integer doubanReviewCount;
private Double personalRating;
private String googleId;
private Instant coverUpdatedOn;
private Set<String> authors;
@@ -68,11 +67,11 @@ public class BookMetadata {
private Boolean goodreadsIdLocked;
private Boolean comicvineIdLocked;
private Boolean hardcoverIdLocked;
private Boolean hardcoverBookIdLocked;
private Boolean doubanIdLocked;
private Boolean googleIdLocked;
private Boolean pageCountLocked;
private Boolean languageLocked;
private Boolean personalRatingLocked;
private Boolean amazonRatingLocked;
private Boolean amazonReviewCountLocked;
private Boolean goodreadsRatingLocked;

View File

@@ -0,0 +1,20 @@
package com.adityachandel.booklore.model.dto;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreateBookMarkRequest {
@NotNull
private Long bookId;
@NotEmpty
private String cfi;
private String title;
}

View File

@@ -31,7 +31,6 @@ public class EpubMetadata {
private Integer pageCount;
private String language;
private String asin;
private Double personalRating;
private Double amazonRating;
private Integer amazonReviewCount;
private String goodreadsId;

View File

@@ -11,4 +11,7 @@ public class KoboSyncSettings {
private boolean syncEnabled;
private Float progressMarkAsReadingThreshold;
private Float progressMarkAsFinishedThreshold;
private boolean autoAddToShelf;
private String hardcoverApiKey;
private boolean hardcoverSyncEnabled;
}

View File

@@ -1,6 +1,7 @@
package com.adityachandel.booklore.model.dto;
import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.model.enums.IconType;
import com.adityachandel.booklore.model.enums.LibraryScanMode;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
@@ -16,6 +17,7 @@ public class Library {
private String name;
private Sort sort;
private String icon;
private IconType iconType;
private String fileNamingPattern;
private boolean watch;
private List<LibraryPath> paths;

View File

@@ -1,5 +1,6 @@
package com.adityachandel.booklore.model.dto;
import com.adityachandel.booklore.model.enums.IconType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
@@ -18,6 +19,8 @@ public class MagicShelf {
@Size(max = 64, message = "Icon must not exceed 64 characters")
private String icon;
private IconType iconType;
@NotNull(message = "Filter JSON must not be null")
@Size(min = 2, message = "Filter JSON must not be empty")
private String filterJson;

View File

@@ -1,5 +1,6 @@
package com.adityachandel.booklore.model.dto;
import com.adityachandel.booklore.model.enums.OpdsSortOrder;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.*;
@@ -14,4 +15,5 @@ public class OpdsUserV2 {
private String username;
@JsonIgnore
private String passwordHash;
private OpdsSortOrder sortOrder;
}

View File

@@ -1,5 +1,6 @@
package com.adityachandel.booklore.model.dto;
import com.adityachandel.booklore.model.enums.IconType;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
@@ -11,6 +12,7 @@ public class Shelf {
private Long id;
private String name;
private String icon;
private IconType iconType;
private Sort sort;
private Long userId;
}

View File

@@ -52,5 +52,6 @@ public class BookEntitlement {
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class ActivePeriod {
private String from;
private String to;
}
}

View File

@@ -0,0 +1,20 @@
package com.adityachandel.booklore.model.dto.request;
import com.adityachandel.booklore.model.dto.BookMetadata;
import lombok.Data;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.Set;
@Data
public class BookdropBulkEditRequest {
@NotNull
private BookMetadata fields;
@NotNull
private Set<String> enabledFields;
private boolean mergeArrays;
private boolean selectAll;
private List<Long> excludedIds;
private List<Long> selectedIds;
}

View File

@@ -0,0 +1,16 @@
package com.adityachandel.booklore.model.dto.request;
import lombok.Data;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
@Data
public class BookdropPatternExtractRequest {
@NotBlank
private String pattern;
private Boolean selectAll;
private List<Long> excludedIds;
private List<Long> selectedIds;
private Boolean preview;
}

View File

@@ -0,0 +1,12 @@
package com.adityachandel.booklore.model.dto.request;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.Set;
@Data
public class BulkBookIdsRequest {
@NotEmpty(message = "At least one book ID is required")
private Set<Long> bookIds;
}

View File

@@ -2,10 +2,12 @@ package com.adityachandel.booklore.model.dto.request;
import com.adityachandel.booklore.model.dto.LibraryPath;
import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.model.enums.IconType;
import com.adityachandel.booklore.model.enums.LibraryScanMode;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Builder;
import lombok.Data;
@@ -15,12 +17,18 @@ import java.util.List;
@Builder
@JsonIgnoreProperties(ignoreUnknown = true)
public class CreateLibraryRequest {
@NotBlank
@NotBlank(message = "Library name must not be empty.")
private String name;
@NotBlank
@NotBlank(message = "Library icon must not be empty.")
private String icon;
@NotEmpty
@NotNull(message = "Library icon type must not be null.")
private IconType iconType;
@NotEmpty(message = "Library paths must not be empty.")
private List<LibraryPath> paths;
private boolean watch;
private LibraryScanMode scanMode;
private BookFileType defaultBookFormat;

View File

@@ -1,9 +1,11 @@
package com.adityachandel.booklore.model.dto.request;
import com.adityachandel.booklore.model.enums.OpdsSortOrder;
import lombok.Data;
@Data
public class OpdsUserV2CreateRequest {
private String username;
private String password;
private OpdsSortOrder sortOrder;
}

View File

@@ -0,0 +1,10 @@
package com.adityachandel.booklore.model.dto.request;
import com.adityachandel.booklore.model.enums.OpdsSortOrder;
import jakarta.validation.constraints.NotNull;
public record OpdsUserV2UpdateRequest(
@NotNull(message = "Sort order is required")
OpdsSortOrder sortOrder
) {
}

View File

@@ -0,0 +1,6 @@
package com.adityachandel.booklore.model.dto.request;
import java.util.List;
public record PersonalRatingUpdateRequest(List<Long> ids, Integer rating) {
}

View File

@@ -1,6 +1,8 @@
package com.adityachandel.booklore.model.dto.request;
import com.adityachandel.booklore.model.enums.IconType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Null;
import lombok.Builder;
import lombok.Data;
@@ -8,7 +10,6 @@ import lombok.Data;
@Builder
@Data
public class ShelfCreateRequest {
@Null(message = "Id should be null for creation.")
private Long id;
@@ -17,4 +18,7 @@ public class ShelfCreateRequest {
@NotBlank(message = "Shelf icon must not be empty.")
private String icon;
@NotNull(message = "Shelf icon type must not be null.")
private IconType iconType;
}

View File

@@ -0,0 +1,20 @@
package com.adityachandel.booklore.model.dto.request;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SvgIconBatchRequest {
@NotEmpty(message = "Icons list cannot be empty")
@Valid
private List<SvgIconCreateRequest> icons;
}

View File

@@ -0,0 +1,18 @@
package com.adityachandel.booklore.model.dto.request;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class SvgIconCreateRequest {
@NotBlank(message = "SVG name is required")
@Size(min = 1, max = 255, message = "SVG name must be between 1 and 255 characters")
@Pattern(regexp = "^[a-zA-Z0-9-]+$", message = "SVG name can only contain alphanumeric characters and hyphens")
private String svgName;
@NotBlank(message = "SVG data is required")
@Size(max = 1048576, message = "SVG data must not exceed 1MB")
private String svgData;
}

View File

@@ -0,0 +1,12 @@
package com.adityachandel.booklore.model.dto.response;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class BookdropBulkEditResult {
private int totalFiles;
private int successfullyUpdated;
private int failed;
}

View File

@@ -0,0 +1,26 @@
package com.adityachandel.booklore.model.dto.response;
import com.adityachandel.booklore.model.dto.BookMetadata;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class BookdropPatternExtractResult {
private int totalFiles;
private int successfullyExtracted;
private int failed;
private List<FileExtractionResult> results;
@Data
@Builder
public static class FileExtractionResult {
private Long fileId;
private String fileName;
private boolean success;
private BookMetadata extractedMetadata;
private String errorMessage;
}
}

View File

@@ -0,0 +1,30 @@
package com.adityachandel.booklore.model.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SvgIconBatchResponse {
private int totalRequested;
private int successCount;
private int failureCount;
private List<IconSaveResult> results;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class IconSaveResult {
private String iconName;
private boolean success;
private String errorMessage;
}
}

View File

@@ -16,6 +16,7 @@ public enum AppSettingKey {
METADATA_PERSISTENCE_SETTINGS("metadata_persistence_settings", true, false),
METADATA_PUBLIC_REVIEWS_SETTINGS("metadata_public_reviews_settings", true, false),
KOBO_SETTINGS("kobo_settings", true, false),
COVER_CROPPING_SETTINGS("cover_cropping_settings", true, false),
AUTO_BOOK_SEARCH("auto_book_search", false, false),
COVER_IMAGE_RESOLUTION("cover_image_resolution", false, false),

View File

@@ -33,4 +33,5 @@ public class AppSettings {
private MetadataPersistenceSettings metadataPersistenceSettings;
private MetadataPublicReviewsSettings metadataPublicReviewsSettings;
private KoboSettings koboSettings;
private CoverCroppingSettings coverCroppingSettings;
}

View File

@@ -0,0 +1,13 @@
package com.adityachandel.booklore.model.dto.settings;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class CoverCroppingSettings {
private boolean verticalCroppingEnabled;
private boolean horizontalCroppingEnabled;
private double aspectRatioThreshold;
private boolean smartCroppingEnabled;
}

View File

@@ -11,4 +11,5 @@ public class KoboSettings {
private boolean convertCbxToEpub;
private int conversionLimitInMbForCbx;
private boolean forceEnableHyphenation;
private int conversionImageCompressionPercentage;
}

View File

@@ -11,12 +11,14 @@ public enum UserSettingKey {
CBX_READER_SETTING("cbxReaderSetting", true),
SIDEBAR_LIBRARY_SORTING("sidebarLibrarySorting", true),
SIDEBAR_SHELF_SORTING("sidebarShelfSorting", true),
SIDEBAR_MAGIC_SHELF_SORTING("sidebarMagicShelfSorting", true),
ENTITY_VIEW_PREFERENCES("entityViewPreferences", true),
TABLE_COLUMN_PREFERENCE("tableColumnPreference", true),
DASHBOARD_CONFIG("dashboardConfig", true),
FILTER_MODE("filterMode", false),
FILTER_SORTING_MODE("filterSortingMode", false),
METADATA_CENTER_VIEW_MODE("metadataCenterViewMode", false);
METADATA_CENTER_VIEW_MODE("metadataCenterViewMode", false),
ENABLE_SERIES_VIEW("enableSeriesView", false);
private final String dbKey;

View File

@@ -29,7 +29,7 @@ public class AuthorEntity {
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof AuthorEntity that)) return false;
return getId() != null && Objects.equals(getId(), that.getId());
return id != null && Objects.equals(id, that.id);
}
@Override

View File

@@ -0,0 +1,45 @@
package com.adityachandel.booklore.model.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "book_marks")
public class BookMarkEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private BookLoreUserEntity user;
@Column(name = "user_id", insertable = false, updatable = false)
private Long userId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "book_id", nullable = false)
private BookEntity book;
@Column(name = "book_id", insertable = false, updatable = false)
private Long bookId;
@Column(name = "cfi", nullable = false, length = 1000)
private String cfi;
@Column(name = "title")
private String title;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}

View File

@@ -1,5 +1,6 @@
package com.adityachandel.booklore.model.entity;
import com.adityachandel.booklore.util.BookUtils;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.*;
@@ -87,9 +88,6 @@ public class BookMetadataEntity {
@Column(name = "hardcover_review_count")
private Integer hardcoverReviewCount;
@Column(name = "personal_rating")
private Double personalRating;
@Column(name = "asin", length = 10)
private String asin;
@@ -99,6 +97,9 @@ public class BookMetadataEntity {
@Column(name = "hardcover_id", length = 100)
private String hardcoverId;
@Column(name = "hardcover_book_id")
private Integer hardcoverBookId;
@Column(name = "google_id", length = 100)
private String googleId;
@@ -170,10 +171,6 @@ public class BookMetadataEntity {
@Builder.Default
private Boolean hardcoverReviewCountLocked = Boolean.FALSE;
@Column(name = "personal_rating_locked")
@Builder.Default
private Boolean personalRatingLocked = Boolean.FALSE;
@Column(name = "cover_locked")
@Builder.Default
private Boolean coverLocked = Boolean.FALSE;
@@ -214,6 +211,10 @@ public class BookMetadataEntity {
@Builder.Default
private Boolean hardcoverIdLocked = Boolean.FALSE;
@Column(name = "hardcover_book_id_locked")
@Builder.Default
private Boolean hardcoverBookIdLocked = Boolean.FALSE;
@Column(name = "google_id_locked")
@Builder.Default
private Boolean googleIdLocked = Boolean.FALSE;
@@ -232,6 +233,15 @@ public class BookMetadataEntity {
@Column(name = "embedding_updated_at")
private Instant embeddingUpdatedAt;
@Column(name = "search_text", columnDefinition = "TEXT")
private String searchText;
@PrePersist
@PreUpdate
public void updateSearchText() {
this.searchText = BookUtils.buildSearchText(this);
}
@OneToOne(fetch = FetchType.LAZY)
@MapsId
@JoinColumn(name = "book_id")
@@ -304,9 +314,9 @@ public class BookMetadataEntity {
this.hardcoverRatingLocked = lock;
this.hardcoverReviewCountLocked = lock;
this.comicvineIdLocked = lock;
this.personalRatingLocked = lock;
this.goodreadsIdLocked = lock;
this.hardcoverIdLocked = lock;
this.hardcoverBookIdLocked = lock;
this.googleIdLocked = lock;
this.reviewsLocked = lock;
}
@@ -336,10 +346,10 @@ public class BookMetadataEntity {
&& Boolean.TRUE.equals(this.goodreadsReviewCountLocked)
&& Boolean.TRUE.equals(this.hardcoverRatingLocked)
&& Boolean.TRUE.equals(this.hardcoverReviewCountLocked)
&& Boolean.TRUE.equals(this.personalRatingLocked)
&& 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.reviewsLocked)
;

View File

@@ -31,7 +31,7 @@ public class CategoryEntity {
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof CategoryEntity that)) return false;
return getId() != null && Objects.equals(getId(), that.getId());
return id != null && Objects.equals(id, that.id);
}
@Override

View File

@@ -33,4 +33,15 @@ public class KoboUserSettingsEntity {
@Column(name = "progress_mark_as_finished_threshold")
@Builder.Default
private Float progressMarkAsFinishedThreshold = 99f;
@Column(name = "auto_add_to_shelf")
@Builder.Default
private boolean autoAddToShelf = false;
@Column(name = "hardcover_api_key", length = 2048)
private String hardcoverApiKey;
@Column(name = "hardcover_sync_enabled")
@Builder.Default
private boolean hardcoverSyncEnabled = false;
}

View File

@@ -3,6 +3,7 @@ package com.adityachandel.booklore.model.entity;
import com.adityachandel.booklore.convertor.SortConverter;
import com.adityachandel.booklore.model.dto.Sort;
import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.model.enums.IconType;
import com.adityachandel.booklore.model.enums.LibraryScanMode;
import jakarta.persistence.*;
import lombok.*;
@@ -40,6 +41,11 @@ public class LibraryEntity {
private String icon;
@Enumerated(EnumType.STRING)
@Column(name = "icon_type", nullable = false)
@Builder.Default
private IconType iconType = IconType.PRIME_NG;
@Column(name = "file_naming_pattern")
private String fileNamingPattern;
@@ -51,4 +57,11 @@ public class LibraryEntity {
@Enumerated(EnumType.STRING)
@Column(name = "default_book_format")
private BookFileType defaultBookFormat;
@PrePersist
public void ensureIconType() {
if (this.iconType == null) {
this.iconType = IconType.PRIME_NG;
}
}
}

View File

@@ -1,5 +1,6 @@
package com.adityachandel.booklore.model.entity;
import com.adityachandel.booklore.model.enums.IconType;
import jakarta.persistence.*;
import lombok.*;
@@ -29,6 +30,11 @@ public class MagicShelfEntity {
@Column(nullable = false)
private String icon;
@Enumerated(EnumType.STRING)
@Column(name = "icon_type", nullable = false)
@Builder.Default
private IconType iconType = IconType.PRIME_NG;
@Column(name = "filter_json", columnDefinition = "json", nullable = false)
private String filterJson;
@@ -48,4 +54,11 @@ public class MagicShelfEntity {
public void onUpdate() {
updatedAt = LocalDateTime.now();
}
@PrePersist
public void ensureIconType() {
if (this.iconType == null) {
this.iconType = IconType.PRIME_NG;
}
}
}

View File

@@ -31,7 +31,7 @@ public class MoodEntity {
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof MoodEntity that)) return false;
return getId() != null && Objects.equals(getId(), that.getId());
return id != null && Objects.equals(id, that.id);
}
@Override

View File

@@ -1,5 +1,6 @@
package com.adityachandel.booklore.model.entity;
import com.adityachandel.booklore.model.enums.OpdsSortOrder;
import jakarta.persistence.*;
import lombok.*;
@@ -28,6 +29,11 @@ public class OpdsUserV2Entity {
@Column(name = "password_hash", nullable = false)
private String passwordHash;
@Enumerated(EnumType.STRING)
@Column(name = "sort_order", length = 20)
@Builder.Default
private OpdsSortOrder sortOrder = OpdsSortOrder.RECENT;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;

View File

@@ -2,6 +2,7 @@ package com.adityachandel.booklore.model.entity;
import com.adityachandel.booklore.convertor.SortConverter;
import com.adityachandel.booklore.model.dto.Sort;
import com.adityachandel.booklore.model.enums.IconType;
import jakarta.persistence.*;
import lombok.*;
@@ -34,6 +35,11 @@ public class ShelfEntity {
private String icon;
@Enumerated(EnumType.STRING)
@Column(name = "icon_type", nullable = false)
@Builder.Default
private IconType iconType = IconType.PRIME_NG;
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "book_shelf_mapping",

View File

@@ -31,7 +31,7 @@ public class TagEntity {
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof TagEntity that)) return false;
return getId() != null && Objects.equals(getId(), that.getId());
return id != null && Objects.equals(id, that.id);
}
@Override

View File

@@ -69,7 +69,7 @@ public class UserBookProgressEntity {
@Column(name = "kobo_location_type", length = 50)
private String koboLocationType;
@Column(name = "kobo_location_source", length = 50)
@Column(name = "kobo_location_source", length = 512)
private String koboLocationSource;
@Enumerated(EnumType.STRING)
@@ -93,4 +93,7 @@ public class UserBookProgressEntity {
@Column(name = "read_status_modified_time")
private Instant readStatusModifiedTime;
@Column(name = "personal_rating")
private Integer personalRating;
}

View File

@@ -13,7 +13,8 @@ public enum BookFileExtension {
EPUB("epub", BookFileType.EPUB),
CBZ("cbz", BookFileType.CBX),
CBR("cbr", BookFileType.CBX),
CB7("cb7", BookFileType.CBX);
CB7("cb7", BookFileType.CBX),
FB2("fb2", BookFileType.FB2);
private final String extension;
private final BookFileType type;

View File

@@ -1,5 +1,5 @@
package com.adityachandel.booklore.model.enums;
public enum BookFileType {
PDF, EPUB, CBX
PDF, EPUB, CBX, FB2
}

View File

@@ -0,0 +1,7 @@
package com.adityachandel.booklore.model.enums;
public enum IconType {
PRIME_NG,
CUSTOM_SVG
}

View File

@@ -0,0 +1,13 @@
package com.adityachandel.booklore.model.enums;
public enum OpdsSortOrder {
RECENT,
TITLE_ASC,
TITLE_DESC,
AUTHOR_ASC,
AUTHOR_DESC,
SERIES_ASC,
SERIES_DESC,
RATING_ASC,
RATING_DESC
}

View File

@@ -0,0 +1,19 @@
package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.BookMarkEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface BookMarkRepository extends JpaRepository<BookMarkEntity, Long> {
Optional<BookMarkEntity> findByIdAndUserId(Long id, Long userId);
@Query("SELECT b FROM BookMarkEntity b WHERE b.bookId = :bookId AND b.userId = :userId ORDER BY b.createdAt DESC")
List<BookMarkEntity> findByBookIdAndUserIdOrderByCreatedAtDesc(@Param("bookId") Long bookId, @Param("userId") Long userId);
boolean existsByCfiAndBookIdAndUserId(String cfi, Long bookId, Long userId);
}

View File

@@ -2,9 +2,12 @@ package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.*;
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.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
public interface BookMetadataRepository extends JpaRepository<BookMetadataEntity, Long> {
@@ -12,6 +15,11 @@ public interface BookMetadataRepository extends JpaRepository<BookMetadataEntity
@Query("SELECT m FROM BookMetadataEntity m WHERE m.bookId IN :bookIds")
List<BookMetadataEntity> getMetadataForBookIds(@Param("bookIds") List<Long> bookIds);
@Modifying
@Transactional
@Query("UPDATE BookMetadataEntity m SET m.coverUpdatedOn = :timestamp WHERE m.bookId = :bookId")
void updateCoverTimestamp(@Param("bookId") Long bookId, @Param("timestamp") Instant timestamp);
List<BookMetadataEntity> findAllByAuthorsContaining(AuthorEntity author);
List<BookMetadataEntity> findAllByCategoriesContaining(CategoryEntity category);

View File

@@ -20,7 +20,7 @@ public interface BookOpdsRepository extends JpaRepository<BookEntity, Long>, Jpa
// ALL BOOKS - Two Query Pattern
// ============================================
@Query("SELECT b.id FROM BookEntity b WHERE (b.deleted IS NULL OR b.deleted = false)")
@Query("SELECT b.id FROM BookEntity b WHERE (b.deleted IS NULL OR b.deleted = false) ORDER BY b.addedOn DESC")
Page<Long> findBookIds(Pageable pageable);
@EntityGraph(attributePaths = {"metadata", "additionalFiles", "shelves"})
@@ -40,7 +40,7 @@ public interface BookOpdsRepository extends JpaRepository<BookEntity, Long>, Jpa
// BOOKS BY LIBRARY IDs - Two Query Pattern
// ============================================
@Query("SELECT b.id FROM BookEntity b WHERE b.library.id IN :libraryIds AND (b.deleted IS NULL OR b.deleted = false)")
@Query("SELECT b.id FROM BookEntity b WHERE b.library.id IN :libraryIds AND (b.deleted IS NULL OR b.deleted = false) ORDER BY b.addedOn DESC")
Page<Long> findBookIdsByLibraryIds(@Param("libraryIds") Collection<Long> libraryIds, Pageable pageable);
@EntityGraph(attributePaths = {"metadata", "additionalFiles", "shelves"})
@@ -60,7 +60,7 @@ public interface BookOpdsRepository extends JpaRepository<BookEntity, Long>, Jpa
// BOOKS BY SHELF ID - Two Query Pattern
// ============================================
@Query("SELECT DISTINCT b.id FROM BookEntity b JOIN b.shelves s WHERE s.id = :shelfId AND (b.deleted IS NULL OR b.deleted = false)")
@Query("SELECT DISTINCT b.id FROM BookEntity b JOIN b.shelves s WHERE s.id = :shelfId AND (b.deleted IS NULL OR b.deleted = false) ORDER BY b.addedOn DESC")
Page<Long> findBookIdsByShelfId(@Param("shelfId") Long shelfId, Pageable pageable);
@EntityGraph(attributePaths = {"metadata", "additionalFiles", "shelves"})
@@ -74,13 +74,10 @@ public interface BookOpdsRepository extends JpaRepository<BookEntity, Long>, Jpa
@Query("""
SELECT DISTINCT b.id FROM BookEntity b
LEFT JOIN b.metadata m
LEFT JOIN m.authors a
WHERE (b.deleted IS NULL OR b.deleted = false) AND (
LOWER(m.title) LIKE LOWER(CONCAT('%', :text, '%'))
OR LOWER(m.subtitle) LIKE LOWER(CONCAT('%', :text, '%'))
OR LOWER(m.seriesName) LIKE LOWER(CONCAT('%', :text, '%'))
OR LOWER(a.name) LIKE LOWER(CONCAT('%', :text, '%'))
m.searchText LIKE CONCAT('%', :text, '%')
)
ORDER BY b.addedOn DESC
""")
Page<Long> findBookIdsByMetadataSearch(@Param("text") String text, Pageable pageable);
@@ -95,15 +92,12 @@ public interface BookOpdsRepository extends JpaRepository<BookEntity, Long>, Jpa
@Query("""
SELECT DISTINCT b.id FROM BookEntity b
LEFT JOIN b.metadata m
LEFT JOIN m.authors a
WHERE (b.deleted IS NULL OR b.deleted = false)
AND b.library.id IN :libraryIds
AND (
LOWER(m.title) LIKE LOWER(CONCAT('%', :text, '%'))
OR LOWER(m.subtitle) LIKE LOWER(CONCAT('%', :text, '%'))
OR LOWER(m.seriesName) LIKE LOWER(CONCAT('%', :text, '%'))
OR LOWER(a.name) LIKE LOWER(CONCAT('%', :text, '%'))
m.searchText LIKE CONCAT('%', :text, '%')
)
ORDER BY b.addedOn DESC
""")
Page<Long> findBookIdsByMetadataSearchAndLibraryIds(@Param("text") String text, @Param("libraryIds") Collection<Long> libraryIds, Pageable pageable);
@@ -120,4 +114,100 @@ public interface BookOpdsRepository extends JpaRepository<BookEntity, Long>, Jpa
@Query(value = "SELECT b.id FROM BookEntity b WHERE b.library.id IN :libraryIds AND (b.deleted IS NULL OR b.deleted = false) ORDER BY function('RAND')", nativeQuery = false)
List<Long> findRandomBookIdsByLibraryIds(@Param("libraryIds") Collection<Long> libraryIds);
}
// ============================================
// AUTHORS - Distinct Authors List
// ============================================
@Query("""
SELECT DISTINCT a FROM AuthorEntity a
JOIN a.bookMetadataEntityList m
JOIN m.book b
WHERE (b.deleted IS NULL OR b.deleted = false)
ORDER BY a.name
""")
List<com.adityachandel.booklore.model.entity.AuthorEntity> findDistinctAuthors();
@Query("""
SELECT DISTINCT a FROM AuthorEntity a
JOIN a.bookMetadataEntityList m
JOIN m.book b
WHERE (b.deleted IS NULL OR b.deleted = false)
AND b.library.id IN :libraryIds
ORDER BY a.name
""")
List<com.adityachandel.booklore.model.entity.AuthorEntity> findDistinctAuthorsByLibraryIds(@Param("libraryIds") Collection<Long> libraryIds);
// ============================================
// BOOKS BY AUTHOR - Two Query Pattern
// ============================================
@Query("""
SELECT DISTINCT b.id FROM BookEntity b
JOIN b.metadata m
JOIN m.authors a
WHERE a.name = :authorName
AND (b.deleted IS NULL OR b.deleted = false)
ORDER BY b.addedOn DESC
""")
Page<Long> findBookIdsByAuthorName(@Param("authorName") String authorName, Pageable pageable);
@Query("""
SELECT DISTINCT b.id FROM BookEntity b
JOIN b.metadata m
JOIN m.authors a
WHERE a.name = :authorName
AND b.library.id IN :libraryIds
AND (b.deleted IS NULL OR b.deleted = false)
ORDER BY b.addedOn DESC
""")
Page<Long> findBookIdsByAuthorNameAndLibraryIds(@Param("authorName") String authorName, @Param("libraryIds") Collection<Long> libraryIds, Pageable pageable);
// ============================================
// SERIES - Distinct Series List
// ============================================
@Query("""
SELECT DISTINCT m.seriesName FROM BookMetadataEntity m
JOIN m.book b
WHERE (b.deleted IS NULL OR b.deleted = false)
AND m.seriesName IS NOT NULL
AND m.seriesName != ''
ORDER BY m.seriesName
""")
List<String> findDistinctSeries();
@Query("""
SELECT DISTINCT m.seriesName FROM BookMetadataEntity m
JOIN m.book b
WHERE (b.deleted IS NULL OR b.deleted = false)
AND b.library.id IN :libraryIds
AND m.seriesName IS NOT NULL
AND m.seriesName != ''
ORDER BY m.seriesName
""")
List<String> findDistinctSeriesByLibraryIds(@Param("libraryIds") Collection<Long> libraryIds);
// ============================================
// BOOKS BY SERIES - Two Query Pattern (sorted by series number)
// ============================================
@Query("""
SELECT DISTINCT b.id FROM BookEntity b
JOIN b.metadata m
WHERE m.seriesName = :seriesName
AND (b.deleted IS NULL OR b.deleted = false)
ORDER BY COALESCE(m.seriesNumber, 999999), b.addedOn DESC
""")
Page<Long> findBookIdsBySeriesName(@Param("seriesName") String seriesName, Pageable pageable);
@Query("""
SELECT DISTINCT b.id FROM BookEntity b
JOIN b.metadata m
WHERE m.seriesName = :seriesName
AND b.library.id IN :libraryIds
AND (b.deleted IS NULL OR b.deleted = false)
ORDER BY COALESCE(m.seriesNumber, 999999), b.addedOn DESC
""")
Page<Long> findBookIdsBySeriesNameAndLibraryIds(@Param("seriesName") String seriesName, @Param("libraryIds") Collection<Long> libraryIds, Pageable pageable);
}

View File

@@ -72,26 +72,35 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
""")
List<BookEntity> findAllFullBooks();
@Query(value = """
SELECT DISTINCT b.* FROM book b
LEFT JOIN book_metadata m ON b.id = m.book_id
WHERE (b.deleted IS NULL OR b.deleted = false)
ORDER BY b.id
LIMIT :limit OFFSET :offset
""", nativeQuery = true)
List<BookEntity> findBooksForMigrationBatch(@Param("offset") int offset, @Param("limit") int limit);
@Query("""
SELECT DISTINCT b FROM BookEntity b
LEFT JOIN FETCH b.metadata m
LEFT JOIN FETCH m.authors
WHERE b.id IN :bookIds
""")
List<BookEntity> findBooksWithMetadataAndAuthors(@Param("bookIds") List<Long> bookIds);
@Query(value = """
SELECT DISTINCT b FROM BookEntity b
LEFT JOIN b.metadata m
LEFT JOIN m.authors a
WHERE (b.deleted IS NULL OR b.deleted = false) AND (
LOWER(m.title) LIKE LOWER(CONCAT('%', :text, '%'))
OR LOWER(m.subtitle) LIKE LOWER(CONCAT('%', :text, '%'))
OR LOWER(m.seriesName) LIKE LOWER(CONCAT('%', :text, '%'))
OR LOWER(a.name) LIKE LOWER(CONCAT('%', :text, '%'))
m.searchText LIKE CONCAT('%', :text, '%')
)
""",
countQuery = """
SELECT COUNT(DISTINCT b.id) FROM BookEntity b
LEFT JOIN b.metadata m
LEFT JOIN m.authors a
WHERE (b.deleted IS NULL OR b.deleted = false) AND (
LOWER(m.title) LIKE LOWER(CONCAT('%', :text, '%'))
OR LOWER(m.subtitle) LIKE LOWER(CONCAT('%', :text, '%'))
OR LOWER(m.seriesName) LIKE LOWER(CONCAT('%', :text, '%'))
OR LOWER(a.name) LIKE LOWER(CONCAT('%', :text, '%'))
m.searchText LIKE CONCAT('%', :text, '%')
)
""")
Page<BookEntity> searchByMetadata(@Param("text") String text, Pageable pageable);

View File

@@ -31,5 +31,8 @@ public interface BookdropFileRepository extends JpaRepository<BookdropFileEntity
@Query("SELECT f.id FROM BookdropFileEntity f WHERE f.id NOT IN :excludedIds")
List<Long> findAllExcludingIdsFlat(@Param("excludedIds") List<Long> excludedIds);
@Query("SELECT f.id FROM BookdropFileEntity f")
List<Long> findAllIds();
}

View File

@@ -4,6 +4,7 @@ import com.adityachandel.booklore.model.entity.KoboUserSettingsEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
@@ -12,4 +13,6 @@ public interface KoboUserSettingsRepository extends JpaRepository<KoboUserSettin
Optional<KoboUserSettingsEntity> findByUserId(Long userId);
Optional<KoboUserSettingsEntity> findByToken(String token);
List<KoboUserSettingsEntity> findByAutoAddToShelfTrueAndSyncEnabledTrue();
}

View File

@@ -18,4 +18,6 @@ public interface ShelfRepository extends JpaRepository<ShelfEntity, Long> {
List<ShelfEntity> findByUserId(Long id);
Optional<ShelfEntity> findByUserIdAndName(Long id, String name);
List<ShelfEntity> findByUserIdInAndName(List<Long> userIds, String name);
}

View File

@@ -17,10 +17,7 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.function.BiFunction;
import java.util.stream.Collectors;
@@ -315,13 +312,13 @@ public class BookRuleEvaluatorService {
case READ_STATUS -> progressJoin.get("readStatus");
case DATE_FINISHED -> progressJoin.get("dateFinished");
case LAST_READ_TIME -> progressJoin.get("lastReadTime");
case PERSONAL_RATING -> progressJoin.get("personalRating");
case FILE_SIZE -> root.get("fileSizeKb");
case METADATA_SCORE -> root.get("metadataMatchScore");
case TITLE -> root.get("metadata").get("title");
case SUBTITLE -> root.get("metadata").get("subtitle");
case PUBLISHER -> root.get("metadata").get("publisher");
case PUBLISHED_DATE -> root.get("metadata").get("publishedDate");
case PERSONAL_RATING -> root.get("metadata").get("personalRating");
case PAGE_COUNT -> root.get("metadata").get("pageCount");
case LANGUAGE -> root.get("metadata").get("language");
case SERIES_NAME -> root.get("metadata").get("seriesName");
@@ -433,7 +430,7 @@ public class BookRuleEvaluatorService {
private List<String> toStringList(Object value) {
if (value == null) return Collections.emptyList();
if (value instanceof List) {
return ((List<?>) value).stream()
return ((Collection<?>) value).stream()
.map(Object::toString)
.collect(Collectors.toList());
}

View File

@@ -0,0 +1,274 @@
package com.adityachandel.booklore.service;
import com.adityachandel.booklore.config.AppProperties;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.model.dto.request.SvgIconCreateRequest;
import com.adityachandel.booklore.model.dto.response.SvgIconBatchResponse;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import java.util.stream.Stream;
@Slf4j
@RequiredArgsConstructor
@Service
public class IconService {
private static final Pattern INVALID_FILENAME_CHARS_PATTERN = Pattern.compile("[^a-zA-Z0-9._-]");
private final AppProperties appProperties;
private final ConcurrentHashMap<String, String> svgCache = new ConcurrentHashMap<>();
private static final String ICONS_DIR = "icons";
private static final String SVG_DIR = "svg";
private static final String SVG_EXTENSION = ".svg";
private static final int MAX_CACHE_SIZE = 1000;
private static final String SVG_START_TAG = "<svg";
private static final String XML_DECLARATION = "<?xml";
private static final String SVG_END_TAG = "</svg>";
@PostConstruct
public void init() {
try {
Path iconsPath = getIconsSvgPath();
if (Files.exists(iconsPath)) {
loadIconsIntoCache();
log.info("Loaded {} SVG icons into cache", svgCache.size());
} else {
Files.createDirectories(iconsPath);
log.info("Created icons directory: {}", iconsPath);
}
} catch (IOException e) {
log.error("Failed to initialize IconService: {}", e.getMessage(), e);
}
}
private void loadIconsIntoCache() throws IOException {
Path iconsPath = getIconsSvgPath();
try (Stream<Path> paths = Files.list(iconsPath)) {
paths.filter(Files::isRegularFile)
.filter(path -> path.toString().endsWith(SVG_EXTENSION))
.limit(MAX_CACHE_SIZE)
.forEach(path -> {
try {
String filename = path.getFileName().toString();
String content = Files.readString(path);
svgCache.put(filename, content);
} catch (IOException e) {
log.warn("Failed to load icon: {}", path.getFileName(), e);
}
});
}
}
public void saveSvgIcon(SvgIconCreateRequest request) {
validateSvgData(request.getSvgData());
String filename = normalizeFilename(request.getSvgName());
Path filePath = getIconsSvgPath().resolve(filename);
if (Files.exists(filePath)) {
log.warn("SVG icon already exists: {}", filename);
throw ApiError.ICON_ALREADY_EXISTS.createException(request.getSvgName());
}
try {
Files.writeString(filePath, request.getSvgData(),
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
updateCache(filename, request.getSvgData());
log.info("SVG icon saved successfully: {}", filename);
} catch (IOException e) {
log.error("Failed to save SVG icon: {}", e.getMessage(), e);
throw ApiError.FILE_READ_ERROR.createException("Failed to save SVG icon: " + e.getMessage());
}
}
public SvgIconBatchResponse saveBatchSvgIcons(List<SvgIconCreateRequest> requests) {
if (requests == null || requests.isEmpty()) {
throw ApiError.INVALID_INPUT.createException("Icons list cannot be empty");
}
List<SvgIconBatchResponse.IconSaveResult> results = new ArrayList<>();
int successCount = 0;
int failureCount = 0;
for (SvgIconCreateRequest request : requests) {
try {
saveSvgIcon(request);
results.add(SvgIconBatchResponse.IconSaveResult.builder()
.iconName(request.getSvgName())
.success(true)
.build());
successCount++;
} catch (Exception e) {
log.warn("Failed to save icon '{}': {}", request.getSvgName(), e.getMessage());
results.add(SvgIconBatchResponse.IconSaveResult.builder()
.iconName(request.getSvgName())
.success(false)
.errorMessage(e.getMessage())
.build());
failureCount++;
}
}
log.info("Batch save completed: {} successful, {} failed", successCount, failureCount);
return SvgIconBatchResponse.builder()
.totalRequested(requests.size())
.successCount(successCount)
.failureCount(failureCount)
.results(results)
.build();
}
public String getSvgIcon(String name) {
String filename = normalizeFilename(name);
String cachedSvg = svgCache.get(filename);
if (cachedSvg != null) {
return cachedSvg;
}
return loadAndCacheIcon(filename, name);
}
private String loadAndCacheIcon(String filename, String originalName) {
Path filePath = getIconsSvgPath().resolve(filename);
if (!Files.exists(filePath)) {
log.warn("SVG icon not found: {}", filename);
throw ApiError.FILE_NOT_FOUND.createException("SVG icon not found: " + originalName);
}
try {
String svgData = Files.readString(filePath);
updateCache(filename, svgData);
return svgData;
} catch (IOException e) {
log.error("Failed to read SVG icon: {}", e.getMessage(), e);
throw ApiError.FILE_READ_ERROR.createException("Failed to read SVG icon: " + e.getMessage());
}
}
public void deleteSvgIcon(String svgName) {
String filename = normalizeFilename(svgName);
Path filePath = getIconsSvgPath().resolve(filename);
try {
if (!Files.exists(filePath)) {
log.warn("SVG icon not found for deletion: {}", filename);
throw ApiError.FILE_NOT_FOUND.createException("SVG icon not found: " + svgName);
}
Files.delete(filePath);
svgCache.remove(filename);
log.info("SVG icon deleted successfully: {}", filename);
} catch (IOException e) {
log.error("Failed to delete SVG icon: {}", e.getMessage(), e);
throw ApiError.FILE_READ_ERROR.createException("Failed to delete SVG icon: " + e.getMessage());
}
}
public Page<String> getIconNames(int page, int size) {
validatePaginationParams(page, size);
Path iconsPath = getIconsSvgPath();
if (!Files.exists(iconsPath)) {
return new PageImpl<>(Collections.emptyList(), PageRequest.of(page, size), 0);
}
try (Stream<Path> paths = Files.list(iconsPath)) {
List<String> allIcons = paths
.filter(Files::isRegularFile)
.filter(path -> path.toString().endsWith(SVG_EXTENSION))
.map(path -> path.getFileName().toString().replace(SVG_EXTENSION, ""))
.sorted()
.toList();
return createPage(allIcons, page, size);
} catch (IOException e) {
log.error("Failed to read icon names: {}", e.getMessage(), e);
throw ApiError.FILE_READ_ERROR.createException("Failed to read icon names: " + e.getMessage());
}
}
private Page<String> createPage(List<String> allIcons, int page, int size) {
int totalElements = allIcons.size();
int fromIndex = page * size;
int toIndex = Math.min(fromIndex + size, totalElements);
List<String> pageContent = fromIndex < totalElements
? allIcons.subList(fromIndex, toIndex)
: Collections.emptyList();
return new PageImpl<>(pageContent, PageRequest.of(page, size), totalElements);
}
private void updateCache(String filename, String content) {
if (!svgCache.containsKey(filename) && svgCache.size() >= MAX_CACHE_SIZE) {
String firstKey = svgCache.keys().nextElement();
svgCache.remove(firstKey);
}
svgCache.put(filename, content);
}
private Path getIconsSvgPath() {
return Paths.get(appProperties.getPathConfig(), ICONS_DIR, SVG_DIR);
}
private void validateSvgData(String svgData) {
if (svgData == null || svgData.isBlank()) {
throw ApiError.INVALID_INPUT.createException("SVG data cannot be empty");
}
String trimmed = svgData.trim();
if (!trimmed.startsWith(SVG_START_TAG) && !trimmed.startsWith(XML_DECLARATION)) {
throw ApiError.INVALID_INPUT.createException("Invalid SVG format: must start with <svg or <?xml");
}
if (!trimmed.contains(SVG_END_TAG)) {
throw ApiError.INVALID_INPUT.createException("Invalid SVG format: missing closing </svg> tag");
}
}
private void validatePaginationParams(int page, int size) {
if (page < 0) {
throw ApiError.INVALID_INPUT.createException("Page index must not be less than zero");
}
if (size < 1) {
throw ApiError.INVALID_INPUT.createException("Page size must not be less than one");
}
}
private String normalizeFilename(String filename) {
if (filename == null || filename.isBlank()) {
throw ApiError.INVALID_INPUT.createException("Filename cannot be empty");
}
String sanitized = INVALID_FILENAME_CHARS_PATTERN.matcher(filename.trim()).replaceAll("_");
return sanitized.endsWith(SVG_EXTENSION) ? sanitized : sanitized + SVG_EXTENSION;
}
ConcurrentHashMap<String, String> getSvgCache() {
return svgCache;
}
}

View File

@@ -56,6 +56,7 @@ public class MagicShelfService {
}
existing.setName(dto.getName());
existing.setIcon(dto.getIcon());
existing.setIconType(dto.getIconType());
existing.setFilterJson(dto.getFilterJson());
existing.setPublic(dto.getIsPublic());
return toDto(magicShelfRepository.save(existing));
@@ -81,6 +82,7 @@ public class MagicShelfService {
dto.setId(entity.getId());
dto.setName(entity.getName());
dto.setIcon(entity.getIcon());
dto.setIconType(entity.getIconType());
dto.setFilterJson(entity.getFilterJson());
dto.setIsPublic(entity.isPublic());
return dto;
@@ -91,6 +93,7 @@ public class MagicShelfService {
entity.setId(dto.getId());
entity.setName(dto.getName());
entity.setIcon(dto.getIcon());
entity.setIconType(dto.getIconType());
entity.setFilterJson(dto.getFilterJson());
entity.setPublic(dto.getIsPublic());
entity.setUserId(userId);

View File

@@ -40,6 +40,7 @@ public class ShelfService {
ShelfEntity shelfEntity = ShelfEntity.builder()
.icon(request.getIcon())
.name(request.getName())
.iconType(request.getIconType())
.user(fetchUserEntityById(userId))
.build();
return shelfMapper.toShelf(shelfRepository.save(shelfEntity));
@@ -49,6 +50,7 @@ public class ShelfService {
ShelfEntity shelfEntity = findShelfByIdOrThrow(id);
shelfEntity.setName(request.getName());
shelfEntity.setIcon(request.getIcon());
shelfEntity.setIconType(request.getIconType());
return shelfMapper.toShelf(shelfRepository.save(shelfEntity));
}

View File

@@ -95,6 +95,7 @@ public class AppSettingService {
builder.metadataPersistenceSettings(settingPersistenceHelper.getJsonSetting(settingsMap, AppSettingKey.METADATA_PERSISTENCE_SETTINGS, MetadataPersistenceSettings.class, settingPersistenceHelper.getDefaultMetadataPersistenceSettings(), true));
builder.metadataPublicReviewsSettings(settingPersistenceHelper.getJsonSetting(settingsMap, AppSettingKey.METADATA_PUBLIC_REVIEWS_SETTINGS, MetadataPublicReviewsSettings.class, settingPersistenceHelper.getDefaultMetadataPublicReviewsSettings(), true));
builder.koboSettings(settingPersistenceHelper.getJsonSetting(settingsMap, AppSettingKey.KOBO_SETTINGS, KoboSettings.class, settingPersistenceHelper.getDefaultKoboSettings(), true));
builder.coverCroppingSettings(settingPersistenceHelper.getJsonSetting(settingsMap, AppSettingKey.COVER_CROPPING_SETTINGS, CoverCroppingSettings.class, settingPersistenceHelper.getDefaultCoverCroppingSettings(), true));
builder.autoBookSearch(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.AUTO_BOOK_SEARCH, "true")));
builder.uploadPattern(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.UPLOAD_FILE_PATTERN, "{authors}/<{series}/><{seriesIndex}. >{title}< - {authors}>< ({year})>"));

View File

@@ -255,7 +255,17 @@ public class SettingPersistenceHelper {
.conversionLimitInMb(100)
.convertCbxToEpub(false)
.conversionLimitInMbForCbx(100)
.conversionImageCompressionPercentage(85)
.forceEnableHyphenation(false)
.build();
}
public CoverCroppingSettings getDefaultCoverCroppingSettings() {
return CoverCroppingSettings.builder()
.verticalCroppingEnabled(false)
.horizontalCroppingEnabled(false)
.aspectRatioThreshold(2.5)
.smartCroppingEnabled(false)
.build();
}
}

View File

@@ -83,6 +83,7 @@ public class BookCreatorService {
.map(authorName -> authorRepository.findByName(authorName)
.orElseGet(() -> authorRepository.save(AuthorEntity.builder().name(authorName).build())))
.forEach(authorEntity -> bookEntity.getMetadata().getAuthors().add(authorEntity));
bookEntity.getMetadata().updateSearchText(); // Manually trigger search text update since collection modification doesn't trigger @PreUpdate
}
private String truncate(String input, int maxLength) {

View File

@@ -96,6 +96,7 @@ public class BookDownloadService {
boolean convertEpubToKepub = isEpub && koboSettings.isConvertToKepub() && bookEntity.getFileSizeKb() <= (long) koboSettings.getConversionLimitInMb() * 1024;
boolean convertCbxToEpub = isCbx && koboSettings.isConvertCbxToEpub() && bookEntity.getFileSizeKb() <= (long) koboSettings.getConversionLimitInMbForCbx() * 1024;
int compressionPercentage = koboSettings.getConversionImageCompressionPercentage();
Path tempDir = null;
try {
File inputFile = new File(FileUtils.getBookFullPath(bookEntity));
@@ -106,7 +107,7 @@ public class BookDownloadService {
}
if (convertCbxToEpub) {
fileToSend = cbxConversionService.convertCbxToEpub(inputFile, tempDir.toFile(), bookEntity);
fileToSend = cbxConversionService.convertCbxToEpub(inputFile, tempDir.toFile(), bookEntity,compressionPercentage);
}
if (convertEpubToKepub) {

View File

@@ -0,0 +1,72 @@
package com.adityachandel.booklore.service.book;
import com.adityachandel.booklore.mapper.BookMarkMapper;
import com.adityachandel.booklore.model.dto.BookMark;
import com.adityachandel.booklore.model.dto.CreateBookMarkRequest;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
import com.adityachandel.booklore.model.entity.BookMarkEntity;
import com.adityachandel.booklore.repository.BookMarkRepository;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.repository.UserRepository;
import com.adityachandel.booklore.config.security.service.AuthenticationService;
import jakarta.persistence.EntityNotFoundException;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import java.util.List;
@Service
@RequiredArgsConstructor
public class BookMarkService {
private final BookMarkRepository bookMarkRepository;
private final BookRepository bookRepository;
private final UserRepository userRepository;
private final BookMarkMapper mapper;
private final AuthenticationService authenticationService;
@Transactional(readOnly = true)
public List<BookMark> getBookmarksForBook(Long bookId) {
Long userId = authenticationService.getAuthenticatedUser().getId();
return bookMarkRepository.findByBookIdAndUserIdOrderByCreatedAtDesc(bookId, userId)
.stream()
.map(mapper::toDto)
.toList();
}
@Transactional
public BookMark createBookmark(CreateBookMarkRequest request) {
Long userId = authenticationService.getAuthenticatedUser().getId();
// Check for existing bookmark
if (bookMarkRepository.existsByCfiAndBookIdAndUserId(request.getCfi(), request.getBookId(), userId)) {
throw new IllegalArgumentException("Bookmark already exists at this location");
}
BookLoreUserEntity currentUser = userRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException("User not found: " + userId));
BookEntity book = bookRepository.findById(request.getBookId())
.orElseThrow(() -> new EntityNotFoundException("Book not found: " + request.getBookId()));
BookMarkEntity entity = BookMarkEntity.builder()
.user(currentUser)
.book(book)
.cfi(request.getCfi())
.title(request.getTitle())
.build();
BookMarkEntity saved = bookMarkRepository.save(entity);
return mapper.toDto(saved);
}
@Transactional
public void deleteBookmark(Long bookmarkId) {
Long userId = authenticationService.getAuthenticatedUser().getId();
BookMarkEntity bookmark = bookMarkRepository.findByIdAndUserId(bookmarkId, userId)
.orElseThrow(() -> new EntityNotFoundException("Bookmark not found: " + bookmarkId));
bookMarkRepository.delete(bookmark);
}
}

View File

@@ -100,6 +100,7 @@ public class BookService {
book.setLastReadTime(progress.getLastReadTime());
book.setReadStatus(progress.getReadStatus() == null ? String.valueOf(ReadStatus.UNSET) : String.valueOf(progress.getReadStatus()));
book.setDateFinished(progress.getDateFinished());
book.setPersonalRating(progress.getPersonalRating());
}
}
@@ -191,6 +192,7 @@ public class BookService {
book.setFilePath(FileUtils.getBookFullPath(bookEntity));
book.setReadStatus(userProgress.getReadStatus() == null ? String.valueOf(ReadStatus.UNSET) : String.valueOf(userProgress.getReadStatus()));
book.setDateFinished(userProgress.getDateFinished());
book.setPersonalRating(userProgress.getPersonalRating());
if (!withDescription) {
book.getMetadata().setDescription(null);
@@ -429,13 +431,7 @@ public class BookService {
.findByUserIdAndBookId(user.getId(), bookEntity.getId())
.orElse(null);
if (progress != null) {
setBookProgress(book, progress);
book.setLastReadTime(progress.getLastReadTime());
book.setReadStatus(progress.getReadStatus() == null ? String.valueOf(ReadStatus.UNSET) : String.valueOf(progress.getReadStatus()));
book.setDateFinished(progress.getDateFinished());
}
this.enrichBookWithProgress(book, progress);
return book;
})
.collect(Collectors.toList());
@@ -491,6 +487,65 @@ public class BookService {
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;
}
@Transactional
public List<Book> assignShelvesToBooks(Set<Long> bookIds, Set<Long> shelfIdsToAssign, Set<Long> shelfIdsToUnassign) {
BookLoreUser user = authenticationService.getAuthenticatedUser();

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