mirror of
https://github.com/booklore-app/booklore.git
synced 2025-12-23 22:28:11 -05:00
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:
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -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. -->
|
||||
|
||||
9
.github/ISSUE_TEMPLATE/feature_request.md
vendored
9
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -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
50
.github/dependabot.yml
vendored
Normal 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
35
.github/pull_request_template.md
vendored
Normal 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
70
.github/scripts/analyze-changes.sh
vendored
Normal 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
96
.github/scripts/check-conflicts.sh
vendored
Normal 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"
|
||||
41
.github/scripts/determine-compare-ref.sh
vendored
Normal file
41
.github/scripts/determine-compare-ref.sh
vendored
Normal 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
79
.github/scripts/validate-versions.sh
vendored
Normal 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
|
||||
224
.github/workflows/docker-build-publish.yml
vendored
224
.github/workflows/docker-build-publish.yml
vendored
@@ -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
4
.gitignore
vendored
@@ -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/
|
||||
383
CONTRIBUTING.md
383
CONTRIBUTING.md
@@ -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! 📚✨**
|
||||
|
||||
18
Dockerfile
18
Dockerfile
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
BIN
booklore-api/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
booklore-api/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
@@ -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
|
||||
|
||||
5
booklore-api/gradlew
vendored
5
booklore-api/gradlew
vendored
@@ -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" \
|
||||
"$@"
|
||||
|
||||
|
||||
3
booklore-api/gradlew.bat
vendored
3
booklore-api/gradlew.bat
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -52,5 +52,6 @@ public class BookEntitlement {
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class ActivePeriod {
|
||||
private String from;
|
||||
private String to;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.adityachandel.booklore.model.dto.request;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record PersonalRatingUpdateRequest(List<Long> ids, Integer rating) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -33,4 +33,5 @@ public class AppSettings {
|
||||
private MetadataPersistenceSettings metadataPersistenceSettings;
|
||||
private MetadataPublicReviewsSettings metadataPublicReviewsSettings;
|
||||
private KoboSettings koboSettings;
|
||||
private CoverCroppingSettings coverCroppingSettings;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -11,4 +11,5 @@ public class KoboSettings {
|
||||
private boolean convertCbxToEpub;
|
||||
private int conversionLimitInMbForCbx;
|
||||
private boolean forceEnableHyphenation;
|
||||
private int conversionImageCompressionPercentage;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
package com.adityachandel.booklore.model.enums;
|
||||
|
||||
public enum BookFileType {
|
||||
PDF, EPUB, CBX
|
||||
PDF, EPUB, CBX, FB2
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.adityachandel.booklore.model.enums;
|
||||
|
||||
public enum IconType {
|
||||
PRIME_NG,
|
||||
CUSTOM_SVG
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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})>"));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user