mirror of
https://github.com/booklore-app/booklore.git
synced 2026-05-24 11:44:44 -04:00
Merge branch 'develop' into dependabot/github_actions/docker/setup-buildx-action-4.0.0
This commit is contained in:
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
@@ -4,7 +4,7 @@ ## 📝 Description
|
||||
|
||||
**Linked Issue:** Fixes #<!-- issue number -->
|
||||
|
||||
> **Required.** Every PR must reference an approved issue. If no issue exists, [open one](https://github.com/booklore-app/booklore/issues/new) and wait for maintainer approval before submitting a PR. Unsolicited PRs without a linked issue will be closed.
|
||||
> **Required.** Every PR must reference an approved issue. If no issue exists, [open one](https://github.com/the-booklore/booklore/issues/new) and wait for maintainer approval before submitting a PR. Unsolicited PRs without a linked issue will be closed.
|
||||
|
||||
## 🏷️ Type of Change
|
||||
|
||||
@@ -82,7 +82,7 @@ ## ✅ Pre-Submission Checklist
|
||||
- [ ] PR is reasonably scoped (PRs over 1000+ changed lines will be closed, split into smaller PRs)
|
||||
- [ ] No unsolicited refactors, cleanups, or "improvements" are bundled in
|
||||
- [ ] Flyway migration versioning is correct _(if schema was modified)_
|
||||
- [ ] Documentation PR submitted to [booklore-docs](https://github.com/booklore-app/booklore-docs) _(if user-facing changes)_
|
||||
- [ ] Documentation PR submitted to [booklore-docs](https://github.com/the-booklore/booklore-docs) _(if user-facing changes)_
|
||||
|
||||
### 🤖 AI-Assisted Contributions
|
||||
|
||||
|
||||
5
.github/release-drafter.yml
vendored
5
.github/release-drafter.yml
vendored
@@ -66,7 +66,6 @@ template: |
|
||||
|
||||
## 🐳 Docker Images
|
||||
|
||||
- **Docker Hub:** `booklore/booklore:v$RESOLVED_VERSION`
|
||||
- **GitHub Container Registry:** `ghcr.io/booklore-app/booklore:v$RESOLVED_VERSION`
|
||||
- **GitHub Container Registry:** `ghcr.io/the-booklore/booklore:v$RESOLVED_VERSION`
|
||||
|
||||
**Full Changelog**: https://github.com/booklore-app/booklore/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION
|
||||
**Full Changelog**: https://github.com/the-booklore/booklore/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION
|
||||
|
||||
16
.github/workflows/develop-pipeline.yml
vendored
16
.github/workflows/develop-pipeline.yml
vendored
@@ -230,16 +230,9 @@ jobs:
|
||||
# ----------------------------------------
|
||||
# Docker login (pushes & internal PRs only)
|
||||
# ----------------------------------------
|
||||
- name: Authenticate to Docker Hub
|
||||
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Authenticate to GitHub Container Registry
|
||||
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -257,14 +250,13 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
booklore/booklore:${{ env.image_tag }}
|
||||
ghcr.io/booklore-app/booklore:${{ env.image_tag }}
|
||||
ghcr.io/the-booklore/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
|
||||
type=registry,ref=ghcr.io/the-booklore/booklore:buildcache
|
||||
cache-to: |
|
||||
type=gha,mode=max
|
||||
type=registry,ref=ghcr.io/booklore-app/booklore:buildcache,mode=max
|
||||
type=registry,ref=ghcr.io/the-booklore/booklore:buildcache,mode=max
|
||||
|
||||
20
.github/workflows/master-pipeline.yml
vendored
20
.github/workflows/master-pipeline.yml
vendored
@@ -161,14 +161,8 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Authenticate to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Authenticate to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -306,22 +300,20 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
booklore/booklore:${{ env.new_tag }}
|
||||
booklore/booklore:latest
|
||||
ghcr.io/booklore-app/booklore:${{ env.new_tag }}
|
||||
ghcr.io/booklore-app/booklore:latest
|
||||
ghcr.io/the-booklore/booklore:${{ env.new_tag }}
|
||||
ghcr.io/the-booklore/booklore:latest
|
||||
build-args: |
|
||||
APP_VERSION=${{ env.new_tag }}
|
||||
APP_REVISION=${{ github.sha }}
|
||||
cache-from: |
|
||||
type=gha
|
||||
type=registry,ref=ghcr.io/booklore-app/booklore:buildcache
|
||||
type=registry,ref=ghcr.io/the-booklore/booklore:buildcache
|
||||
cache-to: |
|
||||
type=gha,mode=max
|
||||
type=registry,ref=ghcr.io/booklore-app/booklore:buildcache,mode=max
|
||||
type=registry,ref=ghcr.io/the-booklore/booklore:buildcache,mode=max
|
||||
|
||||
- name: Update GitHub Release Draft
|
||||
uses: release-drafter/release-drafter@6db134d15f3909ccc9eefd369f02bd1e9cffdf97 # v6
|
||||
uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v6
|
||||
with:
|
||||
tag: ${{ env.new_tag }}
|
||||
name: "Release ${{ env.new_tag }}"
|
||||
|
||||
@@ -34,7 +34,7 @@ ## Table of Contents
|
||||
|
||||
## Before You Start
|
||||
|
||||
> **Issue first, PR second.** Every pull request must be linked to an approved issue. If you want to work on something, [open an issue](https://github.com/booklore-app/booklore/issues/new) (or find an existing one) and wait for a maintainer to approve it before writing code. PRs submitted without a linked, approved issue will be closed.
|
||||
> **Issue first, PR second.** Every pull request must be linked to an approved issue. If you want to work on something, [open an issue](https://github.com/the-booklore/booklore/issues/new) (or find an existing one) and wait for a maintainer to approve it before writing code. PRs submitted without a linked, approved issue will be closed.
|
||||
|
||||
This protects both your time and ours. It ensures that the work is actually wanted and that you're heading in the right direction before you invest effort.
|
||||
|
||||
@@ -50,8 +50,8 @@ ## Where to Start
|
||||
|
||||
Not sure where to begin? Look for issues labeled:
|
||||
|
||||
- [`good first issue`](https://github.com/booklore-app/booklore/labels/good%20first%20issue) - small, well-scoped tasks ideal for newcomers
|
||||
- [`help wanted`](https://github.com/booklore-app/booklore/labels/help%20wanted) - tasks where maintainers would appreciate a hand
|
||||
- [`good first issue`](https://github.com/the-booklore/booklore/labels/good%20first%20issue) - small, well-scoped tasks ideal for newcomers
|
||||
- [`help wanted`](https://github.com/the-booklore/booklore/labels/help%20wanted) - tasks where maintainers would appreciate a hand
|
||||
|
||||
---
|
||||
|
||||
@@ -59,12 +59,12 @@ ## Getting Started
|
||||
|
||||
### Fork and Clone
|
||||
|
||||
First, [fork the repository](https://github.com/booklore-app/booklore/fork) on GitHub, then clone your fork locally:
|
||||
First, [fork the repository](https://github.com/the-booklore/booklore/fork) on GitHub, then clone your fork locally:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/<your-username>/booklore.git
|
||||
cd booklore
|
||||
git remote add upstream https://github.com/booklore-app/booklore.git
|
||||
git remote add upstream https://github.com/the-booklore/booklore.git
|
||||
```
|
||||
|
||||
### Keep Your Fork in Sync
|
||||
@@ -274,7 +274,7 @@ ## Submitting a Pull Request
|
||||
- [ ] PR contains a single logical change (one bug fix OR one feature)
|
||||
- [ ] No unrelated refactors, style changes, or "improvements" are bundled in
|
||||
- [ ] **PR is reasonably sized.** PRs with 1000+ changed lines will be closed without review. Break large changes into small, focused PRs.
|
||||
- [ ] **For user-facing features:** submit a companion docs PR at [booklore-docs](https://github.com/booklore-app/booklore-docs)
|
||||
- [ ] **For user-facing features:** submit a companion docs PR at [booklore-docs](https://github.com/the-booklore/booklore-docs)
|
||||
|
||||
> When you open your PR on GitHub, a **PR template** will appear. Fill it out completely, including test output and screenshots.
|
||||
|
||||
@@ -326,7 +326,7 @@ ## Frontend Conventions
|
||||
|
||||
## Reporting Bugs
|
||||
|
||||
1. **Search [existing issues](https://github.com/booklore-app/booklore/issues)** to avoid duplicates.
|
||||
1. **Search [existing issues](https://github.com/the-booklore/booklore/issues)** to avoid duplicates.
|
||||
2. **Open a new issue** with the `bug` label including:
|
||||
- Clear, descriptive title (e.g., "Book import fails with PDF files over 100MB")
|
||||
- Steps to reproduce
|
||||
@@ -356,7 +356,7 @@ ## Reporting Bugs
|
||||
## Community & Support
|
||||
|
||||
- **Discord:** [Join the server](https://discord.gg/Ee5hd458Uz) for questions and discussion
|
||||
- **GitHub Issues:** [Report bugs or request features](https://github.com/booklore-app/booklore/issues)
|
||||
- **GitHub Issues:** [Report bugs or request features](https://github.com/the-booklore/booklore/issues)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@ ARG APP_REVISION
|
||||
# Set OCI labels
|
||||
LABEL org.opencontainers.image.title="BookLore" \
|
||||
org.opencontainers.image.description="BookLore: A self-hosted, multi-user digital library with smart shelves, auto metadata, Kobo & KOReader sync, BookDrop imports, OPDS support, and a built-in reader for EPUB, PDF, and comics." \
|
||||
org.opencontainers.image.source="https://github.com/booklore-app/booklore" \
|
||||
org.opencontainers.image.url="https://github.com/booklore-app/booklore" \
|
||||
org.opencontainers.image.source="https://github.com/the-booklore/booklore" \
|
||||
org.opencontainers.image.url="https://github.com/the-booklore/booklore" \
|
||||
org.opencontainers.image.documentation="https://booklore.org/docs/getting-started" \
|
||||
org.opencontainers.image.version=$APP_VERSION \
|
||||
org.opencontainers.image.revision=$APP_REVISION \
|
||||
|
||||
@@ -8,8 +8,8 @@ ARG APP_REVISION
|
||||
|
||||
LABEL org.opencontainers.image.title="BookLore" \
|
||||
org.opencontainers.image.description="BookLore: A self-hosted, multi-user digital library with smart shelves, auto metadata, Kobo & KOReader sync, BookDrop imports, OPDS support, and a built-in reader for EPUB, PDF, and comics." \
|
||||
org.opencontainers.image.source="https://github.com/booklore-app/booklore" \
|
||||
org.opencontainers.image.url="https://github.com/booklore-app/booklore" \
|
||||
org.opencontainers.image.source="https://github.com/the-booklore/booklore" \
|
||||
org.opencontainers.image.url="https://github.com/the-booklore/booklore" \
|
||||
org.opencontainers.image.documentation="https://booklore.org/docs/getting-started" \
|
||||
org.opencontainers.image.version=$APP_VERSION \
|
||||
org.opencontainers.image.revision=$APP_REVISION \
|
||||
|
||||
123
README.md
123
README.md
@@ -13,28 +13,6 @@
|
||||
Organize, read, annotate, sync across devices, and share, all without relying on third-party services.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/booklore-app/booklore/releases"><img src="https://img.shields.io/github/v/release/adityachandelgit/BookLore?color=818CF8&style=flat-square&logo=github" alt="Release" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/adityachandelgit/BookLore?color=fab005&style=flat-square" alt="License" /></a>
|
||||
<a href="https://hub.docker.com/r/booklore/booklore"><img src="https://img.shields.io/docker/pulls/booklore/booklore?color=2496ED&style=flat-square&logo=docker&logoColor=white" alt="Docker Pulls" /></a>
|
||||
<a href="https://github.com/booklore-app/booklore/stargazers"><img src="https://img.shields.io/github/stars/adityachandelgit/BookLore?style=flat-square&color=ffd43b" alt="Stars" /></a>
|
||||
<a href="https://discord.gg/Ee5hd458Uz"><img src="https://img.shields.io/badge/Discord-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://opencollective.com/booklore"><img src="https://img.shields.io/opencollective/all/booklore?style=flat-square&color=7FADF2&logo=opencollective" alt="Open Collective" /></a>
|
||||
<a href="https://hosted.weblate.org/engage/booklore/"><img src="https://img.shields.io/weblate/progress/booklore?style=flat-square&logo=weblate&logoColor=white&color=2ECCAA" alt="Translate" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://booklore.org/">🌐 Website</a> ·
|
||||
<a href="https://booklore.org/docs/getting-started">📖 Docs</a> ·
|
||||
<a href="#-live-demo">🎮 Demo</a> ·
|
||||
<a href="#-quick-start">🚀 Quick Start</a> ·
|
||||
<a href="https://discord.gg/Ee5hd458Uz">💬 Discord</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/demo.gif" alt="BookLore Demo" width="800" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
@@ -53,20 +31,14 @@ ## ✨ Features
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
> [!TIP]
|
||||
> Looking for OIDC setup, advanced config, or upgrade guides? See the [full documentation](https://booklore.org/docs/getting-started).
|
||||
|
||||
All you need is [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/).
|
||||
|
||||
<details>
|
||||
<summary><strong>📦 Image Repositories</strong></summary>
|
||||
|
||||
| Registry | Image |
|
||||
|----------|-------|
|
||||
| Docker Hub | `booklore/booklore` |
|
||||
| GitHub Container Registry | `ghcr.io/booklore-app/booklore` |
|
||||
|
||||
> Legacy images at `ghcr.io/adityachandelgit/booklore-app` remain available but won't receive updates.
|
||||
| Registry | Image |
|
||||
|----------|------------------------------------|
|
||||
| GitHub Container Registry | `ghcr.io/the-booklore/booklore` |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -102,8 +74,7 @@ ### Step 2: Docker Compose
|
||||
```yaml
|
||||
services:
|
||||
booklore:
|
||||
image: booklore/booklore:latest
|
||||
# Alternative: ghcr.io/booklore-app/booklore:latest
|
||||
image: ghcr.io/the-booklore/booklore:latest
|
||||
container_name: booklore
|
||||
environment:
|
||||
- USER_ID=${APP_USER_ID}
|
||||
@@ -170,21 +141,6 @@ ## ⚠️ Network Storage (NAS / NFS / SMB / CIFS)
|
||||
|
||||
---
|
||||
|
||||
## 🎮 Live Demo
|
||||
|
||||
See BookLore in action before deploying your own instance.
|
||||
|
||||
| | |
|
||||
|:---|:---|
|
||||
| 🌐 **URL** | **[demo.booklore.org](https://demo.booklore.org)** |
|
||||
| 👤 **Username** | `booklore` |
|
||||
| 🔑 **Password** | `9HC20PGGfitvWaZ1` |
|
||||
|
||||
> [!NOTE]
|
||||
> This is a standard user account. Admin features like library creation, user management, and system settings are only available on your own instance.
|
||||
|
||||
---
|
||||
|
||||
## 📥 BookDrop: Zero-Effort Import
|
||||
|
||||
Drop book files into a folder. BookLore picks them up, pulls metadata, and queues everything for your review.
|
||||
@@ -212,20 +168,6 @@ ## 📥 BookDrop: Zero-Effort Import
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Community & Support
|
||||
|
||||
| | |
|
||||
|:---|:---|
|
||||
| 🐞 **Something not working?** | [Report a Bug](https://github.com/booklore-app/booklore/issues/new?template=bug_report.yml) |
|
||||
| 💡 **Got an idea?** | [Request a Feature](https://github.com/booklore-app/booklore/issues/new?template=feature_request.yml) |
|
||||
| 🛠️ **Want to help build?** | [Contributing Guide](CONTRIBUTING.md) |
|
||||
| 💬 **Come hang out** | [Discord Server](https://discord.gg/Ee5hd458Uz) |
|
||||
|
||||
> [!WARNING]
|
||||
> **Before opening a PR:** Open an issue first and get maintainer approval. PRs without a linked issue, without screenshots/video proof, or without pasted test output will be closed. All code must follow project [backend](CONTRIBUTING.md#backend-conventions) and [frontend](CONTRIBUTING.md#frontend-conventions) conventions. AI-assisted contributions are welcome, but you must run, test, and understand every line you submit. See the [Contributing Guide](CONTRIBUTING.md) for full details.
|
||||
|
||||
---
|
||||
|
||||
## 💜 Support BookLore
|
||||
|
||||
BookLore is free, open source, and built with care. Here's how you can give back:
|
||||
@@ -236,10 +178,6 @@ ## 💜 Support BookLore
|
||||
| 💰 **Sponsor development** | [Open Collective](https://opencollective.com/booklore) funds hosting, testing, and new features |
|
||||
| 📢 **Tell someone** | Share BookLore with a friend, a subreddit, or your local book club |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> We're raising funds for a Kobo device to build and test native Kobo sync support.
|
||||
> [Contribute to the Kobo Bounty →](https://opencollective.com/booklore/projects/kobo-device-for-testing)
|
||||
|
||||
---
|
||||
|
||||
## 🌍 Translations
|
||||
@@ -252,76 +190,23 @@ ## 🌍 Translations
|
||||
|
||||
---
|
||||
|
||||
## 📊 Project Analytics
|
||||
|
||||

|
||||
|
||||
### ⭐ Star History
|
||||
|
||||
<a href="https://www.star-history.com/#booklore-app/booklore&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=booklore-app/booklore&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=booklore-app/booklore&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=booklore-app/booklore&type=date&legend=top-left" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
[](https://github.com/booklore-app/booklore/graphs/contributors)
|
||||
|
||||
Every contribution matters. [See how you can help →](CONTRIBUTING.md)
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
## 🌟 Sponsors & Partners
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%">
|
||||
|
||||
<a href="https://www.pikapods.com/pods?run=booklore">
|
||||
<img src="https://www.pikapods.com/static/run-button.svg" alt="Run on PikaPods" height="40" />
|
||||
</a>
|
||||
|
||||
**PikaPods**
|
||||
|
||||
</td>
|
||||
<td align="center" width="33%">
|
||||
|
||||
<a href="https://docs.elfhosted.com/app/booklore">
|
||||
<img src="https://docs.elfhosted.com/images/logo.svg" alt="ElfHosted" height="40" />
|
||||
</a>
|
||||
|
||||
**ElfHosted**
|
||||
|
||||
</td>
|
||||
<td align="center" width="34%">
|
||||
|
||||
<a href="https://jb.gg/OpenSource">
|
||||
<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg" alt="JetBrains" height="40" />
|
||||
</a>
|
||||
|
||||
**JetBrains**
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
*Want your logo here? [Become a sponsor →](https://opencollective.com/booklore)*
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Note to Integrators
|
||||
|
||||
While BookLore is open source and its API is accessible, it is not designed or maintained as a stable integration point. Endpoints are undocumented, unversioned, and may change or break at any time without notice. No compatibility guarantees or support are provided for third-party use.
|
||||
|
||||
<div align="center">
|
||||
|
||||
## ⚖️ License
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
package org.booklore.app.controller;
|
||||
|
||||
import org.booklore.app.dto.AppAuthorDetail;
|
||||
import org.booklore.app.dto.AppAuthorSummary;
|
||||
import org.booklore.app.dto.AppPageResponse;
|
||||
import org.booklore.app.service.AppAuthorService;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@AllArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/app/authors")
|
||||
public class AppAuthorController {
|
||||
|
||||
private final AppAuthorService mobileAuthorService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<AppPageResponse<AppAuthorSummary>> getAuthors(
|
||||
@RequestParam(required = false, defaultValue = "0") Integer page,
|
||||
@RequestParam(required = false, defaultValue = "30") Integer size,
|
||||
@RequestParam(required = false, defaultValue = "name") String sort,
|
||||
@RequestParam(required = false, defaultValue = "asc") String dir,
|
||||
@RequestParam(required = false) Long libraryId,
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestParam(required = false) Boolean hasPhoto) {
|
||||
|
||||
return ResponseEntity.ok(mobileAuthorService.getAuthors(page, size, sort, dir, libraryId, search, hasPhoto));
|
||||
}
|
||||
|
||||
@GetMapping("/{authorId}")
|
||||
public ResponseEntity<AppAuthorDetail> getAuthorDetail(
|
||||
@PathVariable Long authorId) {
|
||||
|
||||
return ResponseEntity.ok(mobileAuthorService.getAuthorDetail(authorId));
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package org.booklore.app.controller;
|
||||
|
||||
import org.booklore.app.dto.*;
|
||||
import org.booklore.app.service.AppBookService;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.model.enums.ReadStatus;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@AllArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/app/books")
|
||||
public class AppBookController {
|
||||
|
||||
private final AppBookService mobileBookService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<AppPageResponse<AppBookSummary>> getBooks(
|
||||
@RequestParam(required = false, defaultValue = "0") Integer page,
|
||||
@RequestParam(required = false, defaultValue = "50") Integer size,
|
||||
@RequestParam(required = false, defaultValue = "addedOn") String sort,
|
||||
@RequestParam(required = false, defaultValue = "desc") String dir,
|
||||
@RequestParam(required = false) Long libraryId,
|
||||
@RequestParam(required = false) Long shelfId,
|
||||
@RequestParam(required = false) ReadStatus status,
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestParam(required = false) BookFileType fileType,
|
||||
@RequestParam(required = false) Integer minRating,
|
||||
@RequestParam(required = false) Integer maxRating,
|
||||
@RequestParam(required = false) String authors,
|
||||
@RequestParam(required = false) String language) {
|
||||
|
||||
return ResponseEntity.ok(mobileBookService.getBooks(
|
||||
page, size, sort, dir, libraryId, shelfId, status, search,
|
||||
fileType, minRating, maxRating, authors, language));
|
||||
}
|
||||
|
||||
@GetMapping("/{bookId}")
|
||||
public ResponseEntity<AppBookDetail> getBookDetail(
|
||||
@PathVariable Long bookId) {
|
||||
|
||||
return ResponseEntity.ok(mobileBookService.getBookDetail(bookId));
|
||||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<AppPageResponse<AppBookSummary>> searchBooks(
|
||||
@RequestParam String q,
|
||||
@RequestParam(required = false, defaultValue = "0") Integer page,
|
||||
@RequestParam(required = false, defaultValue = "20") Integer size) {
|
||||
|
||||
return ResponseEntity.ok(mobileBookService.searchBooks(q, page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/continue-reading")
|
||||
public ResponseEntity<List<AppBookSummary>> getContinueReading(
|
||||
@RequestParam(required = false, defaultValue = "10") Integer limit) {
|
||||
|
||||
return ResponseEntity.ok(mobileBookService.getContinueReading(limit));
|
||||
}
|
||||
|
||||
@GetMapping("/continue-listening")
|
||||
public ResponseEntity<List<AppBookSummary>> getContinueListening(
|
||||
@RequestParam(required = false, defaultValue = "10") Integer limit) {
|
||||
|
||||
return ResponseEntity.ok(mobileBookService.getContinueListening(limit));
|
||||
}
|
||||
|
||||
@GetMapping("/recently-added")
|
||||
public ResponseEntity<List<AppBookSummary>> getRecentlyAdded(
|
||||
@RequestParam(required = false, defaultValue = "10") Integer limit) {
|
||||
|
||||
return ResponseEntity.ok(mobileBookService.getRecentlyAdded(limit));
|
||||
}
|
||||
|
||||
@GetMapping("/recently-scanned")
|
||||
public ResponseEntity<List<AppBookSummary>> getRecentlyScanned(
|
||||
@RequestParam(required = false, defaultValue = "10") Integer limit) {
|
||||
|
||||
return ResponseEntity.ok(mobileBookService.getRecentlyScanned(limit));
|
||||
}
|
||||
|
||||
@PutMapping("/{bookId}/status")
|
||||
public ResponseEntity<Void> updateStatus(
|
||||
@PathVariable Long bookId,
|
||||
@Valid @RequestBody UpdateStatusRequest request) {
|
||||
|
||||
mobileBookService.updateReadStatus(bookId, request.getStatus());
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@PutMapping("/{bookId}/rating")
|
||||
public ResponseEntity<Void> updateRating(
|
||||
@PathVariable Long bookId,
|
||||
@Valid @RequestBody UpdateRatingRequest request) {
|
||||
|
||||
mobileBookService.updatePersonalRating(bookId, request.getRating());
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@GetMapping("/random")
|
||||
public ResponseEntity<AppPageResponse<AppBookSummary>> getRandomBooks(
|
||||
@RequestParam(required = false, defaultValue = "0") Integer page,
|
||||
@RequestParam(required = false, defaultValue = "20") Integer size,
|
||||
@RequestParam(required = false) Long libraryId) {
|
||||
|
||||
return ResponseEntity.ok(mobileBookService.getRandomBooks(page, size, libraryId));
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package org.booklore.app.controller;
|
||||
|
||||
import org.booklore.app.dto.AppFilterOptions;
|
||||
import org.booklore.app.service.AppBookService;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@AllArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/app")
|
||||
public class AppFilterController {
|
||||
|
||||
private final AppBookService mobileBookService;
|
||||
|
||||
@GetMapping("/filter-options")
|
||||
public ResponseEntity<AppFilterOptions> getFilterOptions(
|
||||
@RequestParam(required = false) Long libraryId,
|
||||
@RequestParam(required = false) Long shelfId,
|
||||
@RequestParam(required = false) Long magicShelfId) {
|
||||
return ResponseEntity.ok(mobileBookService.getFilterOptions(libraryId, shelfId, magicShelfId));
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package org.booklore.app.controller;
|
||||
|
||||
import org.booklore.config.security.service.AuthenticationService;
|
||||
import org.booklore.app.dto.AppLibrarySummary;
|
||||
import org.booklore.app.mapper.AppBookMapper;
|
||||
import org.booklore.model.dto.BookLoreUser;
|
||||
import org.booklore.model.dto.Library;
|
||||
import org.booklore.model.entity.LibraryEntity;
|
||||
import org.booklore.repository.BookRepository;
|
||||
import org.booklore.repository.LibraryRepository;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@AllArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/app/libraries")
|
||||
public class AppLibraryController {
|
||||
|
||||
private final AuthenticationService authenticationService;
|
||||
private final LibraryRepository libraryRepository;
|
||||
private final BookRepository bookRepository;
|
||||
private final AppBookMapper mobileBookMapper;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<AppLibrarySummary>> getLibraries() {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
|
||||
List<LibraryEntity> libraries;
|
||||
if (user.getPermissions().isAdmin()) {
|
||||
libraries = libraryRepository.findAll();
|
||||
} else {
|
||||
List<Long> libraryIds = user.getAssignedLibraries() != null
|
||||
? user.getAssignedLibraries().stream().map(Library::getId).collect(Collectors.toList())
|
||||
: List.of();
|
||||
libraries = libraryRepository.findByIdIn(libraryIds);
|
||||
}
|
||||
|
||||
List<AppLibrarySummary> summaries = libraries.stream()
|
||||
.map(library -> {
|
||||
long bookCount = bookRepository.countByLibraryId(library.getId());
|
||||
return mobileBookMapper.toLibrarySummary(library, bookCount);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(summaries);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package org.booklore.app.controller;
|
||||
|
||||
import org.booklore.app.dto.AppNotebookBookSummary;
|
||||
import org.booklore.app.dto.AppNotebookEntry;
|
||||
import org.booklore.app.dto.AppNotebookUpdateRequest;
|
||||
import org.booklore.app.dto.AppPageResponse;
|
||||
import org.booklore.app.service.AppNotebookService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
@AllArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/app/notebook")
|
||||
public class AppNotebookController {
|
||||
|
||||
private final AppNotebookService mobileNotebookService;
|
||||
|
||||
@GetMapping("/books")
|
||||
public ResponseEntity<AppPageResponse<AppNotebookBookSummary>> getBooksWithAnnotations(
|
||||
@RequestParam(required = false, defaultValue = "0") Integer page,
|
||||
@RequestParam(required = false, defaultValue = "20") Integer size,
|
||||
@RequestParam(required = false) String search) {
|
||||
|
||||
return ResponseEntity.ok(mobileNotebookService.getBooksWithAnnotations(page, size, search));
|
||||
}
|
||||
|
||||
@GetMapping("/books/{bookId}/entries")
|
||||
public ResponseEntity<AppPageResponse<AppNotebookEntry>> getEntriesForBook(
|
||||
@PathVariable Long bookId,
|
||||
@RequestParam(required = false, defaultValue = "0") Integer page,
|
||||
@RequestParam(required = false, defaultValue = "20") Integer size,
|
||||
@RequestParam(required = false) Set<String> types,
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestParam(required = false, defaultValue = "date_desc") String sort) {
|
||||
|
||||
return ResponseEntity.ok(mobileNotebookService.getEntriesForBook(bookId, page, size, types, search, sort));
|
||||
}
|
||||
|
||||
@PutMapping("/entries/{entryId}")
|
||||
public ResponseEntity<AppNotebookEntry> updateEntry(
|
||||
@PathVariable Long entryId,
|
||||
@RequestParam String type,
|
||||
@Valid @RequestBody AppNotebookUpdateRequest request) {
|
||||
|
||||
return ResponseEntity.ok(mobileNotebookService.updateEntry(entryId, type, request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/entries/{entryId}")
|
||||
public ResponseEntity<Void> deleteEntry(
|
||||
@PathVariable Long entryId,
|
||||
@RequestParam String type) {
|
||||
|
||||
mobileNotebookService.deleteEntry(entryId, type);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package org.booklore.app.controller;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.booklore.app.dto.AppBookSummary;
|
||||
import org.booklore.app.dto.AppPageResponse;
|
||||
import org.booklore.app.dto.AppSeriesSummary;
|
||||
import org.booklore.app.service.AppSeriesService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@AllArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/app/series")
|
||||
public class AppSeriesController {
|
||||
|
||||
private final AppSeriesService mobileSeriesService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<AppPageResponse<AppSeriesSummary>> getSeries(
|
||||
@RequestParam(required = false, defaultValue = "0") Integer page,
|
||||
@RequestParam(required = false, defaultValue = "20") Integer size,
|
||||
@RequestParam(required = false, defaultValue = "recentlyAdded") String sort,
|
||||
@RequestParam(required = false, defaultValue = "desc") String dir,
|
||||
@RequestParam(required = false) Long libraryId,
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestParam(required = false) String status) {
|
||||
|
||||
boolean inProgressOnly = "in-progress".equalsIgnoreCase(status);
|
||||
|
||||
AppPageResponse<AppSeriesSummary> response = mobileSeriesService.getSeries(
|
||||
page, size, sort, dir, libraryId, search, inProgressOnly);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@GetMapping("/{seriesName}/books")
|
||||
public ResponseEntity<AppPageResponse<AppBookSummary>> getSeriesBooks(
|
||||
@PathVariable String seriesName,
|
||||
@RequestParam(required = false, defaultValue = "0") Integer page,
|
||||
@RequestParam(required = false, defaultValue = "20") Integer size,
|
||||
@RequestParam(required = false, defaultValue = "seriesNumber") String sort,
|
||||
@RequestParam(required = false, defaultValue = "asc") String dir,
|
||||
@RequestParam(required = false) Long libraryId) {
|
||||
|
||||
AppPageResponse<AppBookSummary> response = mobileSeriesService.getSeriesBooks(
|
||||
seriesName, page, size, sort, dir, libraryId);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package org.booklore.app.controller;
|
||||
|
||||
import org.booklore.config.security.service.AuthenticationService;
|
||||
import org.booklore.app.dto.AppBookSummary;
|
||||
import org.booklore.app.dto.AppMagicShelfSummary;
|
||||
import org.booklore.app.dto.AppPageResponse;
|
||||
import org.booklore.app.dto.AppShelfSummary;
|
||||
import org.booklore.app.mapper.AppBookMapper;
|
||||
import org.booklore.app.service.AppBookService;
|
||||
import org.booklore.model.dto.BookLoreUser;
|
||||
import org.booklore.model.entity.MagicShelfEntity;
|
||||
import org.booklore.model.entity.ShelfEntity;
|
||||
import org.booklore.repository.MagicShelfRepository;
|
||||
import org.booklore.repository.ShelfRepository;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@AllArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/app/shelves")
|
||||
public class AppShelfController {
|
||||
|
||||
private final AuthenticationService authenticationService;
|
||||
private final ShelfRepository shelfRepository;
|
||||
private final MagicShelfRepository magicShelfRepository;
|
||||
private final AppBookMapper mobileBookMapper;
|
||||
private final AppBookService mobileBookService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<AppShelfSummary>> getShelves() {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
|
||||
List<ShelfEntity> shelves = shelfRepository.findByUserIdOrPublicShelfTrue(userId);
|
||||
|
||||
List<AppShelfSummary> summaries = shelves.stream()
|
||||
.map(mobileBookMapper::toShelfSummaryFromEntity)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(summaries);
|
||||
}
|
||||
|
||||
@GetMapping("/magic")
|
||||
public ResponseEntity<List<AppMagicShelfSummary>> getMagicShelves() {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
|
||||
// Get user's own magic shelves
|
||||
List<MagicShelfEntity> userShelves = magicShelfRepository.findAllByUserId(userId);
|
||||
|
||||
// Get public magic shelves
|
||||
List<MagicShelfEntity> publicShelves = magicShelfRepository.findAllByIsPublicIsTrue();
|
||||
|
||||
// Combine and deduplicate (user's shelves that are also public shouldn't appear twice)
|
||||
Set<Long> seenIds = new HashSet<>();
|
||||
List<MagicShelfEntity> allShelves = new ArrayList<>();
|
||||
|
||||
for (MagicShelfEntity shelf : userShelves) {
|
||||
if (seenIds.add(shelf.getId())) {
|
||||
allShelves.add(shelf);
|
||||
}
|
||||
}
|
||||
for (MagicShelfEntity shelf : publicShelves) {
|
||||
if (seenIds.add(shelf.getId())) {
|
||||
allShelves.add(shelf);
|
||||
}
|
||||
}
|
||||
|
||||
List<AppMagicShelfSummary> summaries = allShelves.stream()
|
||||
.map(mobileBookMapper::toMagicShelfSummary)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(summaries);
|
||||
}
|
||||
|
||||
@GetMapping("/magic/{magicShelfId}/books")
|
||||
public ResponseEntity<AppPageResponse<AppBookSummary>> getBooksByMagicShelf(
|
||||
@PathVariable Long magicShelfId,
|
||||
@RequestParam(required = false, defaultValue = "0") Integer page,
|
||||
@RequestParam(required = false, defaultValue = "20") Integer size) {
|
||||
|
||||
return ResponseEntity.ok(mobileBookService.getBooksByMagicShelf(magicShelfId, page, size));
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package org.booklore.app.controller;
|
||||
|
||||
import org.booklore.config.security.service.AuthenticationService;
|
||||
import org.booklore.app.dto.AppUserInfo;
|
||||
import org.booklore.model.dto.BookLoreUser;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@AllArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/app/users")
|
||||
public class AppUserController {
|
||||
|
||||
private final AuthenticationService authenticationService;
|
||||
private final AppSettingService appSettingService;
|
||||
|
||||
@GetMapping("/me")
|
||||
public ResponseEntity<AppUserInfo> getCurrentUser() {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
BookLoreUser.UserPermissions perms = user.getPermissions();
|
||||
|
||||
int maxUploadSizeMb = 100; // default
|
||||
try {
|
||||
Integer configured = appSettingService.getAppSettings().getMaxFileUploadSizeInMb();
|
||||
if (configured != null) {
|
||||
maxUploadSizeMb = configured;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// fall back to default
|
||||
}
|
||||
|
||||
AppUserInfo info = AppUserInfo.builder()
|
||||
.isAdmin(perms.isAdmin())
|
||||
.canUpload(perms.isCanUpload())
|
||||
.canDownload(perms.isCanDownload())
|
||||
.canAccessBookdrop(perms.isCanAccessBookdrop())
|
||||
.maxFileUploadSizeMb(maxUploadSizeMb)
|
||||
.build();
|
||||
|
||||
return ResponseEntity.ok(info);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package org.booklore.app.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AppAuthorDetail {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String description;
|
||||
private String asin;
|
||||
private int bookCount;
|
||||
private boolean hasPhoto;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package org.booklore.app.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AppAuthorSummary {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String asin;
|
||||
private int bookCount;
|
||||
private boolean hasPhoto;
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package org.booklore.app.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AppBookDetail {
|
||||
private Long id;
|
||||
private String title;
|
||||
private List<String> authors;
|
||||
private String thumbnailUrl;
|
||||
private String readStatus;
|
||||
private Integer personalRating;
|
||||
private String seriesName;
|
||||
private Float seriesNumber;
|
||||
private Long libraryId;
|
||||
private Instant addedOn;
|
||||
private Instant lastReadTime;
|
||||
|
||||
private String subtitle;
|
||||
private String description;
|
||||
private Set<String> categories;
|
||||
private String publisher;
|
||||
private LocalDate publishedDate;
|
||||
private Integer pageCount;
|
||||
private String isbn13;
|
||||
private String language;
|
||||
private Double goodreadsRating;
|
||||
private Integer goodreadsReviewCount;
|
||||
private String libraryName;
|
||||
private List<AppShelfSummary> shelves;
|
||||
private Float readProgress;
|
||||
private String primaryFileType;
|
||||
private List<String> fileTypes;
|
||||
private List<AppBookFile> files;
|
||||
private Instant coverUpdatedOn;
|
||||
private Instant audiobookCoverUpdatedOn;
|
||||
private Boolean isPhysical;
|
||||
|
||||
private EpubProgress epubProgress;
|
||||
private PdfProgress pdfProgress;
|
||||
private CbxProgress cbxProgress;
|
||||
private AudiobookProgress audiobookProgress;
|
||||
private KoreaderProgress koreaderProgress;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class EpubProgress {
|
||||
private String cfi;
|
||||
private String href;
|
||||
private Float percentage;
|
||||
private Instant updatedAt;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class PdfProgress {
|
||||
private Integer page;
|
||||
private Float percentage;
|
||||
private Instant updatedAt;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class CbxProgress {
|
||||
private Integer page;
|
||||
private Float percentage;
|
||||
private Instant updatedAt;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class AudiobookProgress {
|
||||
private Long positionMs;
|
||||
private Integer trackIndex;
|
||||
private Float percentage;
|
||||
private Instant updatedAt;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class KoreaderProgress {
|
||||
private Float percentage;
|
||||
private String device;
|
||||
private String deviceId;
|
||||
private Instant lastSyncTime;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package org.booklore.app.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AppBookFile {
|
||||
private Long id;
|
||||
private Long bookId;
|
||||
private String fileName;
|
||||
private boolean isBook;
|
||||
private boolean folderBased;
|
||||
private String bookType;
|
||||
private String archiveType;
|
||||
private Long fileSizeKb;
|
||||
private String extension;
|
||||
private Instant addedOn;
|
||||
private boolean isPrimary;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package org.booklore.app.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AppBookSummary {
|
||||
private Long id;
|
||||
private String title;
|
||||
private List<String> authors;
|
||||
private String thumbnailUrl;
|
||||
private String readStatus;
|
||||
private Integer personalRating;
|
||||
private String seriesName;
|
||||
private Float seriesNumber;
|
||||
private Long libraryId;
|
||||
private Instant addedOn;
|
||||
private Instant lastReadTime;
|
||||
private Float readProgress;
|
||||
private String primaryFileType;
|
||||
private Instant coverUpdatedOn;
|
||||
private Instant audiobookCoverUpdatedOn;
|
||||
private Boolean isPhysical;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package org.booklore.app.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AppFilterOptions {
|
||||
private List<AuthorOption> authors;
|
||||
private List<LanguageOption> languages;
|
||||
private List<String> readStatuses;
|
||||
private List<String> fileTypes;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class AuthorOption {
|
||||
private String name;
|
||||
private long count;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class LanguageOption {
|
||||
private String code;
|
||||
private String label;
|
||||
private long count;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package org.booklore.app.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AppLibrarySummary {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String icon;
|
||||
private long bookCount;
|
||||
private List<BookFileType> allowedFormats;
|
||||
private List<PathSummary> paths;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class PathSummary {
|
||||
private Long id;
|
||||
private String path;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package org.booklore.app.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AppMagicShelfSummary {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String icon;
|
||||
private String iconType;
|
||||
private boolean publicShelf;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package org.booklore.app.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AppNotebookBookSummary {
|
||||
private Long bookId;
|
||||
private String bookTitle;
|
||||
private int noteCount;
|
||||
private List<String> authors;
|
||||
private Instant coverUpdatedOn;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package org.booklore.app.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AppNotebookEntry {
|
||||
private Long id;
|
||||
private String type;
|
||||
private Long bookId;
|
||||
private String text;
|
||||
private String note;
|
||||
private String color;
|
||||
private String style;
|
||||
private String chapterTitle;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package org.booklore.app.dto;
|
||||
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AppNotebookUpdateRequest {
|
||||
@Size(max = 5000)
|
||||
private String note;
|
||||
|
||||
@Pattern(regexp = "^#[0-9A-Fa-f]{6}$")
|
||||
private String color;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package org.booklore.app.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AppPageResponse<T> {
|
||||
private List<T> content;
|
||||
private int page;
|
||||
private int size;
|
||||
private long totalElements;
|
||||
private int totalPages;
|
||||
private boolean hasNext;
|
||||
private boolean hasPrevious;
|
||||
|
||||
public static <T> AppPageResponse<T> of(List<T> content, int page, int size, long totalElements) {
|
||||
int totalPages = size > 0 ? (int) Math.ceil((double) totalElements / size) : 0;
|
||||
return AppPageResponse.<T>builder()
|
||||
.content(content)
|
||||
.page(page)
|
||||
.size(size)
|
||||
.totalElements(totalElements)
|
||||
.totalPages(totalPages)
|
||||
.hasNext(page < totalPages - 1)
|
||||
.hasPrevious(page > 0)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package org.booklore.app.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AppSeriesSummary {
|
||||
private String seriesName;
|
||||
private int bookCount;
|
||||
private Integer seriesTotal;
|
||||
private List<String> authors;
|
||||
private int booksRead;
|
||||
private Instant latestAddedOn;
|
||||
private List<SeriesCoverBook> coverBooks;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package org.booklore.app.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AppShelfSummary {
|
||||
private Long id;
|
||||
private String name;
|
||||
private String icon;
|
||||
private int bookCount;
|
||||
private boolean publicShelf;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package org.booklore.app.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AppUserInfo {
|
||||
private boolean isAdmin;
|
||||
private boolean canUpload;
|
||||
private boolean canDownload;
|
||||
private boolean canAccessBookdrop;
|
||||
private int maxFileUploadSizeMb;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package org.booklore.app.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class SeriesCoverBook {
|
||||
private Long bookId;
|
||||
private Instant coverUpdatedOn;
|
||||
private Float seriesNumber;
|
||||
private String primaryFileType;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package org.booklore.app.dto;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class UpdateRatingRequest {
|
||||
@Min(value = 1, message = "Rating must be at least 1")
|
||||
@Max(value = 5, message = "Rating must be at most 5")
|
||||
private Integer rating;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package org.booklore.app.dto;
|
||||
|
||||
import org.booklore.model.enums.ReadStatus;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class UpdateStatusRequest {
|
||||
@NotNull(message = "Status is required")
|
||||
private ReadStatus status;
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
package org.booklore.app.mapper;
|
||||
|
||||
import org.booklore.app.dto.AppBookDetail;
|
||||
import org.booklore.app.dto.AppBookFile;
|
||||
import org.booklore.app.dto.AppBookSummary;
|
||||
import org.booklore.app.dto.AppLibrarySummary;
|
||||
import org.booklore.app.dto.AppMagicShelfSummary;
|
||||
import org.booklore.app.dto.AppShelfSummary;
|
||||
import org.booklore.model.entity.*;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.mapstruct.*;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
|
||||
public interface AppBookMapper {
|
||||
|
||||
@Mapping(target = "id", source = "book.id")
|
||||
@Mapping(target = "title", source = "book.metadata.title")
|
||||
@Mapping(target = "authors", source = "book.metadata.authors", qualifiedByName = "mapAuthors")
|
||||
@Mapping(target = "thumbnailUrl", source = "book", qualifiedByName = "mapThumbnailUrl")
|
||||
@Mapping(target = "readStatus", source = "progress.readStatus")
|
||||
@Mapping(target = "personalRating", source = "progress.personalRating")
|
||||
@Mapping(target = "seriesName", source = "book.metadata.seriesName")
|
||||
@Mapping(target = "seriesNumber", source = "book.metadata.seriesNumber")
|
||||
@Mapping(target = "libraryId", source = "book.library.id")
|
||||
@Mapping(target = "addedOn", source = "book.addedOn")
|
||||
@Mapping(target = "lastReadTime", source = "progress.lastReadTime")
|
||||
@Mapping(target = "readProgress", source = "progress", qualifiedByName = "mapReadProgress")
|
||||
@Mapping(target = "primaryFileType", source = "book", qualifiedByName = "mapPrimaryFileType")
|
||||
@Mapping(target = "coverUpdatedOn", source = "book.metadata.coverUpdatedOn")
|
||||
@Mapping(target = "audiobookCoverUpdatedOn", source = "book.metadata.audiobookCoverUpdatedOn")
|
||||
@Mapping(target = "isPhysical", source = "book.isPhysical")
|
||||
AppBookSummary toSummary(BookEntity book, UserBookProgressEntity progress);
|
||||
|
||||
@Mapping(target = "id", source = "book.id")
|
||||
@Mapping(target = "title", source = "book.metadata.title")
|
||||
@Mapping(target = "authors", source = "book.metadata.authors", qualifiedByName = "mapAuthors")
|
||||
@Mapping(target = "thumbnailUrl", source = "book", qualifiedByName = "mapThumbnailUrl")
|
||||
@Mapping(target = "readStatus", source = "progress.readStatus")
|
||||
@Mapping(target = "personalRating", source = "progress.personalRating")
|
||||
@Mapping(target = "seriesName", source = "book.metadata.seriesName")
|
||||
@Mapping(target = "seriesNumber", source = "book.metadata.seriesNumber")
|
||||
@Mapping(target = "libraryId", source = "book.library.id")
|
||||
@Mapping(target = "addedOn", source = "book.addedOn")
|
||||
@Mapping(target = "lastReadTime", source = "progress.lastReadTime")
|
||||
@Mapping(target = "subtitle", source = "book.metadata.subtitle")
|
||||
@Mapping(target = "description", source = "book.metadata.description")
|
||||
@Mapping(target = "categories", source = "book.metadata.categories", qualifiedByName = "mapCategories")
|
||||
@Mapping(target = "publisher", source = "book.metadata.publisher")
|
||||
@Mapping(target = "publishedDate", source = "book.metadata.publishedDate")
|
||||
@Mapping(target = "pageCount", source = "book.metadata.pageCount")
|
||||
@Mapping(target = "isbn13", source = "book.metadata.isbn13")
|
||||
@Mapping(target = "language", source = "book.metadata.language")
|
||||
@Mapping(target = "goodreadsRating", source = "book.metadata.goodreadsRating")
|
||||
@Mapping(target = "goodreadsReviewCount", source = "book.metadata.goodreadsReviewCount")
|
||||
@Mapping(target = "libraryName", source = "book.library.name")
|
||||
@Mapping(target = "shelves", source = "book.shelves", qualifiedByName = "mapShelves")
|
||||
@Mapping(target = "readProgress", source = "progress", qualifiedByName = "mapReadProgress")
|
||||
@Mapping(target = "primaryFileType", source = "book", qualifiedByName = "mapPrimaryFileType")
|
||||
@Mapping(target = "coverUpdatedOn", source = "book.metadata.coverUpdatedOn")
|
||||
@Mapping(target = "audiobookCoverUpdatedOn", source = "book.metadata.audiobookCoverUpdatedOn")
|
||||
@Mapping(target = "isPhysical", source = "book.isPhysical")
|
||||
@Mapping(target = "fileTypes", source = "book", qualifiedByName = "mapFileTypes")
|
||||
@Mapping(target = "files", source = "book", qualifiedByName = "mapFiles")
|
||||
@Mapping(target = "epubProgress", source = "progress", qualifiedByName = "mapEpubProgress")
|
||||
@Mapping(target = "pdfProgress", source = "progress", qualifiedByName = "mapPdfProgress")
|
||||
@Mapping(target = "cbxProgress", source = "progress", qualifiedByName = "mapCbxProgress")
|
||||
@Mapping(target = "audiobookProgress", source = "fileProgress", qualifiedByName = "mapAudiobookProgress")
|
||||
@Mapping(target = "koreaderProgress", source = "progress", qualifiedByName = "mapKoreaderProgress")
|
||||
AppBookDetail toDetail(BookEntity book, UserBookProgressEntity progress, UserBookFileProgressEntity fileProgress);
|
||||
|
||||
@Named("mapAuthors")
|
||||
default List<String> mapAuthors(List<AuthorEntity> authors) {
|
||||
if (authors == null || authors.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return authors.stream()
|
||||
.map(AuthorEntity::getName)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Named("mapCategories")
|
||||
default Set<String> mapCategories(Set<CategoryEntity> categories) {
|
||||
if (categories == null || categories.isEmpty()) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
return categories.stream()
|
||||
.map(CategoryEntity::getName)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Named("mapThumbnailUrl")
|
||||
default String mapThumbnailUrl(BookEntity book) {
|
||||
if (book == null || book.getId() == null) {
|
||||
return null;
|
||||
}
|
||||
return "/api/books/" + book.getId() + "/cover";
|
||||
}
|
||||
|
||||
@Named("mapShelves")
|
||||
default List<AppShelfSummary> mapShelves(Set<ShelfEntity> shelves) {
|
||||
if (shelves == null || shelves.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return shelves.stream()
|
||||
.map(this::toShelfSummary)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
default AppShelfSummary toShelfSummary(ShelfEntity shelf) {
|
||||
if (shelf == null) {
|
||||
return null;
|
||||
}
|
||||
return AppShelfSummary.builder()
|
||||
.id(shelf.getId())
|
||||
.name(shelf.getName())
|
||||
.icon(shelf.getIcon())
|
||||
.bookCount(shelf.getBookEntities() != null ? shelf.getBookEntities().size() : 0)
|
||||
.publicShelf(shelf.isPublic())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Named("mapReadProgress")
|
||||
default Float mapReadProgress(UserBookProgressEntity progress) {
|
||||
if (progress == null) {
|
||||
return null;
|
||||
}
|
||||
if (progress.getKoreaderProgressPercent() != null) {
|
||||
return progress.getKoreaderProgressPercent();
|
||||
}
|
||||
if (progress.getKoboProgressPercent() != null) {
|
||||
return progress.getKoboProgressPercent();
|
||||
}
|
||||
if (progress.getEpubProgressPercent() != null) {
|
||||
return progress.getEpubProgressPercent();
|
||||
}
|
||||
if (progress.getPdfProgressPercent() != null) {
|
||||
return progress.getPdfProgressPercent();
|
||||
}
|
||||
if (progress.getCbxProgressPercent() != null) {
|
||||
return progress.getCbxProgressPercent();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Named("mapEpubProgress")
|
||||
default AppBookDetail.EpubProgress mapEpubProgress(UserBookProgressEntity progress) {
|
||||
if (progress == null || progress.getEpubProgress() == null) {
|
||||
return null;
|
||||
}
|
||||
return AppBookDetail.EpubProgress.builder()
|
||||
.cfi(progress.getEpubProgress())
|
||||
.href(progress.getEpubProgressHref())
|
||||
.percentage(progress.getEpubProgressPercent())
|
||||
.updatedAt(progress.getLastReadTime())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Named("mapPdfProgress")
|
||||
default AppBookDetail.PdfProgress mapPdfProgress(UserBookProgressEntity progress) {
|
||||
if (progress == null || progress.getPdfProgress() == null) {
|
||||
return null;
|
||||
}
|
||||
return AppBookDetail.PdfProgress.builder()
|
||||
.page(progress.getPdfProgress())
|
||||
.percentage(progress.getPdfProgressPercent())
|
||||
.updatedAt(progress.getLastReadTime())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Named("mapCbxProgress")
|
||||
default AppBookDetail.CbxProgress mapCbxProgress(UserBookProgressEntity progress) {
|
||||
if (progress == null || progress.getCbxProgress() == null) {
|
||||
return null;
|
||||
}
|
||||
return AppBookDetail.CbxProgress.builder()
|
||||
.page(progress.getCbxProgress())
|
||||
.percentage(progress.getCbxProgressPercent())
|
||||
.updatedAt(progress.getLastReadTime())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Named("mapKoreaderProgress")
|
||||
default AppBookDetail.KoreaderProgress mapKoreaderProgress(UserBookProgressEntity progress) {
|
||||
if (progress == null || progress.getKoreaderProgressPercent() == null) {
|
||||
return null;
|
||||
}
|
||||
return AppBookDetail.KoreaderProgress.builder()
|
||||
.percentage(progress.getKoreaderProgressPercent())
|
||||
.device(progress.getKoreaderDevice())
|
||||
.deviceId(progress.getKoreaderDeviceId())
|
||||
.lastSyncTime(progress.getKoreaderLastSyncTime())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Named("mapAudiobookProgress")
|
||||
default AppBookDetail.AudiobookProgress mapAudiobookProgress(UserBookFileProgressEntity fileProgress) {
|
||||
if (fileProgress == null) return null;
|
||||
if (fileProgress.getBookFile() == null ||
|
||||
fileProgress.getBookFile().getBookType() != BookFileType.AUDIOBOOK) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return AppBookDetail.AudiobookProgress.builder()
|
||||
.positionMs(parseLongOrNull(fileProgress.getPositionData()))
|
||||
.trackIndex(parseIntOrNull(fileProgress.getPositionHref()))
|
||||
.percentage(fileProgress.getProgressPercent())
|
||||
.updatedAt(fileProgress.getLastReadTime())
|
||||
.build();
|
||||
}
|
||||
|
||||
default Long parseLongOrNull(String value) {
|
||||
if (value == null) return null;
|
||||
try {
|
||||
return Long.parseLong(value);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
default Integer parseIntOrNull(String value) {
|
||||
if (value == null) return null;
|
||||
try {
|
||||
return Integer.parseInt(value);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Named("mapPrimaryFileType")
|
||||
default String mapPrimaryFileType(BookEntity book) {
|
||||
if (book == null) {
|
||||
return null;
|
||||
}
|
||||
BookFileEntity primaryFile = book.getPrimaryBookFile();
|
||||
if (primaryFile != null && primaryFile.getBookType() != null) {
|
||||
return primaryFile.getBookType().name();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Named("mapFileTypes")
|
||||
default List<String> mapFileTypes(BookEntity book) {
|
||||
if (book == null || book.getBookFiles() == null || book.getBookFiles().isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return book.getBookFiles().stream()
|
||||
.filter(bf -> bf.getBookType() != null)
|
||||
.map(bf -> bf.getBookType().name())
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Named("mapFiles")
|
||||
default List<AppBookFile> mapFiles(BookEntity book) {
|
||||
if (book == null || book.getBookFiles() == null || book.getBookFiles().isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
BookFileEntity primaryFile = book.getPrimaryBookFile();
|
||||
Long primaryId = primaryFile != null ? primaryFile.getId() : null;
|
||||
|
||||
return book.getBookFiles().stream()
|
||||
.filter(bf -> bf.getBookType() != null && bf.isBook())
|
||||
.map(bf -> {
|
||||
String extension = null;
|
||||
try {
|
||||
String fileName = bf.getFileName();
|
||||
int lastDot = fileName.lastIndexOf('.');
|
||||
if (lastDot > 0) {
|
||||
extension = fileName.substring(lastDot + 1);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Handle case where extension cannot be extracted
|
||||
}
|
||||
|
||||
return AppBookFile.builder()
|
||||
.id(bf.getId())
|
||||
.bookId(bf.getBook() != null ? bf.getBook().getId() : null)
|
||||
.fileName(bf.getFileName())
|
||||
.isBook(bf.isBook())
|
||||
.folderBased(bf.isFolderBased())
|
||||
.bookType(bf.getBookType().name())
|
||||
.archiveType(bf.getArchiveType() != null ? bf.getArchiveType().name() : null)
|
||||
.fileSizeKb(bf.getFileSizeKb())
|
||||
.extension(extension)
|
||||
.addedOn(bf.getAddedOn())
|
||||
.isPrimary(bf.getId().equals(primaryId))
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
default AppLibrarySummary toLibrarySummary(LibraryEntity library, long bookCount) {
|
||||
if (library == null) {
|
||||
return null;
|
||||
}
|
||||
List<AppLibrarySummary.PathSummary> paths = Collections.emptyList();
|
||||
if (library.getLibraryPaths() != null && !library.getLibraryPaths().isEmpty()) {
|
||||
paths = library.getLibraryPaths().stream()
|
||||
.map(lp -> AppLibrarySummary.PathSummary.builder()
|
||||
.id(lp.getId())
|
||||
.path(lp.getPath())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
return AppLibrarySummary.builder()
|
||||
.id(library.getId())
|
||||
.name(library.getName())
|
||||
.icon(library.getIcon())
|
||||
.bookCount(bookCount)
|
||||
.allowedFormats(library.getAllowedFormats())
|
||||
.paths(paths)
|
||||
.build();
|
||||
}
|
||||
|
||||
default AppShelfSummary toShelfSummaryFromEntity(ShelfEntity shelf) {
|
||||
if (shelf == null) {
|
||||
return null;
|
||||
}
|
||||
return AppShelfSummary.builder()
|
||||
.id(shelf.getId())
|
||||
.name(shelf.getName())
|
||||
.icon(shelf.getIcon())
|
||||
.bookCount(shelf.getBookEntities() != null ? shelf.getBookEntities().size() : 0)
|
||||
.publicShelf(shelf.isPublic())
|
||||
.build();
|
||||
}
|
||||
|
||||
default AppMagicShelfSummary toMagicShelfSummary(MagicShelfEntity magicShelf) {
|
||||
if (magicShelf == null) {
|
||||
return null;
|
||||
}
|
||||
return AppMagicShelfSummary.builder()
|
||||
.id(magicShelf.getId())
|
||||
.name(magicShelf.getName())
|
||||
.icon(magicShelf.getIcon())
|
||||
.iconType(magicShelf.getIconType() != null ? magicShelf.getIconType().name() : null)
|
||||
.publicShelf(magicShelf.isPublic())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
package org.booklore.app.service;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.booklore.config.security.service.AuthenticationService;
|
||||
import org.booklore.exception.ApiError;
|
||||
import org.booklore.app.dto.AppAuthorDetail;
|
||||
import org.booklore.app.dto.AppAuthorSummary;
|
||||
import org.booklore.app.dto.AppPageResponse;
|
||||
import org.booklore.model.dto.BookLoreUser;
|
||||
import org.booklore.model.dto.Library;
|
||||
import org.booklore.model.entity.AuthorEntity;
|
||||
import org.booklore.repository.AuthorRepository;
|
||||
import org.booklore.util.FileService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.TypedQuery;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
public class AppAuthorService {
|
||||
|
||||
private static final int DEFAULT_PAGE_SIZE = 30;
|
||||
private static final int MAX_PAGE_SIZE = 50;
|
||||
|
||||
private final AuthorRepository authorRepository;
|
||||
private final AuthenticationService authenticationService;
|
||||
private final FileService fileService;
|
||||
private final EntityManager entityManager;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AppPageResponse<AppAuthorSummary> getAuthors(
|
||||
Integer page,
|
||||
Integer size,
|
||||
String sortBy,
|
||||
String sortDir,
|
||||
Long libraryId,
|
||||
String search,
|
||||
Boolean hasPhoto) {
|
||||
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Set<Long> accessibleLibraryIds = getAccessibleLibraryIds(user);
|
||||
|
||||
int pageNum = page != null && page >= 0 ? page : 0;
|
||||
int pageSize = size != null && size > 0 ? Math.min(size, MAX_PAGE_SIZE) : DEFAULT_PAGE_SIZE;
|
||||
|
||||
StringBuilder whereClause = new StringBuilder(" WHERE (1=1)");
|
||||
buildLibraryFilter(whereClause, accessibleLibraryIds, libraryId);
|
||||
buildSearchFilter(whereClause, search);
|
||||
|
||||
String fromClause = " FROM AuthorEntity a LEFT JOIN a.bookMetadataEntityList bm LEFT JOIN bm.book b";
|
||||
|
||||
// Count query
|
||||
String countJpql = "SELECT COUNT(DISTINCT a.id)" + fromClause + whereClause;
|
||||
TypedQuery<Long> countQuery = entityManager.createQuery(countJpql, Long.class);
|
||||
setQueryParams(countQuery, accessibleLibraryIds, libraryId, search);
|
||||
long totalElements = countQuery.getSingleResult();
|
||||
|
||||
if (totalElements == 0) {
|
||||
return AppPageResponse.of(Collections.emptyList(), pageNum, pageSize, 0L);
|
||||
}
|
||||
|
||||
// Data query with book count
|
||||
String orderClause = buildOrderClause(sortBy, sortDir);
|
||||
String dataJpql = "SELECT a, COUNT(DISTINCT bm.id)" + fromClause + whereClause
|
||||
+ " GROUP BY a" + orderClause;
|
||||
TypedQuery<Object[]> dataQuery = entityManager.createQuery(dataJpql, Object[].class);
|
||||
setQueryParams(dataQuery, accessibleLibraryIds, libraryId, search);
|
||||
dataQuery.setFirstResult(pageNum * pageSize);
|
||||
dataQuery.setMaxResults(pageSize);
|
||||
|
||||
List<Object[]> results = dataQuery.getResultList();
|
||||
|
||||
List<AppAuthorSummary> summaries = results.stream()
|
||||
.map(row -> {
|
||||
AuthorEntity author = (AuthorEntity) row[0];
|
||||
long bookCount = (Long) row[1];
|
||||
boolean authorHasPhoto = Files.exists(Paths.get(fileService.getAuthorThumbnailFile(author.getId())));
|
||||
return AppAuthorSummary.builder()
|
||||
.id(author.getId())
|
||||
.name(author.getName())
|
||||
.asin(author.getAsin())
|
||||
.bookCount((int) bookCount)
|
||||
.hasPhoto(authorHasPhoto)
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// Post-filter by hasPhoto if requested
|
||||
if (hasPhoto != null) {
|
||||
summaries = summaries.stream()
|
||||
.filter(s -> s.isHasPhoto() == hasPhoto)
|
||||
.collect(Collectors.toList());
|
||||
// Adjust total count for hasPhoto filter — requires a separate count
|
||||
long filteredTotal = countAuthorsWithPhotoFilter(accessibleLibraryIds, libraryId, search, hasPhoto);
|
||||
return AppPageResponse.of(summaries, pageNum, pageSize, filteredTotal);
|
||||
}
|
||||
|
||||
return AppPageResponse.of(summaries, pageNum, pageSize, totalElements);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AppAuthorDetail getAuthorDetail(Long authorId) {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Set<Long> accessibleLibraryIds = getAccessibleLibraryIds(user);
|
||||
|
||||
AuthorEntity author = authorRepository.findById(authorId)
|
||||
.orElseThrow(() -> ApiError.AUTHOR_NOT_FOUND.createException(authorId));
|
||||
|
||||
// Verify access for non-admin users
|
||||
if (accessibleLibraryIds != null) {
|
||||
if (accessibleLibraryIds.isEmpty() || !authorRepository.existsByIdAndLibraryIds(authorId, accessibleLibraryIds)) {
|
||||
throw ApiError.AUTHOR_NOT_FOUND.createException(authorId);
|
||||
}
|
||||
}
|
||||
|
||||
// Count books accessible to this user
|
||||
int bookCount = countAccessibleBooks(authorId, accessibleLibraryIds);
|
||||
boolean authorHasPhoto = Files.exists(Paths.get(fileService.getAuthorThumbnailFile(author.getId())));
|
||||
|
||||
return AppAuthorDetail.builder()
|
||||
.id(author.getId())
|
||||
.name(author.getName())
|
||||
.description(author.getDescription())
|
||||
.asin(author.getAsin())
|
||||
.bookCount(bookCount)
|
||||
.hasPhoto(authorHasPhoto)
|
||||
.build();
|
||||
}
|
||||
|
||||
private int countAccessibleBooks(Long authorId, Set<Long> accessibleLibraryIds) {
|
||||
StringBuilder jpql = new StringBuilder(
|
||||
"SELECT COUNT(DISTINCT bm.id) FROM AuthorEntity a JOIN a.bookMetadataEntityList bm JOIN bm.book b"
|
||||
+ " WHERE a.id = :authorId AND (b.deleted IS NULL OR b.deleted = false)"
|
||||
+ " AND b.bookFiles IS NOT EMPTY");
|
||||
if (accessibleLibraryIds != null) {
|
||||
jpql.append(" AND b.library.id IN :libraryIds");
|
||||
}
|
||||
TypedQuery<Long> query = entityManager.createQuery(jpql.toString(), Long.class);
|
||||
query.setParameter("authorId", authorId);
|
||||
if (accessibleLibraryIds != null) {
|
||||
query.setParameter("libraryIds", accessibleLibraryIds);
|
||||
}
|
||||
return query.getSingleResult().intValue();
|
||||
}
|
||||
|
||||
private long countAuthorsWithPhotoFilter(Set<Long> accessibleLibraryIds, Long libraryId, String search, boolean hasPhoto) {
|
||||
// Since hasPhoto is file-system based, we need to count all matching authors
|
||||
// and check their photos. For large datasets this could be optimized with a DB column.
|
||||
StringBuilder whereClause = new StringBuilder(" WHERE (1=1)");
|
||||
buildLibraryFilter(whereClause, accessibleLibraryIds, libraryId);
|
||||
buildSearchFilter(whereClause, search);
|
||||
|
||||
String jpql = "SELECT DISTINCT a FROM AuthorEntity a LEFT JOIN a.bookMetadataEntityList bm LEFT JOIN bm.book b"
|
||||
+ whereClause;
|
||||
TypedQuery<AuthorEntity> query = entityManager.createQuery(jpql, AuthorEntity.class);
|
||||
setQueryParams(query, accessibleLibraryIds, libraryId, search);
|
||||
|
||||
return query.getResultList().stream()
|
||||
.filter(a -> Files.exists(Paths.get(fileService.getAuthorThumbnailFile(a.getId()))) == hasPhoto)
|
||||
.count();
|
||||
}
|
||||
|
||||
private void buildLibraryFilter(StringBuilder whereClause, Set<Long> accessibleLibraryIds, Long libraryId) {
|
||||
if (libraryId != null) {
|
||||
whereClause.append(" AND b.library.id = :libraryId");
|
||||
} else if (accessibleLibraryIds != null) {
|
||||
whereClause.append(" AND b.library.id IN :libraryIds");
|
||||
}
|
||||
whereClause.append(" AND (b.deleted IS NULL OR b.deleted = false)");
|
||||
whereClause.append(" AND b.bookFiles IS NOT EMPTY");
|
||||
}
|
||||
|
||||
private void buildSearchFilter(StringBuilder whereClause, String search) {
|
||||
if (search != null && !search.trim().isEmpty()) {
|
||||
whereClause.append(" AND LOWER(a.name) LIKE :search");
|
||||
}
|
||||
}
|
||||
|
||||
private String buildOrderClause(String sortBy, String sortDir) {
|
||||
String direction = "asc".equalsIgnoreCase(sortDir) ? "ASC" : "DESC";
|
||||
String field = switch (sortBy != null ? sortBy.toLowerCase() : "") {
|
||||
case "name" -> "a.name";
|
||||
case "bookcount", "book_count" -> "COUNT(DISTINCT bm.id)";
|
||||
case "recent", "id" -> "a.id";
|
||||
default -> "a.name";
|
||||
};
|
||||
return " ORDER BY " + field + " " + direction;
|
||||
}
|
||||
|
||||
private void setQueryParams(TypedQuery<?> query, Set<Long> accessibleLibraryIds, Long libraryId, String search) {
|
||||
if (libraryId != null) {
|
||||
query.setParameter("libraryId", libraryId);
|
||||
} else if (accessibleLibraryIds != null) {
|
||||
query.setParameter("libraryIds", accessibleLibraryIds);
|
||||
}
|
||||
if (search != null && !search.trim().isEmpty()) {
|
||||
query.setParameter("search", "%" + search.trim().toLowerCase() + "%");
|
||||
}
|
||||
}
|
||||
|
||||
private Set<Long> getAccessibleLibraryIds(BookLoreUser user) {
|
||||
if (user.getPermissions().isAdmin()) {
|
||||
return null;
|
||||
}
|
||||
if (user.getAssignedLibraries() == null || user.getAssignedLibraries().isEmpty()) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
return user.getAssignedLibraries().stream()
|
||||
.map(Library::getId)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
}
|
||||
@@ -1,698 +0,0 @@
|
||||
package org.booklore.app.service;
|
||||
|
||||
import org.booklore.config.security.service.AuthenticationService;
|
||||
import org.booklore.exception.ApiError;
|
||||
import org.booklore.app.dto.AppBookDetail;
|
||||
import org.booklore.app.dto.AppBookSummary;
|
||||
import org.booklore.app.dto.AppFilterOptions;
|
||||
import org.booklore.app.dto.AppPageResponse;
|
||||
import org.booklore.app.mapper.AppBookMapper;
|
||||
import org.booklore.app.specification.AppBookSpecification;
|
||||
import org.booklore.model.dto.Book;
|
||||
import org.booklore.model.dto.BookLoreUser;
|
||||
import org.booklore.model.dto.Library;
|
||||
import org.booklore.model.entity.*;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.model.enums.ReadStatus;
|
||||
import org.booklore.repository.BookRepository;
|
||||
import org.booklore.repository.ShelfRepository;
|
||||
import org.booklore.repository.UserBookFileProgressRepository;
|
||||
import org.booklore.repository.UserBookProgressRepository;
|
||||
import org.booklore.service.opds.MagicShelfBookService;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.Tuple;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
public class AppBookService {
|
||||
|
||||
private static final int DEFAULT_PAGE_SIZE = 20;
|
||||
private static final int MAX_PAGE_SIZE = 50;
|
||||
|
||||
private final BookRepository bookRepository;
|
||||
private final UserBookProgressRepository userBookProgressRepository;
|
||||
private final UserBookFileProgressRepository userBookFileProgressRepository;
|
||||
private final ShelfRepository shelfRepository;
|
||||
private final AuthenticationService authenticationService;
|
||||
private final AppBookMapper mobileBookMapper;
|
||||
private final MagicShelfBookService magicShelfBookService;
|
||||
private final EntityManager entityManager;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AppPageResponse<AppBookSummary> getBooks(
|
||||
Integer page,
|
||||
Integer size,
|
||||
String sortBy,
|
||||
String sortDir,
|
||||
Long libraryId,
|
||||
Long shelfId,
|
||||
ReadStatus status,
|
||||
String search,
|
||||
BookFileType fileType,
|
||||
Integer minRating,
|
||||
Integer maxRating,
|
||||
String authors,
|
||||
String language) {
|
||||
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
Set<Long> accessibleLibraryIds = getAccessibleLibraryIds(user);
|
||||
|
||||
int pageNum = page != null && page >= 0 ? page : 0;
|
||||
int pageSize = size != null && size > 0 ? Math.min(size, MAX_PAGE_SIZE) : DEFAULT_PAGE_SIZE;
|
||||
|
||||
Sort sort = buildSort(sortBy, sortDir);
|
||||
Pageable pageable = PageRequest.of(pageNum, pageSize, sort);
|
||||
|
||||
Specification<BookEntity> spec = buildSpecification(
|
||||
accessibleLibraryIds, libraryId, shelfId, status, search, userId,
|
||||
fileType, minRating, maxRating, authors, language);
|
||||
|
||||
Page<BookEntity> bookPage = bookRepository.findAll(spec, pageable);
|
||||
|
||||
Set<Long> bookIds = bookPage.getContent().stream()
|
||||
.map(BookEntity::getId)
|
||||
.collect(Collectors.toSet());
|
||||
Map<Long, UserBookProgressEntity> progressMap = getProgressMap(userId, bookIds);
|
||||
|
||||
List<AppBookSummary> summaries = bookPage.getContent().stream()
|
||||
.map(book -> mobileBookMapper.toSummary(book, progressMap.get(book.getId())))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return AppPageResponse.of(summaries, pageNum, pageSize, bookPage.getTotalElements());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AppBookDetail getBookDetail(Long bookId) {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
Set<Long> accessibleLibraryIds = getAccessibleLibraryIds(user);
|
||||
|
||||
BookEntity book = bookRepository.findByIdWithBookFiles(bookId)
|
||||
.orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
|
||||
if (accessibleLibraryIds != null && !accessibleLibraryIds.contains(book.getLibrary().getId())) {
|
||||
throw ApiError.FORBIDDEN.createException("Access denied to this book");
|
||||
}
|
||||
|
||||
UserBookProgressEntity progress = userBookProgressRepository
|
||||
.findByUserIdAndBookId(userId, bookId)
|
||||
.orElse(null);
|
||||
|
||||
UserBookFileProgressEntity fileProgress = userBookFileProgressRepository
|
||||
.findMostRecentAudiobookProgressByUserIdAndBookId(userId, bookId)
|
||||
.orElse(null);
|
||||
|
||||
return mobileBookMapper.toDetail(book, progress, fileProgress);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AppPageResponse<AppBookSummary> searchBooks(
|
||||
String query,
|
||||
Integer page,
|
||||
Integer size) {
|
||||
|
||||
if (query == null || query.trim().isEmpty()) {
|
||||
throw ApiError.INVALID_QUERY_PARAMETERS.createException();
|
||||
}
|
||||
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
Set<Long> accessibleLibraryIds = getAccessibleLibraryIds(user);
|
||||
|
||||
int pageNum = validatePageNumber(page);
|
||||
int pageSize = validatePageSize(size);
|
||||
|
||||
Pageable pageable = PageRequest.of(pageNum, pageSize, Sort.by(Sort.Direction.DESC, "addedOn"));
|
||||
|
||||
Specification<BookEntity> spec = AppBookSpecification.combine(
|
||||
AppBookSpecification.notDeleted(),
|
||||
AppBookSpecification.hasDigitalFile(),
|
||||
AppBookSpecification.inLibraries(accessibleLibraryIds),
|
||||
AppBookSpecification.searchText(query)
|
||||
);
|
||||
|
||||
Page<BookEntity> bookPage = bookRepository.findAll(spec, pageable);
|
||||
return buildPageResponse(bookPage, userId, pageNum, pageSize);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<AppBookSummary> getContinueReading(Integer limit) {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
Set<Long> accessibleLibraryIds = getAccessibleLibraryIds(user);
|
||||
|
||||
int maxItems = validateLimit(limit, 10);
|
||||
|
||||
Specification<BookEntity> spec = AppBookSpecification.combine(
|
||||
AppBookSpecification.notDeleted(),
|
||||
AppBookSpecification.hasDigitalFile(),
|
||||
AppBookSpecification.inLibraries(accessibleLibraryIds),
|
||||
AppBookSpecification.inProgress(userId),
|
||||
AppBookSpecification.hasNonAudiobookFile()
|
||||
);
|
||||
|
||||
List<BookEntity> books = bookRepository.findAll(spec);
|
||||
Map<Long, UserBookProgressEntity> progressMap = getProgressMapForBooks(userId, books);
|
||||
|
||||
return books.stream()
|
||||
.filter(book -> progressMap.containsKey(book.getId()))
|
||||
.sorted((b1, b2) -> {
|
||||
Instant t1 = progressMap.get(b1.getId()).getLastReadTime();
|
||||
Instant t2 = progressMap.get(b2.getId()).getLastReadTime();
|
||||
if (t1 == null && t2 == null) return 0;
|
||||
if (t1 == null) return 1;
|
||||
if (t2 == null) return -1;
|
||||
return t2.compareTo(t1);
|
||||
})
|
||||
.limit(maxItems)
|
||||
.map(book -> mobileBookMapper.toSummary(book, progressMap.get(book.getId())))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<AppBookSummary> getContinueListening(Integer limit) {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
Set<Long> accessibleLibraryIds = getAccessibleLibraryIds(user);
|
||||
|
||||
int maxItems = validateLimit(limit, 10);
|
||||
|
||||
Specification<BookEntity> spec = AppBookSpecification.combine(
|
||||
AppBookSpecification.notDeleted(),
|
||||
AppBookSpecification.hasDigitalFile(),
|
||||
AppBookSpecification.inLibraries(accessibleLibraryIds),
|
||||
AppBookSpecification.inProgress(userId),
|
||||
AppBookSpecification.hasAudiobookFile()
|
||||
);
|
||||
|
||||
List<BookEntity> books = bookRepository.findAll(spec);
|
||||
Map<Long, UserBookProgressEntity> progressMap = getProgressMapForBooks(userId, books);
|
||||
|
||||
return books.stream()
|
||||
.filter(book -> progressMap.containsKey(book.getId()))
|
||||
.sorted((b1, b2) -> {
|
||||
Instant t1 = progressMap.get(b1.getId()).getLastReadTime();
|
||||
Instant t2 = progressMap.get(b2.getId()).getLastReadTime();
|
||||
if (t1 == null && t2 == null) return 0;
|
||||
if (t1 == null) return 1;
|
||||
if (t2 == null) return -1;
|
||||
return t2.compareTo(t1);
|
||||
})
|
||||
.limit(maxItems)
|
||||
.map(book -> mobileBookMapper.toSummary(book, progressMap.get(book.getId())))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<AppBookSummary> getRecentlyAdded(Integer limit) {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
Set<Long> accessibleLibraryIds = getAccessibleLibraryIds(user);
|
||||
|
||||
int maxItems = validateLimit(limit, 10);
|
||||
|
||||
Specification<BookEntity> spec = AppBookSpecification.combine(
|
||||
AppBookSpecification.notDeleted(),
|
||||
AppBookSpecification.hasDigitalFile(),
|
||||
AppBookSpecification.inLibraries(accessibleLibraryIds),
|
||||
AppBookSpecification.addedWithinDays(30)
|
||||
);
|
||||
|
||||
Pageable pageable = PageRequest.of(0, maxItems, Sort.by(Sort.Direction.DESC, "addedOn"));
|
||||
Page<BookEntity> bookPage = bookRepository.findAll(spec, pageable);
|
||||
Map<Long, UserBookProgressEntity> progressMap = getProgressMapForBooks(userId, bookPage.getContent());
|
||||
|
||||
return bookPage.getContent().stream()
|
||||
.map(book -> mobileBookMapper.toSummary(book, progressMap.get(book.getId())))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<AppBookSummary> getRecentlyScanned(Integer limit) {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
Set<Long> accessibleLibraryIds = getAccessibleLibraryIds(user);
|
||||
|
||||
int maxItems = validateLimit(limit, 10);
|
||||
|
||||
Specification<BookEntity> spec = AppBookSpecification.combine(
|
||||
AppBookSpecification.notDeleted(),
|
||||
AppBookSpecification.hasScannedOn(),
|
||||
AppBookSpecification.inLibraries(accessibleLibraryIds)
|
||||
);
|
||||
|
||||
Pageable pageable = PageRequest.of(0, maxItems, Sort.by(Sort.Direction.DESC, "scannedOn"));
|
||||
Page<BookEntity> bookPage = bookRepository.findAll(spec, pageable);
|
||||
Map<Long, UserBookProgressEntity> progressMap = getProgressMapForBooks(userId, bookPage.getContent());
|
||||
|
||||
return bookPage.getContent().stream()
|
||||
.map(book -> mobileBookMapper.toSummary(book, progressMap.get(book.getId())))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AppPageResponse<AppBookSummary> getRandomBooks(
|
||||
Integer page,
|
||||
Integer size,
|
||||
Long libraryId) {
|
||||
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
Set<Long> accessibleLibraryIds = getAccessibleLibraryIds(user);
|
||||
|
||||
int pageNum = validatePageNumber(page);
|
||||
int pageSize = validatePageSize(size);
|
||||
|
||||
Specification<BookEntity> spec = buildBaseSpecification(accessibleLibraryIds, libraryId);
|
||||
|
||||
long totalElements = bookRepository.count(spec);
|
||||
|
||||
if (totalElements == 0) {
|
||||
return AppPageResponse.of(Collections.emptyList(), pageNum, pageSize, 0L);
|
||||
}
|
||||
|
||||
long maxOffset = Math.max(0, totalElements - pageSize);
|
||||
int randomOffset = ThreadLocalRandom.current().nextInt((int) maxOffset + 1);
|
||||
|
||||
Pageable pageable = PageRequest.of(randomOffset / pageSize, pageSize);
|
||||
Page<BookEntity> bookPage = bookRepository.findAll(spec, pageable);
|
||||
|
||||
return buildPageResponse(bookPage, userId, pageNum, pageSize);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AppPageResponse<AppBookSummary> getBooksByMagicShelf(
|
||||
Long magicShelfId,
|
||||
Integer page,
|
||||
Integer size) {
|
||||
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
|
||||
int pageNum = validatePageNumber(page);
|
||||
int pageSize = validatePageSize(size);
|
||||
|
||||
var booksPage = magicShelfBookService.getBooksByMagicShelfId(userId, magicShelfId, pageNum, pageSize);
|
||||
|
||||
Set<Long> bookIds = booksPage.getContent().stream()
|
||||
.map(Book::getId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
if (bookIds.isEmpty()) {
|
||||
return AppPageResponse.of(Collections.emptyList(), pageNum, pageSize, 0L);
|
||||
}
|
||||
|
||||
List<BookEntity> bookEntities = bookRepository.findAllById(bookIds);
|
||||
Map<Long, UserBookProgressEntity> progressMap = getProgressMapForBooks(userId, bookEntities);
|
||||
|
||||
List<AppBookSummary> summaries = bookEntities.stream()
|
||||
.filter(BookEntity::hasFiles)
|
||||
.map(bookEntity -> mobileBookMapper.toSummary(bookEntity, progressMap.get(bookEntity.getId())))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return AppPageResponse.of(summaries, pageNum, pageSize, booksPage.getTotalElements());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AppFilterOptions getFilterOptions(Long libraryId, Long shelfId, Long magicShelfId) {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
Set<Long> accessibleLibraryIds = getAccessibleLibraryIds(user);
|
||||
|
||||
// Resolve magic shelf to a set of book IDs if requested
|
||||
Set<Long> magicBookIds = null;
|
||||
if (magicShelfId != null) {
|
||||
magicBookIds = resolveMagicShelfBookIds(magicShelfId, userId);
|
||||
if (magicBookIds.isEmpty()) {
|
||||
return AppFilterOptions.builder()
|
||||
.authors(Collections.emptyList())
|
||||
.languages(Collections.emptyList())
|
||||
.fileTypes(Collections.emptyList())
|
||||
.readStatuses(getReadStatusOptions())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
// Validate library access
|
||||
if (libraryId != null && accessibleLibraryIds != null && !accessibleLibraryIds.contains(libraryId)) {
|
||||
throw ApiError.FORBIDDEN.createException("Access denied to library " + libraryId);
|
||||
}
|
||||
|
||||
// Validate shelf access
|
||||
if (shelfId != null) {
|
||||
ShelfEntity shelf = shelfRepository.findById(shelfId)
|
||||
.orElseThrow(() -> ApiError.SHELF_NOT_FOUND.createException(shelfId));
|
||||
if (!shelf.isPublic() && !shelf.getUser().getId().equals(userId)) {
|
||||
throw ApiError.FORBIDDEN.createException("Access denied to shelf " + shelfId);
|
||||
}
|
||||
}
|
||||
|
||||
// Build scoping clauses
|
||||
String libraryClause = "";
|
||||
String shelfClause = "";
|
||||
String magicBookClause = "";
|
||||
|
||||
if (magicBookIds != null) {
|
||||
magicBookClause = "AND b.id IN :magicBookIds";
|
||||
} else if (shelfId != null) {
|
||||
shelfClause = "AND b.id IN (SELECT sb.id FROM ShelfEntity s JOIN s.bookEntities sb WHERE s.id = :shelfId)";
|
||||
}
|
||||
|
||||
if (libraryId != null) {
|
||||
libraryClause = "AND b.library.id = :libraryId";
|
||||
} else if (accessibleLibraryIds != null) {
|
||||
libraryClause = "AND b.library.id IN :libraryIds";
|
||||
}
|
||||
|
||||
// Build the optional WHERE suffix once — each clause already starts with "AND"
|
||||
String scopeClause = buildScopeClause(libraryClause, shelfClause, magicBookClause);
|
||||
|
||||
// Authors with book count (top 200 by count)
|
||||
String authorQuery = "SELECT a.name, COUNT(DISTINCT b.id) FROM BookEntity b"
|
||||
+ " JOIN b.metadata m JOIN m.authors a"
|
||||
+ " WHERE (b.deleted IS NULL OR b.deleted = false)"
|
||||
+ " AND b.bookFiles IS NOT EMPTY"
|
||||
+ scopeClause
|
||||
+ " GROUP BY a.name ORDER BY COUNT(DISTINCT b.id) DESC";
|
||||
var authorQ = entityManager.createQuery(authorQuery, Tuple.class);
|
||||
setFilterQueryParams(authorQ, accessibleLibraryIds, libraryId, shelfId, magicBookIds);
|
||||
authorQ.setMaxResults(200);
|
||||
|
||||
List<AppFilterOptions.AuthorOption> authors = authorQ.getResultList().stream()
|
||||
.map(t -> AppFilterOptions.AuthorOption.builder()
|
||||
.name(t.get(0, String.class))
|
||||
.count(t.get(1, Long.class))
|
||||
.build())
|
||||
.toList();
|
||||
|
||||
// Languages with book count
|
||||
String langQuery = "SELECT m.language, COUNT(DISTINCT b.id) FROM BookEntity b"
|
||||
+ " JOIN b.metadata m"
|
||||
+ " WHERE (b.deleted IS NULL OR b.deleted = false)"
|
||||
+ " AND b.bookFiles IS NOT EMPTY"
|
||||
+ " AND m.language IS NOT NULL AND m.language <> ''"
|
||||
+ scopeClause
|
||||
+ " GROUP BY m.language ORDER BY COUNT(DISTINCT b.id) DESC";
|
||||
var langQ = entityManager.createQuery(langQuery, Tuple.class);
|
||||
setFilterQueryParams(langQ, accessibleLibraryIds, libraryId, shelfId, magicBookIds);
|
||||
|
||||
List<AppFilterOptions.LanguageOption> languages = langQ.getResultList().stream()
|
||||
.map(t -> AppFilterOptions.LanguageOption.builder()
|
||||
.code(t.get(0, String.class))
|
||||
.label(Locale.forLanguageTag(t.get(0, String.class)).getDisplayLanguage(Locale.ENGLISH))
|
||||
.count(t.get(1, Long.class))
|
||||
.build())
|
||||
.toList();
|
||||
|
||||
// Distinct file types present in scoped books
|
||||
String fileTypeQuery = "SELECT DISTINCT bf.bookType FROM BookEntity b"
|
||||
+ " JOIN b.bookFiles bf"
|
||||
+ " WHERE (b.deleted IS NULL OR b.deleted = false)"
|
||||
+ " AND b.bookFiles IS NOT EMPTY"
|
||||
+ " AND bf.isBookFormat = true"
|
||||
+ scopeClause;
|
||||
var ftQ = entityManager.createQuery(fileTypeQuery, BookFileType.class);
|
||||
setFilterQueryParams(ftQ, accessibleLibraryIds, libraryId, shelfId, magicBookIds);
|
||||
|
||||
List<String> fileTypes = ftQ.getResultList().stream()
|
||||
.map(Enum::name)
|
||||
.sorted()
|
||||
.toList();
|
||||
|
||||
// Read statuses — return all meaningful values
|
||||
List<String> readStatuses = getReadStatusOptions();
|
||||
|
||||
return AppFilterOptions.builder()
|
||||
.authors(authors)
|
||||
.languages(languages)
|
||||
.fileTypes(fileTypes)
|
||||
.readStatuses(readStatuses)
|
||||
.build();
|
||||
}
|
||||
|
||||
private String buildScopeClause(String libraryClause, String shelfClause, String magicBookClause) {
|
||||
var sb = new StringBuilder();
|
||||
if (!libraryClause.isEmpty()) sb.append(" ").append(libraryClause);
|
||||
if (!shelfClause.isEmpty()) sb.append(" ").append(shelfClause);
|
||||
if (!magicBookClause.isEmpty()) sb.append(" ").append(magicBookClause);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private void setFilterQueryParams(jakarta.persistence.Query query, Set<Long> accessibleLibraryIds, Long libraryId, Long shelfId, Set<Long> magicBookIds) {
|
||||
if (libraryId != null) {
|
||||
query.setParameter("libraryId", libraryId);
|
||||
} else if (accessibleLibraryIds != null) {
|
||||
query.setParameter("libraryIds", accessibleLibraryIds);
|
||||
}
|
||||
if (shelfId != null && magicBookIds == null) {
|
||||
query.setParameter("shelfId", shelfId);
|
||||
}
|
||||
if (magicBookIds != null) {
|
||||
query.setParameter("magicBookIds", magicBookIds);
|
||||
}
|
||||
}
|
||||
|
||||
private Set<Long> resolveMagicShelfBookIds(Long magicShelfId, Long userId) {
|
||||
// Reuse MagicShelfBookService which already handles access validation,
|
||||
// rule evaluation, and library filtering.
|
||||
var booksPage = magicShelfBookService.getBooksByMagicShelfId(userId, magicShelfId, 0, 10000);
|
||||
return booksPage.getContent().stream()
|
||||
.map(Book::getId)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private List<String> getReadStatusOptions() {
|
||||
return Arrays.stream(ReadStatus.values())
|
||||
.filter(s -> s != ReadStatus.UNSET)
|
||||
.map(Enum::name)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateReadStatus(Long bookId, ReadStatus status) {
|
||||
UserBookProgressEntity progress = validateAccessAndGetProgress(bookId);
|
||||
|
||||
progress.setReadStatus(status);
|
||||
progress.setReadStatusModifiedTime(Instant.now());
|
||||
|
||||
if (status == ReadStatus.READ && progress.getDateFinished() == null) {
|
||||
progress.setDateFinished(Instant.now());
|
||||
}
|
||||
|
||||
userBookProgressRepository.save(progress);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updatePersonalRating(Long bookId, Integer rating) {
|
||||
UserBookProgressEntity progress = validateAccessAndGetProgress(bookId);
|
||||
|
||||
progress.setPersonalRating(rating);
|
||||
userBookProgressRepository.save(progress);
|
||||
}
|
||||
|
||||
private UserBookProgressEntity validateAccessAndGetProgress(Long bookId) {
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
Set<Long> accessibleLibraryIds = getAccessibleLibraryIds(user);
|
||||
|
||||
BookEntity book = bookRepository.findById(bookId)
|
||||
.orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
|
||||
validateLibraryAccess(accessibleLibraryIds, book.getLibrary().getId());
|
||||
|
||||
return userBookProgressRepository
|
||||
.findByUserIdAndBookId(userId, bookId)
|
||||
.orElseGet(() -> createNewProgress(userId, book));
|
||||
}
|
||||
|
||||
private void validateLibraryAccess(Set<Long> accessibleLibraryIds, Long libraryId) {
|
||||
if (accessibleLibraryIds != null && !accessibleLibraryIds.contains(libraryId)) {
|
||||
throw ApiError.FORBIDDEN.createException("Access denied to this book");
|
||||
}
|
||||
}
|
||||
|
||||
private UserBookProgressEntity createNewProgress(Long userId, BookEntity book) {
|
||||
return UserBookProgressEntity.builder()
|
||||
.user(BookLoreUserEntity.builder().id(userId).build())
|
||||
.book(book)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Set<Long> getAccessibleLibraryIds(BookLoreUser user) {
|
||||
if (user.getPermissions().isAdmin()) {
|
||||
return null;
|
||||
}
|
||||
if (user.getAssignedLibraries() == null || user.getAssignedLibraries().isEmpty()) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
return user.getAssignedLibraries().stream()
|
||||
.map(Library::getId)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private Map<Long, UserBookProgressEntity> getProgressMap(Long userId, Set<Long> bookIds) {
|
||||
if (bookIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
return userBookProgressRepository.findByUserIdAndBookIdIn(userId, bookIds).stream()
|
||||
.collect(Collectors.toMap(
|
||||
p -> p.getBook().getId(),
|
||||
Function.identity()
|
||||
));
|
||||
}
|
||||
|
||||
private Specification<BookEntity> buildSpecification(
|
||||
Set<Long> accessibleLibraryIds,
|
||||
Long libraryId,
|
||||
Long shelfId,
|
||||
ReadStatus status,
|
||||
String search,
|
||||
Long userId,
|
||||
BookFileType fileType,
|
||||
Integer minRating,
|
||||
Integer maxRating,
|
||||
String authors,
|
||||
String language) {
|
||||
|
||||
List<Specification<BookEntity>> specs = new ArrayList<>();
|
||||
specs.add(AppBookSpecification.notDeleted());
|
||||
specs.add(AppBookSpecification.hasDigitalFile());
|
||||
|
||||
if (accessibleLibraryIds != null) {
|
||||
if (libraryId != null && accessibleLibraryIds.contains(libraryId)) {
|
||||
specs.add(AppBookSpecification.inLibrary(libraryId));
|
||||
} else if (libraryId != null) {
|
||||
throw ApiError.FORBIDDEN.createException("Access denied to library " + libraryId);
|
||||
} else {
|
||||
specs.add(AppBookSpecification.inLibraries(accessibleLibraryIds));
|
||||
}
|
||||
} else if (libraryId != null) {
|
||||
specs.add(AppBookSpecification.inLibrary(libraryId));
|
||||
}
|
||||
|
||||
if (shelfId != null) {
|
||||
ShelfEntity shelf = shelfRepository.findById(shelfId)
|
||||
.orElseThrow(() -> ApiError.SHELF_NOT_FOUND.createException(shelfId));
|
||||
if (!shelf.isPublic() && !shelf.getUser().getId().equals(userId)) {
|
||||
throw ApiError.FORBIDDEN.createException("Access denied to shelf " + shelfId);
|
||||
}
|
||||
specs.add(AppBookSpecification.inShelf(shelfId));
|
||||
}
|
||||
|
||||
if (status != null) {
|
||||
specs.add(AppBookSpecification.withReadStatus(status, userId));
|
||||
}
|
||||
|
||||
if (search != null && !search.trim().isEmpty()) {
|
||||
specs.add(AppBookSpecification.searchText(search));
|
||||
}
|
||||
|
||||
if (fileType != null) {
|
||||
specs.add(AppBookSpecification.withFileType(fileType));
|
||||
}
|
||||
|
||||
if (minRating != null) {
|
||||
specs.add(AppBookSpecification.withMinRating(minRating, userId));
|
||||
}
|
||||
|
||||
if (maxRating != null) {
|
||||
specs.add(AppBookSpecification.withMaxRating(maxRating, userId));
|
||||
}
|
||||
|
||||
if (authors != null && !authors.trim().isEmpty()) {
|
||||
specs.add(AppBookSpecification.withAuthor(authors.trim()));
|
||||
}
|
||||
|
||||
if (language != null && !language.trim().isEmpty()) {
|
||||
specs.add(AppBookSpecification.withLanguage(language.trim()));
|
||||
}
|
||||
|
||||
return AppBookSpecification.combine(specs.toArray(new Specification[0]));
|
||||
}
|
||||
|
||||
private Sort buildSort(String sortBy, String sortDir) {
|
||||
Sort.Direction direction = "asc".equalsIgnoreCase(sortDir)
|
||||
? Sort.Direction.ASC
|
||||
: Sort.Direction.DESC;
|
||||
|
||||
String field = switch (sortBy != null ? sortBy.toLowerCase() : "") {
|
||||
case "title" -> "metadata.title";
|
||||
case "seriesname", "series" -> "metadata.seriesName";
|
||||
case "lastreadtime" -> "addedOn";
|
||||
default -> "addedOn";
|
||||
};
|
||||
|
||||
return Sort.by(direction, field);
|
||||
}
|
||||
|
||||
private int validatePageNumber(Integer page) {
|
||||
return page != null && page >= 0 ? page : 0;
|
||||
}
|
||||
|
||||
private int validatePageSize(Integer size) {
|
||||
return size != null && size > 0 ? Math.min(size, MAX_PAGE_SIZE) : DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
|
||||
private int validateLimit(Integer limit, int defaultValue) {
|
||||
return limit != null && limit > 0 ? Math.min(limit, MAX_PAGE_SIZE) : defaultValue;
|
||||
}
|
||||
|
||||
private Specification<BookEntity> buildBaseSpecification(Set<Long> accessibleLibraryIds, Long libraryId) {
|
||||
List<Specification<BookEntity>> specs = new ArrayList<>();
|
||||
specs.add(AppBookSpecification.notDeleted());
|
||||
specs.add(AppBookSpecification.hasDigitalFile());
|
||||
|
||||
if (accessibleLibraryIds != null) {
|
||||
if (libraryId != null && !accessibleLibraryIds.contains(libraryId)) {
|
||||
throw ApiError.FORBIDDEN.createException("Access denied to library " + libraryId);
|
||||
}
|
||||
specs.add(libraryId != null
|
||||
? AppBookSpecification.inLibrary(libraryId)
|
||||
: AppBookSpecification.inLibraries(accessibleLibraryIds));
|
||||
} else if (libraryId != null) {
|
||||
specs.add(AppBookSpecification.inLibrary(libraryId));
|
||||
}
|
||||
|
||||
return AppBookSpecification.combine(specs.toArray(new Specification[0]));
|
||||
}
|
||||
|
||||
private AppPageResponse<AppBookSummary> buildPageResponse(
|
||||
Page<BookEntity> bookPage,
|
||||
Long userId,
|
||||
int pageNum,
|
||||
int pageSize) {
|
||||
|
||||
Map<Long, UserBookProgressEntity> progressMap = getProgressMapForBooks(userId, bookPage.getContent());
|
||||
|
||||
List<AppBookSummary> summaries = bookPage.getContent().stream()
|
||||
.map(book -> mobileBookMapper.toSummary(book, progressMap.get(book.getId())))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return AppPageResponse.of(summaries, pageNum, pageSize, bookPage.getTotalElements());
|
||||
}
|
||||
|
||||
private Map<Long, UserBookProgressEntity> getProgressMapForBooks(Long userId, List<BookEntity> books) {
|
||||
if (books.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
Set<Long> bookIds = books.stream()
|
||||
.map(BookEntity::getId)
|
||||
.collect(Collectors.toSet());
|
||||
return getProgressMap(userId, bookIds);
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
package org.booklore.app.service;
|
||||
|
||||
import org.booklore.config.security.service.AuthenticationService;
|
||||
import org.booklore.app.dto.AppNotebookBookSummary;
|
||||
import org.booklore.app.dto.AppNotebookEntry;
|
||||
import org.booklore.app.dto.AppNotebookUpdateRequest;
|
||||
import org.booklore.app.dto.AppPageResponse;
|
||||
import org.booklore.model.dto.UpdateAnnotationRequest;
|
||||
import org.booklore.model.dto.UpdateBookMarkRequest;
|
||||
import org.booklore.model.dto.UpdateBookNoteV2Request;
|
||||
import org.booklore.repository.AuthorRepository;
|
||||
import org.booklore.repository.NotebookEntryRepository;
|
||||
import org.booklore.service.book.AnnotationService;
|
||||
import org.booklore.service.book.BookMarkService;
|
||||
import org.booklore.service.book.BookNoteV2Service;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
public class AppNotebookService {
|
||||
|
||||
private final NotebookEntryRepository notebookEntryRepository;
|
||||
private final AuthorRepository authorRepository;
|
||||
private final AuthenticationService authenticationService;
|
||||
private final AnnotationService annotationService;
|
||||
private final BookNoteV2Service bookNoteV2Service;
|
||||
private final BookMarkService bookMarkService;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AppPageResponse<AppNotebookBookSummary> getBooksWithAnnotations(int page, int size, String search) {
|
||||
Long userId = authenticationService.getAuthenticatedUser().getId();
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
|
||||
Page<NotebookEntryRepository.BookWithCountProjection> booksPage =
|
||||
notebookEntryRepository.findBooksWithAnnotationsPaginated(userId, wrapSearch(search), pageable);
|
||||
|
||||
List<NotebookEntryRepository.BookWithCountProjection> content = booksPage.getContent();
|
||||
if (content.isEmpty()) {
|
||||
return AppPageResponse.of(List.of(), page, size, 0);
|
||||
}
|
||||
|
||||
Set<Long> bookIds = content.stream()
|
||||
.map(NotebookEntryRepository.BookWithCountProjection::getBookId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Map<Long, List<String>> authorsByBook = authorRepository.findAuthorNamesByBookIds(bookIds)
|
||||
.stream()
|
||||
.collect(Collectors.groupingBy(
|
||||
AuthorRepository.AuthorBookProjection::getBookId,
|
||||
Collectors.mapping(AuthorRepository.AuthorBookProjection::getAuthorName, Collectors.toList())));
|
||||
|
||||
List<AppNotebookBookSummary> summaries = content.stream()
|
||||
.map(p -> AppNotebookBookSummary.builder()
|
||||
.bookId(p.getBookId())
|
||||
.bookTitle(p.getBookTitle())
|
||||
.noteCount(p.getNoteCount())
|
||||
.authors(authorsByBook.getOrDefault(p.getBookId(), List.of()))
|
||||
.coverUpdatedOn(p.getCoverUpdatedOn())
|
||||
.build())
|
||||
.toList();
|
||||
|
||||
return AppPageResponse.of(summaries, page, size, booksPage.getTotalElements());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AppPageResponse<AppNotebookEntry> getEntriesForBook(Long bookId, int page, int size,
|
||||
Set<String> types, String search, String sort) {
|
||||
Long userId = authenticationService.getAuthenticatedUser().getId();
|
||||
Set<String> entryTypes = (types == null || types.isEmpty())
|
||||
? Set.of("HIGHLIGHT", "NOTE", "BOOKMARK")
|
||||
: types;
|
||||
Pageable pageable = PageRequest.of(page, size, toSort(sort));
|
||||
|
||||
Page<NotebookEntryRepository.EntryProjection> entriesPage =
|
||||
notebookEntryRepository.findEntries(userId, entryTypes, bookId, wrapSearch(search), pageable);
|
||||
|
||||
List<AppNotebookEntry> entries = entriesPage.getContent().stream()
|
||||
.map(AppNotebookService::toMobileEntry)
|
||||
.toList();
|
||||
|
||||
return AppPageResponse.of(entries, page, size, entriesPage.getTotalElements());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AppNotebookEntry updateEntry(Long entryId, String type, AppNotebookUpdateRequest request) {
|
||||
return switch (type.toUpperCase()) {
|
||||
case "HIGHLIGHT" -> {
|
||||
var updateReq = new UpdateAnnotationRequest();
|
||||
if (request.getNote() != null) updateReq.setNote(request.getNote());
|
||||
if (request.getColor() != null) updateReq.setColor(request.getColor());
|
||||
var result = annotationService.updateAnnotation(entryId, updateReq);
|
||||
yield AppNotebookEntry.builder()
|
||||
.id(result.getId())
|
||||
.type("HIGHLIGHT")
|
||||
.bookId(result.getBookId())
|
||||
.text(result.getText())
|
||||
.note(result.getNote())
|
||||
.color(result.getColor())
|
||||
.style(result.getStyle())
|
||||
.chapterTitle(result.getChapterTitle())
|
||||
.createdAt(result.getCreatedAt())
|
||||
.updatedAt(result.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
case "NOTE" -> {
|
||||
var updateReq = new UpdateBookNoteV2Request();
|
||||
if (request.getNote() != null) updateReq.setNoteContent(request.getNote());
|
||||
if (request.getColor() != null) updateReq.setColor(request.getColor());
|
||||
var result = bookNoteV2Service.updateNote(entryId, updateReq);
|
||||
yield AppNotebookEntry.builder()
|
||||
.id(result.getId())
|
||||
.type("NOTE")
|
||||
.bookId(result.getBookId())
|
||||
.text(result.getSelectedText())
|
||||
.note(result.getNoteContent())
|
||||
.color(result.getColor())
|
||||
.chapterTitle(result.getChapterTitle())
|
||||
.createdAt(result.getCreatedAt())
|
||||
.updatedAt(result.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
case "BOOKMARK" -> {
|
||||
var updateReq = new UpdateBookMarkRequest();
|
||||
if (request.getNote() != null) updateReq.setNotes(request.getNote());
|
||||
if (request.getColor() != null) updateReq.setColor(request.getColor());
|
||||
var result = bookMarkService.updateBookmark(entryId, updateReq);
|
||||
yield AppNotebookEntry.builder()
|
||||
.id(result.getId())
|
||||
.type("BOOKMARK")
|
||||
.bookId(result.getBookId())
|
||||
.text(result.getTitle())
|
||||
.note(result.getNotes())
|
||||
.color(result.getColor())
|
||||
.createdAt(result.getCreatedAt())
|
||||
.updatedAt(result.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
default -> throw new IllegalArgumentException("Unknown entry type: " + type);
|
||||
};
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteEntry(Long entryId, String type) {
|
||||
switch (type.toUpperCase()) {
|
||||
case "HIGHLIGHT" -> annotationService.deleteAnnotation(entryId);
|
||||
case "NOTE" -> bookNoteV2Service.deleteNote(entryId);
|
||||
case "BOOKMARK" -> bookMarkService.deleteBookmark(entryId);
|
||||
default -> throw new IllegalArgumentException("Unknown entry type: " + type);
|
||||
}
|
||||
}
|
||||
|
||||
private String wrapSearch(String search) {
|
||||
if (search == null || search.isBlank()) return null;
|
||||
return "%" + escapeLike(search) + "%";
|
||||
}
|
||||
|
||||
private static String escapeLike(String input) {
|
||||
return input.trim()
|
||||
.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_");
|
||||
}
|
||||
|
||||
private static Sort toSort(String sort) {
|
||||
if ("chapter".equalsIgnoreCase(sort)) {
|
||||
return Sort.by(Sort.Order.asc("chapterTitle"), Sort.Order.asc("createdAt"));
|
||||
}
|
||||
if ("date_asc".equalsIgnoreCase(sort)) {
|
||||
return Sort.by("createdAt").ascending();
|
||||
}
|
||||
return Sort.by("createdAt").descending();
|
||||
}
|
||||
|
||||
private static AppNotebookEntry toMobileEntry(NotebookEntryRepository.EntryProjection p) {
|
||||
return AppNotebookEntry.builder()
|
||||
.id(p.getId())
|
||||
.type(p.getType())
|
||||
.bookId(p.getBookId())
|
||||
.text(p.getText())
|
||||
.note(p.getNote())
|
||||
.color(p.getColor())
|
||||
.style(p.getStyle())
|
||||
.chapterTitle(p.getChapterTitle())
|
||||
.createdAt(p.getCreatedAt())
|
||||
.updatedAt(p.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -1,390 +0,0 @@
|
||||
package org.booklore.app.service;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.Tuple;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.booklore.config.security.service.AuthenticationService;
|
||||
import org.booklore.exception.ApiError;
|
||||
import org.booklore.app.dto.*;
|
||||
import org.booklore.app.mapper.AppBookMapper;
|
||||
import org.booklore.app.specification.AppBookSpecification;
|
||||
import org.booklore.model.dto.BookLoreUser;
|
||||
import org.booklore.model.dto.Library;
|
||||
import org.booklore.model.entity.*;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.repository.BookRepository;
|
||||
import org.booklore.repository.UserBookProgressRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
public class AppSeriesService {
|
||||
|
||||
private static final int DEFAULT_PAGE_SIZE = 20;
|
||||
private static final int MAX_PAGE_SIZE = 50;
|
||||
|
||||
private final EntityManager entityManager;
|
||||
private final AuthenticationService authenticationService;
|
||||
private final BookRepository bookRepository;
|
||||
private final UserBookProgressRepository userBookProgressRepository;
|
||||
private final AppBookMapper mobileBookMapper;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AppPageResponse<AppSeriesSummary> getSeries(
|
||||
Integer page,
|
||||
Integer size,
|
||||
String sortBy,
|
||||
String sortDir,
|
||||
Long libraryId,
|
||||
String search,
|
||||
boolean inProgressOnly) {
|
||||
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
Set<Long> accessibleLibraryIds = getAccessibleLibraryIds(user);
|
||||
|
||||
if (libraryId != null) {
|
||||
validateLibraryAccess(accessibleLibraryIds, libraryId);
|
||||
}
|
||||
|
||||
int pageNum = page != null && page >= 0 ? page : 0;
|
||||
int pageSize = size != null && size > 0 ? Math.min(size, MAX_PAGE_SIZE) : DEFAULT_PAGE_SIZE;
|
||||
|
||||
// Build WHERE clause fragments
|
||||
String libraryClause = buildLibraryClause(accessibleLibraryIds, libraryId);
|
||||
String searchClause = (search != null && !search.trim().isEmpty())
|
||||
? " AND LOWER(m.seriesName) LIKE :searchPattern"
|
||||
: "";
|
||||
|
||||
String havingClause = inProgressOnly
|
||||
? " HAVING SUM(CASE WHEN p.readStatus IN (org.booklore.model.enums.ReadStatus.READING, org.booklore.model.enums.ReadStatus.RE_READING) THEN 1 ELSE 0 END) > 0"
|
||||
: "";
|
||||
|
||||
String orderBy = buildSeriesOrderBy(sortBy, sortDir, inProgressOnly);
|
||||
|
||||
// Phase 1: Aggregate query
|
||||
String aggregateQuery = "SELECT m.seriesName, COUNT(b.id), MAX(m.seriesTotal), MAX(b.addedOn),"
|
||||
+ " SUM(CASE WHEN p.readStatus = org.booklore.model.enums.ReadStatus.READ THEN 1 ELSE 0 END)"
|
||||
+ (inProgressOnly ? ", MAX(p.lastReadTime)" : "")
|
||||
+ " FROM BookEntity b JOIN b.metadata m"
|
||||
+ " LEFT JOIN b.userBookProgress p ON p.user.id = :userId"
|
||||
+ " WHERE (b.deleted IS NULL OR b.deleted = false)"
|
||||
+ " AND b.bookFiles IS NOT EMPTY"
|
||||
+ " AND m.seriesName IS NOT NULL"
|
||||
+ libraryClause
|
||||
+ searchClause
|
||||
+ " GROUP BY m.seriesName"
|
||||
+ havingClause
|
||||
+ " ORDER BY " + orderBy;
|
||||
|
||||
var aggregateQ = entityManager.createQuery(aggregateQuery, Tuple.class);
|
||||
aggregateQ.setParameter("userId", userId);
|
||||
setLibraryParams(aggregateQ, accessibleLibraryIds, libraryId);
|
||||
if (!searchClause.isEmpty()) {
|
||||
aggregateQ.setParameter("searchPattern", "%" + search.trim().toLowerCase() + "%");
|
||||
}
|
||||
aggregateQ.setFirstResult(pageNum * pageSize);
|
||||
aggregateQ.setMaxResults(pageSize);
|
||||
|
||||
List<Tuple> aggregateResults = aggregateQ.getResultList();
|
||||
|
||||
// Count query
|
||||
String countQuery = "SELECT COUNT(DISTINCT m.seriesName) FROM BookEntity b JOIN b.metadata m"
|
||||
+ (inProgressOnly ? " LEFT JOIN b.userBookProgress p ON p.user.id = :userId" : "")
|
||||
+ " WHERE (b.deleted IS NULL OR b.deleted = false)"
|
||||
+ " AND b.bookFiles IS NOT EMPTY"
|
||||
+ " AND m.seriesName IS NOT NULL"
|
||||
+ libraryClause
|
||||
+ searchClause;
|
||||
|
||||
if (inProgressOnly) {
|
||||
// For in-progress, we need the HAVING filter in the count — use a subquery approach
|
||||
String countWithHaving = "SELECT COUNT(*) FROM ("
|
||||
+ "SELECT m.seriesName FROM BookEntity b JOIN b.metadata m"
|
||||
+ " LEFT JOIN b.userBookProgress p ON p.user.id = :userId"
|
||||
+ " WHERE (b.deleted IS NULL OR b.deleted = false)"
|
||||
+ " AND b.bookFiles IS NOT EMPTY"
|
||||
+ " AND m.seriesName IS NOT NULL"
|
||||
+ libraryClause
|
||||
+ searchClause
|
||||
+ " GROUP BY m.seriesName"
|
||||
+ " HAVING SUM(CASE WHEN p.readStatus IN (org.booklore.model.enums.ReadStatus.READING, org.booklore.model.enums.ReadStatus.RE_READING) THEN 1 ELSE 0 END) > 0"
|
||||
+ ")";
|
||||
// JPQL doesn't support subqueries in FROM — count via result list size instead
|
||||
String countAlt = "SELECT m.seriesName FROM BookEntity b JOIN b.metadata m"
|
||||
+ " LEFT JOIN b.userBookProgress p ON p.user.id = :userId"
|
||||
+ " WHERE (b.deleted IS NULL OR b.deleted = false)"
|
||||
+ " AND b.bookFiles IS NOT EMPTY"
|
||||
+ " AND m.seriesName IS NOT NULL"
|
||||
+ libraryClause
|
||||
+ searchClause
|
||||
+ " GROUP BY m.seriesName"
|
||||
+ " HAVING SUM(CASE WHEN p.readStatus IN (org.booklore.model.enums.ReadStatus.READING, org.booklore.model.enums.ReadStatus.RE_READING) THEN 1 ELSE 0 END) > 0";
|
||||
var countQ = entityManager.createQuery(countAlt, String.class);
|
||||
countQ.setParameter("userId", userId);
|
||||
setLibraryParams(countQ, accessibleLibraryIds, libraryId);
|
||||
if (!searchClause.isEmpty()) {
|
||||
countQ.setParameter("searchPattern", "%" + search.trim().toLowerCase() + "%");
|
||||
}
|
||||
long totalElements = countQ.getResultList().size();
|
||||
return buildSeriesPage(aggregateResults, userId, accessibleLibraryIds, libraryId, inProgressOnly, pageNum, pageSize, totalElements);
|
||||
}
|
||||
|
||||
var countQ = entityManager.createQuery(countQuery, Long.class);
|
||||
if (inProgressOnly) {
|
||||
countQ.setParameter("userId", userId);
|
||||
}
|
||||
setLibraryParams(countQ, accessibleLibraryIds, libraryId);
|
||||
if (!searchClause.isEmpty()) {
|
||||
countQ.setParameter("searchPattern", "%" + search.trim().toLowerCase() + "%");
|
||||
}
|
||||
long totalElements = countQ.getSingleResult();
|
||||
|
||||
return buildSeriesPage(aggregateResults, userId, accessibleLibraryIds, libraryId, inProgressOnly, pageNum, pageSize, totalElements);
|
||||
}
|
||||
|
||||
private AppPageResponse<AppSeriesSummary> buildSeriesPage(
|
||||
List<Tuple> aggregateResults,
|
||||
Long userId,
|
||||
Set<Long> accessibleLibraryIds,
|
||||
Long libraryId,
|
||||
boolean inProgressOnly,
|
||||
int pageNum,
|
||||
int pageSize,
|
||||
long totalElements) {
|
||||
|
||||
if (aggregateResults.isEmpty()) {
|
||||
return AppPageResponse.of(Collections.emptyList(), pageNum, pageSize, totalElements);
|
||||
}
|
||||
|
||||
List<String> seriesNames = aggregateResults.stream()
|
||||
.map(t -> t.get(0, String.class))
|
||||
.toList();
|
||||
|
||||
// Phase 2: Fetch books for enrichment
|
||||
String libraryClause = buildLibraryClause(accessibleLibraryIds, libraryId);
|
||||
String booksQuery = "SELECT b FROM BookEntity b"
|
||||
+ " JOIN FETCH b.metadata m LEFT JOIN FETCH m.authors"
|
||||
+ " LEFT JOIN FETCH b.bookFiles"
|
||||
+ " WHERE m.seriesName IN :seriesNames"
|
||||
+ " AND (b.deleted IS NULL OR b.deleted = false)"
|
||||
+ " AND b.bookFiles IS NOT EMPTY"
|
||||
+ libraryClause;
|
||||
|
||||
var booksQ = entityManager.createQuery(booksQuery, BookEntity.class);
|
||||
booksQ.setParameter("seriesNames", seriesNames);
|
||||
setLibraryParams(booksQ, accessibleLibraryIds, libraryId);
|
||||
|
||||
List<BookEntity> books = booksQ.getResultList();
|
||||
|
||||
// Group books by series name
|
||||
Map<String, List<BookEntity>> booksBySeries = books.stream()
|
||||
.collect(Collectors.groupingBy(b -> b.getMetadata().getSeriesName()));
|
||||
|
||||
// Build aggregates map from Phase 1
|
||||
Map<String, Tuple> aggregateMap = new LinkedHashMap<>();
|
||||
for (Tuple t : aggregateResults) {
|
||||
aggregateMap.put(t.get(0, String.class), t);
|
||||
}
|
||||
|
||||
// Merge into summaries, preserving Phase 1 order
|
||||
List<AppSeriesSummary> summaries = new ArrayList<>();
|
||||
for (String seriesName : seriesNames) {
|
||||
Tuple agg = aggregateMap.get(seriesName);
|
||||
List<BookEntity> seriesBooks = booksBySeries.getOrDefault(seriesName, Collections.emptyList());
|
||||
|
||||
// Distinct authors across all books in series
|
||||
List<String> authors = seriesBooks.stream()
|
||||
.filter(b -> b.getMetadata() != null && b.getMetadata().getAuthors() != null)
|
||||
.flatMap(b -> b.getMetadata().getAuthors().stream())
|
||||
.map(AuthorEntity::getName)
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
// Cover books sorted by seriesNumber ASC nulls last
|
||||
List<SeriesCoverBook> coverBooks = seriesBooks.stream()
|
||||
.sorted(Comparator.comparing(
|
||||
(BookEntity b) -> b.getMetadata().getSeriesNumber(),
|
||||
Comparator.nullsLast(Comparator.naturalOrder())))
|
||||
.map(b -> {
|
||||
BookFileEntity primaryFile = b.getPrimaryBookFile();
|
||||
String fileType = (primaryFile != null && primaryFile.getBookType() != null)
|
||||
? primaryFile.getBookType().name()
|
||||
: null;
|
||||
return SeriesCoverBook.builder()
|
||||
.bookId(b.getId())
|
||||
.coverUpdatedOn(b.getMetadata().getCoverUpdatedOn())
|
||||
.seriesNumber(b.getMetadata().getSeriesNumber())
|
||||
.primaryFileType(fileType)
|
||||
.build();
|
||||
})
|
||||
.toList();
|
||||
|
||||
Long booksReadLong = agg.get(4, Long.class);
|
||||
int booksRead = booksReadLong != null ? booksReadLong.intValue() : 0;
|
||||
|
||||
summaries.add(AppSeriesSummary.builder()
|
||||
.seriesName(agg.get(0, String.class))
|
||||
.bookCount(agg.get(1, Long.class).intValue())
|
||||
.seriesTotal(agg.get(2, Integer.class))
|
||||
.latestAddedOn(agg.get(3, Instant.class))
|
||||
.booksRead(booksRead)
|
||||
.authors(authors)
|
||||
.coverBooks(coverBooks)
|
||||
.build());
|
||||
}
|
||||
|
||||
return AppPageResponse.of(summaries, pageNum, pageSize, totalElements);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AppPageResponse<AppBookSummary> getSeriesBooks(
|
||||
String seriesName,
|
||||
Integer page,
|
||||
Integer size,
|
||||
String sortBy,
|
||||
String sortDir,
|
||||
Long libraryId) {
|
||||
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
Long userId = user.getId();
|
||||
Set<Long> accessibleLibraryIds = getAccessibleLibraryIds(user);
|
||||
|
||||
if (libraryId != null) {
|
||||
validateLibraryAccess(accessibleLibraryIds, libraryId);
|
||||
}
|
||||
|
||||
int pageNum = page != null && page >= 0 ? page : 0;
|
||||
int pageSize = size != null && size > 0 ? Math.min(size, MAX_PAGE_SIZE) : DEFAULT_PAGE_SIZE;
|
||||
|
||||
Sort sort = buildBookSort(sortBy, sortDir);
|
||||
Pageable pageable = PageRequest.of(pageNum, pageSize, sort);
|
||||
|
||||
Specification<BookEntity> spec = buildSeriesBooksSpec(accessibleLibraryIds, libraryId, seriesName);
|
||||
|
||||
Page<BookEntity> bookPage = bookRepository.findAll(spec, pageable);
|
||||
|
||||
Set<Long> bookIds = bookPage.getContent().stream()
|
||||
.map(BookEntity::getId)
|
||||
.collect(Collectors.toSet());
|
||||
Map<Long, UserBookProgressEntity> progressMap = getProgressMap(userId, bookIds);
|
||||
|
||||
List<AppBookSummary> summaries = bookPage.getContent().stream()
|
||||
.map(book -> mobileBookMapper.toSummary(book, progressMap.get(book.getId())))
|
||||
.toList();
|
||||
|
||||
return AppPageResponse.of(summaries, pageNum, pageSize, bookPage.getTotalElements());
|
||||
}
|
||||
|
||||
// --- Access control helpers (duplicated from AppBookService to minimize blast radius) ---
|
||||
|
||||
private Set<Long> getAccessibleLibraryIds(BookLoreUser user) {
|
||||
if (user.getPermissions().isAdmin()) {
|
||||
return null;
|
||||
}
|
||||
if (user.getAssignedLibraries() == null || user.getAssignedLibraries().isEmpty()) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
return user.getAssignedLibraries().stream()
|
||||
.map(Library::getId)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private void validateLibraryAccess(Set<Long> accessibleLibraryIds, Long libraryId) {
|
||||
if (accessibleLibraryIds != null && !accessibleLibraryIds.contains(libraryId)) {
|
||||
throw ApiError.FORBIDDEN.createException("Access denied to library " + libraryId);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Query helpers ---
|
||||
|
||||
private String buildLibraryClause(Set<Long> accessibleLibraryIds, Long libraryId) {
|
||||
if (libraryId != null) {
|
||||
return " AND b.library.id = :libraryId";
|
||||
} else if (accessibleLibraryIds != null) {
|
||||
return " AND b.library.id IN :libraryIds";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private void setLibraryParams(jakarta.persistence.Query query, Set<Long> accessibleLibraryIds, Long libraryId) {
|
||||
if (libraryId != null) {
|
||||
query.setParameter("libraryId", libraryId);
|
||||
} else if (accessibleLibraryIds != null) {
|
||||
query.setParameter("libraryIds", accessibleLibraryIds);
|
||||
}
|
||||
}
|
||||
|
||||
private String buildSeriesOrderBy(String sortBy, String sortDir, boolean inProgressOnly) {
|
||||
String dir = "asc".equalsIgnoreCase(sortDir) ? "ASC" : "DESC";
|
||||
String nullsClause = "ASC".equals(dir) ? " NULLS LAST" : " NULLS FIRST";
|
||||
|
||||
return switch (sortBy != null ? sortBy.toLowerCase() : "") {
|
||||
case "name" -> "m.seriesName " + dir;
|
||||
case "bookcount" -> "COUNT(b.id) " + dir;
|
||||
case "readprogress" -> "SUM(CASE WHEN p.readStatus = org.booklore.model.enums.ReadStatus.READ THEN 1 ELSE 0 END) " + dir;
|
||||
case "lastreadtime" -> {
|
||||
if (inProgressOnly) {
|
||||
yield "MAX(p.lastReadTime) " + dir + nullsClause;
|
||||
}
|
||||
yield "MAX(b.addedOn) " + dir + nullsClause;
|
||||
}
|
||||
default -> {
|
||||
if (inProgressOnly) {
|
||||
yield "MAX(p.lastReadTime) " + dir + nullsClause;
|
||||
}
|
||||
yield "MAX(b.addedOn) " + dir + nullsClause;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Sort buildBookSort(String sortBy, String sortDir) {
|
||||
Sort.Direction direction = "asc".equalsIgnoreCase(sortDir) ? Sort.Direction.ASC : Sort.Direction.DESC;
|
||||
String field = switch (sortBy != null ? sortBy.toLowerCase() : "") {
|
||||
case "title" -> "metadata.title";
|
||||
case "seriesnumber" -> "metadata.seriesNumber";
|
||||
case "recentlyadded" -> "addedOn";
|
||||
default -> "metadata.seriesNumber";
|
||||
};
|
||||
return Sort.by(direction, field);
|
||||
}
|
||||
|
||||
private Specification<BookEntity> buildSeriesBooksSpec(Set<Long> accessibleLibraryIds, Long libraryId, String seriesName) {
|
||||
List<Specification<BookEntity>> specs = new ArrayList<>();
|
||||
specs.add(AppBookSpecification.notDeleted());
|
||||
specs.add(AppBookSpecification.hasDigitalFile());
|
||||
specs.add(AppBookSpecification.inSeries(seriesName));
|
||||
|
||||
if (accessibleLibraryIds != null) {
|
||||
specs.add(libraryId != null
|
||||
? AppBookSpecification.inLibrary(libraryId)
|
||||
: AppBookSpecification.inLibraries(accessibleLibraryIds));
|
||||
} else if (libraryId != null) {
|
||||
specs.add(AppBookSpecification.inLibrary(libraryId));
|
||||
}
|
||||
|
||||
return AppBookSpecification.combine(specs.toArray(new Specification[0]));
|
||||
}
|
||||
|
||||
private Map<Long, UserBookProgressEntity> getProgressMap(Long userId, Set<Long> bookIds) {
|
||||
if (bookIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
return userBookProgressRepository.findByUserIdAndBookIdIn(userId, bookIds).stream()
|
||||
.collect(Collectors.toMap(
|
||||
p -> p.getBook().getId(),
|
||||
Function.identity()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
package org.booklore.app.specification;
|
||||
|
||||
import org.booklore.model.entity.*;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.model.enums.ReadStatus;
|
||||
import jakarta.persistence.criteria.*;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
public class AppBookSpecification {
|
||||
|
||||
private AppBookSpecification() {
|
||||
}
|
||||
|
||||
public static Specification<BookEntity> inLibraries(Collection<Long> libraryIds) {
|
||||
return (root, query, cb) -> {
|
||||
if (libraryIds == null || libraryIds.isEmpty()) {
|
||||
return cb.conjunction();
|
||||
}
|
||||
return root.get("library").get("id").in(libraryIds);
|
||||
};
|
||||
}
|
||||
|
||||
public static Specification<BookEntity> inLibrary(Long libraryId) {
|
||||
return (root, query, cb) -> {
|
||||
if (libraryId == null) {
|
||||
return cb.conjunction();
|
||||
}
|
||||
return cb.equal(root.get("library").get("id"), libraryId);
|
||||
};
|
||||
}
|
||||
|
||||
public static Specification<BookEntity> inShelf(Long shelfId) {
|
||||
return (root, query, cb) -> {
|
||||
if (shelfId == null) {
|
||||
return cb.conjunction();
|
||||
}
|
||||
Join<BookEntity, ShelfEntity> shelvesJoin = root.join("shelves", JoinType.INNER);
|
||||
return cb.equal(shelvesJoin.get("id"), shelfId);
|
||||
};
|
||||
}
|
||||
|
||||
public static Specification<BookEntity> withReadStatus(ReadStatus status, Long userId) {
|
||||
return (root, query, cb) -> {
|
||||
if (status == null || userId == null) {
|
||||
return cb.conjunction();
|
||||
}
|
||||
Subquery<Long> subquery = query.subquery(Long.class);
|
||||
Root<UserBookProgressEntity> progressRoot = subquery.from(UserBookProgressEntity.class);
|
||||
subquery.select(progressRoot.get("book").get("id"))
|
||||
.where(
|
||||
cb.equal(progressRoot.get("user").get("id"), userId),
|
||||
cb.equal(progressRoot.get("readStatus"), status)
|
||||
);
|
||||
return root.get("id").in(subquery);
|
||||
};
|
||||
}
|
||||
|
||||
public static Specification<BookEntity> inProgress(Long userId) {
|
||||
return (root, query, cb) -> {
|
||||
if (userId == null) {
|
||||
return cb.conjunction();
|
||||
}
|
||||
Subquery<Long> subquery = query.subquery(Long.class);
|
||||
Root<UserBookProgressEntity> progressRoot = subquery.from(UserBookProgressEntity.class);
|
||||
subquery.select(progressRoot.get("book").get("id"))
|
||||
.where(
|
||||
cb.equal(progressRoot.get("user").get("id"), userId),
|
||||
progressRoot.get("readStatus").in(ReadStatus.READING, ReadStatus.RE_READING)
|
||||
);
|
||||
return root.get("id").in(subquery);
|
||||
};
|
||||
}
|
||||
|
||||
public static Specification<BookEntity> addedWithinDays(int days) {
|
||||
return (root, query, cb) -> {
|
||||
Instant cutoff = Instant.now().minus(days, ChronoUnit.DAYS);
|
||||
return cb.greaterThanOrEqualTo(root.get("addedOn"), cutoff);
|
||||
};
|
||||
}
|
||||
|
||||
public static Specification<BookEntity> searchText(String searchQuery) {
|
||||
return (root, query, cb) -> {
|
||||
if (searchQuery == null || searchQuery.trim().isEmpty()) {
|
||||
return cb.conjunction();
|
||||
}
|
||||
String pattern = "%" + searchQuery.toLowerCase().trim() + "%";
|
||||
|
||||
Join<BookEntity, BookMetadataEntity> metadataJoin = root.join("metadata", JoinType.LEFT);
|
||||
Join<BookMetadataEntity, AuthorEntity> authorsJoin = metadataJoin.join("authors", JoinType.LEFT);
|
||||
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
predicates.add(cb.like(cb.lower(metadataJoin.get("title")), pattern));
|
||||
predicates.add(cb.like(cb.lower(metadataJoin.get("seriesName")), pattern));
|
||||
predicates.add(cb.like(cb.lower(authorsJoin.get("name")), pattern));
|
||||
|
||||
query.distinct(true);
|
||||
|
||||
return cb.or(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
}
|
||||
|
||||
public static Specification<BookEntity> notDeleted() {
|
||||
return (root, query, cb) -> cb.or(
|
||||
cb.isNull(root.get("deleted")),
|
||||
cb.equal(root.get("deleted"), false)
|
||||
);
|
||||
}
|
||||
|
||||
public static Specification<BookEntity> hasScannedOn() {
|
||||
return (root, query, cb) -> cb.isNotNull(root.get("scannedOn"));
|
||||
}
|
||||
|
||||
public static Specification<BookEntity> hasDigitalFile() {
|
||||
return (root, query, cb) -> cb.isNotEmpty(root.get("bookFiles"));
|
||||
}
|
||||
|
||||
public static Specification<BookEntity> hasAudiobookFile() {
|
||||
return (root, query, cb) -> {
|
||||
Subquery<Long> subquery = query.subquery(Long.class);
|
||||
Root<BookFileEntity> bookFileRoot = subquery.from(BookFileEntity.class);
|
||||
subquery.select(bookFileRoot.get("book").get("id"))
|
||||
.where(cb.equal(bookFileRoot.get("bookType"), BookFileType.AUDIOBOOK));
|
||||
return root.get("id").in(subquery);
|
||||
};
|
||||
}
|
||||
|
||||
public static Specification<BookEntity> hasNonAudiobookFile() {
|
||||
return (root, query, cb) -> {
|
||||
Subquery<Long> subquery = query.subquery(Long.class);
|
||||
Root<BookFileEntity> bookFileRoot = subquery.from(BookFileEntity.class);
|
||||
subquery.select(bookFileRoot.get("book").get("id"))
|
||||
.where(cb.notEqual(bookFileRoot.get("bookType"), BookFileType.AUDIOBOOK));
|
||||
return root.get("id").in(subquery);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter books that have at least one file of the given type.
|
||||
*/
|
||||
public static Specification<BookEntity> withFileType(BookFileType fileType) {
|
||||
return (root, query, cb) -> {
|
||||
if (fileType == null) {
|
||||
return cb.conjunction();
|
||||
}
|
||||
Subquery<Long> subquery = query.subquery(Long.class);
|
||||
Root<BookFileEntity> bookFileRoot = subquery.from(BookFileEntity.class);
|
||||
subquery.select(bookFileRoot.get("book").get("id"))
|
||||
.where(cb.equal(bookFileRoot.get("bookType"), fileType));
|
||||
return root.get("id").in(subquery);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter books where the user's personal rating is >= minRating.
|
||||
*/
|
||||
public static Specification<BookEntity> withMinRating(int minRating, Long userId) {
|
||||
return (root, query, cb) -> {
|
||||
Subquery<Long> subquery = query.subquery(Long.class);
|
||||
Root<UserBookProgressEntity> progressRoot = subquery.from(UserBookProgressEntity.class);
|
||||
subquery.select(progressRoot.get("book").get("id"))
|
||||
.where(
|
||||
cb.equal(progressRoot.get("user").get("id"), userId),
|
||||
cb.greaterThanOrEqualTo(progressRoot.get("personalRating"), minRating)
|
||||
);
|
||||
return root.get("id").in(subquery);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter books where the user's personal rating is <= maxRating.
|
||||
* Use maxRating=0 to find unrated books.
|
||||
*/
|
||||
public static Specification<BookEntity> withMaxRating(int maxRating, Long userId) {
|
||||
return (root, query, cb) -> {
|
||||
Subquery<Long> subquery = query.subquery(Long.class);
|
||||
Root<UserBookProgressEntity> progressRoot = subquery.from(UserBookProgressEntity.class);
|
||||
|
||||
if (maxRating == 0) {
|
||||
// Unrated: books with no progress entry or null personalRating
|
||||
Subquery<Long> ratedSubquery = query.subquery(Long.class);
|
||||
Root<UserBookProgressEntity> ratedRoot = ratedSubquery.from(UserBookProgressEntity.class);
|
||||
ratedSubquery.select(ratedRoot.get("book").get("id"))
|
||||
.where(
|
||||
cb.equal(ratedRoot.get("user").get("id"), userId),
|
||||
cb.isNotNull(ratedRoot.get("personalRating"))
|
||||
);
|
||||
return cb.not(root.get("id").in(ratedSubquery));
|
||||
}
|
||||
|
||||
subquery.select(progressRoot.get("book").get("id"))
|
||||
.where(
|
||||
cb.equal(progressRoot.get("user").get("id"), userId),
|
||||
cb.lessThanOrEqualTo(progressRoot.get("personalRating"), maxRating)
|
||||
);
|
||||
return root.get("id").in(subquery);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter books by author name (case-insensitive exact match).
|
||||
*/
|
||||
public static Specification<BookEntity> withAuthor(String authorName) {
|
||||
return (root, query, cb) -> {
|
||||
if (authorName == null || authorName.trim().isEmpty()) {
|
||||
return cb.conjunction();
|
||||
}
|
||||
Join<BookEntity, BookMetadataEntity> metadataJoin = root.join("metadata", JoinType.LEFT);
|
||||
Join<BookMetadataEntity, AuthorEntity> authorsJoin = metadataJoin.join("authors", JoinType.LEFT);
|
||||
query.distinct(true);
|
||||
return cb.equal(cb.lower(authorsJoin.get("name")), authorName.toLowerCase().trim());
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter books by language code (case-insensitive).
|
||||
*/
|
||||
public static Specification<BookEntity> withLanguage(String language) {
|
||||
return (root, query, cb) -> {
|
||||
if (language == null || language.trim().isEmpty()) {
|
||||
return cb.conjunction();
|
||||
}
|
||||
Join<BookEntity, BookMetadataEntity> metadataJoin = root.join("metadata", JoinType.LEFT);
|
||||
return cb.equal(cb.lower(metadataJoin.get("language")), language.toLowerCase().trim());
|
||||
};
|
||||
}
|
||||
|
||||
public static Specification<BookEntity> inSeries(String seriesName) {
|
||||
return (root, query, cb) -> {
|
||||
if (seriesName == null || seriesName.trim().isEmpty()) {
|
||||
return cb.conjunction();
|
||||
}
|
||||
Join<BookEntity, BookMetadataEntity> metadataJoin = root.join("metadata", JoinType.LEFT);
|
||||
return cb.equal(metadataJoin.get("seriesName"), seriesName);
|
||||
};
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
public static Specification<BookEntity> combine(Specification<BookEntity>... specs) {
|
||||
Specification<BookEntity> result = (root, query, cb) -> cb.conjunction();
|
||||
for (Specification<BookEntity> spec : specs) {
|
||||
if (spec != null) {
|
||||
result = result.and(spec);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ public class AppProperties {
|
||||
private String version;
|
||||
private RemoteAuth remoteAuth;
|
||||
private Boolean forceDisableOidc = false;
|
||||
private Telemetry telemetry = new Telemetry();
|
||||
|
||||
/**
|
||||
* Type of disk storage where library files are stored.
|
||||
@@ -41,10 +40,4 @@ public class AppProperties {
|
||||
private String adminGroup;
|
||||
private String groupsDelimiter = "\\s+"; // Default to whitespace for backward compatibility
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class Telemetry {
|
||||
private String baseUrl = "https://telemetry.booklore.org";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ public class ReadingSessionController {
|
||||
|
||||
private final ReadingSessionService readingSessionService;
|
||||
|
||||
@Operation(summary = "Record a reading session", description = "Receive telemetry from the reader client and persist or log the session.")
|
||||
@Operation(summary = "Record a reading session", description = "Receive reading session data from the reader client and persist or log the session.")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "202", description = "Reading session accepted"),
|
||||
@ApiResponse(responseCode = "400", description = "Invalid payload")
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
package org.booklore.crons;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.booklore.config.AppProperties;
|
||||
import org.booklore.model.dto.BookloreTelemetry;
|
||||
import org.booklore.model.dto.InstallationPing;
|
||||
import org.booklore.model.dto.settings.AppSettings;
|
||||
import org.booklore.service.TelemetryService;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
import org.springframework.boot.sql.init.dependency.DependsOnDatabaseInitialization;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
@DependsOnDatabaseInitialization
|
||||
@Slf4j
|
||||
public class CronService {
|
||||
|
||||
private static final String LAST_TELEMETRY_KEY = "last_telemetry_sent";
|
||||
private static final String LAST_PING_KEY = "last_ping_sent";
|
||||
private static final String LAST_PING_APP_VERSION_KEY = "last_ping_app_version";
|
||||
private static final long INTERVAL_HOURS = 24;
|
||||
|
||||
private final AppProperties appProperties;
|
||||
private final TelemetryService telemetryService;
|
||||
private final RestClient restClient;
|
||||
private final AppSettingService appSettingService;
|
||||
|
||||
@PostConstruct
|
||||
public void initScheduledTasks() {
|
||||
checkAndRunTelemetry();
|
||||
checkAndRunPing();
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 24, timeUnit = TimeUnit.HOURS, initialDelay = 24)
|
||||
public void sendTelemetryData() {
|
||||
if (isTelemetryEnabled()) {
|
||||
String url = appProperties.getTelemetry().getBaseUrl() + "/api/v1/ingest";
|
||||
BookloreTelemetry telemetry = telemetryService.collectTelemetry();
|
||||
if (postData(url, telemetry)) {
|
||||
appSettingService.saveSetting(LAST_TELEMETRY_KEY, Instant.now().toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 24, timeUnit = TimeUnit.HOURS, initialDelay = 12)
|
||||
public void sendPing() {
|
||||
if (isTelemetryEnabled()) {
|
||||
String url = appProperties.getTelemetry().getBaseUrl() + "/api/v1/heartbeat";
|
||||
InstallationPing ping = telemetryService.getInstallationPing();
|
||||
if (ping != null && postData(url, ping)) {
|
||||
appSettingService.saveSetting(LAST_PING_KEY, Instant.now().toString());
|
||||
appSettingService.saveSetting(LAST_PING_APP_VERSION_KEY, ping.getAppVersion());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected boolean postData(String url, Object body) {
|
||||
try {
|
||||
restClient.post()
|
||||
.uri(url)
|
||||
.body(body)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
return true;
|
||||
} catch (Exception ex) {
|
||||
log.debug("POST request to URL: {}, Message: {}", url, ex.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isTelemetryEnabled() {
|
||||
AppSettings settings = appSettingService.getAppSettings();
|
||||
return settings != null && settings.isTelemetryEnabled();
|
||||
}
|
||||
|
||||
private void checkAndRunTelemetry() {
|
||||
AppSettings settings = appSettingService.getAppSettings();
|
||||
if (settings == null || !settings.isTelemetryEnabled()) {
|
||||
return;
|
||||
}
|
||||
String lastRunStr = appSettingService.getSettingValue(LAST_TELEMETRY_KEY);
|
||||
if (shouldRunTask(lastRunStr)) {
|
||||
log.info("Running stats on startup (last run: {})", lastRunStr);
|
||||
sendTelemetryData();
|
||||
}
|
||||
}
|
||||
|
||||
private void checkAndRunPing() {
|
||||
String lastRunStr = appSettingService.getSettingValue(LAST_PING_KEY);
|
||||
if (hasAppVersionChanged()) {
|
||||
log.info("App version changed, sending immediate ping");
|
||||
sendPing();
|
||||
return;
|
||||
}
|
||||
if (shouldRunTask(lastRunStr)) {
|
||||
log.info("Running ping on startup (last run: {})", lastRunStr);
|
||||
sendPing();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a task should run immediately on startup.
|
||||
* Returns false for new installations (no last run recorded) to follow normal schedule.
|
||||
* Returns true if more than INTERVAL_HOURS have passed since the last run,
|
||||
* preventing data gaps when the server restarts close to scheduled execution time.
|
||||
* <p>
|
||||
* Example: Telemetry normally runs at 2:00 AM daily. If the server restarts at 1:55 AM,
|
||||
* the scheduled task would reset and not run until 2:00 AM the next day (48 hours later).
|
||||
* This method checks if 24+ hours have passed since the last run and executes immediately
|
||||
* on startup if needed, ensuring data is sent at 1:55 AM instead of waiting another 24 hours.
|
||||
*/
|
||||
private boolean shouldRunTask(String lastRunStr) {
|
||||
if (lastRunStr == null || lastRunStr.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Instant lastRun = Instant.parse(lastRunStr);
|
||||
Instant threshold = Instant.now().minus(INTERVAL_HOURS, ChronoUnit.HOURS);
|
||||
return lastRun.isBefore(threshold);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse last run timestamp: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the app version has changed since the last ping.
|
||||
* Returns true if this is an established installation with a version change.
|
||||
*/
|
||||
private boolean hasAppVersionChanged() {
|
||||
String lastPingVersion = appSettingService.getSettingValue(LAST_PING_APP_VERSION_KEY);
|
||||
InstallationPing ping = telemetryService.getInstallationPing();
|
||||
String currentVersion = ping != null ? ping.getAppVersion() : null;
|
||||
if (lastPingVersion == null || lastPingVersion.isEmpty() || currentVersion == null) {
|
||||
return false;
|
||||
}
|
||||
return !lastPingVersion.equals(currentVersion);
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package org.booklore.model.dto;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Builder
|
||||
@Setter
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BookloreTelemetry {
|
||||
private int telemetryVersion;
|
||||
private String installationId;
|
||||
private String installationDate;
|
||||
private String appVersion;
|
||||
|
||||
private int totalLibraries;
|
||||
private long totalBooks;
|
||||
private long totalAdditionalBookFiles;
|
||||
private long totalAuthors;
|
||||
private long totalBookNotes;
|
||||
private long totalBookmarks;
|
||||
private int totalShelves;
|
||||
private int totalMagicShelves;
|
||||
private int totalCategories;
|
||||
private int totalTags;
|
||||
private int totalMoods;
|
||||
private int totalKoreaderUsers;
|
||||
|
||||
private UserStatistics userStatistics;
|
||||
private MetadataStatistics metadataStatistics;
|
||||
private OpdsStatistics opdsStatistics;
|
||||
private KoboStatistics koboStatistics;
|
||||
private EmailStatistics emailStatistics;
|
||||
private BookStatistics bookStatistics;
|
||||
private List<LibraryStatistics> libraryStatisticsList;
|
||||
|
||||
@Builder
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class UserStatistics {
|
||||
private int totalUsers;
|
||||
private int totalLocalUsers;
|
||||
private int totalOidcUsers;
|
||||
private boolean oidcEnabled;
|
||||
}
|
||||
|
||||
@Builder
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class MetadataStatistics {
|
||||
private String[] enabledMetadataProviders;
|
||||
private String[] enabledReviewMetadataProviders;
|
||||
private boolean saveMetadataToFile;
|
||||
private boolean moveFileViaPattern;
|
||||
private boolean autoBookSearchEnabled;
|
||||
private boolean similarBookRecommendationsEnabled;
|
||||
private boolean metadataDownloadOnBookdropEnabled;
|
||||
}
|
||||
|
||||
@Builder
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class OpdsStatistics {
|
||||
private boolean opdsEnabled;
|
||||
private int totalOpdsUsers;
|
||||
}
|
||||
|
||||
@Builder
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class KoboStatistics {
|
||||
private int totalKoboUsers;
|
||||
private int totalHardcoverSyncEnabled;
|
||||
private int totalAutoAddToShelf;
|
||||
private boolean convertToKepubEnabled;
|
||||
}
|
||||
|
||||
@Builder
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class EmailStatistics {
|
||||
private int totalEmailProviders;
|
||||
private int totalEmailRecipients;
|
||||
}
|
||||
|
||||
@Builder
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class BookStatistics {
|
||||
private long totalBooks;
|
||||
private Map<String, Long> bookCountByType;
|
||||
}
|
||||
|
||||
@Builder
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class LibraryStatistics {
|
||||
private long bookCount;
|
||||
private int totalLibraryPaths;
|
||||
private boolean watchEnabled;
|
||||
private String iconType;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package org.booklore.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Builder
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class InstallationPing {
|
||||
private int pingVersion;
|
||||
private String appVersion;
|
||||
private String installationId;
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", timezone = "UTC")
|
||||
private Instant installationDate;
|
||||
}
|
||||
@@ -38,7 +38,6 @@ public enum AppSettingKey {
|
||||
SIMILAR_BOOK_RECOMMENDATION ("similar_book_recommendation", false, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_GLOBAL_PREFERENCES)),
|
||||
PDF_CACHE_SIZE_IN_MB ("pdf_cache_size_in_mb", false, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_GLOBAL_PREFERENCES)),
|
||||
MAX_FILE_UPLOAD_SIZE_IN_MB ("max_file_upload_size_in_mb", false, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_GLOBAL_PREFERENCES)),
|
||||
TELEMETRY_ENABLED ("telemetryEnabled", false, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_GLOBAL_PREFERENCES)),
|
||||
|
||||
// No specific permissions required
|
||||
SIDEBAR_LIBRARY_SORTING ("sidebar_library_sorting", true, false, List.of()),
|
||||
@@ -70,4 +69,4 @@ public enum AppSettingKey {
|
||||
}
|
||||
throw new IllegalArgumentException("Unknown setting key: " + dbKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ public class AppSettings {
|
||||
private boolean remoteAuthEnabled;
|
||||
private boolean metadataDownloadOnBookdrop;
|
||||
private boolean oidcEnabled;
|
||||
private boolean telemetryEnabled;
|
||||
private OidcProviderDetails oidcProviderDetails;
|
||||
private OidcAutoProvisionDetails oidcAutoProvisionDetails;
|
||||
private MetadataProviderSettings metadataProviderSettings;
|
||||
@@ -40,4 +39,4 @@ public class AppSettings {
|
||||
private String oidcGroupSyncMode;
|
||||
private boolean oidcForceOnlyMode;
|
||||
private String diskType;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
package org.booklore.service;
|
||||
|
||||
import org.booklore.model.dto.BookloreTelemetry;
|
||||
import org.booklore.model.dto.Installation;
|
||||
import org.booklore.model.dto.InstallationPing;
|
||||
import org.booklore.model.dto.settings.AppSettings;
|
||||
import org.booklore.model.dto.settings.MetadataProviderSettings;
|
||||
import org.booklore.model.dto.settings.MetadataPublicReviewsSettings;
|
||||
import org.booklore.model.dto.settings.UserSettingKey;
|
||||
import org.booklore.model.entity.LibraryEntity;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.model.enums.MetadataProvider;
|
||||
import org.booklore.model.enums.ProvisioningMethod;
|
||||
import org.booklore.repository.*;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
public class TelemetryService {
|
||||
|
||||
private final VersionService versionService;
|
||||
private final LibraryRepository libraryRepository;
|
||||
private final BookRepository bookRepository;
|
||||
private final BookMarkRepository bookMarkRepository;
|
||||
private final BookNoteRepository bookNoteRepository;
|
||||
private final BookAdditionalFileRepository bookAdditionalFileRepository;
|
||||
private final AuthorRepository authorRepository;
|
||||
private final ShelfRepository shelfRepository;
|
||||
private final MagicShelfRepository magicShelfRepository;
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final TagRepository tagRepository;
|
||||
private final MoodRepository moodRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final EmailProviderV2Repository emailProviderV2Repository;
|
||||
private final EmailRecipientV2Repository emailRecipientV2Repository;
|
||||
private final AppSettingService appSettingService;
|
||||
private final KoboUserSettingsRepository koboUserSettingsRepository;
|
||||
private final UserSettingRepository userSettingRepository;
|
||||
private final KoreaderUserRepository koreaderUserRepository;
|
||||
private final OpdsUserV2Repository opdsUserV2Repository;
|
||||
private final InstallationService installationService;
|
||||
|
||||
public InstallationPing getInstallationPing() {
|
||||
Installation installation = installationService.getOrCreateInstallation();
|
||||
|
||||
return InstallationPing.builder()
|
||||
.pingVersion(1)
|
||||
.appVersion(versionService.appVersion)
|
||||
.installationId(installation.getId())
|
||||
.installationDate(installation.getDate())
|
||||
.build();
|
||||
}
|
||||
|
||||
public BookloreTelemetry collectTelemetry() {
|
||||
long totalUsers = userRepository.count();
|
||||
long localUsers = userRepository.countByProvisioningMethod(ProvisioningMethod.LOCAL);
|
||||
long oidcUsers = userRepository.countByProvisioningMethod(ProvisioningMethod.OIDC);
|
||||
|
||||
AppSettings settings = appSettingService.getAppSettings();
|
||||
|
||||
BookloreTelemetry.BookStatistics bookStatistics = BookloreTelemetry.BookStatistics.builder()
|
||||
.totalBooks(bookRepository.count())
|
||||
.bookCountByType(getBookFileTypeCounts())
|
||||
.build();
|
||||
|
||||
List<BookloreTelemetry.LibraryStatistics> libraryStatisticsList = libraryRepository.findAll().stream()
|
||||
.map(this::mapLibraryStatistics)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
String[] enabledMetadataProviders = getEnabledMetadataProviders(settings.getMetadataProviderSettings());
|
||||
String[] enabledReviewMetadataProviders = getEnabledReviewMetadataProviders(settings.getMetadataPublicReviewsSettings());
|
||||
|
||||
Installation installation = installationService.getOrCreateInstallation();
|
||||
|
||||
return BookloreTelemetry.builder()
|
||||
.telemetryVersion(2)
|
||||
.installationId(installation.getId())
|
||||
.installationDate(installation.getDate() != null ? installation.getDate().toString() : null)
|
||||
.appVersion(versionService.appVersion)
|
||||
.totalLibraries((int) libraryRepository.count())
|
||||
.totalBooks(bookRepository.count())
|
||||
.totalAdditionalBookFiles(bookAdditionalFileRepository.count())
|
||||
.totalAuthors(authorRepository.count())
|
||||
.totalBookmarks(bookMarkRepository.count())
|
||||
.totalBookNotes(bookNoteRepository.count())
|
||||
.totalShelves((int) shelfRepository.count())
|
||||
.totalMagicShelves((int) magicShelfRepository.count())
|
||||
.totalCategories((int) categoryRepository.count())
|
||||
.totalTags((int) tagRepository.count())
|
||||
.totalMoods((int) moodRepository.count())
|
||||
.totalKoreaderUsers((int) koreaderUserRepository.count())
|
||||
.userStatistics(BookloreTelemetry.UserStatistics.builder()
|
||||
.totalUsers((int) totalUsers)
|
||||
.totalLocalUsers((int) localUsers)
|
||||
.totalOidcUsers((int) oidcUsers)
|
||||
.oidcEnabled(oidcUsers > 0)
|
||||
.build())
|
||||
.metadataStatistics(BookloreTelemetry.MetadataStatistics.builder()
|
||||
.enabledMetadataProviders(enabledMetadataProviders)
|
||||
.enabledReviewMetadataProviders(enabledReviewMetadataProviders)
|
||||
.saveMetadataToFile(settings.getMetadataPersistenceSettings().getSaveToOriginalFile().isAnyFormatEnabled())
|
||||
.moveFileViaPattern(settings.getMetadataPersistenceSettings().isMoveFilesToLibraryPattern())
|
||||
.autoBookSearchEnabled(settings.isAutoBookSearch())
|
||||
.similarBookRecommendationsEnabled(settings.isSimilarBookRecommendation())
|
||||
.metadataDownloadOnBookdropEnabled(settings.isMetadataDownloadOnBookdrop())
|
||||
.build())
|
||||
.opdsStatistics(BookloreTelemetry.OpdsStatistics.builder()
|
||||
.opdsEnabled(settings.isOpdsServerEnabled())
|
||||
.totalOpdsUsers((int) opdsUserV2Repository.count())
|
||||
.build())
|
||||
.emailStatistics(BookloreTelemetry.EmailStatistics.builder()
|
||||
.totalEmailProviders((int) emailProviderV2Repository.count())
|
||||
.totalEmailRecipients((int) emailRecipientV2Repository.count())
|
||||
.build())
|
||||
.koboStatistics(BookloreTelemetry.KoboStatistics.builder()
|
||||
.convertToKepubEnabled(settings.getKoboSettings().isConvertToKepub())
|
||||
.totalKoboUsers((int) koboUserSettingsRepository.count())
|
||||
.totalHardcoverSyncEnabled((int) userSettingRepository.countBySettingKeyAndSettingValue(
|
||||
UserSettingKey.HARDCOVER_SYNC_ENABLED.getDbKey(), "true"))
|
||||
.totalAutoAddToShelf((int) koboUserSettingsRepository.countByAutoAddToShelfTrue())
|
||||
.build())
|
||||
.bookStatistics(bookStatistics)
|
||||
.libraryStatisticsList(libraryStatisticsList)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Map<String, Long> getBookFileTypeCounts() {
|
||||
Map<String, Long> countByType = new HashMap<>();
|
||||
for (BookFileType type : BookFileType.values()) {
|
||||
countByType.put(type.name(), bookRepository.countByBookType(type));
|
||||
}
|
||||
return countByType;
|
||||
}
|
||||
|
||||
private BookloreTelemetry.LibraryStatistics mapLibraryStatistics(LibraryEntity lib) {
|
||||
return BookloreTelemetry.LibraryStatistics.builder()
|
||||
.totalLibraryPaths(lib.getLibraryPaths() != null ? lib.getLibraryPaths().size() : 0)
|
||||
.bookCount(bookRepository.countByLibraryId(lib.getId()))
|
||||
.watchEnabled(lib.isWatch())
|
||||
.iconType(lib.getIconType() != null ? lib.getIconType().name() : null)
|
||||
.build();
|
||||
}
|
||||
|
||||
private String[] getEnabledMetadataProviders(MetadataProviderSettings providers) {
|
||||
List<String> enabled = new ArrayList<>();
|
||||
if (providers.getAmazon() != null && providers.getAmazon().isEnabled())
|
||||
enabled.add(MetadataProvider.Amazon.name());
|
||||
if (providers.getGoogle() != null && providers.getGoogle().isEnabled())
|
||||
enabled.add(MetadataProvider.Google.name());
|
||||
if (providers.getGoodReads() != null && providers.getGoodReads().isEnabled())
|
||||
enabled.add(MetadataProvider.GoodReads.name());
|
||||
if (providers.getHardcover() != null && providers.getHardcover().isEnabled())
|
||||
enabled.add(MetadataProvider.Hardcover.name());
|
||||
if (providers.getComicvine() != null && providers.getComicvine().isEnabled())
|
||||
enabled.add(MetadataProvider.Comicvine.name());
|
||||
if (providers.getRanobedb() != null && providers.getRanobedb().isEnabled())
|
||||
enabled.add(MetadataProvider.Ranobedb.name());
|
||||
if (providers.getDouban() != null && providers.getDouban().isEnabled())
|
||||
enabled.add(MetadataProvider.Douban.name());
|
||||
if (providers.getLubimyczytac() != null && providers.getLubimyczytac().isEnabled())
|
||||
enabled.add(MetadataProvider.Lubimyczytac.name());
|
||||
return enabled.toArray(new String[0]);
|
||||
}
|
||||
|
||||
private String[] getEnabledReviewMetadataProviders(MetadataPublicReviewsSettings reviewSettings) {
|
||||
List<String> enabled = new ArrayList<>();
|
||||
if (reviewSettings.getProviders() != null) {
|
||||
reviewSettings.getProviders().stream()
|
||||
.filter(MetadataPublicReviewsSettings.ReviewProviderConfig::isEnabled)
|
||||
.forEach(cfg -> enabled.add(cfg.getProvider().name()));
|
||||
}
|
||||
return enabled.toArray(new String[0]);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ public class VersionService {
|
||||
@Value("${app.version:unknown}")
|
||||
String appVersion;
|
||||
|
||||
private static final String GITHUB_REPO = "booklore-app/booklore";
|
||||
private static final String GITHUB_REPO = "the-booklore/booklore";
|
||||
private static final String BASE_URI = "https://api.github.com/repos/" + GITHUB_REPO;
|
||||
private static final int MAX_RELEASES = 15;
|
||||
private static final RestClient REST_CLIENT = RestClient.builder()
|
||||
@@ -87,7 +87,7 @@ public class VersionService {
|
||||
if (tag == null || !isVersionGreater(tag, currentVersion)) {
|
||||
continue;
|
||||
}
|
||||
String url = "https://github.com/booklore-app/booklore" + "/releases/tag/" + tag;
|
||||
String url = "https://github.com/the-booklore/booklore" + "/releases/tag/" + tag;
|
||||
LocalDateTime published = LocalDateTime.parse(release.path("published_at").asText(), DateTimeFormatter.ISO_DATE_TIME);
|
||||
updates.add(new ReleaseNote(tag, release.path("name").asText(tag), release.path("body").asText(""), url, published));
|
||||
}
|
||||
|
||||
@@ -185,7 +185,6 @@ public class AppSettingService {
|
||||
builder.opdsServerEnabled(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.OPDS_SERVER_ENABLED, "false")));
|
||||
builder.komgaApiEnabled(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.KOMGA_API_ENABLED, "false")));
|
||||
builder.komgaGroupUnknown(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.KOMGA_GROUP_UNKNOWN, "true")));
|
||||
builder.telemetryEnabled(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.TELEMETRY_ENABLED, "true")));
|
||||
builder.pdfCacheSizeInMb(Integer.parseInt(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.PDF_CACHE_SIZE_IN_MB, "5120")));
|
||||
builder.maxFileUploadSizeInMb(Integer.parseInt(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.MAX_FILE_UPLOAD_SIZE_IN_MB, "100")));
|
||||
builder.metadataDownloadOnBookdrop(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.METADATA_DOWNLOAD_ON_BOOKDROP, "true")));
|
||||
|
||||
@@ -19,7 +19,7 @@ public class KepubConversionService {
|
||||
@Autowired
|
||||
private FileService fileService;
|
||||
|
||||
private static final String KEPUBIFY_GITHUB_BASE_URL = "https://github.com/booklore-app/booklore-tools/raw/main/kepubify/";
|
||||
private static final String KEPUBIFY_GITHUB_BASE_URL = "https://github.com/the-booklore/booklore-tools/raw/main/kepubify/";
|
||||
|
||||
private static final String BIN_DARWIN_ARM64 = "kepubify-darwin-arm64";
|
||||
private static final String BIN_DARWIN_X64 = "kepubify-darwin-64bit";
|
||||
|
||||
@@ -388,11 +388,11 @@ public class KomgaService {
|
||||
|
||||
// Make sure pages are cached
|
||||
if (isPDF) {
|
||||
cbxReaderService.getAvailablePages(bookId);
|
||||
cbxReaderService.streamPageImage(bookId, pageNumber, outputStream);
|
||||
} else {
|
||||
pdfReaderService.getAvailablePages(bookId);
|
||||
pdfReaderService.streamPageImage(bookId, pageNumber, outputStream);
|
||||
} else {
|
||||
cbxReaderService.getAvailablePages(bookId);
|
||||
cbxReaderService.streamPageImage(bookId, pageNumber, outputStream);
|
||||
}
|
||||
|
||||
byte[] imageData = outputStream.toByteArray();
|
||||
@@ -417,4 +417,4 @@ public class KomgaService {
|
||||
return outputStream.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,7 +467,7 @@ public class ComicvineBookParser implements BookParser, DetailedMetadataProvider
|
||||
log.debug("ComicVine API call #{} to {}", callNumber, endpoint);
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(uri)
|
||||
.header("User-Agent", "BookLore/1.0 (Book and Comic Metadata Fetcher; +https://github.com/booklore-app/booklore)")
|
||||
.header("User-Agent", "BookLore/1.0 (Book and Comic Metadata Fetcher; +https://github.com/the-booklore/booklore)")
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ public class RanobeDbParser implements BookParser {
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(uri)
|
||||
.header("User-Agent", "BookLore/1.0 (Book and Comic Metadata Fetcher; +https://github.com/booklore-app/booklore)")
|
||||
.header("User-Agent", "BookLore/1.0 (Book and Comic Metadata Fetcher; +https://github.com/the-booklore/booklore)")
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
@@ -189,7 +189,7 @@ public class RanobeDbParser implements BookParser {
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(uri)
|
||||
.header("User-Agent", "BookLore/1.0 (Book and Comic Metadata Fetcher; +https://github.com/booklore-app/booklore)")
|
||||
.header("User-Agent", "BookLore/1.0 (Book and Comic Metadata Fetcher; +https://github.com/the-booklore/booklore)")
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import java.nio.file.StandardCopyOption;
|
||||
@AllArgsConstructor
|
||||
public class FfprobeService {
|
||||
|
||||
private static final String FFPROBE_GITHUB_BASE_URL = "https://github.com/booklore-app/booklore-tools/raw/main/ffprobe/";
|
||||
private static final String FFPROBE_GITHUB_BASE_URL = "https://github.com/the-booklore/booklore-tools/raw/main/ffprobe/";
|
||||
|
||||
private static final String BIN_DARWIN_ARM64 = "ffprobe-darwin-arm64";
|
||||
private static final String BIN_DARWIN_X64 = "ffprobe-darwin-64";
|
||||
|
||||
@@ -290,7 +290,7 @@ public class FileService {
|
||||
}
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set(HttpHeaders.USER_AGENT, "BookLore/1.0 (Book and Comic Metadata Fetcher; +https://github.com/booklore-app/booklore)");
|
||||
headers.set(HttpHeaders.USER_AGENT, "BookLore/1.0 (Book and Comic Metadata Fetcher; +https://github.com/the-booklore/booklore)");
|
||||
headers.set(HttpHeaders.ACCEPT, "image/*");
|
||||
|
||||
HttpEntity<String> entity = new HttpEntity<>(headers);
|
||||
|
||||
@@ -19,8 +19,6 @@ app:
|
||||
admin-group: ${REMOTE_AUTH_ADMIN_GROUP}
|
||||
groups-delimiter: ${REMOTE_AUTH_GROUPS_DELIMITER:\\s+}
|
||||
force-disable-oidc: ${FORCE_DISABLE_OIDC:false}
|
||||
telemetry:
|
||||
base-url: ${TELEMETRY_BASE_URL:https://telemetry.booklore.org}
|
||||
disk-type: ${DISK_TYPE:LOCAL}
|
||||
|
||||
server:
|
||||
@@ -99,4 +97,4 @@ logging:
|
||||
org.quartz.simpl.SimpleThreadPool: WARN
|
||||
org.quartz.simpl.RAMJobStore: WARN
|
||||
org.hibernate.orm.connections.pooling: WARN
|
||||
org.springframework.security.config.annotation.authentication.configuration.InitializeUserDetailsBeanManagerConfigurer: ERROR
|
||||
org.springframework.security.config.annotation.authentication.configuration.InitializeUserDetailsBeanManagerConfigurer: ERROR
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package org.booklore.app.controller;
|
||||
|
||||
import org.booklore.app.dto.AppFilterOptions;
|
||||
import org.booklore.app.service.AppBookService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AppFilterControllerTest {
|
||||
|
||||
@Mock
|
||||
private AppBookService mobileBookService;
|
||||
|
||||
@InjectMocks
|
||||
private AppFilterController controller;
|
||||
|
||||
@Test
|
||||
void getFilterOptions_noParams_delegatesWithNulls() {
|
||||
AppFilterOptions expected = buildOptions(List.of("Author A"), List.of("EPUB"), List.of("en"));
|
||||
when(mobileBookService.getFilterOptions(null, null, null)).thenReturn(expected);
|
||||
|
||||
ResponseEntity<AppFilterOptions> response = controller.getFilterOptions(null, null, null);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertSame(expected, response.getBody());
|
||||
verify(mobileBookService).getFilterOptions(null, null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFilterOptions_withLibraryId_passesLibraryId() {
|
||||
AppFilterOptions expected = buildOptions(List.of("Author B"), List.of("PDF"), List.of("fr"));
|
||||
when(mobileBookService.getFilterOptions(5L, null, null)).thenReturn(expected);
|
||||
|
||||
ResponseEntity<AppFilterOptions> response = controller.getFilterOptions(5L, null, null);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertEquals(1, response.getBody().getAuthors().size());
|
||||
assertEquals("Author B", response.getBody().getAuthors().getFirst().getName());
|
||||
verify(mobileBookService).getFilterOptions(5L, null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFilterOptions_withShelfId_passesShelfId() {
|
||||
AppFilterOptions expected = buildOptions(List.of(), List.of("EPUB"), List.of());
|
||||
when(mobileBookService.getFilterOptions(null, 10L, null)).thenReturn(expected);
|
||||
|
||||
ResponseEntity<AppFilterOptions> response = controller.getFilterOptions(null, 10L, null);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertTrue(response.getBody().getAuthors().isEmpty());
|
||||
verify(mobileBookService).getFilterOptions(null, 10L, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFilterOptions_withMagicShelfId_passesMagicShelfId() {
|
||||
AppFilterOptions expected = buildOptions(List.of("Author C"), List.of("MOBI"), List.of("de"));
|
||||
when(mobileBookService.getFilterOptions(null, null, 7L)).thenReturn(expected);
|
||||
|
||||
ResponseEntity<AppFilterOptions> response = controller.getFilterOptions(null, null, 7L);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertEquals("Author C", response.getBody().getAuthors().getFirst().getName());
|
||||
verify(mobileBookService).getFilterOptions(null, null, 7L);
|
||||
}
|
||||
|
||||
private AppFilterOptions buildOptions(List<String> authorNames, List<String> fileTypes, List<String> langCodes) {
|
||||
List<AppFilterOptions.AuthorOption> authors = authorNames.stream()
|
||||
.map(name -> AppFilterOptions.AuthorOption.builder().name(name).count(1L).build())
|
||||
.toList();
|
||||
List<AppFilterOptions.LanguageOption> languages = langCodes.stream()
|
||||
.map(code -> AppFilterOptions.LanguageOption.builder().code(code).label(code).count(1L).build())
|
||||
.toList();
|
||||
return AppFilterOptions.builder()
|
||||
.authors(authors)
|
||||
.languages(languages)
|
||||
.fileTypes(fileTypes)
|
||||
.readStatuses(List.of("READ", "READING", "UNREAD"))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package org.booklore.app.controller;
|
||||
|
||||
import org.booklore.app.dto.*;
|
||||
import org.booklore.app.service.AppSeriesService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AppSeriesControllerTest {
|
||||
|
||||
@Mock
|
||||
private AppSeriesService mobileSeriesService;
|
||||
|
||||
@InjectMocks
|
||||
private AppSeriesController controller;
|
||||
|
||||
@Test
|
||||
void getSeries_defaultParams_delegatesCorrectly() {
|
||||
AppPageResponse<AppSeriesSummary> expected = buildSeriesPage();
|
||||
when(mobileSeriesService.getSeries(0, 20, "recentlyAdded", "desc", null, null, false))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<AppPageResponse<AppSeriesSummary>> response =
|
||||
controller.getSeries(0, 20, "recentlyAdded", "desc", null, null, null);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertSame(expected, response.getBody());
|
||||
verify(mobileSeriesService).getSeries(0, 20, "recentlyAdded", "desc", null, null, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeries_withLibraryId_passesLibraryId() {
|
||||
AppPageResponse<AppSeriesSummary> expected = buildSeriesPage();
|
||||
when(mobileSeriesService.getSeries(0, 20, "recentlyAdded", "desc", 5L, null, false))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<AppPageResponse<AppSeriesSummary>> response =
|
||||
controller.getSeries(0, 20, "recentlyAdded", "desc", 5L, null, null);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
verify(mobileSeriesService).getSeries(0, 20, "recentlyAdded", "desc", 5L, null, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeries_withSearch_passesSearch() {
|
||||
AppPageResponse<AppSeriesSummary> expected = buildSeriesPage();
|
||||
when(mobileSeriesService.getSeries(0, 20, "recentlyAdded", "desc", null, "harry", false))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<AppPageResponse<AppSeriesSummary>> response =
|
||||
controller.getSeries(0, 20, "recentlyAdded", "desc", null, "harry", null);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
verify(mobileSeriesService).getSeries(0, 20, "recentlyAdded", "desc", null, "harry", false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeries_withInProgressStatus_setsInProgressTrue() {
|
||||
AppPageResponse<AppSeriesSummary> expected = buildSeriesPage();
|
||||
when(mobileSeriesService.getSeries(0, 20, "recentlyAdded", "desc", null, null, true))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<AppPageResponse<AppSeriesSummary>> response =
|
||||
controller.getSeries(0, 20, "recentlyAdded", "desc", null, null, "in-progress");
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
verify(mobileSeriesService).getSeries(0, 20, "recentlyAdded", "desc", null, null, true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeries_withUnknownStatus_treatedAsNotInProgress() {
|
||||
AppPageResponse<AppSeriesSummary> expected = buildSeriesPage();
|
||||
when(mobileSeriesService.getSeries(0, 20, "recentlyAdded", "desc", null, null, false))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<AppPageResponse<AppSeriesSummary>> response =
|
||||
controller.getSeries(0, 20, "recentlyAdded", "desc", null, null, "completed");
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
verify(mobileSeriesService).getSeries(0, 20, "recentlyAdded", "desc", null, null, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeriesBooks_defaultParams_delegatesCorrectly() {
|
||||
AppPageResponse<AppBookSummary> expected = buildBookPage();
|
||||
when(mobileSeriesService.getSeriesBooks("Dune", 0, 20, "seriesNumber", "asc", null))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<AppPageResponse<AppBookSummary>> response =
|
||||
controller.getSeriesBooks("Dune", 0, 20, "seriesNumber", "asc", null);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertSame(expected, response.getBody());
|
||||
verify(mobileSeriesService).getSeriesBooks("Dune", 0, 20, "seriesNumber", "asc", null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeriesBooks_withLibraryId_passesLibraryId() {
|
||||
AppPageResponse<AppBookSummary> expected = buildBookPage();
|
||||
when(mobileSeriesService.getSeriesBooks("Dune", 0, 20, "seriesNumber", "asc", 5L))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<AppPageResponse<AppBookSummary>> response =
|
||||
controller.getSeriesBooks("Dune", 0, 20, "seriesNumber", "asc", 5L);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
verify(mobileSeriesService).getSeriesBooks("Dune", 0, 20, "seriesNumber", "asc", 5L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeriesBooks_encodedSeriesName_passedAsIs() {
|
||||
AppPageResponse<AppBookSummary> expected = buildBookPage();
|
||||
when(mobileSeriesService.getSeriesBooks("A Song of Ice and Fire", 0, 20, "seriesNumber", "asc", null))
|
||||
.thenReturn(expected);
|
||||
|
||||
ResponseEntity<AppPageResponse<AppBookSummary>> response =
|
||||
controller.getSeriesBooks("A Song of Ice and Fire", 0, 20, "seriesNumber", "asc", null);
|
||||
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
verify(mobileSeriesService).getSeriesBooks("A Song of Ice and Fire", 0, 20, "seriesNumber", "asc", null);
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
private AppPageResponse<AppSeriesSummary> buildSeriesPage() {
|
||||
AppSeriesSummary summary = AppSeriesSummary.builder()
|
||||
.seriesName("Test Series")
|
||||
.bookCount(5)
|
||||
.seriesTotal(5)
|
||||
.booksRead(2)
|
||||
.authors(List.of("Author A"))
|
||||
.latestAddedOn(Instant.now())
|
||||
.coverBooks(List.of())
|
||||
.build();
|
||||
return AppPageResponse.of(List.of(summary), 0, 20, 1);
|
||||
}
|
||||
|
||||
private AppPageResponse<AppBookSummary> buildBookPage() {
|
||||
AppBookSummary summary = AppBookSummary.builder()
|
||||
.id(1L)
|
||||
.title("Test Book")
|
||||
.authors(List.of("Author A"))
|
||||
.seriesName("Dune")
|
||||
.seriesNumber(1.0f)
|
||||
.build();
|
||||
return AppPageResponse.of(List.of(summary), 0, 20, 1);
|
||||
}
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
package org.booklore.app.service;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.TypedQuery;
|
||||
import org.booklore.config.security.service.AuthenticationService;
|
||||
import org.booklore.exception.APIException;
|
||||
import org.booklore.app.dto.AppAuthorDetail;
|
||||
import org.booklore.app.dto.AppAuthorSummary;
|
||||
import org.booklore.app.dto.AppPageResponse;
|
||||
import org.booklore.model.dto.BookLoreUser;
|
||||
import org.booklore.model.dto.Library;
|
||||
import org.booklore.model.entity.AuthorEntity;
|
||||
import org.booklore.repository.AuthorRepository;
|
||||
import org.booklore.util.FileService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class AppAuthorServiceTest {
|
||||
|
||||
@Mock private EntityManager entityManager;
|
||||
@Mock private AuthenticationService authenticationService;
|
||||
@Mock private AuthorRepository authorRepository;
|
||||
@Mock private FileService fileService;
|
||||
|
||||
private AppAuthorService service;
|
||||
|
||||
private final Long userId = 1L;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new AppAuthorService(authorRepository, authenticationService, fileService, entityManager);
|
||||
}
|
||||
|
||||
// ---- getAuthors tests ----
|
||||
|
||||
@Nested
|
||||
class GetAuthorsTests {
|
||||
|
||||
@Test
|
||||
void getAuthors_admin_noFilters_returnsPage() {
|
||||
mockAdminUser();
|
||||
mockCountQuery(2L);
|
||||
mockDataQuery(List.<Object[]>of(
|
||||
new Object[]{buildAuthor(1L, "Author A"), 5L},
|
||||
new Object[]{buildAuthor(2L, "Author B"), 3L}
|
||||
));
|
||||
mockAuthorThumbnailExists(1L, true);
|
||||
mockAuthorThumbnailExists(2L, false);
|
||||
|
||||
AppPageResponse<AppAuthorSummary> result = service.getAuthors(0, 30, "name", "asc", null, null, null);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(2, result.getContent().size());
|
||||
assertEquals("Author A", result.getContent().get(0).getName());
|
||||
assertEquals(5, result.getContent().get(0).getBookCount());
|
||||
assertTrue(result.getContent().get(0).isHasPhoto());
|
||||
assertEquals("Author B", result.getContent().get(1).getName());
|
||||
assertEquals(3, result.getContent().get(1).getBookCount());
|
||||
assertFalse(result.getContent().get(1).isHasPhoto());
|
||||
assertEquals(2, result.getTotalElements());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAuthors_admin_emptyResult_returnsEmptyPage() {
|
||||
mockAdminUser();
|
||||
mockCountQuery(0L);
|
||||
|
||||
AppPageResponse<AppAuthorSummary> result = service.getAuthors(0, 30, null, null, null, null, null);
|
||||
|
||||
assertNotNull(result);
|
||||
assertTrue(result.getContent().isEmpty());
|
||||
assertEquals(0, result.getTotalElements());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAuthors_nonAdmin_withAccessibleLibrary_succeeds() {
|
||||
mockNonAdminUser(Set.of(5L, 10L));
|
||||
mockCountQuery(1L);
|
||||
mockDataQuery(List.<Object[]>of(
|
||||
new Object[]{buildAuthor(3L, "Author C"), 2L}
|
||||
));
|
||||
mockAuthorThumbnailExists(3L, false);
|
||||
|
||||
AppPageResponse<AppAuthorSummary> result = service.getAuthors(0, 30, null, null, 5L, null, null);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.getContent().size());
|
||||
assertEquals("Author C", result.getContent().get(0).getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAuthors_nonAdmin_noLibraryFilter_usesAccessibleLibraries() {
|
||||
mockNonAdminUser(Set.of(5L));
|
||||
mockCountQuery(1L);
|
||||
mockDataQuery(List.<Object[]>of(
|
||||
new Object[]{buildAuthor(4L, "Author D"), 1L}
|
||||
));
|
||||
mockAuthorThumbnailExists(4L, true);
|
||||
|
||||
AppPageResponse<AppAuthorSummary> result = service.getAuthors(0, 30, null, null, null, null, null);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.getContent().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAuthors_withSearch_filtersResultsByName() {
|
||||
mockAdminUser();
|
||||
mockCountQuery(1L);
|
||||
mockDataQuery(List.<Object[]>of(
|
||||
new Object[]{buildAuthor(5L, "Brandon Sanderson"), 12L}
|
||||
));
|
||||
mockAuthorThumbnailExists(5L, true);
|
||||
|
||||
AppPageResponse<AppAuthorSummary> result = service.getAuthors(0, 30, null, null, null, "brandon", null);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.getContent().size());
|
||||
assertEquals("Brandon Sanderson", result.getContent().get(0).getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAuthors_withHasPhotoTrue_filtersToAuthorsWithPhotos() {
|
||||
mockAdminUser();
|
||||
mockCountQuery(2L);
|
||||
mockDataQuery(List.<Object[]>of(
|
||||
new Object[]{buildAuthor(6L, "Author E"), 4L},
|
||||
new Object[]{buildAuthor(7L, "Author F"), 2L}
|
||||
));
|
||||
mockAuthorThumbnailExists(6L, true);
|
||||
mockAuthorThumbnailExists(7L, false);
|
||||
// Mock the hasPhoto count query
|
||||
mockAuthorEntityQuery(List.of(buildAuthor(6L, "Author E"), buildAuthor(7L, "Author F")));
|
||||
|
||||
AppPageResponse<AppAuthorSummary> result = service.getAuthors(0, 30, null, null, null, null, true);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.getContent().size());
|
||||
assertEquals("Author E", result.getContent().get(0).getName());
|
||||
assertTrue(result.getContent().get(0).isHasPhoto());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAuthors_withHasPhotoFalse_filtersToAuthorsWithoutPhotos() {
|
||||
mockAdminUser();
|
||||
mockCountQuery(2L);
|
||||
mockDataQuery(List.<Object[]>of(
|
||||
new Object[]{buildAuthor(8L, "Author G"), 3L},
|
||||
new Object[]{buildAuthor(9L, "Author H"), 1L}
|
||||
));
|
||||
mockAuthorThumbnailExists(8L, true);
|
||||
mockAuthorThumbnailExists(9L, false);
|
||||
mockAuthorEntityQuery(List.of(buildAuthor(8L, "Author G"), buildAuthor(9L, "Author H")));
|
||||
|
||||
AppPageResponse<AppAuthorSummary> result = service.getAuthors(0, 30, null, null, null, null, false);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.getContent().size());
|
||||
assertEquals("Author H", result.getContent().get(0).getName());
|
||||
assertFalse(result.getContent().get(0).isHasPhoto());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAuthors_paginationDefaults_appliedCorrectly() {
|
||||
mockAdminUser();
|
||||
mockCountQuery(0L);
|
||||
|
||||
AppPageResponse<AppAuthorSummary> result = service.getAuthors(null, null, null, null, null, null, null);
|
||||
|
||||
assertEquals(0, result.getPage());
|
||||
assertEquals(30, result.getSize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAuthors_pageSizeCapped_atMax() {
|
||||
mockAdminUser();
|
||||
mockCountQuery(0L);
|
||||
|
||||
AppPageResponse<AppAuthorSummary> result = service.getAuthors(0, 100, null, null, null, null, null);
|
||||
|
||||
assertEquals(50, result.getSize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAuthors_sortByName_asc() {
|
||||
mockAdminUser();
|
||||
mockCountQuery(0L);
|
||||
|
||||
AppPageResponse<AppAuthorSummary> result = service.getAuthors(0, 30, "name", "asc", null, null, null);
|
||||
|
||||
assertNotNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAuthors_sortByBookCount_desc() {
|
||||
mockAdminUser();
|
||||
mockCountQuery(0L);
|
||||
|
||||
AppPageResponse<AppAuthorSummary> result = service.getAuthors(0, 30, "bookCount", "desc", null, null, null);
|
||||
|
||||
assertNotNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAuthors_sortByRecent_desc() {
|
||||
mockAdminUser();
|
||||
mockCountQuery(0L);
|
||||
|
||||
AppPageResponse<AppAuthorSummary> result = service.getAuthors(0, 30, "recent", "desc", null, null, null);
|
||||
|
||||
assertNotNull(result);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getAuthorDetail tests ----
|
||||
|
||||
@Nested
|
||||
class GetAuthorDetailTests {
|
||||
|
||||
@Test
|
||||
void getAuthorDetail_admin_returnsDetail() {
|
||||
mockAdminUser();
|
||||
AuthorEntity author = buildAuthor(1L, "J.R.R. Tolkien");
|
||||
author.setDescription("English writer and philologist.");
|
||||
author.setAsin("B000AP9MCS");
|
||||
when(authorRepository.findById(1L)).thenReturn(Optional.of(author));
|
||||
mockAuthorThumbnailExists(1L, true);
|
||||
mockBookCountQuery(3);
|
||||
|
||||
AppAuthorDetail result = service.getAuthorDetail(1L);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(1L, result.getId());
|
||||
assertEquals("J.R.R. Tolkien", result.getName());
|
||||
assertEquals("English writer and philologist.", result.getDescription());
|
||||
assertEquals("B000AP9MCS", result.getAsin());
|
||||
assertEquals(3, result.getBookCount());
|
||||
assertTrue(result.isHasPhoto());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAuthorDetail_nonAdmin_withAccess_returnsDetail() {
|
||||
mockNonAdminUser(Set.of(5L));
|
||||
AuthorEntity author = buildAuthor(2L, "Frank Herbert");
|
||||
when(authorRepository.findById(2L)).thenReturn(Optional.of(author));
|
||||
when(authorRepository.existsByIdAndLibraryIds(eq(2L), anySet())).thenReturn(true);
|
||||
mockAuthorThumbnailExists(2L, false);
|
||||
mockBookCountQuery(6);
|
||||
|
||||
AppAuthorDetail result = service.getAuthorDetail(2L);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("Frank Herbert", result.getName());
|
||||
assertEquals(6, result.getBookCount());
|
||||
assertFalse(result.isHasPhoto());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAuthorDetail_nonAdmin_noAccess_throwsNotFound() {
|
||||
mockNonAdminUser(Set.of(5L));
|
||||
AuthorEntity author = buildAuthor(3L, "Secret Author");
|
||||
when(authorRepository.findById(3L)).thenReturn(Optional.of(author));
|
||||
when(authorRepository.existsByIdAndLibraryIds(eq(3L), anySet())).thenReturn(false);
|
||||
|
||||
assertThrows(APIException.class, () -> service.getAuthorDetail(3L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAuthorDetail_nonExistentAuthor_throwsNotFound() {
|
||||
mockAdminUser();
|
||||
when(authorRepository.findById(999L)).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(APIException.class, () -> service.getAuthorDetail(999L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAuthorDetail_nonAdmin_emptyLibraries_throwsNotFound() {
|
||||
mockNonAdminUser(Collections.emptySet());
|
||||
AuthorEntity author = buildAuthor(4L, "Author X");
|
||||
when(authorRepository.findById(4L)).thenReturn(Optional.of(author));
|
||||
|
||||
assertThrows(APIException.class, () -> service.getAuthorDetail(4L));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
private void mockAdminUser() {
|
||||
var permissions = new BookLoreUser.UserPermissions();
|
||||
permissions.setAdmin(true);
|
||||
BookLoreUser user = BookLoreUser.builder()
|
||||
.id(userId)
|
||||
.permissions(permissions)
|
||||
.build();
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
|
||||
}
|
||||
|
||||
private void mockNonAdminUser(Set<Long> libraryIds) {
|
||||
List<Library> assignedLibraries = libraryIds.stream()
|
||||
.map(id -> Library.builder().id(id).build())
|
||||
.toList();
|
||||
var permissions = new BookLoreUser.UserPermissions();
|
||||
permissions.setAdmin(false);
|
||||
BookLoreUser user = BookLoreUser.builder()
|
||||
.id(userId)
|
||||
.permissions(permissions)
|
||||
.assignedLibraries(assignedLibraries)
|
||||
.build();
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void mockCountQuery(long count) {
|
||||
TypedQuery<Long> countQ = mock(TypedQuery.class);
|
||||
when(countQ.setParameter(anyString(), any())).thenReturn(countQ);
|
||||
when(countQ.getSingleResult()).thenReturn(count);
|
||||
when(entityManager.createQuery(anyString(), eq(Long.class))).thenReturn(countQ);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void mockDataQuery(List<Object[]> results) {
|
||||
TypedQuery<Object[]> dataQ = mock(TypedQuery.class);
|
||||
when(dataQ.setParameter(anyString(), any())).thenReturn(dataQ);
|
||||
when(dataQ.setFirstResult(anyInt())).thenReturn(dataQ);
|
||||
when(dataQ.setMaxResults(anyInt())).thenReturn(dataQ);
|
||||
when(dataQ.getResultList()).thenReturn(results);
|
||||
when(entityManager.createQuery(anyString(), eq(Object[].class))).thenReturn(dataQ);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void mockAuthorEntityQuery(List<AuthorEntity> authors) {
|
||||
TypedQuery<AuthorEntity> authorQ = mock(TypedQuery.class);
|
||||
when(authorQ.setParameter(anyString(), any())).thenReturn(authorQ);
|
||||
when(authorQ.getResultList()).thenReturn(authors);
|
||||
when(entityManager.createQuery(anyString(), eq(AuthorEntity.class))).thenReturn(authorQ);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void mockBookCountQuery(int count) {
|
||||
TypedQuery<Long> countQ = mock(TypedQuery.class);
|
||||
when(countQ.setParameter(anyString(), any())).thenReturn(countQ);
|
||||
when(countQ.getSingleResult()).thenReturn((long) count);
|
||||
when(entityManager.createQuery(anyString(), eq(Long.class))).thenReturn(countQ);
|
||||
}
|
||||
|
||||
private void mockAuthorThumbnailExists(Long authorId, boolean exists) {
|
||||
String path = "/mock/authors/" + authorId + "/thumbnail.jpg";
|
||||
when(fileService.getAuthorThumbnailFile(authorId)).thenReturn(path);
|
||||
// Since Files.exists checks real filesystem, a non-existent mock path returns false.
|
||||
// For "exists = true" tests, we need a path that actually exists.
|
||||
if (exists) {
|
||||
// Use a path we know exists — the temp directory
|
||||
when(fileService.getAuthorThumbnailFile(authorId)).thenReturn(System.getProperty("java.io.tmpdir"));
|
||||
}
|
||||
}
|
||||
|
||||
private AuthorEntity buildAuthor(Long id, String name) {
|
||||
return AuthorEntity.builder()
|
||||
.id(id)
|
||||
.name(name)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
package org.booklore.app.service;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.Tuple;
|
||||
import jakarta.persistence.TypedQuery;
|
||||
import org.booklore.config.security.service.AuthenticationService;
|
||||
import org.booklore.exception.APIException;
|
||||
import org.booklore.app.dto.AppFilterOptions;
|
||||
import org.booklore.app.mapper.AppBookMapper;
|
||||
import org.booklore.model.dto.Book;
|
||||
import org.booklore.model.dto.BookLoreUser;
|
||||
import org.booklore.model.dto.Library;
|
||||
import org.booklore.model.entity.BookLoreUserEntity;
|
||||
import org.booklore.model.entity.ShelfEntity;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.repository.BookRepository;
|
||||
import org.booklore.repository.ShelfRepository;
|
||||
import org.booklore.repository.UserBookFileProgressRepository;
|
||||
import org.booklore.repository.UserBookProgressRepository;
|
||||
import org.booklore.service.opds.MagicShelfBookService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class AppBookServiceFilterOptionsTest {
|
||||
|
||||
@Mock private BookRepository bookRepository;
|
||||
@Mock private UserBookProgressRepository userBookProgressRepository;
|
||||
@Mock private UserBookFileProgressRepository userBookFileProgressRepository;
|
||||
@Mock private ShelfRepository shelfRepository;
|
||||
@Mock private AuthenticationService authenticationService;
|
||||
@Mock private AppBookMapper mobileBookMapper;
|
||||
@Mock private MagicShelfBookService magicShelfBookService;
|
||||
@Mock private EntityManager entityManager;
|
||||
|
||||
private AppBookService service;
|
||||
|
||||
private final Long userId = 1L;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new AppBookService(
|
||||
bookRepository, userBookProgressRepository, userBookFileProgressRepository,
|
||||
shelfRepository, authenticationService, mobileBookMapper,
|
||||
magicShelfBookService, entityManager
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Global (no scoping params)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void getFilterOptions_noParams_returnsGlobalOptions() {
|
||||
mockAdminUser();
|
||||
mockJpqlQueries();
|
||||
|
||||
AppFilterOptions result = service.getFilterOptions(null, null, null);
|
||||
|
||||
assertNotNull(result);
|
||||
assertNotNull(result.getAuthors());
|
||||
assertNotNull(result.getLanguages());
|
||||
assertNotNull(result.getFileTypes());
|
||||
assertFalse(result.getReadStatuses().isEmpty());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Library scoping
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void getFilterOptions_withLibraryId_admin_succeeds() {
|
||||
mockAdminUser();
|
||||
mockJpqlQueries();
|
||||
|
||||
AppFilterOptions result = service.getFilterOptions(5L, null, null);
|
||||
|
||||
assertNotNull(result);
|
||||
verify(entityManager, times(3)).createQuery(anyString(), any(Class.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFilterOptions_withLibraryId_nonAdminWithAccess_succeeds() {
|
||||
mockNonAdminUser(Set.of(5L, 10L));
|
||||
mockJpqlQueries();
|
||||
|
||||
AppFilterOptions result = service.getFilterOptions(5L, null, null);
|
||||
|
||||
assertNotNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFilterOptions_withLibraryId_nonAdminNoAccess_throwsForbidden() {
|
||||
mockNonAdminUser(Set.of(10L));
|
||||
|
||||
assertThrows(APIException.class, () -> service.getFilterOptions(5L, null, null));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Shelf scoping
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void getFilterOptions_withShelfId_publicShelf_succeeds() {
|
||||
mockAdminUser();
|
||||
ShelfEntity shelf = ShelfEntity.builder().id(10L).isPublic(true)
|
||||
.user(BookLoreUserEntity.builder().id(99L).build()).build();
|
||||
when(shelfRepository.findById(10L)).thenReturn(Optional.of(shelf));
|
||||
mockJpqlQueries();
|
||||
|
||||
AppFilterOptions result = service.getFilterOptions(null, 10L, null);
|
||||
|
||||
assertNotNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFilterOptions_withShelfId_ownPrivateShelf_succeeds() {
|
||||
mockAdminUser();
|
||||
ShelfEntity shelf = ShelfEntity.builder().id(10L).isPublic(false)
|
||||
.user(BookLoreUserEntity.builder().id(userId).build()).build();
|
||||
when(shelfRepository.findById(10L)).thenReturn(Optional.of(shelf));
|
||||
mockJpqlQueries();
|
||||
|
||||
AppFilterOptions result = service.getFilterOptions(null, 10L, null);
|
||||
|
||||
assertNotNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFilterOptions_withShelfId_otherPrivateShelf_throwsForbidden() {
|
||||
mockAdminUser();
|
||||
ShelfEntity shelf = ShelfEntity.builder().id(10L).isPublic(false)
|
||||
.user(BookLoreUserEntity.builder().id(99L).build()).build();
|
||||
when(shelfRepository.findById(10L)).thenReturn(Optional.of(shelf));
|
||||
|
||||
assertThrows(APIException.class, () -> service.getFilterOptions(null, 10L, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFilterOptions_withShelfId_notFound_throwsException() {
|
||||
mockAdminUser();
|
||||
when(shelfRepository.findById(10L)).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(APIException.class, () -> service.getFilterOptions(null, 10L, null));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Magic shelf scoping
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void getFilterOptions_withMagicShelfId_emptyResult_returnsEmptyOptions() {
|
||||
mockAdminUser();
|
||||
mockMagicShelfBooks(7L, Collections.emptyList());
|
||||
|
||||
AppFilterOptions result = service.getFilterOptions(null, null, 7L);
|
||||
|
||||
assertNotNull(result);
|
||||
assertTrue(result.getAuthors().isEmpty());
|
||||
assertTrue(result.getLanguages().isEmpty());
|
||||
assertTrue(result.getFileTypes().isEmpty());
|
||||
assertFalse(result.getReadStatuses().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFilterOptions_withMagicShelfId_withBooks_returnsFilteredOptions() {
|
||||
mockAdminUser();
|
||||
Book book1 = Book.builder().id(100L).build();
|
||||
Book book2 = Book.builder().id(200L).build();
|
||||
mockMagicShelfBooks(7L, List.of(book1, book2));
|
||||
mockJpqlQueries();
|
||||
|
||||
AppFilterOptions result = service.getFilterOptions(null, null, 7L);
|
||||
|
||||
assertNotNull(result);
|
||||
verify(magicShelfBookService).getBooksByMagicShelfId(eq(userId), eq(7L), eq(0), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFilterOptions_withMagicShelfId_serviceThrows_propagatesException() {
|
||||
mockAdminUser();
|
||||
when(magicShelfBookService.getBooksByMagicShelfId(eq(userId), eq(7L), eq(0), anyInt()))
|
||||
.thenThrow(new RuntimeException("Magic shelf not found"));
|
||||
|
||||
assertThrows(RuntimeException.class, () -> service.getFilterOptions(null, null, 7L));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void mockAdminUser() {
|
||||
var permissions = new BookLoreUser.UserPermissions();
|
||||
permissions.setAdmin(true);
|
||||
BookLoreUser user = BookLoreUser.builder()
|
||||
.id(userId)
|
||||
.permissions(permissions)
|
||||
.build();
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
|
||||
}
|
||||
|
||||
private void mockNonAdminUser(Set<Long> libraryIds) {
|
||||
List<Library> assignedLibraries = libraryIds.stream()
|
||||
.map(id -> Library.builder().id(id).build())
|
||||
.toList();
|
||||
var permissions = new BookLoreUser.UserPermissions();
|
||||
permissions.setAdmin(false);
|
||||
BookLoreUser user = BookLoreUser.builder()
|
||||
.id(userId)
|
||||
.permissions(permissions)
|
||||
.assignedLibraries(assignedLibraries)
|
||||
.build();
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
|
||||
}
|
||||
|
||||
private void mockMagicShelfBooks(Long magicShelfId, List<Book> books) {
|
||||
var page = new PageImpl<>(books, PageRequest.of(0, Math.max(books.size(), 1)), books.size());
|
||||
when(magicShelfBookService.getBooksByMagicShelfId(eq(userId), eq(magicShelfId), eq(0), anyInt()))
|
||||
.thenReturn(page);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void mockJpqlQueries() {
|
||||
TypedQuery<Tuple> authorQuery = mock(TypedQuery.class);
|
||||
when(authorQuery.setMaxResults(anyInt())).thenReturn(authorQuery);
|
||||
when(authorQuery.setParameter(anyString(), any())).thenReturn(authorQuery);
|
||||
when(authorQuery.getResultList()).thenReturn(Collections.emptyList());
|
||||
|
||||
TypedQuery<Tuple> langQuery = mock(TypedQuery.class);
|
||||
when(langQuery.setParameter(anyString(), any())).thenReturn(langQuery);
|
||||
when(langQuery.getResultList()).thenReturn(Collections.emptyList());
|
||||
|
||||
TypedQuery<BookFileType> ftQuery = mock(TypedQuery.class);
|
||||
when(ftQuery.setParameter(anyString(), any())).thenReturn(ftQuery);
|
||||
when(ftQuery.getResultList()).thenReturn(Collections.emptyList());
|
||||
|
||||
when(entityManager.createQuery(anyString(), eq(Tuple.class)))
|
||||
.thenReturn(authorQuery)
|
||||
.thenReturn(langQuery);
|
||||
when(entityManager.createQuery(anyString(), eq(BookFileType.class)))
|
||||
.thenReturn(ftQuery);
|
||||
}
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
package org.booklore.app.service;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.Tuple;
|
||||
import jakarta.persistence.TypedQuery;
|
||||
import org.booklore.config.security.service.AuthenticationService;
|
||||
import org.booklore.exception.APIException;
|
||||
import org.booklore.app.dto.AppBookSummary;
|
||||
import org.booklore.app.dto.AppPageResponse;
|
||||
import org.booklore.app.dto.AppSeriesSummary;
|
||||
import org.booklore.app.mapper.AppBookMapper;
|
||||
import org.booklore.model.dto.BookLoreUser;
|
||||
import org.booklore.model.dto.Library;
|
||||
import org.booklore.model.entity.*;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.repository.BookRepository;
|
||||
import org.booklore.repository.UserBookProgressRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class AppSeriesServiceTest {
|
||||
|
||||
@Mock private EntityManager entityManager;
|
||||
@Mock private AuthenticationService authenticationService;
|
||||
@Mock private BookRepository bookRepository;
|
||||
@Mock private UserBookProgressRepository userBookProgressRepository;
|
||||
@Mock private AppBookMapper mobileBookMapper;
|
||||
|
||||
private AppSeriesService service;
|
||||
|
||||
private final Long userId = 1L;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new AppSeriesService(
|
||||
entityManager, authenticationService, bookRepository,
|
||||
userBookProgressRepository, mobileBookMapper
|
||||
);
|
||||
}
|
||||
|
||||
// ---- getSeries tests ----
|
||||
|
||||
@Nested
|
||||
class GetSeriesTests {
|
||||
|
||||
@Test
|
||||
void getSeries_admin_noParams_returnsPage() {
|
||||
mockAdminUser();
|
||||
mockAggregateQuery(List.of(
|
||||
mockSeriesTuple("The Expanse", 9L, 9, Instant.now(), 3L)
|
||||
));
|
||||
mockCountQuery(1L);
|
||||
mockBooksQuery(List.of(buildBook(1L, "The Expanse", 1.0f, "Author A")));
|
||||
|
||||
AppPageResponse<AppSeriesSummary> result = service.getSeries(0, 20, null, null, null, null, false);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.getContent().size());
|
||||
assertEquals("The Expanse", result.getContent().getFirst().getSeriesName());
|
||||
assertEquals(9, result.getContent().getFirst().getBookCount());
|
||||
assertEquals(3, result.getContent().getFirst().getBooksRead());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeries_admin_emptyResult_returnsEmptyPage() {
|
||||
mockAdminUser();
|
||||
mockAggregateQuery(Collections.emptyList());
|
||||
mockCountQuery(0L);
|
||||
|
||||
AppPageResponse<AppSeriesSummary> result = service.getSeries(0, 20, null, null, null, null, false);
|
||||
|
||||
assertNotNull(result);
|
||||
assertTrue(result.getContent().isEmpty());
|
||||
assertEquals(0, result.getTotalElements());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeries_nonAdmin_withAccessibleLibrary_succeeds() {
|
||||
mockNonAdminUser(Set.of(5L, 10L));
|
||||
mockAggregateQuery(List.of(
|
||||
mockSeriesTuple("Dune", 6L, 6, Instant.now(), 2L)
|
||||
));
|
||||
mockCountQuery(1L);
|
||||
mockBooksQuery(List.of(buildBook(2L, "Dune", 1.0f, "Frank Herbert")));
|
||||
|
||||
AppPageResponse<AppSeriesSummary> result = service.getSeries(0, 20, null, null, 5L, null, false);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.getContent().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeries_nonAdmin_noAccessToLibrary_throwsForbidden() {
|
||||
mockNonAdminUser(Set.of(10L));
|
||||
|
||||
assertThrows(APIException.class, () ->
|
||||
service.getSeries(0, 20, null, null, 5L, null, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeries_withSearch_passesSearchPattern() {
|
||||
mockAdminUser();
|
||||
mockAggregateQuery(List.of(
|
||||
mockSeriesTuple("Harry Potter", 7L, 7, Instant.now(), 7L)
|
||||
));
|
||||
mockCountQuery(1L);
|
||||
mockBooksQuery(List.of(buildBook(3L, "Harry Potter", 1.0f, "J.K. Rowling")));
|
||||
|
||||
AppPageResponse<AppSeriesSummary> result = service.getSeries(0, 20, null, null, null, "harry", false);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.getContent().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeries_inProgressOnly_returnsFilteredResults() {
|
||||
mockAdminUser();
|
||||
mockAggregateQueryInProgress(List.of(
|
||||
mockSeriesTupleInProgress("The Expanse", 9L, 9, Instant.now(), 3L, Instant.now())
|
||||
));
|
||||
mockCountQueryInProgress(1L);
|
||||
mockBooksQuery(List.of(buildBook(1L, "The Expanse", 1.0f, "Author A")));
|
||||
|
||||
AppPageResponse<AppSeriesSummary> result = service.getSeries(0, 20, null, null, null, null, true);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.getContent().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeries_paginationDefaults_appliedCorrectly() {
|
||||
mockAdminUser();
|
||||
mockAggregateQuery(Collections.emptyList());
|
||||
mockCountQuery(0L);
|
||||
|
||||
AppPageResponse<AppSeriesSummary> result = service.getSeries(null, null, null, null, null, null, false);
|
||||
|
||||
assertEquals(0, result.getPage());
|
||||
assertEquals(20, result.getSize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeries_pageSizeCapped_atMax() {
|
||||
mockAdminUser();
|
||||
mockAggregateQuery(Collections.emptyList());
|
||||
mockCountQuery(0L);
|
||||
|
||||
AppPageResponse<AppSeriesSummary> result = service.getSeries(0, 100, null, null, null, null, false);
|
||||
|
||||
assertEquals(50, result.getSize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeries_enrichesCoverBooksAndAuthors() {
|
||||
mockAdminUser();
|
||||
BookEntity book1 = buildBook(10L, "Series X", 1.0f, "Author A");
|
||||
BookEntity book2 = buildBook(11L, "Series X", 2.0f, "Author B");
|
||||
|
||||
mockAggregateQuery(List.of(
|
||||
mockSeriesTuple("Series X", 2L, 3, Instant.now(), 1L)
|
||||
));
|
||||
mockCountQuery(1L);
|
||||
mockBooksQuery(List.of(book1, book2));
|
||||
|
||||
AppPageResponse<AppSeriesSummary> result = service.getSeries(0, 20, null, null, null, null, false);
|
||||
|
||||
AppSeriesSummary series = result.getContent().getFirst();
|
||||
assertEquals(2, series.getCoverBooks().size());
|
||||
assertEquals(2, series.getAuthors().size());
|
||||
// Cover books should be ordered by seriesNumber ASC
|
||||
assertEquals(1.0f, series.getCoverBooks().get(0).getSeriesNumber());
|
||||
assertEquals(2.0f, series.getCoverBooks().get(1).getSeriesNumber());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeries_sortByName_asc() {
|
||||
mockAdminUser();
|
||||
mockAggregateQuery(Collections.emptyList());
|
||||
mockCountQuery(0L);
|
||||
|
||||
AppPageResponse<AppSeriesSummary> result = service.getSeries(0, 20, "name", "asc", null, null, false);
|
||||
|
||||
assertNotNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeries_sortByBookCount_desc() {
|
||||
mockAdminUser();
|
||||
mockAggregateQuery(Collections.emptyList());
|
||||
mockCountQuery(0L);
|
||||
|
||||
AppPageResponse<AppSeriesSummary> result = service.getSeries(0, 20, "bookCount", "desc", null, null, false);
|
||||
|
||||
assertNotNull(result);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- getSeriesBooks tests ----
|
||||
|
||||
@Nested
|
||||
class GetSeriesBooksTests {
|
||||
|
||||
@Test
|
||||
void getSeriesBooks_admin_returnsBooks() {
|
||||
mockAdminUser();
|
||||
BookEntity book = buildBook(1L, "Dune", 1.0f, "Frank Herbert");
|
||||
mockBookPage(List.of(book), 1L);
|
||||
mockProgress(Collections.emptyList());
|
||||
mockMapperSummary();
|
||||
|
||||
AppPageResponse<AppBookSummary> result = service.getSeriesBooks("Dune", 0, 20, null, null, null);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.getContent().size());
|
||||
assertEquals(1, result.getTotalElements());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeriesBooks_nonAdmin_withAccess_succeeds() {
|
||||
mockNonAdminUser(Set.of(5L));
|
||||
BookEntity book = buildBook(2L, "Dune", 2.0f, "Frank Herbert");
|
||||
mockBookPage(List.of(book), 1L);
|
||||
mockProgress(Collections.emptyList());
|
||||
mockMapperSummary();
|
||||
|
||||
AppPageResponse<AppBookSummary> result = service.getSeriesBooks("Dune", 0, 20, null, null, 5L);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.getContent().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeriesBooks_nonAdmin_noAccess_throwsForbidden() {
|
||||
mockNonAdminUser(Set.of(10L));
|
||||
|
||||
assertThrows(APIException.class, () ->
|
||||
service.getSeriesBooks("Dune", 0, 20, null, null, 5L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeriesBooks_emptyResult_returnsEmptyPage() {
|
||||
mockAdminUser();
|
||||
mockBookPage(Collections.emptyList(), 0L);
|
||||
|
||||
AppPageResponse<AppBookSummary> result = service.getSeriesBooks("Nonexistent", 0, 20, null, null, null);
|
||||
|
||||
assertNotNull(result);
|
||||
assertTrue(result.getContent().isEmpty());
|
||||
assertEquals(0, result.getTotalElements());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeriesBooks_sortByTitle_desc() {
|
||||
mockAdminUser();
|
||||
mockBookPage(Collections.emptyList(), 0L);
|
||||
|
||||
AppPageResponse<AppBookSummary> result = service.getSeriesBooks("Dune", 0, 20, "title", "desc", null);
|
||||
|
||||
assertNotNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeriesBooks_sortByRecentlyAdded_asc() {
|
||||
mockAdminUser();
|
||||
mockBookPage(Collections.emptyList(), 0L);
|
||||
|
||||
AppPageResponse<AppBookSummary> result = service.getSeriesBooks("Dune", 0, 20, "recentlyAdded", "asc", null);
|
||||
|
||||
assertNotNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeriesBooks_defaultSort_isSeriesNumber() {
|
||||
mockAdminUser();
|
||||
mockBookPage(Collections.emptyList(), 0L);
|
||||
|
||||
AppPageResponse<AppBookSummary> result = service.getSeriesBooks("Dune", 0, 20, null, null, null);
|
||||
|
||||
assertNotNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeriesBooks_paginationDefaults() {
|
||||
mockAdminUser();
|
||||
mockBookPage(Collections.emptyList(), 0L);
|
||||
|
||||
AppPageResponse<AppBookSummary> result = service.getSeriesBooks("Dune", null, null, null, null, null);
|
||||
|
||||
assertEquals(0, result.getPage());
|
||||
assertEquals(20, result.getSize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSeriesBooks_pageSizeCapped() {
|
||||
mockAdminUser();
|
||||
mockBookPage(Collections.emptyList(), 0L);
|
||||
|
||||
AppPageResponse<AppBookSummary> result = service.getSeriesBooks("Dune", 0, 200, null, null, null);
|
||||
|
||||
assertEquals(50, result.getSize());
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
private void mockAdminUser() {
|
||||
var permissions = new BookLoreUser.UserPermissions();
|
||||
permissions.setAdmin(true);
|
||||
BookLoreUser user = BookLoreUser.builder()
|
||||
.id(userId)
|
||||
.permissions(permissions)
|
||||
.build();
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
|
||||
}
|
||||
|
||||
private void mockNonAdminUser(Set<Long> libraryIds) {
|
||||
List<Library> assignedLibraries = libraryIds.stream()
|
||||
.map(id -> Library.builder().id(id).build())
|
||||
.toList();
|
||||
var permissions = new BookLoreUser.UserPermissions();
|
||||
permissions.setAdmin(false);
|
||||
BookLoreUser user = BookLoreUser.builder()
|
||||
.id(userId)
|
||||
.permissions(permissions)
|
||||
.assignedLibraries(assignedLibraries)
|
||||
.build();
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
|
||||
}
|
||||
|
||||
private Tuple mockSeriesTuple(String name, Long count, Integer total, Instant addedOn, Long booksRead) {
|
||||
Tuple tuple = mock(Tuple.class);
|
||||
when(tuple.get(0, String.class)).thenReturn(name);
|
||||
when(tuple.get(1, Long.class)).thenReturn(count);
|
||||
when(tuple.get(2, Integer.class)).thenReturn(total);
|
||||
when(tuple.get(3, Instant.class)).thenReturn(addedOn);
|
||||
when(tuple.get(4, Long.class)).thenReturn(booksRead);
|
||||
return tuple;
|
||||
}
|
||||
|
||||
private Tuple mockSeriesTupleInProgress(String name, Long count, Integer total, Instant addedOn, Long booksRead, Instant lastReadTime) {
|
||||
Tuple tuple = mockSeriesTuple(name, count, total, addedOn, booksRead);
|
||||
when(tuple.get(5, Instant.class)).thenReturn(lastReadTime);
|
||||
return tuple;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void mockAggregateQuery(List<Tuple> results) {
|
||||
TypedQuery<Tuple> aggregateQ = mock(TypedQuery.class);
|
||||
when(aggregateQ.setParameter(anyString(), any())).thenReturn(aggregateQ);
|
||||
when(aggregateQ.setFirstResult(anyInt())).thenReturn(aggregateQ);
|
||||
when(aggregateQ.setMaxResults(anyInt())).thenReturn(aggregateQ);
|
||||
when(aggregateQ.getResultList()).thenReturn(results);
|
||||
|
||||
TypedQuery<BookEntity> booksQ = mock(TypedQuery.class);
|
||||
when(booksQ.setParameter(anyString(), any())).thenReturn(booksQ);
|
||||
when(booksQ.getResultList()).thenReturn(Collections.emptyList());
|
||||
|
||||
when(entityManager.createQuery(anyString(), eq(Tuple.class))).thenReturn(aggregateQ);
|
||||
when(entityManager.createQuery(anyString(), eq(BookEntity.class))).thenReturn(booksQ);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void mockAggregateQueryInProgress(List<Tuple> results) {
|
||||
// Pre-compute series names before setting up mocks to avoid calling .get() on mock Tuples during stubbing
|
||||
List<String> seriesNames = new ArrayList<>();
|
||||
for (Tuple t : results) {
|
||||
seriesNames.add(t.get(0, String.class));
|
||||
}
|
||||
|
||||
TypedQuery<Tuple> aggregateQ = mock(TypedQuery.class);
|
||||
when(aggregateQ.setParameter(anyString(), any())).thenReturn(aggregateQ);
|
||||
when(aggregateQ.setFirstResult(anyInt())).thenReturn(aggregateQ);
|
||||
when(aggregateQ.setMaxResults(anyInt())).thenReturn(aggregateQ);
|
||||
when(aggregateQ.getResultList()).thenReturn(results);
|
||||
|
||||
TypedQuery<BookEntity> booksQ = mock(TypedQuery.class);
|
||||
when(booksQ.setParameter(anyString(), any())).thenReturn(booksQ);
|
||||
when(booksQ.getResultList()).thenReturn(Collections.emptyList());
|
||||
|
||||
// In-progress count uses String.class query
|
||||
TypedQuery<String> countQ = mock(TypedQuery.class);
|
||||
when(countQ.setParameter(anyString(), any())).thenReturn(countQ);
|
||||
when(countQ.getResultList()).thenReturn(seriesNames);
|
||||
|
||||
when(entityManager.createQuery(anyString(), eq(Tuple.class))).thenReturn(aggregateQ);
|
||||
when(entityManager.createQuery(anyString(), eq(BookEntity.class))).thenReturn(booksQ);
|
||||
when(entityManager.createQuery(anyString(), eq(String.class))).thenReturn(countQ);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void mockCountQuery(long count) {
|
||||
TypedQuery<Long> countQ = mock(TypedQuery.class);
|
||||
when(countQ.setParameter(anyString(), any())).thenReturn(countQ);
|
||||
when(countQ.getSingleResult()).thenReturn(count);
|
||||
when(entityManager.createQuery(anyString(), eq(Long.class))).thenReturn(countQ);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void mockCountQueryInProgress(long count) {
|
||||
TypedQuery<String> countQ = mock(TypedQuery.class);
|
||||
when(countQ.setParameter(anyString(), any())).thenReturn(countQ);
|
||||
List<String> names = new ArrayList<>();
|
||||
for (int i = 0; i < count; i++) names.add("series" + i);
|
||||
when(countQ.getResultList()).thenReturn(names);
|
||||
when(entityManager.createQuery(anyString(), eq(String.class))).thenReturn(countQ);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void mockBooksQuery(List<BookEntity> books) {
|
||||
TypedQuery<BookEntity> booksQ = mock(TypedQuery.class);
|
||||
when(booksQ.setParameter(anyString(), any())).thenReturn(booksQ);
|
||||
when(booksQ.getResultList()).thenReturn(books);
|
||||
when(entityManager.createQuery(anyString(), eq(BookEntity.class))).thenReturn(booksQ);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void mockBookPage(List<BookEntity> books, long total) {
|
||||
var page = new PageImpl<>(books, Pageable.ofSize(20), total);
|
||||
when(bookRepository.findAll(any(Specification.class), any(Pageable.class))).thenReturn(page);
|
||||
}
|
||||
|
||||
private void mockProgress(List<UserBookProgressEntity> progressList) {
|
||||
when(userBookProgressRepository.findByUserIdAndBookIdIn(eq(userId), anySet()))
|
||||
.thenReturn(progressList);
|
||||
}
|
||||
|
||||
private void mockMapperSummary() {
|
||||
when(mobileBookMapper.toSummary(any(BookEntity.class), any()))
|
||||
.thenAnswer(inv -> {
|
||||
BookEntity b = inv.getArgument(0);
|
||||
return AppBookSummary.builder()
|
||||
.id(b.getId())
|
||||
.title(b.getMetadata() != null ? b.getMetadata().getTitle() : null)
|
||||
.build();
|
||||
});
|
||||
}
|
||||
|
||||
private BookEntity buildBook(Long id, String seriesName, Float seriesNumber, String authorName) {
|
||||
AuthorEntity author = new AuthorEntity();
|
||||
author.setName(authorName);
|
||||
|
||||
BookMetadataEntity metadata = BookMetadataEntity.builder()
|
||||
.bookId(id)
|
||||
.title(seriesName + " #" + seriesNumber.intValue())
|
||||
.seriesName(seriesName)
|
||||
.seriesNumber(seriesNumber)
|
||||
.coverUpdatedOn(Instant.now())
|
||||
.authors(List.of(author))
|
||||
.build();
|
||||
|
||||
BookFileEntity bookFile = BookFileEntity.builder()
|
||||
.id(id)
|
||||
.bookType(BookFileType.EPUB)
|
||||
.build();
|
||||
|
||||
List<BookFileEntity> bookFiles = new ArrayList<>(List.of(bookFile));
|
||||
|
||||
BookEntity book = BookEntity.builder()
|
||||
.id(id)
|
||||
.metadata(metadata)
|
||||
.addedOn(Instant.now())
|
||||
.bookFiles(bookFiles)
|
||||
.build();
|
||||
|
||||
metadata.setBook(book);
|
||||
bookFile.setBook(book);
|
||||
|
||||
return book;
|
||||
}
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
package org.booklore.crons;
|
||||
|
||||
import org.booklore.config.AppProperties;
|
||||
import org.booklore.model.dto.BookloreTelemetry;
|
||||
import org.booklore.model.dto.InstallationPing;
|
||||
import org.booklore.model.dto.settings.AppSettings;
|
||||
import org.booklore.service.TelemetryService;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class CronServiceTest {
|
||||
|
||||
private AppProperties appProperties;
|
||||
private TelemetryService telemetryService;
|
||||
private RestClient restClient;
|
||||
private AppSettingService appSettingService;
|
||||
private CronService cronService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
appProperties = mock(AppProperties.class, RETURNS_DEEP_STUBS);
|
||||
telemetryService = mock(TelemetryService.class);
|
||||
restClient = mock(RestClient.class, RETURNS_DEEP_STUBS);
|
||||
appSettingService = mock(AppSettingService.class);
|
||||
cronService = new CronService(appProperties, telemetryService, restClient, appSettingService);
|
||||
|
||||
AppSettings defaultSettings = mock(AppSettings.class);
|
||||
when(appSettingService.getAppSettings()).thenReturn(defaultSettings);
|
||||
when(defaultSettings.isTelemetryEnabled()).thenReturn(true);
|
||||
|
||||
InstallationPing defaultPing = InstallationPing.builder().appVersion("1.0.0").build();
|
||||
when(telemetryService.getInstallationPing()).thenReturn(defaultPing);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendTelemetryData_telemetryDisabled_doesNotSend() {
|
||||
AppSettings settings = mock(AppSettings.class);
|
||||
when(appSettingService.getAppSettings()).thenReturn(settings);
|
||||
when(settings.isTelemetryEnabled()).thenReturn(false);
|
||||
cronService.sendTelemetryData();
|
||||
verifyNoInteractions(telemetryService);
|
||||
verify(appSettingService, never()).saveSetting(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendTelemetryData_telemetryEnabled_postSuccess_savesSetting() {
|
||||
AppSettings settings = mock(AppSettings.class);
|
||||
when(appSettingService.getAppSettings()).thenReturn(settings);
|
||||
when(settings.isTelemetryEnabled()).thenReturn(true);
|
||||
when(appProperties.getTelemetry().getBaseUrl()).thenReturn("http://telemetry");
|
||||
BookloreTelemetry telemetry = mock(BookloreTelemetry.class);
|
||||
when(telemetryService.collectTelemetry()).thenReturn(telemetry);
|
||||
RestClient.RequestBodyUriSpec post = mock(RestClient.RequestBodyUriSpec.class, RETURNS_DEEP_STUBS);
|
||||
when(restClient.post()).thenReturn(post);
|
||||
when(post.uri(anyString())).thenReturn(post);
|
||||
when(post.body(any())).thenReturn(post);
|
||||
when(post.retrieve().body(String.class)).thenReturn("ok");
|
||||
cronService.sendTelemetryData();
|
||||
verify(appSettingService).saveSetting(eq("last_telemetry_sent"), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendTelemetryData_telemetryEnabled_postFails_doesNotSaveSetting() {
|
||||
AppSettings settings = mock(AppSettings.class);
|
||||
when(appSettingService.getAppSettings()).thenReturn(settings);
|
||||
when(settings.isTelemetryEnabled()).thenReturn(true);
|
||||
when(appProperties.getTelemetry().getBaseUrl()).thenReturn("http://telemetry");
|
||||
BookloreTelemetry telemetry = mock(BookloreTelemetry.class);
|
||||
when(telemetryService.collectTelemetry()).thenReturn(telemetry);
|
||||
|
||||
CronService spy = spy(cronService);
|
||||
doReturn(false).when(spy).postData(anyString(), any());
|
||||
|
||||
spy.sendTelemetryData();
|
||||
verify(appSettingService, never()).saveSetting(eq("last_telemetry_sent"), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendPing_telemetryDisabled_doesNotSend() {
|
||||
AppSettings settings = mock(AppSettings.class);
|
||||
when(appSettingService.getAppSettings()).thenReturn(settings);
|
||||
when(settings.isTelemetryEnabled()).thenReturn(false);
|
||||
cronService.sendPing();
|
||||
verifyNoInteractions(restClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendPing_telemetryEnabled_postSuccess_savesSettings() {
|
||||
AppSettings settings = mock(AppSettings.class);
|
||||
when(appSettingService.getAppSettings()).thenReturn(settings);
|
||||
when(settings.isTelemetryEnabled()).thenReturn(true);
|
||||
when(appProperties.getTelemetry().getBaseUrl()).thenReturn("http://telemetry");
|
||||
InstallationPing ping = InstallationPing.builder().appVersion("1.0.0").build();
|
||||
when(telemetryService.getInstallationPing()).thenReturn(ping);
|
||||
RestClient.RequestBodyUriSpec post = mock(RestClient.RequestBodyUriSpec.class, RETURNS_DEEP_STUBS);
|
||||
when(restClient.post()).thenReturn(post);
|
||||
when(post.uri(anyString())).thenReturn(post);
|
||||
when(post.body(any())).thenReturn(post);
|
||||
when(post.retrieve().body(String.class)).thenReturn("ok");
|
||||
cronService.sendPing();
|
||||
verify(appSettingService).saveSetting(eq("last_ping_sent"), anyString());
|
||||
verify(appSettingService).saveSetting(eq("last_ping_app_version"), eq("1.0.0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendPing_telemetryEnabled_postFails_doesNotSaveSettings() {
|
||||
AppSettings settings = mock(AppSettings.class);
|
||||
when(appSettingService.getAppSettings()).thenReturn(settings);
|
||||
when(settings.isTelemetryEnabled()).thenReturn(true);
|
||||
when(appProperties.getTelemetry().getBaseUrl()).thenReturn("http://telemetry");
|
||||
InstallationPing ping = InstallationPing.builder().appVersion("1.0.0").build();
|
||||
when(telemetryService.getInstallationPing()).thenReturn(ping);
|
||||
|
||||
CronService spy = spy(cronService);
|
||||
doReturn(false).when(spy).postData(anyString(), any());
|
||||
|
||||
spy.sendPing();
|
||||
verify(appSettingService, never()).saveSetting(eq("last_ping_sent"), anyString());
|
||||
verify(appSettingService, never()).saveSetting(eq("last_ping_app_version"), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRunTask_nullOrEmpty_returnsFalse() {
|
||||
assertFalse(cronServiceShouldRunTask(null));
|
||||
assertFalse(cronServiceShouldRunTask(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRunTask_invalidTimestamp_returnsFalse() {
|
||||
assertFalse(cronServiceShouldRunTask("not-a-timestamp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRunTask_recentTimestamp_returnsFalse() {
|
||||
String now = Instant.now().toString();
|
||||
assertFalse(cronServiceShouldRunTask(now));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRunTask_oldTimestamp_returnsTrue() {
|
||||
String old = Instant.now().minusSeconds(60 * 60 * 25).toString();
|
||||
assertTrue(cronServiceShouldRunTask(old));
|
||||
}
|
||||
|
||||
boolean cronServiceShouldRunTask(String lastRunStr) {
|
||||
return invokeShouldRunTask(cronService, lastRunStr);
|
||||
}
|
||||
|
||||
boolean invokeShouldRunTask(CronService cronService, String lastRunStr) {
|
||||
try {
|
||||
var m = CronService.class.getDeclaredMethod("shouldRunTask", String.class);
|
||||
m.setAccessible(true);
|
||||
return (boolean) m.invoke(cronService, lastRunStr);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasAppVersionChanged_noLastPing_returnsFalse() {
|
||||
when(appSettingService.getSettingValue("last_ping_app_version")).thenReturn(null);
|
||||
InstallationPing ping = InstallationPing.builder().appVersion("1.0.0").build();
|
||||
when(telemetryService.getInstallationPing()).thenReturn(ping);
|
||||
assertFalse(invokeHasAppVersionChanged(cronService));
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasAppVersionChanged_lastPingEmpty_returnsFalse() {
|
||||
when(appSettingService.getSettingValue("last_ping_app_version")).thenReturn("");
|
||||
InstallationPing ping = InstallationPing.builder().appVersion("1.0.0").build();
|
||||
when(telemetryService.getInstallationPing()).thenReturn(ping);
|
||||
assertFalse(invokeHasAppVersionChanged(cronService));
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasAppVersionChanged_sameVersion_returnsFalse() {
|
||||
when(appSettingService.getSettingValue("last_ping_app_version")).thenReturn("1.0.0");
|
||||
InstallationPing ping = InstallationPing.builder().appVersion("1.0.0").build();
|
||||
when(telemetryService.getInstallationPing()).thenReturn(ping);
|
||||
assertFalse(invokeHasAppVersionChanged(cronService));
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasAppVersionChanged_differentVersion_returnsTrue() {
|
||||
when(appSettingService.getSettingValue("last_ping_app_version")).thenReturn("1.0.0");
|
||||
InstallationPing ping = InstallationPing.builder().appVersion("2.0.0").build();
|
||||
when(telemetryService.getInstallationPing()).thenReturn(ping);
|
||||
assertTrue(invokeHasAppVersionChanged(cronService));
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasAppVersionChanged_nullPing_returnsFalse() {
|
||||
when(appSettingService.getSettingValue("last_ping_app_version")).thenReturn("1.0.0");
|
||||
when(telemetryService.getInstallationPing()).thenReturn(null);
|
||||
assertFalse(invokeHasAppVersionChanged(cronService));
|
||||
}
|
||||
|
||||
boolean invokeHasAppVersionChanged(CronService cronService) {
|
||||
try {
|
||||
var m = CronService.class.getDeclaredMethod("hasAppVersionChanged");
|
||||
m.setAccessible(true);
|
||||
return (boolean) m.invoke(cronService);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndRunTelemetry_telemetryDisabled_doesNothing() {
|
||||
AppSettings settings = mock(AppSettings.class);
|
||||
when(appSettingService.getAppSettings()).thenReturn(settings);
|
||||
when(settings.isTelemetryEnabled()).thenReturn(false);
|
||||
cronService.initScheduledTasks();
|
||||
verify(appSettingService, never()).getSettingValue("last_telemetry_sent");
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndRunTelemetry_shouldRunTaskTrue_callsSendTelemetryData() {
|
||||
AppSettings settings = mock(AppSettings.class);
|
||||
when(appSettingService.getAppSettings()).thenReturn(settings);
|
||||
when(settings.isTelemetryEnabled()).thenReturn(true);
|
||||
when(appSettingService.getSettingValue("last_telemetry_sent")).thenReturn(Instant.now().minusSeconds(60 * 60 * 25).toString());
|
||||
CronService spy = spy(cronService);
|
||||
doNothing().when(spy).sendTelemetryData();
|
||||
// Ensure getInstallationPing returns non-null for ping checks
|
||||
InstallationPing ping = InstallationPing.builder().appVersion("1.0.0").build();
|
||||
when(telemetryService.getInstallationPing()).thenReturn(ping);
|
||||
spy.initScheduledTasks();
|
||||
verify(spy).sendTelemetryData();
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndRunPing_appVersionChanged_callsSendPing() {
|
||||
when(appSettingService.getSettingValue("last_ping_app_version")).thenReturn("1.0.0");
|
||||
InstallationPing ping = InstallationPing.builder().appVersion("2.0.0").build();
|
||||
when(telemetryService.getInstallationPing()).thenReturn(ping);
|
||||
CronService spy = spy(cronService);
|
||||
doNothing().when(spy).sendPing();
|
||||
spy.initScheduledTasks();
|
||||
verify(spy).sendPing();
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndRunPing_shouldRunTaskTrue_callsSendPing() {
|
||||
when(appSettingService.getSettingValue("last_ping_app_version")).thenReturn("1.0.0");
|
||||
InstallationPing ping = InstallationPing.builder().appVersion("1.0.0").build();
|
||||
when(telemetryService.getInstallationPing()).thenReturn(ping);
|
||||
when(appSettingService.getSettingValue("last_ping_sent")).thenReturn(Instant.now().minusSeconds(60 * 60 * 25).toString());
|
||||
CronService spy = spy(cronService);
|
||||
doNothing().when(spy).sendPing();
|
||||
spy.initScheduledTasks();
|
||||
verify(spy).sendPing();
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndRunPing_shouldRunTaskFalse_doesNotCallSendPing() {
|
||||
when(appSettingService.getSettingValue("last_ping_app_version")).thenReturn("1.0.0");
|
||||
InstallationPing ping = InstallationPing.builder().appVersion("1.0.0").build();
|
||||
when(telemetryService.getInstallationPing()).thenReturn(ping);
|
||||
when(appSettingService.getSettingValue("last_ping_sent")).thenReturn(Instant.now().toString());
|
||||
CronService spy = spy(cronService);
|
||||
doNothing().when(spy).sendPing();
|
||||
spy.initScheduledTasks();
|
||||
verify(spy, never()).sendPing();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendTelemetryData_nullSettings_doesNotThrowOrSend() {
|
||||
when(appSettingService.getAppSettings()).thenReturn(null);
|
||||
cronService.sendTelemetryData();
|
||||
verifyNoInteractions(telemetryService);
|
||||
verify(appSettingService, never()).saveSetting(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendTelemetryData_telemetryEnabled_collectTelemetryReturnsNull_doesNotThrow() {
|
||||
AppSettings settings = mock(AppSettings.class);
|
||||
when(appSettingService.getAppSettings()).thenReturn(settings);
|
||||
when(settings.isTelemetryEnabled()).thenReturn(true);
|
||||
when(appProperties.getTelemetry().getBaseUrl()).thenReturn("http://telemetry");
|
||||
when(telemetryService.collectTelemetry()).thenReturn(null);
|
||||
|
||||
CronService spy = spy(cronService);
|
||||
doReturn(false).when(spy).postData(anyString(), any());
|
||||
|
||||
spy.sendTelemetryData();
|
||||
verify(appSettingService, never()).saveSetting(eq("last_telemetry_sent"), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendPing_nullPing_doesNotSave() {
|
||||
when(telemetryService.getInstallationPing()).thenReturn(null);
|
||||
cronService.sendPing();
|
||||
verify(appSettingService, never()).saveSetting(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRunTask_farFutureTimestamp_returnsFalse() {
|
||||
String future = Instant.now().plusSeconds(60 * 60 * 25).toString();
|
||||
assertFalse(cronServiceShouldRunTask(future));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRunTask_epoch_returnsTrue() {
|
||||
String epoch = Instant.EPOCH.toString();
|
||||
assertTrue(cronServiceShouldRunTask(epoch));
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasAppVersionChanged_lastPingNullCurrentNull_returnsFalse() {
|
||||
when(appSettingService.getSettingValue("last_ping_app_version")).thenReturn(null);
|
||||
when(telemetryService.getInstallationPing()).thenReturn(null);
|
||||
assertFalse(invokeHasAppVersionChanged(cronService));
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasAppVersionChanged_lastPingEmptyCurrentNull_returnsFalse() {
|
||||
when(appSettingService.getSettingValue("last_ping_app_version")).thenReturn("");
|
||||
when(telemetryService.getInstallationPing()).thenReturn(null);
|
||||
assertFalse(invokeHasAppVersionChanged(cronService));
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasAppVersionChanged_lastPingNonEmptyCurrentNull_returnsFalse() {
|
||||
when(appSettingService.getSettingValue("last_ping_app_version")).thenReturn("1.0.0");
|
||||
when(telemetryService.getInstallationPing()).thenReturn(null);
|
||||
assertFalse(invokeHasAppVersionChanged(cronService));
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndRunTelemetry_nullSettings_doesNothing() {
|
||||
when(appSettingService.getAppSettings()).thenReturn(null);
|
||||
cronService.initScheduledTasks();
|
||||
verify(appSettingService, never()).getSettingValue("last_telemetry_sent");
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndRunTelemetry_shouldRunTaskFalse_doesNotCallSendTelemetryData() {
|
||||
AppSettings settings = mock(AppSettings.class);
|
||||
when(appSettingService.getAppSettings()).thenReturn(settings);
|
||||
when(settings.isTelemetryEnabled()).thenReturn(true);
|
||||
when(appSettingService.getSettingValue("last_telemetry_sent")).thenReturn(Instant.now().toString());
|
||||
CronService spy = spy(cronService);
|
||||
doNothing().when(spy).sendTelemetryData();
|
||||
spy.initScheduledTasks();
|
||||
verify(spy, never()).sendTelemetryData();
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndRunPing_nullLastPingVersion_doesNotCallSendPing() {
|
||||
when(appSettingService.getSettingValue("last_ping_app_version")).thenReturn(null);
|
||||
InstallationPing ping = InstallationPing.builder().appVersion("1.0.0").build();
|
||||
when(telemetryService.getInstallationPing()).thenReturn(ping);
|
||||
CronService spy = spy(cronService);
|
||||
doNothing().when(spy).sendPing();
|
||||
spy.initScheduledTasks();
|
||||
verify(spy, never()).sendPing();
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndRunPing_nullPing_doesNotCallSendPing() {
|
||||
when(appSettingService.getSettingValue("last_ping_app_version")).thenReturn("1.0.0");
|
||||
when(telemetryService.getInstallationPing()).thenReturn(null);
|
||||
CronService spy = spy(cronService);
|
||||
doNothing().when(spy).sendPing();
|
||||
spy.initScheduledTasks();
|
||||
verify(spy, never()).sendPing();
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndRunPing_shouldRunTaskFalseAndNoVersionChange_doesNotCallSendPing() {
|
||||
when(appSettingService.getSettingValue("last_ping_app_version")).thenReturn("1.0.0");
|
||||
InstallationPing ping = InstallationPing.builder().appVersion("1.0.0").build();
|
||||
when(telemetryService.getInstallationPing()).thenReturn(ping);
|
||||
when(appSettingService.getSettingValue("last_ping_sent")).thenReturn(Instant.now().toString());
|
||||
CronService spy = spy(cronService);
|
||||
doNothing().when(spy).sendPing();
|
||||
spy.initScheduledTasks();
|
||||
verify(spy, never()).sendPing();
|
||||
}
|
||||
}
|
||||
2319
booklore-ui/package-lock.json
generated
2319
booklore-ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,7 @@
|
||||
"devDependencies": {
|
||||
"@analogjs/vite-plugin-angular": "^2.3.0",
|
||||
"@analogjs/vitest-angular": "^2.3.0",
|
||||
"@angular/build": "^21.2.0",
|
||||
"@angular/build": "^21.2.7",
|
||||
"@angular/cli": "^21.2.0",
|
||||
"@angular/compiler-cli": "^21.2.4",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
|
||||
@@ -220,33 +220,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Telemetry Setting Section -->
|
||||
<div class="preferences-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">
|
||||
<i class="pi pi-chart-bar"></i>
|
||||
{{ t('telemetry.sectionTitle') }}
|
||||
<app-external-doc-link docType="telemetry"></app-external-doc-link>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label-row">
|
||||
<label class="setting-label">{{ t('telemetry.enableTelemetry') }}</label>
|
||||
<p-toggleswitch
|
||||
[(ngModel)]="toggles.enableTelemetry"
|
||||
(onChange)="onToggleChange('enableTelemetry', $event.checked)">
|
||||
</p-toggleswitch>
|
||||
</div>
|
||||
<p class="setting-description">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
{{ t('telemetry.telemetryDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -12,7 +12,6 @@ import {AppSettingKey, AppSettings, CoverCroppingSettings} from '../../../shared
|
||||
import {filter, take} from 'rxjs/operators';
|
||||
import {InputText} from 'primeng/inputtext';
|
||||
import {Slider} from 'primeng/slider';
|
||||
import {ExternalDocLinkComponent} from '../../../shared/components/external-doc-link/external-doc-link.component';
|
||||
import {TranslocoDirective, TranslocoPipe, TranslocoService} from '@jsverse/transloco';
|
||||
|
||||
export const SUPPORT_ANIMATION_KEY = 'booklore-support-animation';
|
||||
@@ -27,7 +26,6 @@ export const SUPPORT_ANIMATION_KEY = 'booklore-support-animation';
|
||||
InputText,
|
||||
Slider,
|
||||
SplitButton,
|
||||
ExternalDocLinkComponent,
|
||||
TranslocoDirective,
|
||||
TranslocoPipe
|
||||
],
|
||||
@@ -38,8 +36,7 @@ export class GlobalPreferencesComponent implements OnInit {
|
||||
|
||||
toggles = {
|
||||
autoBookSearch: false,
|
||||
similarBookRecommendation: false,
|
||||
enableTelemetry: true,
|
||||
similarBookRecommendation: false
|
||||
};
|
||||
|
||||
supportButtonAnimation = localStorage.getItem(SUPPORT_ANIMATION_KEY) !== 'false';
|
||||
@@ -81,7 +78,6 @@ export class GlobalPreferencesComponent implements OnInit {
|
||||
}
|
||||
this.toggles.autoBookSearch = settings.autoBookSearch ?? false;
|
||||
this.toggles.similarBookRecommendation = settings.similarBookRecommendation ?? false;
|
||||
this.toggles.enableTelemetry = settings?.telemetryEnabled ?? true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,8 +85,7 @@ export class GlobalPreferencesComponent implements OnInit {
|
||||
this.toggles[settingKey] = checked;
|
||||
const toggleKeyMap: Record<string, AppSettingKey> = {
|
||||
autoBookSearch: AppSettingKey.AUTO_BOOK_SEARCH,
|
||||
similarBookRecommendation: AppSettingKey.SIMILAR_BOOK_RECOMMENDATION,
|
||||
enableTelemetry: AppSettingKey.TELEMETRY_ENABLED,
|
||||
similarBookRecommendation: AppSettingKey.SIMILAR_BOOK_RECOMMENDATION
|
||||
};
|
||||
const keyToSend = toggleKeyMap[settingKey];
|
||||
if (keyToSend) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {Tooltip} from 'primeng/tooltip';
|
||||
|
||||
export type DocType = 'kobo' | 'opds' | 'metadataManager' | 'koReader' | 'email'
|
||||
| 'amazonCookie' | 'fetchConfig' | 'hardcover' | 'taskManagement' | 'fileNamePatterns'
|
||||
| 'authentication' | 'telemetry';
|
||||
| 'authentication';
|
||||
|
||||
@Component({
|
||||
selector: 'app-external-doc-link',
|
||||
@@ -38,8 +38,7 @@ export class ExternalDocLinkComponent {
|
||||
fetchConfig: `${this.BASE_URL}/metadata/metadata-fetch-configuration`,
|
||||
taskManagement: `${this.BASE_URL}/tools/task-manager`,
|
||||
fileNamePatterns: `${this.BASE_URL}/metadata/file-naming-patterns`,
|
||||
authentication: `${this.BASE_URL}/authentication/overview#setting-up-oidc`,
|
||||
telemetry: `${this.BASE_URL}/tools/telemetry`
|
||||
authentication: `${this.BASE_URL}/authentication/overview#setting-up-oidc`
|
||||
};
|
||||
|
||||
@Input() docType!: DocType;
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</p>
|
||||
|
||||
<div class="support-cards">
|
||||
<a class="support-card star-card" href="https://github.com/booklore-app/booklore" target="_blank" rel="noopener noreferrer">
|
||||
<a class="support-card star-card" href="https://github.com/the-booklore/booklore" target="_blank" rel="noopener noreferrer">
|
||||
<i class="pi pi-github card-icon"></i>
|
||||
<div class="card-text">
|
||||
<span class="card-title">{{ t('starTitle') }}</span>
|
||||
|
||||
@@ -252,8 +252,8 @@ export class AppMenuComponent implements OnInit {
|
||||
getVersionUrl(version: string | undefined): string {
|
||||
if (!version) return '#';
|
||||
return version.startsWith('v')
|
||||
? `https://github.com/booklore-app/booklore/releases/tag/${version}`
|
||||
: `https://github.com/booklore-app/booklore/commit/${version}`;
|
||||
? `https://github.com/the-booklore/booklore/releases/tag/${version}`
|
||||
: `https://github.com/the-booklore/booklore/commit/${version}`;
|
||||
}
|
||||
|
||||
isSemanticVersion(version: string | undefined): boolean {
|
||||
|
||||
@@ -192,7 +192,6 @@ export interface AppSettings {
|
||||
koboSettings: KoboSettings;
|
||||
coverCroppingSettings: CoverCroppingSettings;
|
||||
metadataDownloadOnBookdrop: boolean;
|
||||
telemetryEnabled: boolean;
|
||||
metadataProviderSpecificFields: MetadataProviderSpecificFields;
|
||||
oidcSessionDurationHours: number | null;
|
||||
oidcGroupSyncMode: string | null;
|
||||
@@ -242,7 +241,6 @@ export enum AppSettingKey {
|
||||
METADATA_PUBLIC_REVIEWS_SETTINGS = 'METADATA_PUBLIC_REVIEWS_SETTINGS',
|
||||
KOBO_SETTINGS = 'KOBO_SETTINGS',
|
||||
COVER_CROPPING_SETTINGS = 'COVER_CROPPING_SETTINGS',
|
||||
TELEMETRY_ENABLED = 'TELEMETRY_ENABLED',
|
||||
METADATA_PROVIDER_SPECIFIC_FIELDS = 'METADATA_PROVIDER_SPECIFIC_FIELDS',
|
||||
OIDC_SESSION_DURATION_HOURS = 'OIDC_SESSION_DURATION_HOURS',
|
||||
OIDC_GROUP_SYNC_MODE = 'OIDC_GROUP_SYNC_MODE',
|
||||
|
||||
@@ -1,51 +1,46 @@
|
||||
{
|
||||
"title": "",
|
||||
"description": "",
|
||||
"covers": {
|
||||
"sectionTitle": "",
|
||||
"regenerate": "",
|
||||
"regenerateAllBtn": "",
|
||||
"regenerateMissingBtn": "",
|
||||
"regenerateDesc": "",
|
||||
"regenerateStarted": "",
|
||||
"regenerateStartedDetail": "",
|
||||
"regenerateError": "",
|
||||
"verticalCropping": "",
|
||||
"verticalCroppingDesc": "",
|
||||
"horizontalCropping": "",
|
||||
"horizontalCroppingDesc": "",
|
||||
"aspectRatio": "",
|
||||
"aspectRatioDesc": "",
|
||||
"smartCropping": "",
|
||||
"smartCroppingDesc": ""
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "",
|
||||
"autoBookSearch": "",
|
||||
"autoBookSearchDesc": "",
|
||||
"similarBook": "",
|
||||
"similarBookDesc": ""
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "",
|
||||
"maxUploadSize": "",
|
||||
"maxUploadPlaceholder": "",
|
||||
"maxUploadDesc": "",
|
||||
"restartWarning": "",
|
||||
"invalidInput": "",
|
||||
"invalidInputDetail": ""
|
||||
},
|
||||
"appearance": {
|
||||
"sectionTitle": "",
|
||||
"supportButtonAnimation": "",
|
||||
"supportButtonAnimationDesc": ""
|
||||
},
|
||||
"telemetry": {
|
||||
"sectionTitle": "",
|
||||
"enableTelemetry": "",
|
||||
"telemetryDesc": ""
|
||||
},
|
||||
"settingsSaved": "",
|
||||
"settingsSavedDetail": "",
|
||||
"settingsError": ""
|
||||
"title": "",
|
||||
"description": "",
|
||||
"covers": {
|
||||
"sectionTitle": "",
|
||||
"regenerate": "",
|
||||
"regenerateAllBtn": "",
|
||||
"regenerateMissingBtn": "",
|
||||
"regenerateDesc": "",
|
||||
"regenerateStarted": "",
|
||||
"regenerateStartedDetail": "",
|
||||
"regenerateError": "",
|
||||
"verticalCropping": "",
|
||||
"verticalCroppingDesc": "",
|
||||
"horizontalCropping": "",
|
||||
"horizontalCroppingDesc": "",
|
||||
"aspectRatio": "",
|
||||
"aspectRatioDesc": "",
|
||||
"smartCropping": "",
|
||||
"smartCroppingDesc": ""
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "",
|
||||
"autoBookSearch": "",
|
||||
"autoBookSearchDesc": "",
|
||||
"similarBook": "",
|
||||
"similarBookDesc": ""
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "",
|
||||
"maxUploadSize": "",
|
||||
"maxUploadPlaceholder": "",
|
||||
"maxUploadDesc": "",
|
||||
"restartWarning": "",
|
||||
"invalidInput": "",
|
||||
"invalidInputDetail": ""
|
||||
},
|
||||
"appearance": {
|
||||
"sectionTitle": "",
|
||||
"supportButtonAnimation": "",
|
||||
"supportButtonAnimationDesc": ""
|
||||
},
|
||||
"settingsSaved": "",
|
||||
"settingsSavedDetail": "",
|
||||
"settingsError": ""
|
||||
}
|
||||
|
||||
@@ -1,52 +1,47 @@
|
||||
{
|
||||
"title": "Globale Einstellungen",
|
||||
"description": "Konfigurieren Sie globale Einstellungen für Ihre Booklore-Instanz, einschließlich Coverbild-Verarbeitung, Sucheinstellungen und Dateiupload-Limits.",
|
||||
"covers": {
|
||||
"sectionTitle": "Buchcover-Bild",
|
||||
"regenerate": "Cover neu generieren",
|
||||
"regenerateBtn": "Neu generieren",
|
||||
"regenerateDesc": "Generiert Coverbilder für alle Bücher aus den in der Datei eingebetteten Covern neu. Verwenden Sie „Fehlende neu generieren“, um nur Cover für Bücher zu erstellen, die noch kein Cover haben.",
|
||||
"regenerateStarted": "Cover-Neugenerierung gestartet",
|
||||
"regenerateStartedDetail": "Buchcover werden neu generiert.",
|
||||
"regenerateError": "Cover-Neugenerierung konnte nicht gestartet werden.",
|
||||
"verticalCropping": "Vertikaler Cover-Zuschnitt",
|
||||
"verticalCroppingDesc": "Extrem hohe Bilder (wie Web-Comics) automatisch von oben zuschneiden, um brauchbare Cover-Miniaturansichten zu erstellen.",
|
||||
"horizontalCropping": "Horizontaler Cover-Zuschnitt",
|
||||
"horizontalCroppingDesc": "Extrem breite Bilder automatisch von links zuschneiden, um brauchbare Cover-Miniaturansichten zu erstellen.",
|
||||
"aspectRatio": "Seitenverhältnis-Schwellenwert: {{value}}",
|
||||
"aspectRatioDesc": "Bilder mit Seitenverhältnissen über diesem Schwellenwert werden zugeschnitten. Ein Wert von 2,5 bedeutet, dass Bilder, die mehr als 2,5-mal höher (oder breiter) als normal sind, zugeschnitten werden.",
|
||||
"smartCropping": "Intelligenter Zuschnitt",
|
||||
"smartCroppingDesc": "Gleichmäßige Farbbereiche beim Zuschnitt überspringen. Fokussiert das Coverbild auf den relevantesten Inhalt.",
|
||||
"regenerateAllBtn": "Alle neu generieren",
|
||||
"regenerateMissingBtn": "Fehlende neu generieren"
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Suche & Empfehlungen",
|
||||
"autoBookSearch": "Automatische Buchsuche",
|
||||
"autoBookSearchDesc": "Versucht automatisch Metadaten abzugleichen, wenn das Buchinformationspanel geöffnet wird.",
|
||||
"similarBook": "Buchempfehlungen",
|
||||
"similarBookDesc": "Aktiviert oder deaktiviert Buchempfehlungen basierend auf Ihrer Bibliothek."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Dateiverwaltung",
|
||||
"maxUploadSize": "Maximale Dateiupload-Größe",
|
||||
"maxUploadPlaceholder": "Max. Größe",
|
||||
"maxUploadDesc": "Legt die maximal erlaubte Größe (in MB) pro hochgeladener Datei fest. Gilt für alle unterstützten Formate.",
|
||||
"restartWarning": "Änderungen werden nach einem Neustart des Servers wirksam",
|
||||
"invalidInput": "Ungültige Eingabe",
|
||||
"invalidInputDetail": "Bitte geben Sie eine gültige maximale Dateiupload-Größe in MB ein."
|
||||
},
|
||||
"telemetry": {
|
||||
"sectionTitle": "Telemetrie",
|
||||
"enableTelemetry": "Telemetrie aktivieren",
|
||||
"telemetryDesc": "Helfen Sie Booklore zu verbessern, indem Sie anonyme Nutzungsstatistiken teilen. Diese Daten helfen den Entwicklern zu sehen, welche Funktionen am meisten genutzt werden, Fehler zu erkennen und Leistungsprobleme zu identifizieren, damit sie wissen, woran sie als nächstes arbeiten sollten. Es werden niemals persönliche Informationen, Buchinhalte oder andere identifizierbare Daten gesendet. Daten werden automatisch alle 24 Stunden an den Booklore-Server gesendet. Es ist absolut sicher und sehr hilfreich für die Entwickler."
|
||||
},
|
||||
"settingsSaved": "Einstellungen gespeichert",
|
||||
"settingsSavedDetail": "Die Einstellungen wurden erfolgreich gespeichert!",
|
||||
"settingsError": "Beim Speichern der Einstellungen ist ein Fehler aufgetreten.",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "Animation der Support-Schaltfläche",
|
||||
"supportButtonAnimationDesc": "Zeigt den animierten Herz-Effekt auf der Support-Schaltfläche in der oberen Leiste an. Wenn Sie diese Option deaktivieren, bleibt die Schaltfläche sichtbar, jedoch wird die Animation deaktiviert.",
|
||||
"sectionTitle": "Erscheinungsbild"
|
||||
}
|
||||
"title": "Globale Einstellungen",
|
||||
"description": "Konfigurieren Sie globale Einstellungen für Ihre Booklore-Instanz, einschließlich Coverbild-Verarbeitung, Sucheinstellungen und Dateiupload-Limits.",
|
||||
"covers": {
|
||||
"sectionTitle": "Buchcover-Bild",
|
||||
"regenerate": "Cover neu generieren",
|
||||
"regenerateBtn": "Neu generieren",
|
||||
"regenerateDesc": "Generiert Coverbilder für alle Bücher aus den in der Datei eingebetteten Covern neu. Verwenden Sie „Fehlende neu generieren“, um nur Cover für Bücher zu erstellen, die noch kein Cover haben.",
|
||||
"regenerateStarted": "Cover-Neugenerierung gestartet",
|
||||
"regenerateStartedDetail": "Buchcover werden neu generiert.",
|
||||
"regenerateError": "Cover-Neugenerierung konnte nicht gestartet werden.",
|
||||
"verticalCropping": "Vertikaler Cover-Zuschnitt",
|
||||
"verticalCroppingDesc": "Extrem hohe Bilder (wie Web-Comics) automatisch von oben zuschneiden, um brauchbare Cover-Miniaturansichten zu erstellen.",
|
||||
"horizontalCropping": "Horizontaler Cover-Zuschnitt",
|
||||
"horizontalCroppingDesc": "Extrem breite Bilder automatisch von links zuschneiden, um brauchbare Cover-Miniaturansichten zu erstellen.",
|
||||
"aspectRatio": "Seitenverhältnis-Schwellenwert: {{value}}",
|
||||
"aspectRatioDesc": "Bilder mit Seitenverhältnissen über diesem Schwellenwert werden zugeschnitten. Ein Wert von 2,5 bedeutet, dass Bilder, die mehr als 2,5-mal höher (oder breiter) als normal sind, zugeschnitten werden.",
|
||||
"smartCropping": "Intelligenter Zuschnitt",
|
||||
"smartCroppingDesc": "Gleichmäßige Farbbereiche beim Zuschnitt überspringen. Fokussiert das Coverbild auf den relevantesten Inhalt.",
|
||||
"regenerateAllBtn": "Alle neu generieren",
|
||||
"regenerateMissingBtn": "Fehlende neu generieren"
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Suche & Empfehlungen",
|
||||
"autoBookSearch": "Automatische Buchsuche",
|
||||
"autoBookSearchDesc": "Versucht automatisch Metadaten abzugleichen, wenn das Buchinformationspanel geöffnet wird.",
|
||||
"similarBook": "Buchempfehlungen",
|
||||
"similarBookDesc": "Aktiviert oder deaktiviert Buchempfehlungen basierend auf Ihrer Bibliothek."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Dateiverwaltung",
|
||||
"maxUploadSize": "Maximale Dateiupload-Größe",
|
||||
"maxUploadPlaceholder": "Max. Größe",
|
||||
"maxUploadDesc": "Legt die maximal erlaubte Größe (in MB) pro hochgeladener Datei fest. Gilt für alle unterstützten Formate.",
|
||||
"restartWarning": "Änderungen werden nach einem Neustart des Servers wirksam",
|
||||
"invalidInput": "Ungültige Eingabe",
|
||||
"invalidInputDetail": "Bitte geben Sie eine gültige maximale Dateiupload-Größe in MB ein."
|
||||
},
|
||||
"settingsSaved": "Einstellungen gespeichert",
|
||||
"settingsSavedDetail": "Die Einstellungen wurden erfolgreich gespeichert!",
|
||||
"settingsError": "Beim Speichern der Einstellungen ist ein Fehler aufgetreten.",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "Animation der Support-Schaltfläche",
|
||||
"supportButtonAnimationDesc": "Zeigt den animierten Herz-Effekt auf der Support-Schaltfläche in der oberen Leiste an. Wenn Sie diese Option deaktivieren, bleibt die Schaltfläche sichtbar, jedoch wird die Animation deaktiviert.",
|
||||
"sectionTitle": "Erscheinungsbild"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,11 +40,6 @@
|
||||
"supportButtonAnimation": "Support Button Animation",
|
||||
"supportButtonAnimationDesc": "Show the animated heart effect on the support button in the top bar. Disabling this keeps the button visible but removes the animation."
|
||||
},
|
||||
"telemetry": {
|
||||
"sectionTitle": "Telemetry",
|
||||
"enableTelemetry": "Enable Telemetry",
|
||||
"telemetryDesc": "Help improve Booklore by sharing anonymous usage statistics. This data lets the developers see which features are most used, identify bugs, and spot performance issues so they know what to work on next. No personal information, book content, or any identifiable data is ever sent. Data is sent to the Booklore server automatically once every 24 hours. It's completely safe and very helpful to the developers."
|
||||
},
|
||||
"settingsSaved": "Settings Saved",
|
||||
"settingsSavedDetail": "The settings were successfully saved!",
|
||||
"settingsError": "There was an error saving the settings."
|
||||
|
||||
@@ -1,51 +1,46 @@
|
||||
{
|
||||
"title": "Preferencias globales",
|
||||
"description": "Configura los ajustes globales de tu instancia de Booklore, incluyendo el manejo de imágenes de portada, preferencias de búsqueda y límites de carga de archivos.",
|
||||
"covers": {
|
||||
"sectionTitle": "Imagen de portada del libro",
|
||||
"regenerate": "Regenerar portadas",
|
||||
"regenerateAllBtn": "Regenerar todas",
|
||||
"regenerateMissingBtn": "Regenerar faltantes",
|
||||
"regenerateDesc": "Regenera las imágenes de portada de todos los libros a partir de las portadas incrustadas en el archivo. Usa \"Regenerar faltantes\" para generar portadas solo para libros que aún no tienen una.",
|
||||
"regenerateStarted": "Regeneración de portadas iniciada",
|
||||
"regenerateStartedDetail": "Las portadas de los libros se están regenerando.",
|
||||
"regenerateError": "Error al iniciar la regeneración de portadas.",
|
||||
"verticalCropping": "Recorte vertical de portada",
|
||||
"verticalCroppingDesc": "Recortar automáticamente imágenes extremadamente altas (como webcómics) desde la parte superior para crear miniaturas de portada utilizables.",
|
||||
"horizontalCropping": "Recorte horizontal de portada",
|
||||
"horizontalCroppingDesc": "Recortar automáticamente imágenes extremadamente anchas desde la izquierda para crear miniaturas de portada utilizables.",
|
||||
"aspectRatio": "Umbral de relación de aspecto: {{value}}",
|
||||
"aspectRatioDesc": "Las imágenes con relaciones de aspecto que excedan este umbral serán recortadas. Un valor de 2.5 significa que las imágenes más de 2.5 veces más altas (o anchas) de lo normal serán recortadas.",
|
||||
"smartCropping": "Recorte inteligente",
|
||||
"smartCroppingDesc": "Omitir regiones de color uniforme al determinar dónde recortar. Enfoca la imagen de portada en el contenido más relevante."
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Búsqueda y recomendaciones",
|
||||
"autoBookSearch": "Búsqueda automática de libros",
|
||||
"autoBookSearchDesc": "Intenta automáticamente emparejar metadatos cuando se abre el panel de información del libro.",
|
||||
"similarBook": "Recomendación de libros similares",
|
||||
"similarBookDesc": "Habilita o deshabilita las recomendaciones de libros similares basadas en tu biblioteca."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Gestión de archivos",
|
||||
"maxUploadSize": "Tamaño máximo de carga de archivo",
|
||||
"maxUploadPlaceholder": "Tamaño máximo",
|
||||
"maxUploadDesc": "Define el tamaño máximo permitido (en MB) para cada archivo cargado. Se aplica a todos los formatos de archivo compatibles.",
|
||||
"restartWarning": "Los cambios surtirán efecto después de reiniciar el servidor",
|
||||
"invalidInput": "Entrada inválida",
|
||||
"invalidInputDetail": "Introduce un tamaño máximo de carga de archivo válido en MB."
|
||||
},
|
||||
"appearance": {
|
||||
"sectionTitle": "Apariencia",
|
||||
"supportButtonAnimation": "Animación del botón de apoyo",
|
||||
"supportButtonAnimationDesc": "Muestra el efecto animado de corazón en el botón de apoyo en la barra superior. Desactivar esto mantiene el botón visible pero elimina la animación."
|
||||
},
|
||||
"telemetry": {
|
||||
"sectionTitle": "Telemetría",
|
||||
"enableTelemetry": "Habilitar telemetría",
|
||||
"telemetryDesc": "Ayuda a mejorar Booklore compartiendo estadísticas de uso anónimas. Estos datos permiten a los desarrolladores ver qué funciones se usan más, identificar errores y detectar problemas de rendimiento para saber en qué trabajar a continuación. Nunca se envía información personal, contenido de libros ni datos identificables. Los datos se envían al servidor de Booklore automáticamente una vez cada 24 horas. Es completamente seguro y muy útil para los desarrolladores."
|
||||
},
|
||||
"settingsSaved": "Ajustes guardados",
|
||||
"settingsSavedDetail": "¡Los ajustes se guardaron correctamente!",
|
||||
"settingsError": "Hubo un error al guardar los ajustes."
|
||||
"title": "Preferencias globales",
|
||||
"description": "Configura los ajustes globales de tu instancia de Booklore, incluyendo el manejo de imágenes de portada, preferencias de búsqueda y límites de carga de archivos.",
|
||||
"covers": {
|
||||
"sectionTitle": "Imagen de portada del libro",
|
||||
"regenerate": "Regenerar portadas",
|
||||
"regenerateAllBtn": "Regenerar todas",
|
||||
"regenerateMissingBtn": "Regenerar faltantes",
|
||||
"regenerateDesc": "Regenera las imágenes de portada de todos los libros a partir de las portadas incrustadas en el archivo. Usa \"Regenerar faltantes\" para generar portadas solo para libros que aún no tienen una.",
|
||||
"regenerateStarted": "Regeneración de portadas iniciada",
|
||||
"regenerateStartedDetail": "Las portadas de los libros se están regenerando.",
|
||||
"regenerateError": "Error al iniciar la regeneración de portadas.",
|
||||
"verticalCropping": "Recorte vertical de portada",
|
||||
"verticalCroppingDesc": "Recortar automáticamente imágenes extremadamente altas (como webcómics) desde la parte superior para crear miniaturas de portada utilizables.",
|
||||
"horizontalCropping": "Recorte horizontal de portada",
|
||||
"horizontalCroppingDesc": "Recortar automáticamente imágenes extremadamente anchas desde la izquierda para crear miniaturas de portada utilizables.",
|
||||
"aspectRatio": "Umbral de relación de aspecto: {{value}}",
|
||||
"aspectRatioDesc": "Las imágenes con relaciones de aspecto que excedan este umbral serán recortadas. Un valor de 2.5 significa que las imágenes más de 2.5 veces más altas (o anchas) de lo normal serán recortadas.",
|
||||
"smartCropping": "Recorte inteligente",
|
||||
"smartCroppingDesc": "Omitir regiones de color uniforme al determinar dónde recortar. Enfoca la imagen de portada en el contenido más relevante."
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Búsqueda y recomendaciones",
|
||||
"autoBookSearch": "Búsqueda automática de libros",
|
||||
"autoBookSearchDesc": "Intenta automáticamente emparejar metadatos cuando se abre el panel de información del libro.",
|
||||
"similarBook": "Recomendación de libros similares",
|
||||
"similarBookDesc": "Habilita o deshabilita las recomendaciones de libros similares basadas en tu biblioteca."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Gestión de archivos",
|
||||
"maxUploadSize": "Tamaño máximo de carga de archivo",
|
||||
"maxUploadPlaceholder": "Tamaño máximo",
|
||||
"maxUploadDesc": "Define el tamaño máximo permitido (en MB) para cada archivo cargado. Se aplica a todos los formatos de archivo compatibles.",
|
||||
"restartWarning": "Los cambios surtirán efecto después de reiniciar el servidor",
|
||||
"invalidInput": "Entrada inválida",
|
||||
"invalidInputDetail": "Introduce un tamaño máximo de carga de archivo válido en MB."
|
||||
},
|
||||
"appearance": {
|
||||
"sectionTitle": "Apariencia",
|
||||
"supportButtonAnimation": "Animación del botón de apoyo",
|
||||
"supportButtonAnimationDesc": "Muestra el efecto animado de corazón en el botón de apoyo en la barra superior. Desactivar esto mantiene el botón visible pero elimina la animación."
|
||||
},
|
||||
"settingsSaved": "Ajustes guardados",
|
||||
"settingsSavedDetail": "¡Los ajustes se guardaron correctamente!",
|
||||
"settingsError": "Hubo un error al guardar los ajustes."
|
||||
}
|
||||
|
||||
@@ -1,50 +1,45 @@
|
||||
{
|
||||
"title": "Préférences globales",
|
||||
"description": "Configurez les paramètres globaux de votre instance Booklore, y compris la gestion des images de couverture, les préférences de recherche et les limites de téléversement de fichiers.",
|
||||
"covers": {
|
||||
"sectionTitle": "Image de couverture des livres",
|
||||
"regenerate": "Régénérer les couvertures",
|
||||
"regenerateBtn": "Régénérer",
|
||||
"regenerateDesc": "Régénère les images de couverture pour tous les livres à partir des couvertures intégrées dans le fichier.",
|
||||
"regenerateStarted": "Régénération des couvertures lancée",
|
||||
"regenerateStartedDetail": "Les couvertures des livres sont en cours de régénération.",
|
||||
"regenerateError": "Échec du lancement de la régénération des couvertures.",
|
||||
"verticalCropping": "Recadrage vertical des couvertures",
|
||||
"verticalCroppingDesc": "Recadrer automatiquement les images extrêmement hautes (comme les webcomics) depuis le haut pour créer des miniatures de couverture utilisables.",
|
||||
"horizontalCropping": "Recadrage horizontal des couvertures",
|
||||
"horizontalCroppingDesc": "Recadrer automatiquement les images extrêmement larges depuis la gauche pour créer des miniatures de couverture utilisables.",
|
||||
"aspectRatio": "Seuil de rapport d'aspect : {{value}}",
|
||||
"aspectRatioDesc": "Les images dont le rapport d'aspect dépasse ce seuil seront recadrées. Une valeur de 2,5 signifie que les images plus de 2,5 fois plus hautes (ou plus larges) que la normale seront recadrées.",
|
||||
"smartCropping": "Recadrage intelligent",
|
||||
"smartCroppingDesc": "Ignorer les régions de couleur uniforme lors de la détermination de l'endroit où recadrer. Concentre l'image de couverture sur le contenu le plus pertinent."
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Recherche et recommandations",
|
||||
"autoBookSearch": "Recherche automatique de livres",
|
||||
"autoBookSearchDesc": "Tente automatiquement la correspondance des métadonnées lorsque le panneau d'informations du livre est ouvert.",
|
||||
"similarBook": "Recommandation de livres similaires",
|
||||
"similarBookDesc": "Active ou désactive les recommandations de livres similaires basées sur votre bibliothèque."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Gestion des fichiers",
|
||||
"maxUploadSize": "Taille max. de téléversement",
|
||||
"maxUploadPlaceholder": "Taille max.",
|
||||
"maxUploadDesc": "Définit la taille maximale autorisée (en Mo) pour chaque fichier téléversé. S'applique à tous les formats supportés.",
|
||||
"restartWarning": "Les modifications prendront effet après le redémarrage du serveur",
|
||||
"invalidInput": "Entrée invalide",
|
||||
"invalidInputDetail": "Veuillez entrer une taille maximale de téléversement valide en Mo."
|
||||
},
|
||||
"telemetry": {
|
||||
"sectionTitle": "Télémétrie",
|
||||
"enableTelemetry": "Activer la télémétrie",
|
||||
"telemetryDesc": "Aidez à améliorer Booklore en partageant des statistiques d'utilisation anonymes. Ces données permettent aux développeurs de voir quelles fonctionnalités sont les plus utilisées, d'identifier les bugs et de repérer les problèmes de performance afin de savoir sur quoi travailler ensuite. Aucune information personnelle, contenu de livre ou donnée identifiable n'est jamais envoyé. Les données sont envoyées automatiquement au serveur Booklore une fois toutes les 24 heures. C'est totalement sûr et très utile pour les développeurs."
|
||||
},
|
||||
"settingsSaved": "Paramètres enregistrés",
|
||||
"settingsSavedDetail": "Les paramètres ont été enregistrés avec succès !",
|
||||
"settingsError": "Une erreur s'est produite lors de l'enregistrement des paramètres.",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "Animation du bouton de support",
|
||||
"supportButtonAnimationDesc": "",
|
||||
"sectionTitle": "Apparence"
|
||||
}
|
||||
"title": "Préférences globales",
|
||||
"description": "Configurez les paramètres globaux de votre instance Booklore, y compris la gestion des images de couverture, les préférences de recherche et les limites de téléversement de fichiers.",
|
||||
"covers": {
|
||||
"sectionTitle": "Image de couverture des livres",
|
||||
"regenerate": "Régénérer les couvertures",
|
||||
"regenerateBtn": "Régénérer",
|
||||
"regenerateDesc": "Régénère les images de couverture pour tous les livres à partir des couvertures intégrées dans le fichier.",
|
||||
"regenerateStarted": "Régénération des couvertures lancée",
|
||||
"regenerateStartedDetail": "Les couvertures des livres sont en cours de régénération.",
|
||||
"regenerateError": "Échec du lancement de la régénération des couvertures.",
|
||||
"verticalCropping": "Recadrage vertical des couvertures",
|
||||
"verticalCroppingDesc": "Recadrer automatiquement les images extrêmement hautes (comme les webcomics) depuis le haut pour créer des miniatures de couverture utilisables.",
|
||||
"horizontalCropping": "Recadrage horizontal des couvertures",
|
||||
"horizontalCroppingDesc": "Recadrer automatiquement les images extrêmement larges depuis la gauche pour créer des miniatures de couverture utilisables.",
|
||||
"aspectRatio": "Seuil de rapport d'aspect : {{value}}",
|
||||
"aspectRatioDesc": "Les images dont le rapport d'aspect dépasse ce seuil seront recadrées. Une valeur de 2,5 signifie que les images plus de 2,5 fois plus hautes (ou plus larges) que la normale seront recadrées.",
|
||||
"smartCropping": "Recadrage intelligent",
|
||||
"smartCroppingDesc": "Ignorer les régions de couleur uniforme lors de la détermination de l'endroit où recadrer. Concentre l'image de couverture sur le contenu le plus pertinent."
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Recherche et recommandations",
|
||||
"autoBookSearch": "Recherche automatique de livres",
|
||||
"autoBookSearchDesc": "Tente automatiquement la correspondance des métadonnées lorsque le panneau d'informations du livre est ouvert.",
|
||||
"similarBook": "Recommandation de livres similaires",
|
||||
"similarBookDesc": "Active ou désactive les recommandations de livres similaires basées sur votre bibliothèque."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Gestion des fichiers",
|
||||
"maxUploadSize": "Taille max. de téléversement",
|
||||
"maxUploadPlaceholder": "Taille max.",
|
||||
"maxUploadDesc": "Définit la taille maximale autorisée (en Mo) pour chaque fichier téléversé. S'applique à tous les formats supportés.",
|
||||
"restartWarning": "Les modifications prendront effet après le redémarrage du serveur",
|
||||
"invalidInput": "Entrée invalide",
|
||||
"invalidInputDetail": "Veuillez entrer une taille maximale de téléversement valide en Mo."
|
||||
},
|
||||
"settingsSaved": "Paramètres enregistrés",
|
||||
"settingsSavedDetail": "Les paramètres ont été enregistrés avec succès !",
|
||||
"settingsError": "Une erreur s'est produite lors de l'enregistrement des paramètres.",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "Animation du bouton de support",
|
||||
"supportButtonAnimationDesc": "",
|
||||
"sectionTitle": "Apparence"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,47 @@
|
||||
{
|
||||
"title": "Globalne postavke",
|
||||
"description": "Konfigurirajte globalne postavke za vašu Booklore instancu, uključujući rukovanje naslovnicama, preferencije pretraživanja i ograničenja učitavanja datoteka.",
|
||||
"covers": {
|
||||
"sectionTitle": "Naslovna slika knjige",
|
||||
"regenerate": "Regeneriraj naslovnice",
|
||||
"regenerateBtn": "Regeneriraj",
|
||||
"regenerateDesc": "Obnavlja slike naslovnica za sve knjige iz ugrađenih naslovnica u datoteci. Koristite \"Obnovi nedostajuće\" za generiranje naslovnica samo za knjige koje još nemaju naslovnicu.",
|
||||
"regenerateStarted": "Regeneracija naslovnica pokrenuta",
|
||||
"regenerateStartedDetail": "Naslovnice knjiga se regeneriraju.",
|
||||
"regenerateError": "Pokretanje regeneracije naslovnica nije uspjelo.",
|
||||
"verticalCropping": "Vertikalno izrezivanje naslovnica",
|
||||
"verticalCroppingDesc": "Automatski izrezuje iznimno visoke slike (poput web stripova) od vrha kako bi se stvorile upotrebljive minijature naslovnica.",
|
||||
"horizontalCropping": "Horizontalno izrezivanje naslovnica",
|
||||
"horizontalCroppingDesc": "Automatski izrezuje iznimno široke slike s lijeve strane kako bi se stvorile upotrebljive minijature naslovnica.",
|
||||
"aspectRatio": "Prag omjera slike: {{value}}",
|
||||
"aspectRatioDesc": "Slike s omjerima koji premašuju ovaj prag bit će izrezane. Vrijednost 2,5 znači da će slike koje su više od 2,5x više (ili šire) od normalnog biti izrezane.",
|
||||
"smartCropping": "Pametno izrezivanje",
|
||||
"smartCroppingDesc": "Preskoči jednobojne regije pri određivanju mjesta izrezivanja. Fokusira naslovnicu na najrelevantniji sadržaj.",
|
||||
"regenerateAllBtn": "Obnovi sve",
|
||||
"regenerateMissingBtn": "Obnovi nedostajuće"
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Pretraživanje i preporuke",
|
||||
"autoBookSearch": "Automatsko pretraživanje knjiga",
|
||||
"autoBookSearchDesc": "Automatski pokušava pronaći metapodatke kada se otvori panel informacija o knjizi.",
|
||||
"similarBook": "Preporuka sličnih knjiga",
|
||||
"similarBookDesc": "Omogućuje ili onemogućuje preporuke sličnih knjiga na temelju vaše zbirke."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Upravljanje datotekama",
|
||||
"maxUploadSize": "Maks. veličina učitavanja",
|
||||
"maxUploadPlaceholder": "Maks. veličina",
|
||||
"maxUploadDesc": "Definira maksimalnu dopuštenu veličinu (u MB) za svaku prenesenu datoteku. Odnosi se na sve podržane formate datoteka.",
|
||||
"restartWarning": "Promjene će stupiti na snagu nakon ponovnog pokretanja poslužitelja",
|
||||
"invalidInput": "Nevažeći unos",
|
||||
"invalidInputDetail": "Unesite valjanu maksimalnu veličinu učitavanja datoteke u MB."
|
||||
},
|
||||
"telemetry": {
|
||||
"sectionTitle": "Telemetrija",
|
||||
"enableTelemetry": "Omogući telemetriju",
|
||||
"telemetryDesc": "Pomozite poboljšati Booklore dijeljenjem anonimne statistike korištenja. Ovi podaci omogućuju programerima da vide koje se značajke najviše koriste, identificiraju bugove i uoče probleme s performansama kako bi znali na čemu dalje raditi. Nikada se ne šalju osobni podaci, sadržaj knjiga ili bilo koji identifikacijski podaci. Podaci se automatski šalju na Booklore poslužitelj jednom u 24 sata. Potpuno je sigurno i vrlo korisno za programere."
|
||||
},
|
||||
"settingsSaved": "Postavke spremljene",
|
||||
"settingsSavedDetail": "Postavke su uspješno spremljene!",
|
||||
"settingsError": "Došlo je do pogreške prilikom spremanja postavki.",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "Animacija gumba za podršku",
|
||||
"supportButtonAnimationDesc": "Prikaži animirani efekt srca na gumbu za podršku u gornjoj traci. Isključivanjem ove opcije gumb ostaje vidljiv, ali se animacija uklanja.",
|
||||
"sectionTitle": "Izgled"
|
||||
}
|
||||
"title": "Globalne postavke",
|
||||
"description": "Konfigurirajte globalne postavke za vašu Booklore instancu, uključujući rukovanje naslovnicama, preferencije pretraživanja i ograničenja učitavanja datoteka.",
|
||||
"covers": {
|
||||
"sectionTitle": "Naslovna slika knjige",
|
||||
"regenerate": "Regeneriraj naslovnice",
|
||||
"regenerateBtn": "Regeneriraj",
|
||||
"regenerateDesc": "Obnavlja slike naslovnica za sve knjige iz ugrađenih naslovnica u datoteci. Koristite \"Obnovi nedostajuće\" za generiranje naslovnica samo za knjige koje još nemaju naslovnicu.",
|
||||
"regenerateStarted": "Regeneracija naslovnica pokrenuta",
|
||||
"regenerateStartedDetail": "Naslovnice knjiga se regeneriraju.",
|
||||
"regenerateError": "Pokretanje regeneracije naslovnica nije uspjelo.",
|
||||
"verticalCropping": "Vertikalno izrezivanje naslovnica",
|
||||
"verticalCroppingDesc": "Automatski izrezuje iznimno visoke slike (poput web stripova) od vrha kako bi se stvorile upotrebljive minijature naslovnica.",
|
||||
"horizontalCropping": "Horizontalno izrezivanje naslovnica",
|
||||
"horizontalCroppingDesc": "Automatski izrezuje iznimno široke slike s lijeve strane kako bi se stvorile upotrebljive minijature naslovnica.",
|
||||
"aspectRatio": "Prag omjera slike: {{value}}",
|
||||
"aspectRatioDesc": "Slike s omjerima koji premašuju ovaj prag bit će izrezane. Vrijednost 2,5 znači da će slike koje su više od 2,5x više (ili šire) od normalnog biti izrezane.",
|
||||
"smartCropping": "Pametno izrezivanje",
|
||||
"smartCroppingDesc": "Preskoči jednobojne regije pri određivanju mjesta izrezivanja. Fokusira naslovnicu na najrelevantniji sadržaj.",
|
||||
"regenerateAllBtn": "Obnovi sve",
|
||||
"regenerateMissingBtn": "Obnovi nedostajuće"
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Pretraživanje i preporuke",
|
||||
"autoBookSearch": "Automatsko pretraživanje knjiga",
|
||||
"autoBookSearchDesc": "Automatski pokušava pronaći metapodatke kada se otvori panel informacija o knjizi.",
|
||||
"similarBook": "Preporuka sličnih knjiga",
|
||||
"similarBookDesc": "Omogućuje ili onemogućuje preporuke sličnih knjiga na temelju vaše zbirke."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Upravljanje datotekama",
|
||||
"maxUploadSize": "Maks. veličina učitavanja",
|
||||
"maxUploadPlaceholder": "Maks. veličina",
|
||||
"maxUploadDesc": "Definira maksimalnu dopuštenu veličinu (u MB) za svaku prenesenu datoteku. Odnosi se na sve podržane formate datoteka.",
|
||||
"restartWarning": "Promjene će stupiti na snagu nakon ponovnog pokretanja poslužitelja",
|
||||
"invalidInput": "Nevažeći unos",
|
||||
"invalidInputDetail": "Unesite valjanu maksimalnu veličinu učitavanja datoteke u MB."
|
||||
},
|
||||
"settingsSaved": "Postavke spremljene",
|
||||
"settingsSavedDetail": "Postavke su uspješno spremljene!",
|
||||
"settingsError": "Došlo je do pogreške prilikom spremanja postavki.",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "Animacija gumba za podršku",
|
||||
"supportButtonAnimationDesc": "Prikaži animirani efekt srca na gumbu za podršku u gornjoj traci. Isključivanjem ove opcije gumb ostaje vidljiv, ali se animacija uklanja.",
|
||||
"sectionTitle": "Izgled"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,45 @@
|
||||
{
|
||||
"title": "",
|
||||
"description": "",
|
||||
"covers": {
|
||||
"sectionTitle": "",
|
||||
"regenerate": "",
|
||||
"regenerateBtn": "",
|
||||
"regenerateDesc": "",
|
||||
"regenerateStarted": "",
|
||||
"regenerateStartedDetail": "",
|
||||
"regenerateError": "",
|
||||
"verticalCropping": "",
|
||||
"verticalCroppingDesc": "",
|
||||
"horizontalCropping": "",
|
||||
"horizontalCroppingDesc": "",
|
||||
"aspectRatio": "",
|
||||
"aspectRatioDesc": "",
|
||||
"smartCropping": "",
|
||||
"smartCroppingDesc": ""
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "",
|
||||
"autoBookSearch": "",
|
||||
"autoBookSearchDesc": "",
|
||||
"similarBook": "",
|
||||
"similarBookDesc": ""
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "",
|
||||
"maxUploadSize": "",
|
||||
"maxUploadPlaceholder": "",
|
||||
"maxUploadDesc": "",
|
||||
"restartWarning": "",
|
||||
"invalidInput": "",
|
||||
"invalidInputDetail": ""
|
||||
},
|
||||
"telemetry": {
|
||||
"sectionTitle": "",
|
||||
"enableTelemetry": "",
|
||||
"telemetryDesc": ""
|
||||
},
|
||||
"settingsSaved": "",
|
||||
"settingsSavedDetail": "",
|
||||
"settingsError": "",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "",
|
||||
"supportButtonAnimationDesc": "",
|
||||
"sectionTitle": ""
|
||||
}
|
||||
"title": "",
|
||||
"description": "",
|
||||
"covers": {
|
||||
"sectionTitle": "",
|
||||
"regenerate": "",
|
||||
"regenerateBtn": "",
|
||||
"regenerateDesc": "",
|
||||
"regenerateStarted": "",
|
||||
"regenerateStartedDetail": "",
|
||||
"regenerateError": "",
|
||||
"verticalCropping": "",
|
||||
"verticalCroppingDesc": "",
|
||||
"horizontalCropping": "",
|
||||
"horizontalCroppingDesc": "",
|
||||
"aspectRatio": "",
|
||||
"aspectRatioDesc": "",
|
||||
"smartCropping": "",
|
||||
"smartCroppingDesc": ""
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "",
|
||||
"autoBookSearch": "",
|
||||
"autoBookSearchDesc": "",
|
||||
"similarBook": "",
|
||||
"similarBookDesc": ""
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "",
|
||||
"maxUploadSize": "",
|
||||
"maxUploadPlaceholder": "",
|
||||
"maxUploadDesc": "",
|
||||
"restartWarning": "",
|
||||
"invalidInput": "",
|
||||
"invalidInputDetail": ""
|
||||
},
|
||||
"settingsSaved": "",
|
||||
"settingsSavedDetail": "",
|
||||
"settingsError": "",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "",
|
||||
"supportButtonAnimationDesc": "",
|
||||
"sectionTitle": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,45 @@
|
||||
{
|
||||
"title": "Preferensi Global",
|
||||
"description": "",
|
||||
"covers": {
|
||||
"sectionTitle": "Gambar Sampul Buku",
|
||||
"regenerate": "",
|
||||
"regenerateBtn": "",
|
||||
"regenerateDesc": "",
|
||||
"regenerateStarted": "",
|
||||
"regenerateStartedDetail": "",
|
||||
"regenerateError": "",
|
||||
"verticalCropping": "",
|
||||
"verticalCroppingDesc": "",
|
||||
"horizontalCropping": "",
|
||||
"horizontalCroppingDesc": "",
|
||||
"aspectRatio": "",
|
||||
"aspectRatioDesc": "",
|
||||
"smartCropping": "",
|
||||
"smartCroppingDesc": ""
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "",
|
||||
"autoBookSearch": "",
|
||||
"autoBookSearchDesc": "",
|
||||
"similarBook": "",
|
||||
"similarBookDesc": ""
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "",
|
||||
"maxUploadSize": "",
|
||||
"maxUploadPlaceholder": "",
|
||||
"maxUploadDesc": "",
|
||||
"restartWarning": "",
|
||||
"invalidInput": "",
|
||||
"invalidInputDetail": ""
|
||||
},
|
||||
"appearance": {
|
||||
"sectionTitle": "",
|
||||
"supportButtonAnimation": "",
|
||||
"supportButtonAnimationDesc": ""
|
||||
},
|
||||
"telemetry": {
|
||||
"sectionTitle": "",
|
||||
"enableTelemetry": "",
|
||||
"telemetryDesc": ""
|
||||
},
|
||||
"settingsSaved": "",
|
||||
"settingsSavedDetail": "",
|
||||
"settingsError": ""
|
||||
"title": "Preferensi Global",
|
||||
"description": "",
|
||||
"covers": {
|
||||
"sectionTitle": "Gambar Sampul Buku",
|
||||
"regenerate": "",
|
||||
"regenerateBtn": "",
|
||||
"regenerateDesc": "",
|
||||
"regenerateStarted": "",
|
||||
"regenerateStartedDetail": "",
|
||||
"regenerateError": "",
|
||||
"verticalCropping": "",
|
||||
"verticalCroppingDesc": "",
|
||||
"horizontalCropping": "",
|
||||
"horizontalCroppingDesc": "",
|
||||
"aspectRatio": "",
|
||||
"aspectRatioDesc": "",
|
||||
"smartCropping": "",
|
||||
"smartCroppingDesc": ""
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "",
|
||||
"autoBookSearch": "",
|
||||
"autoBookSearchDesc": "",
|
||||
"similarBook": "",
|
||||
"similarBookDesc": ""
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "",
|
||||
"maxUploadSize": "",
|
||||
"maxUploadPlaceholder": "",
|
||||
"maxUploadDesc": "",
|
||||
"restartWarning": "",
|
||||
"invalidInput": "",
|
||||
"invalidInputDetail": ""
|
||||
},
|
||||
"appearance": {
|
||||
"sectionTitle": "",
|
||||
"supportButtonAnimation": "",
|
||||
"supportButtonAnimationDesc": ""
|
||||
},
|
||||
"settingsSaved": "",
|
||||
"settingsSavedDetail": "",
|
||||
"settingsError": ""
|
||||
}
|
||||
|
||||
@@ -1,50 +1,45 @@
|
||||
{
|
||||
"title": "Preferenze globali",
|
||||
"description": "Configura le impostazioni globali per la tua istanza di Booklore, inclusa la gestione delle immagini di copertina, le preferenze di ricerca e i limiti di caricamento file.",
|
||||
"covers": {
|
||||
"sectionTitle": "Immagine di copertina",
|
||||
"regenerate": "Rigenera copertine",
|
||||
"regenerateBtn": "Rigenera",
|
||||
"regenerateDesc": "Rigenera le immagini di copertina per tutti i libri dalle copertine incorporate nei file.",
|
||||
"regenerateStarted": "Rigenerazione copertine avviata",
|
||||
"regenerateStartedDetail": "Le copertine dei libri sono in fase di rigenerazione.",
|
||||
"regenerateError": "Impossibile avviare la rigenerazione delle copertine.",
|
||||
"verticalCropping": "Ritaglio verticale copertina",
|
||||
"verticalCroppingDesc": "Ritaglia automaticamente le immagini estremamente alte (come i web comic) dall'alto per creare miniature di copertina utilizzabili.",
|
||||
"horizontalCropping": "Ritaglio orizzontale copertina",
|
||||
"horizontalCroppingDesc": "Ritaglia automaticamente le immagini estremamente larghe da sinistra per creare miniature di copertina utilizzabili.",
|
||||
"aspectRatio": "Soglia rapporto d'aspetto: {{value}}",
|
||||
"aspectRatioDesc": "Le immagini con rapporti d'aspetto superiori a questa soglia verranno ritagliate. Un valore di 2.5 significa che le immagini più di 2.5 volte più alte (o più larghe) del normale verranno ritagliate.",
|
||||
"smartCropping": "Ritaglio intelligente",
|
||||
"smartCroppingDesc": "Ignora le regioni di colore uniforme quando si determina dove ritagliare. Focalizza l'immagine di copertina sul contenuto più rilevante."
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Ricerca e raccomandazioni",
|
||||
"autoBookSearch": "Ricerca automatica libri",
|
||||
"autoBookSearchDesc": "Tenta automaticamente la corrispondenza dei metadati quando viene aperto il pannello informazioni del libro.",
|
||||
"similarBook": "Raccomandazione libri simili",
|
||||
"similarBookDesc": "Abilita o disabilita le raccomandazioni di libri simili basate sulla tua libreria."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Gestione file",
|
||||
"maxUploadSize": "Dimensione massima caricamento file",
|
||||
"maxUploadPlaceholder": "Dimensione massima",
|
||||
"maxUploadDesc": "Definisce la dimensione massima consentita (in MB) per ogni file caricato. Si applica ai formati EPUB, PDF, CBZ, CBR e CB7.",
|
||||
"restartWarning": "Le modifiche avranno effetto dopo il riavvio del server",
|
||||
"invalidInput": "Input non valido",
|
||||
"invalidInputDetail": "Inserisci una dimensione massima valida per il caricamento file in MB."
|
||||
},
|
||||
"telemetry": {
|
||||
"sectionTitle": "Telemetria",
|
||||
"enableTelemetry": "Abilita telemetria",
|
||||
"telemetryDesc": "Aiuta a migliorare Booklore condividendo statistiche d'uso anonime. Questi dati permettono agli sviluppatori di vedere quali funzionalità sono più utilizzate, identificare bug e individuare problemi di prestazioni per sapere su cosa lavorare. Nessuna informazione personale, contenuto dei libri o dato identificabile viene mai inviato. I dati vengono inviati automaticamente al server Booklore una volta ogni 24 ore. È completamente sicuro e molto utile per gli sviluppatori."
|
||||
},
|
||||
"settingsSaved": "Impostazioni salvate",
|
||||
"settingsSavedDetail": "Le impostazioni sono state salvate con successo!",
|
||||
"settingsError": "Si è verificato un errore durante il salvataggio delle impostazioni.",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "",
|
||||
"supportButtonAnimationDesc": "",
|
||||
"sectionTitle": ""
|
||||
}
|
||||
"title": "Preferenze globali",
|
||||
"description": "Configura le impostazioni globali per la tua istanza di Booklore, inclusa la gestione delle immagini di copertina, le preferenze di ricerca e i limiti di caricamento file.",
|
||||
"covers": {
|
||||
"sectionTitle": "Immagine di copertina",
|
||||
"regenerate": "Rigenera copertine",
|
||||
"regenerateBtn": "Rigenera",
|
||||
"regenerateDesc": "Rigenera le immagini di copertina per tutti i libri dalle copertine incorporate nei file.",
|
||||
"regenerateStarted": "Rigenerazione copertine avviata",
|
||||
"regenerateStartedDetail": "Le copertine dei libri sono in fase di rigenerazione.",
|
||||
"regenerateError": "Impossibile avviare la rigenerazione delle copertine.",
|
||||
"verticalCropping": "Ritaglio verticale copertina",
|
||||
"verticalCroppingDesc": "Ritaglia automaticamente le immagini estremamente alte (come i web comic) dall'alto per creare miniature di copertina utilizzabili.",
|
||||
"horizontalCropping": "Ritaglio orizzontale copertina",
|
||||
"horizontalCroppingDesc": "Ritaglia automaticamente le immagini estremamente larghe da sinistra per creare miniature di copertina utilizzabili.",
|
||||
"aspectRatio": "Soglia rapporto d'aspetto: {{value}}",
|
||||
"aspectRatioDesc": "Le immagini con rapporti d'aspetto superiori a questa soglia verranno ritagliate. Un valore di 2.5 significa che le immagini più di 2.5 volte più alte (o più larghe) del normale verranno ritagliate.",
|
||||
"smartCropping": "Ritaglio intelligente",
|
||||
"smartCroppingDesc": "Ignora le regioni di colore uniforme quando si determina dove ritagliare. Focalizza l'immagine di copertina sul contenuto più rilevante."
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Ricerca e raccomandazioni",
|
||||
"autoBookSearch": "Ricerca automatica libri",
|
||||
"autoBookSearchDesc": "Tenta automaticamente la corrispondenza dei metadati quando viene aperto il pannello informazioni del libro.",
|
||||
"similarBook": "Raccomandazione libri simili",
|
||||
"similarBookDesc": "Abilita o disabilita le raccomandazioni di libri simili basate sulla tua libreria."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Gestione file",
|
||||
"maxUploadSize": "Dimensione massima caricamento file",
|
||||
"maxUploadPlaceholder": "Dimensione massima",
|
||||
"maxUploadDesc": "Definisce la dimensione massima consentita (in MB) per ogni file caricato. Si applica ai formati EPUB, PDF, CBZ, CBR e CB7.",
|
||||
"restartWarning": "Le modifiche avranno effetto dopo il riavvio del server",
|
||||
"invalidInput": "Input non valido",
|
||||
"invalidInputDetail": "Inserisci una dimensione massima valida per il caricamento file in MB."
|
||||
},
|
||||
"settingsSaved": "Impostazioni salvate",
|
||||
"settingsSavedDetail": "Le impostazioni sono state salvate con successo!",
|
||||
"settingsError": "Si è verificato un errore durante il salvataggio delle impostazioni.",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "",
|
||||
"supportButtonAnimationDesc": "",
|
||||
"sectionTitle": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,45 @@
|
||||
{
|
||||
"title": "グローバル設定",
|
||||
"description": "カバー画像の処理、検索設定、ファイルアップロード制限など、Bookloreインスタンスのグローバル設定を構成します。",
|
||||
"covers": {
|
||||
"sectionTitle": "ブックカバー画像",
|
||||
"regenerate": "カバーを再生成",
|
||||
"regenerateBtn": "再生成",
|
||||
"regenerateDesc": "すべての本のカバー画像をファイルに埋め込まれたカバーから再生成します。",
|
||||
"regenerateStarted": "カバー再生成を開始しました",
|
||||
"regenerateStartedDetail": "ブックカバーを再生成中です。",
|
||||
"regenerateError": "カバー再生成の開始に失敗しました。",
|
||||
"verticalCropping": "縦方向カバートリミング",
|
||||
"verticalCroppingDesc": "非常に縦長の画像(Webコミックなど)を上部から自動的にトリミングして、使いやすいカバーサムネイルを作成します。",
|
||||
"horizontalCropping": "横方向カバートリミング",
|
||||
"horizontalCroppingDesc": "非常に横長の画像を左側から自動的にトリミングして、使いやすいカバーサムネイルを作成します。",
|
||||
"aspectRatio": "アスペクト比しきい値: {{value}}",
|
||||
"aspectRatioDesc": "このしきい値を超えるアスペクト比の画像がトリミングされます。2.5の値は、通常の2.5倍以上縦長(または横長)の画像がトリミングされることを意味します。",
|
||||
"smartCropping": "スマートトリミング",
|
||||
"smartCroppingDesc": "トリミング位置を決定する際に均一な色の領域をスキップします。カバー画像を最も関連性の高いコンテンツに焦点を当てます。"
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "検索とおすすめ",
|
||||
"autoBookSearch": "自動ブック検索",
|
||||
"autoBookSearchDesc": "本の情報パネルを開いたときに、自動的にメタデータマッチングを試みます。",
|
||||
"similarBook": "類似本のおすすめ",
|
||||
"similarBookDesc": "ライブラリに基づいた類似本のおすすめを有効または無効にします。"
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "ファイル管理",
|
||||
"maxUploadSize": "最大ファイルアップロードサイズ",
|
||||
"maxUploadPlaceholder": "最大サイズ",
|
||||
"maxUploadDesc": "アップロードファイルの最大許容サイズ(MB単位)を定義します。EPUB、PDF、CBZ、CBR、CB7形式に適用されます。",
|
||||
"restartWarning": "変更はサーバーの再起動後に有効になります",
|
||||
"invalidInput": "無効な入力",
|
||||
"invalidInputDetail": "有効な最大ファイルアップロードサイズ(MB)を入力してください。"
|
||||
},
|
||||
"telemetry": {
|
||||
"sectionTitle": "テレメトリ",
|
||||
"enableTelemetry": "テレメトリを有効化",
|
||||
"telemetryDesc": "匿名の使用統計を共有してBookloreの改善にご協力ください。このデータにより開発者は、最も使用されている機能の把握、バグの特定、パフォーマンスの問題の発見が可能になり、次に取り組むべきことがわかります。個人情報、本の内容、その他の識別可能なデータは一切送信されません。データは24時間ごとに自動的にBookloreサーバーに送信されます。完全に安全で、開発者にとって非常に有用です。"
|
||||
},
|
||||
"settingsSaved": "設定を保存しました",
|
||||
"settingsSavedDetail": "設定が正常に保存されました!",
|
||||
"settingsError": "設定の保存中にエラーが発生しました。",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "",
|
||||
"supportButtonAnimationDesc": "",
|
||||
"sectionTitle": ""
|
||||
}
|
||||
"title": "グローバル設定",
|
||||
"description": "カバー画像の処理、検索設定、ファイルアップロード制限など、Bookloreインスタンスのグローバル設定を構成します。",
|
||||
"covers": {
|
||||
"sectionTitle": "ブックカバー画像",
|
||||
"regenerate": "カバーを再生成",
|
||||
"regenerateBtn": "再生成",
|
||||
"regenerateDesc": "すべての本のカバー画像をファイルに埋め込まれたカバーから再生成します。",
|
||||
"regenerateStarted": "カバー再生成を開始しました",
|
||||
"regenerateStartedDetail": "ブックカバーを再生成中です。",
|
||||
"regenerateError": "カバー再生成の開始に失敗しました。",
|
||||
"verticalCropping": "縦方向カバートリミング",
|
||||
"verticalCroppingDesc": "非常に縦長の画像(Webコミックなど)を上部から自動的にトリミングして、使いやすいカバーサムネイルを作成します。",
|
||||
"horizontalCropping": "横方向カバートリミング",
|
||||
"horizontalCroppingDesc": "非常に横長の画像を左側から自動的にトリミングして、使いやすいカバーサムネイルを作成します。",
|
||||
"aspectRatio": "アスペクト比しきい値: {{value}}",
|
||||
"aspectRatioDesc": "このしきい値を超えるアスペクト比の画像がトリミングされます。2.5の値は、通常の2.5倍以上縦長(または横長)の画像がトリミングされることを意味します。",
|
||||
"smartCropping": "スマートトリミング",
|
||||
"smartCroppingDesc": "トリミング位置を決定する際に均一な色の領域をスキップします。カバー画像を最も関連性の高いコンテンツに焦点を当てます。"
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "検索とおすすめ",
|
||||
"autoBookSearch": "自動ブック検索",
|
||||
"autoBookSearchDesc": "本の情報パネルを開いたときに、自動的にメタデータマッチングを試みます。",
|
||||
"similarBook": "類似本のおすすめ",
|
||||
"similarBookDesc": "ライブラリに基づいた類似本のおすすめを有効または無効にします。"
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "ファイル管理",
|
||||
"maxUploadSize": "最大ファイルアップロードサイズ",
|
||||
"maxUploadPlaceholder": "最大サイズ",
|
||||
"maxUploadDesc": "アップロードファイルの最大許容サイズ(MB単位)を定義します。EPUB、PDF、CBZ、CBR、CB7形式に適用されます。",
|
||||
"restartWarning": "変更はサーバーの再起動後に有効になります",
|
||||
"invalidInput": "無効な入力",
|
||||
"invalidInputDetail": "有効な最大ファイルアップロードサイズ(MB)を入力してください。"
|
||||
},
|
||||
"settingsSaved": "設定を保存しました",
|
||||
"settingsSavedDetail": "設定が正常に保存されました!",
|
||||
"settingsError": "設定の保存中にエラーが発生しました。",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "",
|
||||
"supportButtonAnimationDesc": "",
|
||||
"sectionTitle": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,45 @@
|
||||
{
|
||||
"title": "Globale voorkeuren",
|
||||
"description": "Configureer globale instellingen voor uw Booklore-instantie, waaronder omslagafbeeldingbeheer, zoekvoorkeuren en uploadlimieten.",
|
||||
"covers": {
|
||||
"sectionTitle": "Boekomslagafbeelding",
|
||||
"regenerate": "Omslagen opnieuw genereren",
|
||||
"regenerateBtn": "Opnieuw genereren",
|
||||
"regenerateDesc": "",
|
||||
"regenerateStarted": "Omslaggeneratie gestart",
|
||||
"regenerateStartedDetail": "Boekomslagen worden opnieuw gegenereerd.",
|
||||
"regenerateError": "Kon omslaggeneratie niet starten.",
|
||||
"verticalCropping": "Verticaal omslagbijsnijden",
|
||||
"verticalCroppingDesc": "Snijdt automatisch extreem hoge afbeeldingen (zoals webcomics) vanaf de bovenkant bij om bruikbare omslagminiaturen te maken.",
|
||||
"horizontalCropping": "Horizontaal omslagbijsnijden",
|
||||
"horizontalCroppingDesc": "Snijdt automatisch extreem brede afbeeldingen vanaf links bij om bruikbare omslagminiaturen te maken.",
|
||||
"aspectRatio": "Beeldverhoudingsdrempel: {{value}}",
|
||||
"aspectRatioDesc": "Afbeeldingen met een beeldverhouding boven deze drempel worden bijgesneden. Een waarde van 2,5 betekent dat afbeeldingen meer dan 2,5x hoger (of breder) dan normaal worden bijgesneden.",
|
||||
"smartCropping": "Slim bijsnijden",
|
||||
"smartCroppingDesc": "Sla uniforme kleurgebieden over bij het bepalen waar bijgesneden moet worden. Richt de omslagafbeelding op de meest relevante inhoud."
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Zoeken en aanbevelingen",
|
||||
"autoBookSearch": "Automatisch boek zoeken",
|
||||
"autoBookSearchDesc": "Probeert automatisch metadata te matchen wanneer het boekinformatiepaneel wordt geopend.",
|
||||
"similarBook": "Vergelijkbare boekaanbeveling",
|
||||
"similarBookDesc": "Schakelt vergelijkbare boekaanbevelingen in of uit op basis van uw bibliotheek."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Bestandsbeheer",
|
||||
"maxUploadSize": "Max. uploadgrootte",
|
||||
"maxUploadPlaceholder": "Max. grootte",
|
||||
"maxUploadDesc": "Bepaalt de maximaal toegestane grootte (in MB) per geüpload bestand. Van toepassing op alle ondersteunde bestandsformaten.",
|
||||
"restartWarning": "Wijzigingen worden van kracht na het herstarten van de server",
|
||||
"invalidInput": "Ongeldige invoer",
|
||||
"invalidInputDetail": "Voer een geldige maximale uploadgrootte in MB in."
|
||||
},
|
||||
"telemetry": {
|
||||
"sectionTitle": "Telemetrie",
|
||||
"enableTelemetry": "Telemetrie inschakelen",
|
||||
"telemetryDesc": "Help Booklore te verbeteren door anonieme gebruiksstatistieken te delen. Deze gegevens laten de ontwikkelaars zien welke functies het meest worden gebruikt, bugs identificeren en prestatieproblemen opsporen zodat ze weten waar ze aan moeten werken. Er worden nooit persoonlijke gegevens, boekinhoud of identificeerbare gegevens verzonden. Gegevens worden automatisch elke 24 uur naar de Booklore-server verzonden. Het is volledig veilig en zeer nuttig voor de ontwikkelaars."
|
||||
},
|
||||
"settingsSaved": "Instellingen opgeslagen",
|
||||
"settingsSavedDetail": "De instellingen zijn succesvol opgeslagen!",
|
||||
"settingsError": "Er is een fout opgetreden bij het opslaan van de instellingen.",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "",
|
||||
"supportButtonAnimationDesc": "",
|
||||
"sectionTitle": ""
|
||||
}
|
||||
"title": "Globale voorkeuren",
|
||||
"description": "Configureer globale instellingen voor uw Booklore-instantie, waaronder omslagafbeeldingbeheer, zoekvoorkeuren en uploadlimieten.",
|
||||
"covers": {
|
||||
"sectionTitle": "Boekomslagafbeelding",
|
||||
"regenerate": "Omslagen opnieuw genereren",
|
||||
"regenerateBtn": "Opnieuw genereren",
|
||||
"regenerateDesc": "",
|
||||
"regenerateStarted": "Omslaggeneratie gestart",
|
||||
"regenerateStartedDetail": "Boekomslagen worden opnieuw gegenereerd.",
|
||||
"regenerateError": "Kon omslaggeneratie niet starten.",
|
||||
"verticalCropping": "Verticaal omslagbijsnijden",
|
||||
"verticalCroppingDesc": "Snijdt automatisch extreem hoge afbeeldingen (zoals webcomics) vanaf de bovenkant bij om bruikbare omslagminiaturen te maken.",
|
||||
"horizontalCropping": "Horizontaal omslagbijsnijden",
|
||||
"horizontalCroppingDesc": "Snijdt automatisch extreem brede afbeeldingen vanaf links bij om bruikbare omslagminiaturen te maken.",
|
||||
"aspectRatio": "Beeldverhoudingsdrempel: {{value}}",
|
||||
"aspectRatioDesc": "Afbeeldingen met een beeldverhouding boven deze drempel worden bijgesneden. Een waarde van 2,5 betekent dat afbeeldingen meer dan 2,5x hoger (of breder) dan normaal worden bijgesneden.",
|
||||
"smartCropping": "Slim bijsnijden",
|
||||
"smartCroppingDesc": "Sla uniforme kleurgebieden over bij het bepalen waar bijgesneden moet worden. Richt de omslagafbeelding op de meest relevante inhoud."
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Zoeken en aanbevelingen",
|
||||
"autoBookSearch": "Automatisch boek zoeken",
|
||||
"autoBookSearchDesc": "Probeert automatisch metadata te matchen wanneer het boekinformatiepaneel wordt geopend.",
|
||||
"similarBook": "Vergelijkbare boekaanbeveling",
|
||||
"similarBookDesc": "Schakelt vergelijkbare boekaanbevelingen in of uit op basis van uw bibliotheek."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Bestandsbeheer",
|
||||
"maxUploadSize": "Max. uploadgrootte",
|
||||
"maxUploadPlaceholder": "Max. grootte",
|
||||
"maxUploadDesc": "Bepaalt de maximaal toegestane grootte (in MB) per geüpload bestand. Van toepassing op alle ondersteunde bestandsformaten.",
|
||||
"restartWarning": "Wijzigingen worden van kracht na het herstarten van de server",
|
||||
"invalidInput": "Ongeldige invoer",
|
||||
"invalidInputDetail": "Voer een geldige maximale uploadgrootte in MB in."
|
||||
},
|
||||
"settingsSaved": "Instellingen opgeslagen",
|
||||
"settingsSavedDetail": "De instellingen zijn succesvol opgeslagen!",
|
||||
"settingsError": "Er is een fout opgetreden bij het opslaan van de instellingen.",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "",
|
||||
"supportButtonAnimationDesc": "",
|
||||
"sectionTitle": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,45 @@
|
||||
{
|
||||
"title": "Preferencje globalne",
|
||||
"description": "Skonfiguruj globalne ustawienia instancji Booklore, w tym obsługę obrazów okładek, preferencje wyszukiwania i limity przesyłania plików.",
|
||||
"covers": {
|
||||
"sectionTitle": "Obraz okładki książki",
|
||||
"regenerate": "Ponownie wygeneruj okładki",
|
||||
"regenerateBtn": "Ponownie wygeneruj",
|
||||
"regenerateDesc": "Ponownie generuje obrazy okładek dla wszystkich książek z osadzonych okładek w plikach.",
|
||||
"regenerateStarted": "Rozpoczęto ponowne generowanie okładek",
|
||||
"regenerateStartedDetail": "Okładki książek są ponownie generowane.",
|
||||
"regenerateError": "Nie udało się rozpocząć ponownego generowania okładek.",
|
||||
"verticalCropping": "Pionowe przycinanie okładek",
|
||||
"verticalCroppingDesc": "Automatycznie przycinaj wyjątkowo wysokie obrazy (jak komiksy webowe) od góry, aby utworzyć użyteczne miniaturki okładek.",
|
||||
"horizontalCropping": "Poziome przycinanie okładek",
|
||||
"horizontalCroppingDesc": "Automatycznie przycinaj wyjątkowo szerokie obrazy od lewej, aby utworzyć użyteczne miniaturki okładek.",
|
||||
"aspectRatio": "Próg proporcji: {{value}}",
|
||||
"aspectRatioDesc": "Obrazy o proporcjach przekraczających ten próg zostaną przycięte. Wartość 2,5 oznacza, że obrazy ponad 2,5x wyższe (lub szersze) niż normalne zostaną przycięte.",
|
||||
"smartCropping": "Inteligentne przycinanie",
|
||||
"smartCroppingDesc": "Pomijaj jednokolorowe regiony przy określaniu miejsca przycinania. Skupia obraz okładki na najbardziej istotnej treści."
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Wyszukiwanie i rekomendacje",
|
||||
"autoBookSearch": "Automatyczne wyszukiwanie książek",
|
||||
"autoBookSearchDesc": "Automatycznie próbuje dopasować metadane po otwarciu panelu informacji o książce.",
|
||||
"similarBook": "Rekomendacje podobnych książek",
|
||||
"similarBookDesc": "Włącza lub wyłącza rekomendacje podobnych książek na podstawie Twojej biblioteki."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Zarządzanie plikami",
|
||||
"maxUploadSize": "Maks. rozmiar przesyłanego pliku",
|
||||
"maxUploadPlaceholder": "Maks. rozmiar",
|
||||
"maxUploadDesc": "Określa maksymalny dozwolony rozmiar (w MB) każdego przesyłanego pliku. Dotyczy formatów EPUB, PDF, CBZ, CBR i CB7.",
|
||||
"restartWarning": "Zmiany zostaną zastosowane po ponownym uruchomieniu serwera",
|
||||
"invalidInput": "Nieprawidłowe dane",
|
||||
"invalidInputDetail": "Proszę wprowadzić prawidłowy maksymalny rozmiar przesyłanego pliku w MB."
|
||||
},
|
||||
"telemetry": {
|
||||
"sectionTitle": "Telemetria",
|
||||
"enableTelemetry": "Włącz telemetrię",
|
||||
"telemetryDesc": "Pomóż ulepszyć Booklore, udostępniając anonimowe statystyki użytkowania. Te dane pozwalają deweloperom zobaczyć, które funkcje są najczęściej używane, identyfikować błędy i wykrywać problemy z wydajnością, aby wiedzieli, nad czym pracować dalej. Żadne dane osobowe, treści książek ani żadne identyfikowalne dane nigdy nie są wysyłane. Dane są automatycznie wysyłane na serwer Booklore raz na 24 godziny. Jest to całkowicie bezpieczne i bardzo pomocne dla deweloperów."
|
||||
},
|
||||
"settingsSaved": "Ustawienia zapisane",
|
||||
"settingsSavedDetail": "Ustawienia zostały pomyślnie zapisane!",
|
||||
"settingsError": "Wystąpił błąd podczas zapisywania ustawień.",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "",
|
||||
"supportButtonAnimationDesc": "",
|
||||
"sectionTitle": ""
|
||||
}
|
||||
"title": "Preferencje globalne",
|
||||
"description": "Skonfiguruj globalne ustawienia instancji Booklore, w tym obsługę obrazów okładek, preferencje wyszukiwania i limity przesyłania plików.",
|
||||
"covers": {
|
||||
"sectionTitle": "Obraz okładki książki",
|
||||
"regenerate": "Ponownie wygeneruj okładki",
|
||||
"regenerateBtn": "Ponownie wygeneruj",
|
||||
"regenerateDesc": "Ponownie generuje obrazy okładek dla wszystkich książek z osadzonych okładek w plikach.",
|
||||
"regenerateStarted": "Rozpoczęto ponowne generowanie okładek",
|
||||
"regenerateStartedDetail": "Okładki książek są ponownie generowane.",
|
||||
"regenerateError": "Nie udało się rozpocząć ponownego generowania okładek.",
|
||||
"verticalCropping": "Pionowe przycinanie okładek",
|
||||
"verticalCroppingDesc": "Automatycznie przycinaj wyjątkowo wysokie obrazy (jak komiksy webowe) od góry, aby utworzyć użyteczne miniaturki okładek.",
|
||||
"horizontalCropping": "Poziome przycinanie okładek",
|
||||
"horizontalCroppingDesc": "Automatycznie przycinaj wyjątkowo szerokie obrazy od lewej, aby utworzyć użyteczne miniaturki okładek.",
|
||||
"aspectRatio": "Próg proporcji: {{value}}",
|
||||
"aspectRatioDesc": "Obrazy o proporcjach przekraczających ten próg zostaną przycięte. Wartość 2,5 oznacza, że obrazy ponad 2,5x wyższe (lub szersze) niż normalne zostaną przycięte.",
|
||||
"smartCropping": "Inteligentne przycinanie",
|
||||
"smartCroppingDesc": "Pomijaj jednokolorowe regiony przy określaniu miejsca przycinania. Skupia obraz okładki na najbardziej istotnej treści."
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Wyszukiwanie i rekomendacje",
|
||||
"autoBookSearch": "Automatyczne wyszukiwanie książek",
|
||||
"autoBookSearchDesc": "Automatycznie próbuje dopasować metadane po otwarciu panelu informacji o książce.",
|
||||
"similarBook": "Rekomendacje podobnych książek",
|
||||
"similarBookDesc": "Włącza lub wyłącza rekomendacje podobnych książek na podstawie Twojej biblioteki."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Zarządzanie plikami",
|
||||
"maxUploadSize": "Maks. rozmiar przesyłanego pliku",
|
||||
"maxUploadPlaceholder": "Maks. rozmiar",
|
||||
"maxUploadDesc": "Określa maksymalny dozwolony rozmiar (w MB) każdego przesyłanego pliku. Dotyczy formatów EPUB, PDF, CBZ, CBR i CB7.",
|
||||
"restartWarning": "Zmiany zostaną zastosowane po ponownym uruchomieniu serwera",
|
||||
"invalidInput": "Nieprawidłowe dane",
|
||||
"invalidInputDetail": "Proszę wprowadzić prawidłowy maksymalny rozmiar przesyłanego pliku w MB."
|
||||
},
|
||||
"settingsSaved": "Ustawienia zapisane",
|
||||
"settingsSavedDetail": "Ustawienia zostały pomyślnie zapisane!",
|
||||
"settingsError": "Wystąpił błąd podczas zapisywania ustawień.",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "",
|
||||
"supportButtonAnimationDesc": "",
|
||||
"sectionTitle": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,45 @@
|
||||
{
|
||||
"title": "Preferências Globais",
|
||||
"description": "Configure definições globais para sua instância do Booklore, incluindo tratamento de imagens de capa, preferências de pesquisa e limites de envio de arquivos.",
|
||||
"covers": {
|
||||
"sectionTitle": "Imagem de Capa do Livro",
|
||||
"regenerate": "Regenerar Capas",
|
||||
"regenerateBtn": "Regenerar",
|
||||
"regenerateDesc": "Regenera as imagens de capa de todos os livros a partir das capas incorporadas nos arquivos.",
|
||||
"regenerateStarted": "Regeneração de Capas Iniciada",
|
||||
"regenerateStartedDetail": "As capas dos livros estão sendo regeneradas.",
|
||||
"regenerateError": "Falha ao iniciar a regeneração de capas.",
|
||||
"verticalCropping": "Corte Vertical de Capa",
|
||||
"verticalCroppingDesc": "Cortar automaticamente imagens extremamente altas (como web comics) a partir do topo para criar miniaturas de capa utilizáveis.",
|
||||
"horizontalCropping": "Corte Horizontal de Capa",
|
||||
"horizontalCroppingDesc": "Cortar automaticamente imagens extremamente largas a partir da esquerda para criar miniaturas de capa utilizáveis.",
|
||||
"aspectRatio": "Limiar de Proporção: {{value}}",
|
||||
"aspectRatioDesc": "Imagens com proporções que excedem este limiar serão cortadas. Um valor de 2.5 significa que imagens mais de 2.5x mais altas (ou mais largas) que o normal serão cortadas.",
|
||||
"smartCropping": "Corte Inteligente",
|
||||
"smartCroppingDesc": "Ignorar regiões de cor uniforme ao determinar onde cortar. Foca a imagem da capa no conteúdo mais relevante."
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Pesquisa e Recomendações",
|
||||
"autoBookSearch": "Pesquisa Automática de Livro",
|
||||
"autoBookSearchDesc": "Tenta automaticamente a correspondência de metadados quando o painel de informações do livro é aberto.",
|
||||
"similarBook": "Recomendação de Livros Similares",
|
||||
"similarBookDesc": "Ativa ou desativa recomendações de livros similares baseadas na sua biblioteca."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Gerenciamento de Arquivos",
|
||||
"maxUploadSize": "Tamanho Máximo de Upload de Arquivo",
|
||||
"maxUploadPlaceholder": "Tamanho máximo",
|
||||
"maxUploadDesc": "Define o tamanho máximo permitido (em MB) para cada arquivo enviado. Aplica-se aos formatos EPUB, PDF, CBZ, CBR e CB7.",
|
||||
"restartWarning": "As alterações terão efeito após reiniciar o servidor",
|
||||
"invalidInput": "Entrada Inválida",
|
||||
"invalidInputDetail": "Digite um tamanho máximo de upload de arquivo válido em MB."
|
||||
},
|
||||
"telemetry": {
|
||||
"sectionTitle": "Telemetria",
|
||||
"enableTelemetry": "Ativar Telemetria",
|
||||
"telemetryDesc": "Ajude a melhorar o Booklore compartilhando estatísticas de uso anônimas. Esses dados permitem que os desenvolvedores vejam quais recursos são mais usados, identifiquem bugs e detectem problemas de desempenho para saberem no que trabalhar em seguida. Nenhuma informação pessoal, conteúdo de livros ou dados identificáveis são enviados. Os dados são enviados automaticamente ao servidor do Booklore uma vez a cada 24 horas. É completamente seguro e muito útil para os desenvolvedores."
|
||||
},
|
||||
"settingsSaved": "Configurações Salvas",
|
||||
"settingsSavedDetail": "As configurações foram salvas com sucesso!",
|
||||
"settingsError": "Houve um erro ao salvar as configurações.",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "",
|
||||
"supportButtonAnimationDesc": "",
|
||||
"sectionTitle": ""
|
||||
}
|
||||
"title": "Preferências Globais",
|
||||
"description": "Configure definições globais para sua instância do Booklore, incluindo tratamento de imagens de capa, preferências de pesquisa e limites de envio de arquivos.",
|
||||
"covers": {
|
||||
"sectionTitle": "Imagem de Capa do Livro",
|
||||
"regenerate": "Regenerar Capas",
|
||||
"regenerateBtn": "Regenerar",
|
||||
"regenerateDesc": "Regenera as imagens de capa de todos os livros a partir das capas incorporadas nos arquivos.",
|
||||
"regenerateStarted": "Regeneração de Capas Iniciada",
|
||||
"regenerateStartedDetail": "As capas dos livros estão sendo regeneradas.",
|
||||
"regenerateError": "Falha ao iniciar a regeneração de capas.",
|
||||
"verticalCropping": "Corte Vertical de Capa",
|
||||
"verticalCroppingDesc": "Cortar automaticamente imagens extremamente altas (como web comics) a partir do topo para criar miniaturas de capa utilizáveis.",
|
||||
"horizontalCropping": "Corte Horizontal de Capa",
|
||||
"horizontalCroppingDesc": "Cortar automaticamente imagens extremamente largas a partir da esquerda para criar miniaturas de capa utilizáveis.",
|
||||
"aspectRatio": "Limiar de Proporção: {{value}}",
|
||||
"aspectRatioDesc": "Imagens com proporções que excedem este limiar serão cortadas. Um valor de 2.5 significa que imagens mais de 2.5x mais altas (ou mais largas) que o normal serão cortadas.",
|
||||
"smartCropping": "Corte Inteligente",
|
||||
"smartCroppingDesc": "Ignorar regiões de cor uniforme ao determinar onde cortar. Foca a imagem da capa no conteúdo mais relevante."
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Pesquisa e Recomendações",
|
||||
"autoBookSearch": "Pesquisa Automática de Livro",
|
||||
"autoBookSearchDesc": "Tenta automaticamente a correspondência de metadados quando o painel de informações do livro é aberto.",
|
||||
"similarBook": "Recomendação de Livros Similares",
|
||||
"similarBookDesc": "Ativa ou desativa recomendações de livros similares baseadas na sua biblioteca."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Gerenciamento de Arquivos",
|
||||
"maxUploadSize": "Tamanho Máximo de Upload de Arquivo",
|
||||
"maxUploadPlaceholder": "Tamanho máximo",
|
||||
"maxUploadDesc": "Define o tamanho máximo permitido (em MB) para cada arquivo enviado. Aplica-se aos formatos EPUB, PDF, CBZ, CBR e CB7.",
|
||||
"restartWarning": "As alterações terão efeito após reiniciar o servidor",
|
||||
"invalidInput": "Entrada Inválida",
|
||||
"invalidInputDetail": "Digite um tamanho máximo de upload de arquivo válido em MB."
|
||||
},
|
||||
"settingsSaved": "Configurações Salvas",
|
||||
"settingsSavedDetail": "As configurações foram salvas com sucesso!",
|
||||
"settingsError": "Houve um erro ao salvar as configurações.",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "",
|
||||
"supportButtonAnimationDesc": "",
|
||||
"sectionTitle": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,45 @@
|
||||
{
|
||||
"title": "Глобальные настройки",
|
||||
"description": "Настройте глобальные параметры Вашего экземпляра Booklore, включая обработку обложек, параметры поиска и ограничения загрузки файлов.",
|
||||
"covers": {
|
||||
"sectionTitle": "Обложки книг",
|
||||
"regenerate": "Перегенерация обложек",
|
||||
"regenerateBtn": "Перегенерировать",
|
||||
"regenerateDesc": "Перегенерирует обложки для всех книг из встроенных обложек в файлах.",
|
||||
"regenerateStarted": "Перегенерация обложек запущена",
|
||||
"regenerateStartedDetail": "Обложки книг перегенерируются.",
|
||||
"regenerateError": "Не удалось запустить перегенерацию обложек.",
|
||||
"verticalCropping": "Вертикальная обрезка обложек",
|
||||
"verticalCroppingDesc": "Автоматически обрезать чрезмерно высокие изображения (как веб-комиксы) сверху для создания пригодных миниатюр обложек.",
|
||||
"horizontalCropping": "Горизонтальная обрезка обложек",
|
||||
"horizontalCroppingDesc": "Автоматически обрезать чрезмерно широкие изображения слева для создания пригодных миниатюр обложек.",
|
||||
"aspectRatio": "Порог соотношения сторон: {{value}}",
|
||||
"aspectRatioDesc": "Изображения, соотношение сторон которых превышает этот порог, будут обрезаны. Значение 2.5 означает, что изображения более чем в 2.5 раза выше (или шире) нормы будут обрезаны.",
|
||||
"smartCropping": "Умная обрезка",
|
||||
"smartCroppingDesc": "Пропускать однотонные области при определении места обрезки. Фокусирует обложку на наиболее значимом содержании."
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Поиск и рекомендации",
|
||||
"autoBookSearch": "Автоматический поиск книг",
|
||||
"autoBookSearchDesc": "Автоматически пытается сопоставить метаданные при открытии панели информации о книге.",
|
||||
"similarBook": "Рекомендации похожих книг",
|
||||
"similarBookDesc": "Включает или отключает рекомендации похожих книг на основе Вашей библиотеки."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Управление файлами",
|
||||
"maxUploadSize": "Максимальный размер загружаемого файла",
|
||||
"maxUploadPlaceholder": "Макс. размер",
|
||||
"maxUploadDesc": "Определяет максимально допустимый размер (в МБ) для каждого загружаемого файла. Применяется к форматам EPUB, PDF, CBZ, CBR и CB7.",
|
||||
"restartWarning": "Изменения вступят в силу после перезапуска сервера",
|
||||
"invalidInput": "Неверные данные",
|
||||
"invalidInputDetail": "Пожалуйста, введите корректный максимальный размер файла в МБ."
|
||||
},
|
||||
"telemetry": {
|
||||
"sectionTitle": "Телеметрия",
|
||||
"enableTelemetry": "Включить телеметрию",
|
||||
"telemetryDesc": "Помогите улучшить Booklore, делясь анонимной статистикой использования. Эти данные позволяют разработчикам видеть, какие функции наиболее востребованы, выявлять ошибки и обнаруживать проблемы с производительностью, чтобы знать, над чем работать дальше. Личная информация, содержание книг и любые идентифицирующие данные никогда не передаются. Данные отправляются на сервер Booklore автоматически раз в 24 часа. Это полностью безопасно и очень полезно для разработчиков."
|
||||
},
|
||||
"settingsSaved": "Настройки сохранены",
|
||||
"settingsSavedDetail": "Настройки были успешно сохранены!",
|
||||
"settingsError": "Произошла ошибка при сохранении настроек.",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "",
|
||||
"supportButtonAnimationDesc": "",
|
||||
"sectionTitle": ""
|
||||
}
|
||||
"title": "Глобальные настройки",
|
||||
"description": "Настройте глобальные параметры Вашего экземпляра Booklore, включая обработку обложек, параметры поиска и ограничения загрузки файлов.",
|
||||
"covers": {
|
||||
"sectionTitle": "Обложки книг",
|
||||
"regenerate": "Перегенерация обложек",
|
||||
"regenerateBtn": "Перегенерировать",
|
||||
"regenerateDesc": "Перегенерирует обложки для всех книг из встроенных обложек в файлах.",
|
||||
"regenerateStarted": "Перегенерация обложек запущена",
|
||||
"regenerateStartedDetail": "Обложки книг перегенерируются.",
|
||||
"regenerateError": "Не удалось запустить перегенерацию обложек.",
|
||||
"verticalCropping": "Вертикальная обрезка обложек",
|
||||
"verticalCroppingDesc": "Автоматически обрезать чрезмерно высокие изображения (как веб-комиксы) сверху для создания пригодных миниатюр обложек.",
|
||||
"horizontalCropping": "Горизонтальная обрезка обложек",
|
||||
"horizontalCroppingDesc": "Автоматически обрезать чрезмерно широкие изображения слева для создания пригодных миниатюр обложек.",
|
||||
"aspectRatio": "Порог соотношения сторон: {{value}}",
|
||||
"aspectRatioDesc": "Изображения, соотношение сторон которых превышает этот порог, будут обрезаны. Значение 2.5 означает, что изображения более чем в 2.5 раза выше (или шире) нормы будут обрезаны.",
|
||||
"smartCropping": "Умная обрезка",
|
||||
"smartCroppingDesc": "Пропускать однотонные области при определении места обрезки. Фокусирует обложку на наиболее значимом содержании."
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Поиск и рекомендации",
|
||||
"autoBookSearch": "Автоматический поиск книг",
|
||||
"autoBookSearchDesc": "Автоматически пытается сопоставить метаданные при открытии панели информации о книге.",
|
||||
"similarBook": "Рекомендации похожих книг",
|
||||
"similarBookDesc": "Включает или отключает рекомендации похожих книг на основе Вашей библиотеки."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Управление файлами",
|
||||
"maxUploadSize": "Максимальный размер загружаемого файла",
|
||||
"maxUploadPlaceholder": "Макс. размер",
|
||||
"maxUploadDesc": "Определяет максимально допустимый размер (в МБ) для каждого загружаемого файла. Применяется к форматам EPUB, PDF, CBZ, CBR и CB7.",
|
||||
"restartWarning": "Изменения вступят в силу после перезапуска сервера",
|
||||
"invalidInput": "Неверные данные",
|
||||
"invalidInputDetail": "Пожалуйста, введите корректный максимальный размер файла в МБ."
|
||||
},
|
||||
"settingsSaved": "Настройки сохранены",
|
||||
"settingsSavedDetail": "Настройки были успешно сохранены!",
|
||||
"settingsError": "Произошла ошибка при сохранении настроек.",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "",
|
||||
"supportButtonAnimationDesc": "",
|
||||
"sectionTitle": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,45 @@
|
||||
{
|
||||
"title": "Všeobecné nastavenia",
|
||||
"description": "Nakonfigurujte globálne nastavenia pre vašu inštanciu Booklore, vrátane spracovania obrázkov obálok, preferencií vyhľadávania a limitov pre nahrávanie súborov.",
|
||||
"covers": {
|
||||
"sectionTitle": "",
|
||||
"regenerate": "",
|
||||
"regenerateBtn": "",
|
||||
"regenerateDesc": "",
|
||||
"regenerateStarted": "",
|
||||
"regenerateStartedDetail": "",
|
||||
"regenerateError": "",
|
||||
"verticalCropping": "",
|
||||
"verticalCroppingDesc": "",
|
||||
"horizontalCropping": "",
|
||||
"horizontalCroppingDesc": "",
|
||||
"aspectRatio": "",
|
||||
"aspectRatioDesc": "",
|
||||
"smartCropping": "",
|
||||
"smartCroppingDesc": ""
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "",
|
||||
"autoBookSearch": "",
|
||||
"autoBookSearchDesc": "",
|
||||
"similarBook": "",
|
||||
"similarBookDesc": ""
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "",
|
||||
"maxUploadSize": "",
|
||||
"maxUploadPlaceholder": "",
|
||||
"maxUploadDesc": "",
|
||||
"restartWarning": "",
|
||||
"invalidInput": "",
|
||||
"invalidInputDetail": ""
|
||||
},
|
||||
"telemetry": {
|
||||
"sectionTitle": "",
|
||||
"enableTelemetry": "",
|
||||
"telemetryDesc": ""
|
||||
},
|
||||
"settingsSaved": "",
|
||||
"settingsSavedDetail": "",
|
||||
"settingsError": "",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "",
|
||||
"supportButtonAnimationDesc": "",
|
||||
"sectionTitle": ""
|
||||
}
|
||||
"title": "Všeobecné nastavenia",
|
||||
"description": "Nakonfigurujte globálne nastavenia pre vašu inštanciu Booklore, vrátane spracovania obrázkov obálok, preferencií vyhľadávania a limitov pre nahrávanie súborov.",
|
||||
"covers": {
|
||||
"sectionTitle": "",
|
||||
"regenerate": "",
|
||||
"regenerateBtn": "",
|
||||
"regenerateDesc": "",
|
||||
"regenerateStarted": "",
|
||||
"regenerateStartedDetail": "",
|
||||
"regenerateError": "",
|
||||
"verticalCropping": "",
|
||||
"verticalCroppingDesc": "",
|
||||
"horizontalCropping": "",
|
||||
"horizontalCroppingDesc": "",
|
||||
"aspectRatio": "",
|
||||
"aspectRatioDesc": "",
|
||||
"smartCropping": "",
|
||||
"smartCroppingDesc": ""
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "",
|
||||
"autoBookSearch": "",
|
||||
"autoBookSearchDesc": "",
|
||||
"similarBook": "",
|
||||
"similarBookDesc": ""
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "",
|
||||
"maxUploadSize": "",
|
||||
"maxUploadPlaceholder": "",
|
||||
"maxUploadDesc": "",
|
||||
"restartWarning": "",
|
||||
"invalidInput": "",
|
||||
"invalidInputDetail": ""
|
||||
},
|
||||
"settingsSaved": "",
|
||||
"settingsSavedDetail": "",
|
||||
"settingsError": "",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "",
|
||||
"supportButtonAnimationDesc": "",
|
||||
"sectionTitle": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,47 @@
|
||||
{
|
||||
"title": "Splošne nastavitve",
|
||||
"description": "Konfiguriraj splošne nastavitve za svojo instanco Booklore, vključno z upravljanjem naslovnic, nastavitvami iskanja in omejitvami nalaganja datotek.",
|
||||
"covers": {
|
||||
"sectionTitle": "Naslovnica knjige",
|
||||
"regenerate": "Ponovno ustvari naslovnice",
|
||||
"regenerateBtn": "Ponovno ustvari",
|
||||
"regenerateDesc": "Obnovi slike platnic za vse knjige iz vdelanih platnic v datoteki. Uporabi možnost \"Obnovi manjkajoče\", da ustvariš samo platnice za knjige, ki jih še nimajo.",
|
||||
"regenerateStarted": "Ustvarjanje naslovnic se je začelo",
|
||||
"regenerateStartedDetail": "Slike naslovnic se trenutno ponovno ustvarjajo.",
|
||||
"regenerateError": "Ponovnega ustvarjanja naslovnic ni bilo mogoče zagnati.",
|
||||
"verticalCropping": "Navpično obrezovanje naslovnic",
|
||||
"verticalCroppingDesc": "Samodejno obreži izjemno visoke slike (npr. spletni stripi) z vrha, da ustvariš uporabne sličice naslovnic.",
|
||||
"horizontalCropping": "Vodoravno obrezovanje naslovnic",
|
||||
"horizontalCroppingDesc": "Samodejno obreži izjemno široke slike z leve strani, da ustvariš uporabne sličice naslovnic.",
|
||||
"aspectRatio": "Prag razmerja stranic: {{value}}",
|
||||
"aspectRatioDesc": "Slike, ki presegajo to razmerje stranic, bodo obrezane. Vrednost 2.5 pomeni, da bodo obrezane slike, ki so več kot 2,5-krat višje (ali širše) od običajnih.",
|
||||
"smartCropping": "Pametno obrezovanje",
|
||||
"smartCroppingDesc": "Preskoči enobarvne regije pri določanju mesta obreza. Fokusira naslovnico na najbolj relevantno vsebino.",
|
||||
"regenerateAllBtn": "Obnovi vse",
|
||||
"regenerateMissingBtn": "Obnovi manjkajoče"
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Iskanje in priporočila",
|
||||
"autoBookSearch": "Samodejno iskanje knjig",
|
||||
"autoBookSearchDesc": "Samodejno poskusi poiskati ujemajoče se metapodatke, ko odpreš ploščo z informacijami o knjigi.",
|
||||
"similarBook": "Priporočila podobnih knjig",
|
||||
"similarBookDesc": "Omogoči ali onemogoči priporočila podobnih knjig na podlagi tvoje knjižnice."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Upravljanje datotek",
|
||||
"maxUploadSize": "Največja velikost naložene datoteke",
|
||||
"maxUploadPlaceholder": "Največja velikost",
|
||||
"maxUploadDesc": "Določa največjo dovoljeno velikost (v MB) za vsako naloženo datoteko. Velja za formate EPUB, PDF, CBZ, CBR in CB7.",
|
||||
"restartWarning": "Spremembe bodo stopile v veljavo po ponovnem zagonu strežnika",
|
||||
"invalidInput": "Neveljaven vnos",
|
||||
"invalidInputDetail": "Prosim, vnesi veljavno največjo velikost datoteke v MB."
|
||||
},
|
||||
"telemetry": {
|
||||
"sectionTitle": "Telemetrija",
|
||||
"enableTelemetry": "Omogoči telemetrijo",
|
||||
"telemetryDesc": "Pomagaj izboljšati Booklore z deljenjem anonimnih statistik uporabe. Ti podatki razvijalcem omogočajo vpogled v to, katere funkcije so najbolj uporabljene, prepoznavanje hroščev in težav z zmogljivostjo. Osebni podatki, vsebina knjig ali kateri koli drugi identifikacijski podatki se nikoli ne pošiljajo. Podatki se samodejno pošljejo na strežnik Booklore enkrat na 24 ur. To je popolnoma varno in zelo v pomoč razvijalcem."
|
||||
},
|
||||
"settingsSaved": "Nastavitve shranjene",
|
||||
"settingsSavedDetail": "Nastavitve so bile uspešno shranjene!",
|
||||
"settingsError": "Pri shranjevanju nastavitev je prišlo do napake.",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "Animacija gumba za podporo",
|
||||
"supportButtonAnimationDesc": "Prikaži animirani učinek srca na gumbu za podporo v zgornji vrstici. Če to onemogočiš, gumb ostane viden, vendar se animacija odstrani.",
|
||||
"sectionTitle": "Izgled"
|
||||
}
|
||||
"title": "Splošne nastavitve",
|
||||
"description": "Konfiguriraj splošne nastavitve za svojo instanco Booklore, vključno z upravljanjem naslovnic, nastavitvami iskanja in omejitvami nalaganja datotek.",
|
||||
"covers": {
|
||||
"sectionTitle": "Naslovnica knjige",
|
||||
"regenerate": "Ponovno ustvari naslovnice",
|
||||
"regenerateBtn": "Ponovno ustvari",
|
||||
"regenerateDesc": "Obnovi slike platnic za vse knjige iz vdelanih platnic v datoteki. Uporabi možnost \"Obnovi manjkajoče\", da ustvariš samo platnice za knjige, ki jih še nimajo.",
|
||||
"regenerateStarted": "Ustvarjanje naslovnic se je začelo",
|
||||
"regenerateStartedDetail": "Slike naslovnic se trenutno ponovno ustvarjajo.",
|
||||
"regenerateError": "Ponovnega ustvarjanja naslovnic ni bilo mogoče zagnati.",
|
||||
"verticalCropping": "Navpično obrezovanje naslovnic",
|
||||
"verticalCroppingDesc": "Samodejno obreži izjemno visoke slike (npr. spletni stripi) z vrha, da ustvariš uporabne sličice naslovnic.",
|
||||
"horizontalCropping": "Vodoravno obrezovanje naslovnic",
|
||||
"horizontalCroppingDesc": "Samodejno obreži izjemno široke slike z leve strani, da ustvariš uporabne sličice naslovnic.",
|
||||
"aspectRatio": "Prag razmerja stranic: {{value}}",
|
||||
"aspectRatioDesc": "Slike, ki presegajo to razmerje stranic, bodo obrezane. Vrednost 2.5 pomeni, da bodo obrezane slike, ki so več kot 2,5-krat višje (ali širše) od običajnih.",
|
||||
"smartCropping": "Pametno obrezovanje",
|
||||
"smartCroppingDesc": "Preskoči enobarvne regije pri določanju mesta obreza. Fokusira naslovnico na najbolj relevantno vsebino.",
|
||||
"regenerateAllBtn": "Obnovi vse",
|
||||
"regenerateMissingBtn": "Obnovi manjkajoče"
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Iskanje in priporočila",
|
||||
"autoBookSearch": "Samodejno iskanje knjig",
|
||||
"autoBookSearchDesc": "Samodejno poskusi poiskati ujemajoče se metapodatke, ko odpreš ploščo z informacijami o knjigi.",
|
||||
"similarBook": "Priporočila podobnih knjig",
|
||||
"similarBookDesc": "Omogoči ali onemogoči priporočila podobnih knjig na podlagi tvoje knjižnice."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Upravljanje datotek",
|
||||
"maxUploadSize": "Največja velikost naložene datoteke",
|
||||
"maxUploadPlaceholder": "Največja velikost",
|
||||
"maxUploadDesc": "Določa največjo dovoljeno velikost (v MB) za vsako naloženo datoteko. Velja za formate EPUB, PDF, CBZ, CBR in CB7.",
|
||||
"restartWarning": "Spremembe bodo stopile v veljavo po ponovnem zagonu strežnika",
|
||||
"invalidInput": "Neveljaven vnos",
|
||||
"invalidInputDetail": "Prosim, vnesi veljavno največjo velikost datoteke v MB."
|
||||
},
|
||||
"settingsSaved": "Nastavitve shranjene",
|
||||
"settingsSavedDetail": "Nastavitve so bile uspešno shranjene!",
|
||||
"settingsError": "Pri shranjevanju nastavitev je prišlo do napake.",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "Animacija gumba za podporo",
|
||||
"supportButtonAnimationDesc": "Prikaži animirani učinek srca na gumbu za podporo v zgornji vrstici. Če to onemogočiš, gumb ostane viden, vendar se animacija odstrani.",
|
||||
"sectionTitle": "Izgled"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,47 @@
|
||||
{
|
||||
"title": "Globala inställningar",
|
||||
"description": "Konfigurera globala inställningar för din Booklore-instans, inklusive omslagsbildshantering, sökinställningar och filuppladdningsgränser.",
|
||||
"covers": {
|
||||
"sectionTitle": "Bokomslag",
|
||||
"regenerate": "Återskapa omslag",
|
||||
"regenerateBtn": "Återskapa",
|
||||
"regenerateDesc": "Återskapar omslagsbilder för alla böcker från de inbäddade omslagen i filen. Använd \"Regenerera saknade\" för att bara generera omslag för böcker som ännu inte har ett.",
|
||||
"regenerateStarted": "Omslagsåterskapning startad",
|
||||
"regenerateStartedDetail": "Bokomslag håller på att återskapas.",
|
||||
"regenerateError": "Kunde inte starta omslagsåterskapning.",
|
||||
"verticalCropping": "Vertikal omslagsbeskärning",
|
||||
"verticalCroppingDesc": "Beskär automatiskt extremt höga bilder (som webbserier) från toppen för att skapa användbara omslagsminiatyrer.",
|
||||
"horizontalCropping": "Horisontell omslagsbeskärning",
|
||||
"horizontalCroppingDesc": "Beskär automatiskt extremt breda bilder från vänster för att skapa användbara omslagsminiatyrer.",
|
||||
"aspectRatio": "Bildförhållandeströskel: {{value}}",
|
||||
"aspectRatioDesc": "Bilder med bildförhållanden som överstiger detta tröskelvärde beskärs. Värdet 2.5 innebär att bilder som är mer än 2,5x högre (eller bredare) än normalt beskärs.",
|
||||
"smartCropping": "Smart beskärning",
|
||||
"smartCroppingDesc": "Hoppa över enhetliga färgområden vid bestämning av var beskärning ska ske. Fokuserar omslagsbilden på det mest relevanta innehållet.",
|
||||
"regenerateAllBtn": "Regenerera alla",
|
||||
"regenerateMissingBtn": "Regenerera saknade"
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Sök och rekommendationer",
|
||||
"autoBookSearch": "Automatisk boksökning",
|
||||
"autoBookSearchDesc": "Försöker automatiskt matcha metadata när bokinformationspanelen öppnas.",
|
||||
"similarBook": "Liknande bokrekommendationer",
|
||||
"similarBookDesc": "Aktiverar eller inaktiverar rekommendationer av liknande böcker baserat på ditt bibliotek."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Filhantering",
|
||||
"maxUploadSize": "Max filuppladdningsstorlek",
|
||||
"maxUploadPlaceholder": "Max storlek",
|
||||
"maxUploadDesc": "Definierar den maximalt tillåtna storleken (i MB) för varje uppladdad fil. Gäller alla filformat som stöds.",
|
||||
"restartWarning": "Ändringar träder i kraft efter omstart av servern",
|
||||
"invalidInput": "Ogiltig inmatning",
|
||||
"invalidInputDetail": "Ange en giltig max filuppladdningsstorlek i MB."
|
||||
},
|
||||
"telemetry": {
|
||||
"sectionTitle": "Telemetri",
|
||||
"enableTelemetry": "Aktivera telemetri",
|
||||
"telemetryDesc": "Hjälp till att förbättra Booklore genom att dela anonym användningsstatistik. Denna data låter utvecklarna se vilka funktioner som används mest, identifiera buggar och upptäcka prestandaproblem så att de vet vad de ska arbeta med härnäst. Ingen personlig information, bokinnehåll eller identifierbar data skickas någonsin. Data skickas till Booklore-servern automatiskt var 24:e timme. Det är helt säkert och mycket hjälpsamt för utvecklarna."
|
||||
},
|
||||
"settingsSaved": "Inställningar sparade",
|
||||
"settingsSavedDetail": "Inställningarna sparades!",
|
||||
"settingsError": "Det uppstod ett fel vid sparning av inställningarna.",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "Supportknappsanimation",
|
||||
"supportButtonAnimationDesc": "Visa den animerade hjärteffekten för supportknappen på övre menyraden. Om du inaktiverar detta syns knappen fortfarande men animationen tas bort.",
|
||||
"sectionTitle": "Utseende"
|
||||
}
|
||||
"title": "Globala inställningar",
|
||||
"description": "Konfigurera globala inställningar för din Booklore-instans, inklusive omslagsbildshantering, sökinställningar och filuppladdningsgränser.",
|
||||
"covers": {
|
||||
"sectionTitle": "Bokomslag",
|
||||
"regenerate": "Återskapa omslag",
|
||||
"regenerateBtn": "Återskapa",
|
||||
"regenerateDesc": "Återskapar omslagsbilder för alla böcker från de inbäddade omslagen i filen. Använd \"Regenerera saknade\" för att bara generera omslag för böcker som ännu inte har ett.",
|
||||
"regenerateStarted": "Omslagsåterskapning startad",
|
||||
"regenerateStartedDetail": "Bokomslag håller på att återskapas.",
|
||||
"regenerateError": "Kunde inte starta omslagsåterskapning.",
|
||||
"verticalCropping": "Vertikal omslagsbeskärning",
|
||||
"verticalCroppingDesc": "Beskär automatiskt extremt höga bilder (som webbserier) från toppen för att skapa användbara omslagsminiatyrer.",
|
||||
"horizontalCropping": "Horisontell omslagsbeskärning",
|
||||
"horizontalCroppingDesc": "Beskär automatiskt extremt breda bilder från vänster för att skapa användbara omslagsminiatyrer.",
|
||||
"aspectRatio": "Bildförhållandeströskel: {{value}}",
|
||||
"aspectRatioDesc": "Bilder med bildförhållanden som överstiger detta tröskelvärde beskärs. Värdet 2.5 innebär att bilder som är mer än 2,5x högre (eller bredare) än normalt beskärs.",
|
||||
"smartCropping": "Smart beskärning",
|
||||
"smartCroppingDesc": "Hoppa över enhetliga färgområden vid bestämning av var beskärning ska ske. Fokuserar omslagsbilden på det mest relevanta innehållet.",
|
||||
"regenerateAllBtn": "Regenerera alla",
|
||||
"regenerateMissingBtn": "Regenerera saknade"
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Sök och rekommendationer",
|
||||
"autoBookSearch": "Automatisk boksökning",
|
||||
"autoBookSearchDesc": "Försöker automatiskt matcha metadata när bokinformationspanelen öppnas.",
|
||||
"similarBook": "Liknande bokrekommendationer",
|
||||
"similarBookDesc": "Aktiverar eller inaktiverar rekommendationer av liknande böcker baserat på ditt bibliotek."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Filhantering",
|
||||
"maxUploadSize": "Max filuppladdningsstorlek",
|
||||
"maxUploadPlaceholder": "Max storlek",
|
||||
"maxUploadDesc": "Definierar den maximalt tillåtna storleken (i MB) för varje uppladdad fil. Gäller alla filformat som stöds.",
|
||||
"restartWarning": "Ändringar träder i kraft efter omstart av servern",
|
||||
"invalidInput": "Ogiltig inmatning",
|
||||
"invalidInputDetail": "Ange en giltig max filuppladdningsstorlek i MB."
|
||||
},
|
||||
"settingsSaved": "Inställningar sparade",
|
||||
"settingsSavedDetail": "Inställningarna sparades!",
|
||||
"settingsError": "Det uppstod ett fel vid sparning av inställningarna.",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "Supportknappsanimation",
|
||||
"supportButtonAnimationDesc": "Visa den animerade hjärteffekten för supportknappen på övre menyraden. Om du inaktiverar detta syns knappen fortfarande men animationen tas bort.",
|
||||
"sectionTitle": "Utseende"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,47 @@
|
||||
{
|
||||
"title": "Глобальні налаштування",
|
||||
"description": "Налаштуйте глобальні параметри вашого екземпляра Booklore, зокрема обробку обкладинок, параметри пошуку та ліміти завантаження файлів.",
|
||||
"covers": {
|
||||
"sectionTitle": "Обкладинка книги",
|
||||
"regenerate": "Перегенерувати обкладинки",
|
||||
"regenerateBtn": "",
|
||||
"regenerateDesc": "Перегенеровує обкладинки для всіх книг із вбудованих у файл обкладинок. Використовуйте \"Перегенерувати відсутні\", щоб створити обкладинки лише для книг, у яких їх ще немає.",
|
||||
"regenerateStarted": "Перегенерацію обкладинок розпочато",
|
||||
"regenerateStartedDetail": "Обкладинки книг перегенеровуються.",
|
||||
"regenerateError": "Не вдалося запустити перегенерацію обкладинок.",
|
||||
"verticalCropping": "Вертикальне обрізання обкладинок",
|
||||
"verticalCroppingDesc": "Автоматично обрізає надто високі зображення (наприклад, вебкомікси) зверху, щоб створити придатні мініатюри обкладинок.",
|
||||
"horizontalCropping": "Горизонтальне обрізання обкладинок",
|
||||
"horizontalCroppingDesc": "Автоматично обрізає надто широкі зображення зліва, щоб створити придатні мініатюри обкладинок.",
|
||||
"aspectRatio": "Поріг співвідношення сторін: {{value}}",
|
||||
"aspectRatioDesc": "Зображення зі співвідношенням сторін, що перевищує цей поріг, буде обрізано. Значення 2.5 означає, що зображення, вищі (або ширші) за нормальні більш ніж у 2.5 раза, буде обрізано.",
|
||||
"smartCropping": "Розумне обрізання",
|
||||
"smartCroppingDesc": "Пропускає області однорідного кольору під час визначення місця обрізання. Фокусує обкладинку на найрелевантнішому вмісті.",
|
||||
"regenerateAllBtn": "Перегенерувати всі",
|
||||
"regenerateMissingBtn": "Перегенерувати відсутні"
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Пошук і рекомендації",
|
||||
"autoBookSearch": "Автопошук книги",
|
||||
"autoBookSearchDesc": "Автоматично намагається зіставити метадані, коли відкрито панель інформації про книгу.",
|
||||
"similarBook": "Рекомендації схожих книг",
|
||||
"similarBookDesc": "Вмикає або вимикає рекомендації схожих книг на основі вашої бібліотеки."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Керування файлами",
|
||||
"maxUploadSize": "Максимальний розмір файлу для завантаження",
|
||||
"maxUploadPlaceholder": "Макс. розмір",
|
||||
"maxUploadDesc": "Визначає максимально дозволений розмір (у МБ) для кожного завантажуваного файлу. Застосовується до всіх підтримуваних форматів.",
|
||||
"restartWarning": "Зміни набудуть чинності після перезапуску сервера",
|
||||
"invalidInput": "Некоректне значення",
|
||||
"invalidInputDetail": "Введіть коректний максимальний розмір файлу для завантаження в МБ."
|
||||
},
|
||||
"appearance": {
|
||||
"sectionTitle": "Зовнішній вигляд",
|
||||
"supportButtonAnimation": "Анімація кнопки підтримки",
|
||||
"supportButtonAnimationDesc": "Показувати анімований ефект серця на кнопці підтримки у верхній панелі. Вимкнення залишає кнопку видимою, але прибирає анімацію."
|
||||
},
|
||||
"telemetry": {
|
||||
"sectionTitle": "Телеметрія",
|
||||
"enableTelemetry": "Увімкнути телеметрію",
|
||||
"telemetryDesc": "Допоможіть покращувати Booklore, надсилаючи анонімну статистику використання. Ці дані дають змогу розробникам бачити, які функції найчастіше використовуються, виявляти помилки та проблеми продуктивності, щоб розуміти, над чим працювати далі. Жодна особиста інформація, вміст книг або будь-які ідентифікаційні дані ніколи не надсилаються. Дані автоматично надсилаються на сервер Booklore раз на 24 години. Це повністю безпечно і дуже корисно для розробників."
|
||||
},
|
||||
"settingsSaved": "Налаштування збережено",
|
||||
"settingsSavedDetail": "Налаштування успішно збережено!",
|
||||
"settingsError": "Під час збереження налаштувань сталася помилка."
|
||||
"title": "Глобальні налаштування",
|
||||
"description": "Налаштуйте глобальні параметри вашого екземпляра Booklore, зокрема обробку обкладинок, параметри пошуку та ліміти завантаження файлів.",
|
||||
"covers": {
|
||||
"sectionTitle": "Обкладинка книги",
|
||||
"regenerate": "Перегенерувати обкладинки",
|
||||
"regenerateBtn": "",
|
||||
"regenerateDesc": "Перегенеровує обкладинки для всіх книг із вбудованих у файл обкладинок. Використовуйте \"Перегенерувати відсутні\", щоб створити обкладинки лише для книг, у яких їх ще немає.",
|
||||
"regenerateStarted": "Перегенерацію обкладинок розпочато",
|
||||
"regenerateStartedDetail": "Обкладинки книг перегенеровуються.",
|
||||
"regenerateError": "Не вдалося запустити перегенерацію обкладинок.",
|
||||
"verticalCropping": "Вертикальне обрізання обкладинок",
|
||||
"verticalCroppingDesc": "Автоматично обрізає надто високі зображення (наприклад, вебкомікси) зверху, щоб створити придатні мініатюри обкладинок.",
|
||||
"horizontalCropping": "Горизонтальне обрізання обкладинок",
|
||||
"horizontalCroppingDesc": "Автоматично обрізає надто широкі зображення зліва, щоб створити придатні мініатюри обкладинок.",
|
||||
"aspectRatio": "Поріг співвідношення сторін: {{value}}",
|
||||
"aspectRatioDesc": "Зображення зі співвідношенням сторін, що перевищує цей поріг, буде обрізано. Значення 2.5 означає, що зображення, вищі (або ширші) за нормальні більш ніж у 2.5 раза, буде обрізано.",
|
||||
"smartCropping": "Розумне обрізання",
|
||||
"smartCroppingDesc": "Пропускає області однорідного кольору під час визначення місця обрізання. Фокусує обкладинку на найрелевантнішому вмісті.",
|
||||
"regenerateAllBtn": "Перегенерувати всі",
|
||||
"regenerateMissingBtn": "Перегенерувати відсутні"
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "Пошук і рекомендації",
|
||||
"autoBookSearch": "Автопошук книги",
|
||||
"autoBookSearchDesc": "Автоматично намагається зіставити метадані, коли відкрито панель інформації про книгу.",
|
||||
"similarBook": "Рекомендації схожих книг",
|
||||
"similarBookDesc": "Вмикає або вимикає рекомендації схожих книг на основі вашої бібліотеки."
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "Керування файлами",
|
||||
"maxUploadSize": "Максимальний розмір файлу для завантаження",
|
||||
"maxUploadPlaceholder": "Макс. розмір",
|
||||
"maxUploadDesc": "Визначає максимально дозволений розмір (у МБ) для кожного завантажуваного файлу. Застосовується до всіх підтримуваних форматів.",
|
||||
"restartWarning": "Зміни набудуть чинності після перезапуску сервера",
|
||||
"invalidInput": "Некоректне значення",
|
||||
"invalidInputDetail": "Введіть коректний максимальний розмір файлу для завантаження в МБ."
|
||||
},
|
||||
"appearance": {
|
||||
"sectionTitle": "Зовнішній вигляд",
|
||||
"supportButtonAnimation": "Анімація кнопки підтримки",
|
||||
"supportButtonAnimationDesc": "Показувати анімований ефект серця на кнопці підтримки у верхній панелі. Вимкнення залишає кнопку видимою, але прибирає анімацію."
|
||||
},
|
||||
"settingsSaved": "Налаштування збережено",
|
||||
"settingsSavedDetail": "Налаштування успішно збережено!",
|
||||
"settingsError": "Під час збереження налаштувань сталася помилка."
|
||||
}
|
||||
|
||||
@@ -1,50 +1,45 @@
|
||||
{
|
||||
"title": "全局偏好设置",
|
||||
"description": "配置 BookLore 实例的全局设置,包括封面图片处理、搜索偏好和文件上传限制。",
|
||||
"covers": {
|
||||
"sectionTitle": "书籍封面图片",
|
||||
"regenerate": "重新生成封面",
|
||||
"regenerateBtn": "重新生成",
|
||||
"regenerateDesc": "从文件中嵌入的封面重新生成所有书籍的封面图片。",
|
||||
"regenerateStarted": "封面重新生成已开始",
|
||||
"regenerateStartedDetail": "正在重新生成书籍封面。",
|
||||
"regenerateError": "启动封面重新生成失败。",
|
||||
"verticalCropping": "垂直封面裁剪",
|
||||
"verticalCroppingDesc": "自动从顶部裁剪超高图片(如网络漫画),以创建可用的封面缩略图。",
|
||||
"horizontalCropping": "水平封面裁剪",
|
||||
"horizontalCroppingDesc": "自动从左侧裁剪超宽图片,以创建可用的封面缩略图。",
|
||||
"aspectRatio": "宽高比阈值:{{value}}",
|
||||
"aspectRatioDesc": "宽高比超过此阈值的图片将被裁剪。值为 2.5 表示高度(或宽度)超过正常值 2.5 倍的图片将被裁剪。",
|
||||
"smartCropping": "智能裁剪",
|
||||
"smartCroppingDesc": "在确定裁剪位置时跳过纯色区域,将封面图片聚焦在最相关的内容上。"
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "搜索与推荐",
|
||||
"autoBookSearch": "自动书籍搜索",
|
||||
"autoBookSearchDesc": "打开书籍信息面板时自动尝试元数据匹配。",
|
||||
"similarBook": "相似书籍推荐",
|
||||
"similarBookDesc": "启用或禁用基于您书库的相似书籍推荐。"
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "文件管理",
|
||||
"maxUploadSize": "最大文件上传大小",
|
||||
"maxUploadPlaceholder": "最大大小",
|
||||
"maxUploadDesc": "定义每个上传文件的最大允许大小(以 MB 为单位)。适用于所有支持的格式。",
|
||||
"restartWarning": "更改将在重启服务器后生效",
|
||||
"invalidInput": "无效输入",
|
||||
"invalidInputDetail": "请输入有效的最大文件上传大小(以 MB 为单位)。"
|
||||
},
|
||||
"telemetry": {
|
||||
"sectionTitle": "遥测",
|
||||
"enableTelemetry": "启用遥测",
|
||||
"telemetryDesc": "通过共享匿名使用统计数据来帮助改进 BookLore。这些数据可以让开发者了解哪些功能最常使用、识别错误和发现性能问题,从而知道下一步该做什么。不会发送任何个人信息、书籍内容或任何可识别的数据。数据每 24 小时自动发送一次到 BookLore 服务器。这完全安全,对开发者非常有帮助。"
|
||||
},
|
||||
"settingsSaved": "设置已保存",
|
||||
"settingsSavedDetail": "设置已成功保存!",
|
||||
"settingsError": "保存设置时出错。",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "",
|
||||
"supportButtonAnimationDesc": "",
|
||||
"sectionTitle": ""
|
||||
}
|
||||
"title": "全局偏好设置",
|
||||
"description": "配置 BookLore 实例的全局设置,包括封面图片处理、搜索偏好和文件上传限制。",
|
||||
"covers": {
|
||||
"sectionTitle": "书籍封面图片",
|
||||
"regenerate": "重新生成封面",
|
||||
"regenerateBtn": "重新生成",
|
||||
"regenerateDesc": "从文件中嵌入的封面重新生成所有书籍的封面图片。",
|
||||
"regenerateStarted": "封面重新生成已开始",
|
||||
"regenerateStartedDetail": "正在重新生成书籍封面。",
|
||||
"regenerateError": "启动封面重新生成失败。",
|
||||
"verticalCropping": "垂直封面裁剪",
|
||||
"verticalCroppingDesc": "自动从顶部裁剪超高图片(如网络漫画),以创建可用的封面缩略图。",
|
||||
"horizontalCropping": "水平封面裁剪",
|
||||
"horizontalCroppingDesc": "自动从左侧裁剪超宽图片,以创建可用的封面缩略图。",
|
||||
"aspectRatio": "宽高比阈值:{{value}}",
|
||||
"aspectRatioDesc": "宽高比超过此阈值的图片将被裁剪。值为 2.5 表示高度(或宽度)超过正常值 2.5 倍的图片将被裁剪。",
|
||||
"smartCropping": "智能裁剪",
|
||||
"smartCroppingDesc": "在确定裁剪位置时跳过纯色区域,将封面图片聚焦在最相关的内容上。"
|
||||
},
|
||||
"search": {
|
||||
"sectionTitle": "搜索与推荐",
|
||||
"autoBookSearch": "自动书籍搜索",
|
||||
"autoBookSearchDesc": "打开书籍信息面板时自动尝试元数据匹配。",
|
||||
"similarBook": "相似书籍推荐",
|
||||
"similarBookDesc": "启用或禁用基于您书库的相似书籍推荐。"
|
||||
},
|
||||
"fileManagement": {
|
||||
"sectionTitle": "文件管理",
|
||||
"maxUploadSize": "最大文件上传大小",
|
||||
"maxUploadPlaceholder": "最大大小",
|
||||
"maxUploadDesc": "定义每个上传文件的最大允许大小(以 MB 为单位)。适用于所有支持的格式。",
|
||||
"restartWarning": "更改将在重启服务器后生效",
|
||||
"invalidInput": "无效输入",
|
||||
"invalidInputDetail": "请输入有效的最大文件上传大小(以 MB 为单位)。"
|
||||
},
|
||||
"settingsSaved": "设置已保存",
|
||||
"settingsSavedDetail": "设置已成功保存!",
|
||||
"settingsError": "保存设置时出错。",
|
||||
"appearance": {
|
||||
"supportButtonAnimation": "",
|
||||
"supportButtonAnimationDesc": "",
|
||||
"sectionTitle": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,4 +53,4 @@ ### Step 3: Configure BookLore OIDC Settings
|
||||
|
||||
### Step 4: Test the Integration
|
||||
|
||||
Once configured, simply click Save Settings and then click the Enabled radio button to activate it. Simply log out and log back in and you should be working with no issues! Any issues please raise an issue on the [Github](https://github.com/booklore-app/booklore/issues/new?template=bug_report.yml)
|
||||
Once configured, simply click Save Settings and then click the Enabled radio button to activate it. Simply log out and log back in and you should be working with no issues! Any issues please raise an issue on the [Github](https://github.com/the-booklore/booklore/issues/new?template=bug_report.yml)
|
||||
|
||||
@@ -37,7 +37,7 @@ ### Docker Compose Example
|
||||
```yaml
|
||||
services:
|
||||
booklore:
|
||||
image: ghcr.io/adityachandelgit/booklore-app:latest
|
||||
image: ghcr.io/the-booklore/booklore:latest
|
||||
environment:
|
||||
# Forward Auth Configuration
|
||||
- REMOTE_AUTH_ENABLED=true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: booklore
|
||||
description: A Helm chart for [Booklore](https://github.com/booklore-app/booklore)
|
||||
description: A Helm chart for [Booklore](https://github.com/the-booklore/booklore)
|
||||
|
||||
# A chart can be either an 'application' or a 'library' chart.
|
||||
#
|
||||
|
||||
@@ -12,7 +12,7 @@ mariadb:
|
||||
|
||||
# This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/
|
||||
image:
|
||||
repository: booklore/booklore
|
||||
repository: ghcr.io/the-booklore/booklore
|
||||
# This sets the pull policy for images.
|
||||
pullPolicy: IfNotPresent
|
||||
# Overrides the image tag whose default is the chart appVersion.
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
services:
|
||||
booklore:
|
||||
# Official Docker Hub image:
|
||||
image: booklore/booklore:latest
|
||||
# Or the GHCR image:
|
||||
# image: ghcr.io/booklore-app/booklore:latest
|
||||
image: ghcr.io/the-booklore/booklore:latest
|
||||
container_name: booklore
|
||||
environment:
|
||||
- USER_ID=1000 # Modify this if the volume's ownership is not root
|
||||
@@ -51,4 +48,4 @@ services:
|
||||
test: [ "CMD", "mariadb-admin", "ping", "-h", "localhost" ]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
retries: 10
|
||||
|
||||
@@ -10,7 +10,7 @@ # Setup
|
||||
```bash
|
||||
echo -n "YOUR PASSWORD" | podman secret create booklore_db_pass -
|
||||
```
|
||||
4. (Optional) `podman pull ghcr.io/booklore-app/booklore:latest` to pre-pull the image
|
||||
4. (Optional) `podman pull ghcr.io/the-booklore/booklore:latest` to pre-pull the image
|
||||
* If you have a slow connection, this is recommended because systemd will time out if the image pull takes too long.
|
||||
5. Run `systemctl --user daemon-reload` to pick up the new Quadlet unit.
|
||||
6. Start the pod with `systemctl --user start booklore-pod.service`
|
||||
|
||||
@@ -5,7 +5,7 @@ After=booklore-db.container
|
||||
|
||||
[Container]
|
||||
ContainerName=booklore
|
||||
Image=ghcr.io/booklore-app/booklore:latest
|
||||
Image=ghcr.io/the-booklore/booklore:latest
|
||||
Pod=booklore.pod
|
||||
AutoUpdate=registry
|
||||
Pull=always
|
||||
|
||||
@@ -14,7 +14,10 @@ fi
|
||||
|
||||
VERSION="$1"
|
||||
|
||||
IMAGE_REF="ghcr.io/the-booklore/booklore:$VERSION"
|
||||
|
||||
echo "Building Booklore App with multi-arch version: $VERSION"
|
||||
echo "Target registry image: $IMAGE_REF"
|
||||
|
||||
# Ensure Docker Buildx builder exists and is used
|
||||
docker buildx create --use --name multiarch-builder || true
|
||||
@@ -22,8 +25,8 @@ docker buildx create --use --name multiarch-builder || true
|
||||
# Build and push multi-arch Docker image
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t retr0spect101/booklore:"$VERSION" \
|
||||
-t "$IMAGE_REF" \
|
||||
--push \
|
||||
.
|
||||
|
||||
echo "Multi-arch Docker image retr0spect101/booklore:$VERSION pushed successfully!"
|
||||
echo "Multi-arch Docker image $IMAGE_REF pushed successfully!"
|
||||
|
||||
@@ -29,8 +29,7 @@ release_name=$(jq -r '.name' <<< "$release_json")
|
||||
release_body=$(jq -r '.body' <<< "$release_json")
|
||||
release_url=$(jq -r '.url' <<< "$release_json")
|
||||
|
||||
dockerhub_image="https://hub.docker.com/r/booklore/booklore/tags/$NEW_TAG"
|
||||
ghcr_image="https://github.com/booklore-app/booklore/pkgs/container/booklore/$NEW_TAG"
|
||||
ghcr_image="https://github.com/the-booklore/booklore/pkgs/container/booklore/$NEW_TAG"
|
||||
|
||||
# Clean up body for Discord
|
||||
clean_body=$(echo "$release_body" | tr -d '\r')
|
||||
@@ -44,7 +43,6 @@ payload=$(jq -n \
|
||||
--arg title "New Release: $release_name" \
|
||||
--arg url "$release_url" \
|
||||
--arg desc "$clean_body" \
|
||||
--arg hub "[View image]($dockerhub_image)" \
|
||||
--arg gh "[View image]($ghcr_image)" \
|
||||
'{
|
||||
content: null,
|
||||
@@ -54,8 +52,7 @@ payload=$(jq -n \
|
||||
description: $desc,
|
||||
color: 3066993,
|
||||
fields: [
|
||||
{ name: "Docker Hub", value: $hub, inline: true },
|
||||
{ name: "GHCR", value: $gh, inline: true }
|
||||
{ name: "GHCR", value: $gh, inline: true }
|
||||
]
|
||||
}]
|
||||
}')
|
||||
@@ -70,4 +67,4 @@ echo "Sending notification to Discord..."
|
||||
curl -i -H "Content-Type: application/json" -d "$payload" "$DISCORD_WEBHOOK_URL" \
|
||||
|| echo "⚠️ Request completed with an error; check the above HTTP response"
|
||||
|
||||
echo "Notification sent!"
|
||||
echo "Notification sent!"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
#
|
||||
# Prerequisites:
|
||||
# 1. Get your API token from https://hosted.weblate.org/accounts/profile/#api
|
||||
# 2. Ensure your GitHub repo (booklore-app/booklore) is public (required for Libre plan)
|
||||
# 2. Ensure your GitHub repo (the-booklore/booklore) is public (required for Libre plan)
|
||||
#
|
||||
# Usage:
|
||||
# WEBLATE_TOKEN=your-api-token ./scripts/weblate-setup.sh
|
||||
@@ -22,9 +22,9 @@ TOKEN="${WEBLATE_TOKEN:?Set WEBLATE_TOKEN to your Weblate API token}"
|
||||
|
||||
PROJECT_NAME="BookLore"
|
||||
PROJECT_SLUG="booklore"
|
||||
PROJECT_WEB="https://github.com/booklore-app/booklore"
|
||||
PROJECT_WEB="https://github.com/the-booklore/booklore"
|
||||
|
||||
REPO_URL="https://github.com/booklore-app/booklore.git"
|
||||
REPO_URL="https://github.com/the-booklore/booklore.git"
|
||||
REPO_BRANCH="develop"
|
||||
FILE_BASE="booklore-ui/src/i18n"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user