Merge branch 'main' into fk-delete-cascade
This commit is contained in:
@@ -314,7 +314,6 @@ Copyright (C) 2024-2026 Calibre-Web Automated contributors
|
||||
- zelazna (1 commits)
|
||||
- zhiyue (1 commits)
|
||||
# Fork Contributors (crocodilestick/calibre-web-automated)
|
||||
|
||||
- crocodilestick (962 commits)
|
||||
- jmarmstrong1207 (73 commits)
|
||||
- demitrix (30 commits)
|
||||
|
||||
+4
-1
@@ -209,6 +209,8 @@ RUN \
|
||||
nano \
|
||||
sqlite3 \
|
||||
zip \
|
||||
gettext \
|
||||
libasound2t64 \
|
||||
libxtst6 \
|
||||
libxrandr2 \
|
||||
libxkbfile1 \
|
||||
@@ -282,8 +284,9 @@ RUN \
|
||||
# Add unrar from unrar stage
|
||||
COPY --from=unrar /usr/bin/unrar-ubuntu /usr/bin/unrar
|
||||
|
||||
# Set calibre environment variable
|
||||
# Set calibre environment variables
|
||||
ENV CALIBRE_CONFIG_DIR=/config/.config/calibre
|
||||
ENV LD_LIBRARY_PATH=/app/calibre/lib
|
||||
|
||||
# Ports and volumes
|
||||
WORKDIR /config
|
||||
|
||||
@@ -46,11 +46,14 @@ Calibre-Web Automated aims to be an all-in-one solution, combining the modern li
|
||||
|
||||
## _Affiliated Projects_ 👬
|
||||
|
||||
### Calibre-Web Automated Book Downloader
|
||||
### Shelfmark: Book Downloader
|
||||
|
||||
- An intuitive web interface for searching and requesting book downloads, designed to work seamlessly with Calibre-Web-Automated. This project streamlines the process of downloading books and preparing them for integration into your Calibre library
|
||||
|
||||
[<img src="https://raw.githubusercontent.com/vadret/android/master/assets/get-github.png" alt="Get it on GitHub" height="80">](https://github.com/calibrain/calibre-web-automated-book-downloader)
|
||||
> [!IMPORTANT]
|
||||
> CWA does not approve of or support piracy of copyrighted materials and is not responsible for user behaviour
|
||||
|
||||
[<img src="https://raw.githubusercontent.com/vadret/android/master/assets/get-github.png" alt="Get it on GitHub" height="80">](https://github.com/calibrain/shelfmark)
|
||||
|
||||
___
|
||||
|
||||
@@ -142,7 +145,7 @@ This tells CWA to avoid enabling WAL on the Calibre `metadata.db` and the `app.d
|
||||
- Using the information provided in the Calibre eBook-converter documentation on which formats convert best into epubs, CWA is able to determine from downloads containing multiple eBook formats, which format will convert most optimally, ignoring the other formats to ensure the **best possible quality** and no **duplicate imports** -->
|
||||
|
||||
#### **Automatic Conversion Service** 🔃
|
||||
- On by default though can be toggled of in the CWA Settings page, with EPUB as the default target format
|
||||
- On by default though can be toggled off in the CWA Settings page, with EPUB as the default target format
|
||||
- _Available target formats include:_ **EPUB**, **MOBI**, **AZW3**, **KEPUB** & **PDF**
|
||||
- Upon detecting new files in the Ingest Directory, if any of the files are in formats the user has configured CWA to auto-convert to the current target format,
|
||||
- The following **28 file types are currently supported:**
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<svg id="svg" version="1.1" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<g>
|
||||
<path d="M139.0375,0h21.67525c1.864,0.66172 4.587,0.75269 6.60825,0.98178c14.362,1.62762 30.66525,6.16092 43.83875,11.98574c31.044,13.88923 56.46775,37.88373 72.13025,68.07273c6.6075,13.201 11.43,27.22375 14.335,41.69775c0.8675,4.54525 1.3425,9.18925 1.8925,13.782c0.11,0.91925 0.2075,1.67225 0.4825,2.55875v21.89675c-0.6675,2.704 -1.1775,8.3615 -1.5675,11.402c-0.5525,4.15375 -1.3825,8.26625 -2.48,12.3105c-2.13,8.08925 -5.11,17.74225 -8.4175,25.42375c-12.4975,28.52675 -33.5475,52.46825 -60.24,68.51075c-15.797,9.5475 -31.389,14.8075 -49.34175,18.8775c-3.1155,0.705 -14.491,1.8725 -16.0435,2.5h-23.32525c-1.70625,-0.6425 -12.53625,-1.6325 -15.91975,-2.3925c-15.76,-3.545 -30.024,-8.035 -44.217,-15.7525c-29.30175,-15.9175 -52.4845,-41.11925 -65.9052,-71.6455c-5.01527,-11.5625 -8.586,-24.659 -10.87763,-37.0395c-0.6535,-3.5305 -0.62435,-9.02225 -1.66492,-12.2455v-21.62875c0.52103,-1.477 0.93812,-6.6495 1.14481,-8.472c0.29498,-2.50825 0.68585,-5.004 1.17177,-7.48225c2.41594,-12.9415 7.84032,-29.3795 13.75957,-41.09325c18.6896,-37.13075 52.06885,-64.74277 92.0446,-76.14122c8.96775,-2.6359 17.584,-4.64987 26.92525,-5.39709c1.28975,-0.10318 2.782,-0.27301 3.9915,-0.70993z" fill="#363636"/>
|
||||
<path d="M108.58775,62.8495c6.25375,-0.7235 16.46725,1.6955 21.44525,5.73075c4.65025,3.7695 6.1835,8.268 6.86525,13.9075c1.4035,11.309 -1.807,22.7065 -8.90675,31.6205c-8.35475,10.6295 -23.74125,21.3145 -37.39825,22.944c-2.8465,0.33975 -7.82125,-0.03125 -10.0625,-1.9735c-4.30075,-3.7265 -2.59925,-9.74875 -3.1935,-14.50775l-0.0355,-0.27025c10.6825,2.91175 17.28175,1.44625 26.36675,-4.43125c5.64975,-3.65525 10.177,-6.0065 13.3075,-12.434c2.588,-5.31375 5.45175,-11.529 3.715,-17.52675c-2.3655,-8.13925 -12.1815,-8.96775 -18.98625,-6.631c-9.44825,3.2445 -19.13875,14.31975 -23.248,23.183c-11.66975,25.17 -18.909,68.57875 -10.0315,95.104c7.45475,20.60175 24.0305,31.25325 44.091,17.803c4.64375,-2.6245 12.56225,-9.70325 18.0205,-5.98575c1.8175,1.2835 3.5795,5.22075 2.76375,7.3735c-4.7055,12.56 -24.66,18.7565 -36.55375,20.17225c-30.8965,3.44225 -44.8805,-27.21675 -47.02125,-53.2865c-1.99075,-26.48375 0.619,-54.4925 10.1055,-79.41625c7.4265,-19.51175 27.27,-39.3095 48.75675,-41.3755z" fill="#55baab"/>
|
||||
<path d="M220.009,66.6745c2.064,0.06 4.79325,0.36425 6.80725,0.78425c4.715,0.9835 6.05075,2.3885 8.5015,6.08375c-6.63975,5.051 -7.15375,10.39475 -6.9695,18.1695c0.41325,17.423 4.839,48.35625 -18.67875,52.76325c-8.24,1.54425 -13.9785,-2.944 -20.56225,-6.87875c-2.716,4.54475 -5.37625,6.121 -10.609,7.22925c-3.9915,0.8455 -9.97875,0.17425 -13.2875,-2.361c-12.609,-9.68875 -10.3295,-28.95675 -10.13475,-43.005c0.139,-10.01075 2.1095,-19.204 -7.244,-25.663c2.54625,-3.9505 5.47975,-7.5475 10.80075,-6.621c3.22975,0.56225 5.1465,2.3205 7.0055,4.91075c4.53175,8.1595 1.9215,25.46325 2.387,34.8755c0.07875,6.66925 -1.0125,21.039 7.02875,23.72775c4.3835,0.839 7.7395,-3.42275 8.4635,-7.26c2.96725,-15.58 0.66225,-32.39425 1.4395,-48.04325c0.28575,-5.753 13.5475,-4.00425 13.49375,6.62675c0.171,9.04075 -0.74825,17.94025 -0.4555,27.02c0.19625,6.08575 -0.27875,13.7875 3.05425,19.19875c1.4745,2.39425 3.82525,2.67325 6.3665,3.15975c10.20475,-4.69375 6.96175,-29.25525 6.89825,-38.6445c-0.0815,-12.0215 0.3625,-15.6 5.69475,-26.07275z" fill="#55baab"/>
|
||||
<path d="M133.571,158.5725c3.636,-0.17475 7.94725,-0.187 11.50725,0.24925c0.70375,4.0855 2.2815,9.8565 3.33075,13.966c1.51625,5.93975 3.05,12.4805 4.77075,18.33525c-1.41725,0.09975 -3.23975,0.1825 -4.608,0.3525c-5.4995,0.683 -5.74525,-1.53175 -6.8015,-6.16975l-0.2485,-0.91375c-0.9915,-0.7595 -6.1185,-0.409 -7.68,-0.391c-0.12075,1.079 -0.26025,2.15575 -0.41825,3.23c-0.85925,5.60925 -5.624,4.3385 -9.784,3.64475c2.676,-8.77025 5.567,-22.55725 8.33825,-31.7705c0.103,-0.3425 1.124,-0.441 1.59325,-0.53275z" fill="#55baab"/>
|
||||
<path d="M137.67625,168.0935c0.573,0.65475 2.004,8.601 2.2045,9.975c-0.59975,0.302 -1.7755,0.18575 -2.5105,0.1755c-0.8505,0.0505 -1.0385,0.05625 -1.8725,-0.10225c-0.14275,-0.4425 1.90875,-8.86875 2.1785,-10.04825z" fill="#363636"/>
|
||||
<path d="M163.78725,200.57775c3.445,-0.357 5.69075,-0.44425 9.1405,0.24425c0.47025,5.738 1.03475,17.5505 0.9005,23.0705c-2.77075,0.5005 -3.7995,0.41925 -6.4955,0.0175c-0.351,-3.5955 -0.57,-8.3815 -0.575,-11.9725l-0.06775,-0.8475l-0.262,0.003c-1.1725,2.237 -1.746,5.1805 -2.46625,7.6235c-0.40025,1.35875 -1.16425,4.83775 -2.587,5.4675c-0.6905,0.30575 -2.1215,0.034 -2.8125,-0.23175c-1.25325,-0.482 -4.45225,-12.557 -5.343,-14.673c0.3565,3.61675 0.1325,10.93775 -0.30775,14.5935c-1.634,0.3275 -4.6195,0.80675 -6.06275,-0.088c-0.45125,-1.89025 0.1685,-21.02675 0.57025,-22.8905c0.689,-0.50975 1.642,-0.481 2.516,-0.5495c6.59075,-0.62175 7.0025,1.377 8.6625,7.0625c0.43525,1.491 1.167,3.23275 1.77725,4.68075c0.5625,-3.04825 2.418,-8.47825 3.4125,-11.51025z" fill="#55baab"/>
|
||||
<path d="M207.395,165.829c2.54825,-0.33475 5.775,0.37375 7.867,1.84725c6.26875,4.4155 6.28725,17.7495 -0.08175,22.109c-1.82825,1.2515 -3.309,1.5835 -5.4755,1.878c-2.8045,0.168 -5.7155,-0.237 -7.99975,-1.92975c-6.89825,-5.112 -6.24075,-19.34925 1.91,-23.00325c1.195,-0.53575 2.497,-0.7105 3.78,-0.90125z" fill="#55baab"/>
|
||||
<path d="M208.171,172.7055c6.1125,1.1955 5.5575,10.4295 0.86175,12.1c-0.35975,-0.01025 -1.36925,-0.099 -1.683,-0.2355c-3.75925,-1.63475 -3.72375,-8.17775 -1.10825,-10.7775c0.4005,-0.398 1.3955,-0.84325 1.9295,-1.087z" fill="#363636"/>
|
||||
<path d="M157.93575,166.352c1.674,-0.16875 3.49275,0.08925 5.18,0.25575c0.174,3.52525 -0.23875,14.75375 0.3305,17.13675c1.13025,1.29625 2.52525,0.93975 3.88225,0.328c0.8655,-1.21125 0.47525,-14.91975 0.48775,-17.50675c3.3695,-0.32525 4.517,-0.29275 7.85725,0.052c0.07425,2.824 0.02575,5.91325 0.03375,8.75725c0.00875,3.17825 0.3395,8.7455 -0.88175,11.56875c-2.96725,7.00425 -16.66725,5.79775 -18.8995,-1.06675c-1.184,-3.64125 -0.37225,-9.7125 -0.5335,-13.5815c-0.06925,-1.66225 -0.5215,-3.85025 0.2075,-5.301c0.917,-0.729 0.95475,-0.509 2.33575,-0.6425z" fill="#55baab"/>
|
||||
<path d="M235.50775,200.569c7.75675,-0.54525 17.05225,-0.39325 17.24475,9.75175c0.0825,4.45025 -0.1125,7.9975 -3.2895,11.5235c-3.50475,3.0335 -10.21525,2.60375 -14.61375,2.2625c-0.7895,-4.17375 -0.19875,-16.96525 -0.2245,-21.8865c-0.00475,-0.866 0.29975,-1.216 0.883,-1.65125z" fill="#55baab"/>
|
||||
<path d="M241.14675,206.66275c2.05975,0.224 3.5385,0.63775 4.23525,2.82875c0.74825,2.3535 0.13925,5.408 -0.911,7.56375c-0.854,0.65525 -2.12825,1.1405 -3.16175,0.73725c-0.4375,-1.51975 -0.1925,-9.14525 -0.1625,-11.12975z" fill="#363636"/>
|
||||
<path d="M184.89925,200.59925c2.50075,-0.1665 4.65375,0.0055 7.14075,0.16025c1.691,7.718 4.23375,15.64425 5.82625,23.3725c-5.9055,0.458 -7.0335,0.984 -8.28,-5.01325c-1.39975,-0.223 -3.9785,-0.131 -5.50675,-0.1185c-0.4115,1.84675 -0.71025,3.081 -1.307,4.882c-2.565,0.473 -3.6675,0.45275 -6.167,0.139l3.69625,-14.39975c0.676,-2.6035 1.47375,-6.077 2.3565,-8.55775c0.64375,-0.5455 1.34825,-0.439 2.241,-0.4645z" fill="#55baab"/>
|
||||
<path d="M186.65375,207.6975c0.77425,0.85425 1.91725,5.57775 1.55225,6.6995l-0.64375,0.10725c-1.02075,0.01525 -1.3695,0.2015 -2.10725,-0.27225c-0.5675,-1.4045 0.738,-4.76275 1.19875,-6.5345z" fill="#363636"/>
|
||||
<path d="M217.3875,200.5205c4.21375,-0.075 9.15225,-0.13225 13.32475,0.0735c0.31575,2.38475 0.2135,3.50025 0.017,5.8505c-2.02125,0.1015 -4.615,0.0315 -6.6795,0.03375l-0.0145,3.28925c5.2605,-0.1145 5.28075,-0.0945 4.73675,5.114c-1.38425,0.11775 -3.3755,0.1145 -4.81175,0.15075c-0.00575,1.02525 -0.08425,2.06575 0.2215,3.03525l0.51275,0.18475c2.101,-0.01125 4.09225,-0.06575 6.1815,0.17875c0.24675,2.31475 0.27025,3.4665 -0.229,5.73425c-3.98725,0.217 -9.19975,0.1365 -13.2015,0.00425c-0.2825,-2.441 -0.17325,-6.89275 -0.16925,-9.4805c-0.02625,-4.72325 0.01075,-9.4465 0.11125,-14.1685z" fill="#55baab"/>
|
||||
<path d="M178.026,166.32675c2.32625,-0.11975 15.34375,-0.4595 16.61275,0.43125c0.5365,0.91375 0.49525,1.60075 0.54425,2.61575c0.19775,4.10175 -1.81225,3.43125 -5.18375,3.35025l0.03325,14.2185l-0.0365,4.193c-2.426,0.51625 -4.57275,0.49475 -6.99675,0.0275c-0.282,-5.5655 -0.232,-12.81375 -0.08625,-18.4345c-5.4705,0.19225 -6.2655,-0.6875 -4.887,-6.40175z" fill="#55baab"/>
|
||||
<path d="M198.7775,200.50675c4.4635,-0.0335 8.90475,-0.06175 13.36825,-0.0155c3.7045,0.03825 2.8025,3.22125 2.4005,5.9765l-4.51775,0.0025l-0.01325,13.2295l-0.04225,4.45125c-2.193,0.16575 -4.4695,0.0935 -6.67425,0.06075c-0.1935,-5.66625 -0.05475,-12.0155 -0.05425,-17.73175c-1.5065,0.0255 -3.09775,-0.00225 -4.61175,-0.0065c-0.4795,-2.268 -0.577,-3.76525 0.14475,-5.96675z" fill="#55baab"/>
|
||||
<path d="M223.269,180.297c2.4135,-0.10925 20.56725,-1.811 15.21575,4.13675c-2.26475,0.16925 -14.38,0.49925 -15.46675,-0.6345c-0.45675,-1.331 -0.16975,-2.216 0.251,-3.50225z" fill="#55baab"/>
|
||||
</g>
|
||||
</svg>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.6 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 12 KiB |
+136
-27
@@ -11,7 +11,7 @@ import sys
|
||||
import os
|
||||
import mimetypes
|
||||
|
||||
from flask import Flask
|
||||
from flask import Flask, g, session
|
||||
from .MyLoginManager import MyLoginManager
|
||||
from flask_principal import Principal
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
@@ -25,7 +25,7 @@ from .dep_check import dependency_check
|
||||
from .updater import Updater
|
||||
from . import config_sql
|
||||
from . import cache_buster
|
||||
from . import ub, db
|
||||
from . import ub, db, magic_shelf
|
||||
|
||||
try:
|
||||
from flask_limiter import Limiter
|
||||
@@ -165,8 +165,8 @@ def create_app():
|
||||
lm.anonymous_user = ub.Anonymous
|
||||
lm.session_protection = 'strong' if config.config_session == 1 else "basic"
|
||||
|
||||
db.CalibreDB.update_config(config)
|
||||
db.CalibreDB.setup_db(config.config_calibre_dir, cli_param.settings_path)
|
||||
from .calibre_init import init_calibre_db_from_config
|
||||
init_calibre_db_from_config(config, cli_param.settings_path)
|
||||
calibre_db.init_db()
|
||||
|
||||
updater_thread.init_updater(config, web_server)
|
||||
@@ -207,6 +207,17 @@ def create_app():
|
||||
else:
|
||||
babel.init_app(app, locale_selector=get_locale)
|
||||
|
||||
# Initialize OAuth blueprints AFTER babel to ensure translations are loaded
|
||||
# Issue: OAuth blueprint generation was happening during module import (before babel init),
|
||||
# causing babel.list_translations() to return empty list and hiding language options
|
||||
if ub.oauth_support:
|
||||
try:
|
||||
from . import oauth_bb
|
||||
oauth_bb.init_oauth_blueprints()
|
||||
log.info("OAuth blueprints initialized successfully")
|
||||
except Exception as e:
|
||||
log.error("Failed to initialize OAuth blueprints: %s", e)
|
||||
|
||||
from . import services
|
||||
|
||||
if services.ldap:
|
||||
@@ -233,6 +244,127 @@ def create_app():
|
||||
# Ensure a valid calibre_db session exists before handling each request
|
||||
@app.before_request
|
||||
def _cwa_ensure_db_session():
|
||||
from flask import g, request
|
||||
from .cw_login import current_user
|
||||
from sqlalchemy import or_
|
||||
import time
|
||||
|
||||
if config.config_allow_reverse_proxy_header_login:
|
||||
"""
|
||||
Load user from reverse proxy authentication header if configured.
|
||||
Sets g.flask_httpauth_user early so that current_user proxy resolves correctly
|
||||
for user-specific settings like theme preferences.
|
||||
|
||||
This must run before any blueprint before_request handlers that access current_user.
|
||||
"""
|
||||
|
||||
from . import usermanagement
|
||||
user = usermanagement.load_user_from_reverse_proxy_header(request)
|
||||
if user:
|
||||
g.flask_httpauth_user = user
|
||||
else:
|
||||
# Explicitly set to None to indicate we checked but found nothing
|
||||
g.flask_httpauth_user = None
|
||||
|
||||
if current_user.is_authenticated:
|
||||
try:
|
||||
# Verify required tables exist before querying
|
||||
from sqlalchemy import inspect
|
||||
inspector = inspect(ub.session.bind)
|
||||
required_tables = ['magic_shelf', 'hidden_magic_shelf_templates']
|
||||
existing_tables = inspector.get_table_names()
|
||||
|
||||
missing_tables = [t for t in required_tables if t not in existing_tables]
|
||||
if missing_tables:
|
||||
log.error(f"Magic shelf tables missing from database: {missing_tables}. Run migration to create them.")
|
||||
g.magic_shelves_access = []
|
||||
return
|
||||
|
||||
# Get hidden items for this user (both system templates and custom shelves)
|
||||
hidden_items = ub.session.query(
|
||||
ub.HiddenMagicShelfTemplate.template_key,
|
||||
ub.HiddenMagicShelfTemplate.shelf_id
|
||||
).filter(
|
||||
ub.HiddenMagicShelfTemplate.user_id == current_user.id
|
||||
).all()
|
||||
|
||||
hidden_template_keys = {item.template_key for item in hidden_items if item.template_key}
|
||||
hidden_shelf_ids = {item.shelf_id for item in hidden_items if item.shelf_id}
|
||||
|
||||
# Get user's own shelves + public shelves (will filter hidden ones below)
|
||||
g.magic_shelves_access = ub.session.query(ub.MagicShelf).filter(
|
||||
or_(
|
||||
ub.MagicShelf.is_public == 1,
|
||||
ub.MagicShelf.user_id == current_user.id
|
||||
)
|
||||
).all()
|
||||
|
||||
log.debug(f"Found {len(g.magic_shelves_access)} total magic shelves for user {current_user.id} before filtering")
|
||||
|
||||
# Filter out hidden items
|
||||
from . import magic_shelf
|
||||
filtered_shelves = []
|
||||
for shelf in g.magic_shelves_access:
|
||||
# Skip hidden system templates
|
||||
if shelf.is_system and shelf.user_id == current_user.id:
|
||||
# Find template key for this system shelf
|
||||
template_key = None
|
||||
for key, template in magic_shelf.SYSTEM_SHELF_TEMPLATES.items():
|
||||
if template['name'] == shelf.name:
|
||||
template_key = key
|
||||
break
|
||||
|
||||
# If template_key not found, this is an orphaned/deprecated system shelf
|
||||
if template_key is None:
|
||||
log.warning(f"System shelf '{shelf.name}' (ID: {shelf.id}) doesn't match any current template - may need migration")
|
||||
# Show it anyway - migration should clean it up on next restart
|
||||
filtered_shelves.append(shelf)
|
||||
continue
|
||||
|
||||
# Skip if hidden
|
||||
if template_key in hidden_template_keys:
|
||||
log.debug(f"Hiding system shelf template '{template_key}' for user {current_user.id}")
|
||||
continue
|
||||
|
||||
# Skip hidden custom public shelves (not owned by user)
|
||||
if shelf.is_public == 1 and shelf.user_id != current_user.id:
|
||||
if shelf.id in hidden_shelf_ids:
|
||||
log.debug(f"Hiding public shelf '{shelf.name}' (ID: {shelf.id}) for user {current_user.id}")
|
||||
continue
|
||||
|
||||
filtered_shelves.append(shelf)
|
||||
|
||||
g.magic_shelves_access = filtered_shelves
|
||||
log.debug(f"Filtered to {len(filtered_shelves)} visible magic shelves for user {current_user.id}")
|
||||
|
||||
# Magic Shelf Count Caching
|
||||
if 'magic_shelf_counts' not in session:
|
||||
session['magic_shelf_counts'] = {}
|
||||
|
||||
counts = session['magic_shelf_counts']
|
||||
cache_updated = False
|
||||
now = time.time()
|
||||
CACHE_DURATION = 300 # 5 minutes
|
||||
|
||||
for shelf in g.magic_shelves_access:
|
||||
shelf_id_str = str(shelf.id)
|
||||
cached_data = counts.get(shelf_id_str)
|
||||
|
||||
if cached_data and (now - cached_data.get('timestamp', 0) < CACHE_DURATION):
|
||||
shelf.book_count = cached_data['count']
|
||||
else:
|
||||
count = magic_shelf.get_book_count_for_magic_shelf(shelf.id)
|
||||
counts[shelf_id_str] = {'count': count, 'timestamp': now}
|
||||
shelf.book_count = count
|
||||
cache_updated = True
|
||||
|
||||
if cache_updated:
|
||||
session.modified = True
|
||||
except Exception as e:
|
||||
log.error(f"Error populating magic shelves for user {current_user.id}: {str(e)}", exc_info=True)
|
||||
g.magic_shelves_access = []
|
||||
else:
|
||||
g.magic_shelves_access = []
|
||||
try:
|
||||
calibre_db.ensure_session()
|
||||
except Exception:
|
||||
@@ -244,32 +376,9 @@ def create_app():
|
||||
if calibre_db.session_factory:
|
||||
calibre_db.session_factory.remove()
|
||||
|
||||
# Load user from reverse proxy header early in request lifecycle
|
||||
# This ensures current_user resolves correctly before any code accesses user settings
|
||||
@app.before_request
|
||||
def _load_reverse_proxy_user():
|
||||
"""
|
||||
Load user from reverse proxy authentication header if configured.
|
||||
Sets g.flask_httpauth_user early so that current_user proxy resolves correctly
|
||||
for user-specific settings like theme preferences.
|
||||
|
||||
This must run before any blueprint before_request handlers that access current_user.
|
||||
"""
|
||||
from flask import g, request
|
||||
|
||||
if config.config_allow_reverse_proxy_header_login:
|
||||
from . import usermanagement
|
||||
user = usermanagement.load_user_from_reverse_proxy_header(request)
|
||||
if user:
|
||||
g.flask_httpauth_user = user
|
||||
else:
|
||||
# Explicitly set to None to indicate we checked but found nothing
|
||||
g.flask_httpauth_user = None
|
||||
|
||||
from .schedule import register_scheduled_tasks, register_startup_tasks
|
||||
register_scheduled_tasks(config.schedule_reconnect)
|
||||
register_startup_tasks()
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
+301
-12
@@ -63,14 +63,17 @@ except (ImportError, SyntaxError):
|
||||
feature_support['rar'] = False
|
||||
|
||||
try:
|
||||
from .oauth_bb import oauth_check, oauthblueprints
|
||||
from . import oauth_bb
|
||||
|
||||
feature_support['oauth'] = True
|
||||
except ImportError as err:
|
||||
log.debug('Cannot import Flask-Dance, login with Oauth will not work: %s', err)
|
||||
feature_support['oauth'] = False
|
||||
oauthblueprints = []
|
||||
oauth_check = {}
|
||||
# Create a mock oauth_bb module with empty lists for when OAuth is not available
|
||||
class MockOAuth:
|
||||
oauthblueprints = []
|
||||
oauth_check = {}
|
||||
oauth_bb = MockOAuth()
|
||||
|
||||
admi = Blueprint('admin', __name__)
|
||||
|
||||
@@ -187,6 +190,193 @@ def queue_metadata_backup():
|
||||
return json.dumps(show_text)
|
||||
|
||||
|
||||
@admi.route("/hardcover_auto_fetch", methods=["POST"])
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def trigger_hardcover_auto_fetch():
|
||||
"""Manually trigger Hardcover auto-fetch task"""
|
||||
show_text = {}
|
||||
|
||||
try:
|
||||
# Check if token is available
|
||||
from os import getenv
|
||||
token_available = bool(
|
||||
getattr(config, "config_hardcover_token", None) or
|
||||
getenv("HARDCOVER_TOKEN")
|
||||
)
|
||||
|
||||
if not token_available:
|
||||
show_text['text'] = _('Error: No Hardcover token available. Set HARDCOVER_TOKEN environment variable or configure in Basic Configuration.')
|
||||
return json.dumps(show_text), 400
|
||||
|
||||
# Get settings
|
||||
import sys as _sys
|
||||
if '/app/calibre-web-automated/scripts/' not in _sys.path:
|
||||
_sys.path.insert(1, '/app/calibre-web-automated/scripts/')
|
||||
from cwa_db import CWA_DB
|
||||
from cps.tasks.auto_hardcover_id import TaskAutoHardcoverID
|
||||
from cps.services.worker import WorkerThread
|
||||
|
||||
cwa_db = CWA_DB()
|
||||
cwa_settings = cwa_db.get_cwa_settings()
|
||||
|
||||
min_confidence = float(cwa_settings.get('hardcover_auto_fetch_min_confidence', 0.85))
|
||||
batch_size = int(cwa_settings.get('hardcover_auto_fetch_batch_size', 50))
|
||||
rate_limit = float(cwa_settings.get('hardcover_auto_fetch_rate_limit', 5.0))
|
||||
|
||||
# Create and enqueue task
|
||||
task = TaskAutoHardcoverID(
|
||||
min_confidence=min_confidence,
|
||||
batch_size=batch_size,
|
||||
rate_limit_delay=rate_limit
|
||||
)
|
||||
|
||||
WorkerThread.add(current_user.name, task, hidden=False)
|
||||
|
||||
log.info(f"Hardcover auto-fetch task manually triggered by {current_user.name}")
|
||||
show_text['text'] = _('Success! Hardcover auto-fetch task started. Check Tasks panel for progress.')
|
||||
return json.dumps(show_text)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error triggering Hardcover auto-fetch: {e}")
|
||||
show_text['text'] = _('Error starting Hardcover auto-fetch task: %(error)s', error=str(e))
|
||||
return json.dumps(show_text), 500
|
||||
|
||||
|
||||
@admi.route("/admin/hardcover/review-matches")
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def hardcover_review_matches():
|
||||
"""Display queue of Hardcover matches needing manual review"""
|
||||
try:
|
||||
# Get pending matches from database
|
||||
pending_matches = ub.session.query(ub.HardcoverMatchQueue).filter(
|
||||
ub.HardcoverMatchQueue.reviewed == 0
|
||||
).order_by(ub.HardcoverMatchQueue.created_at.desc()).all()
|
||||
|
||||
# Parse JSON data for each match
|
||||
matches_data = []
|
||||
for match in pending_matches:
|
||||
import json
|
||||
try:
|
||||
results = json.loads(match.hardcover_results)
|
||||
scores = json.loads(match.confidence_scores)
|
||||
|
||||
matches_data.append({
|
||||
'id': match.id,
|
||||
'book_id': match.book_id,
|
||||
'book_title': match.book_title,
|
||||
'book_authors': match.book_authors,
|
||||
'search_query': match.search_query,
|
||||
'results': results,
|
||||
'scores': scores,
|
||||
'created_at': match.created_at
|
||||
})
|
||||
except Exception as e:
|
||||
log.error(f"Error parsing match queue entry {match.id}: {e}")
|
||||
continue
|
||||
|
||||
return render_title_template(
|
||||
"hardcover_review_matches.html",
|
||||
title=_("Review Hardcover Matches"),
|
||||
page="hardcover-review",
|
||||
matches=matches_data
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error loading Hardcover review queue: {e}")
|
||||
flash(_("Error loading review queue: %(error)s", error=str(e)), category="error")
|
||||
return redirect(url_for('admin.admin'))
|
||||
|
||||
|
||||
@admi.route("/admin/hardcover/review-action", methods=["POST"])
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def hardcover_review_action():
|
||||
"""Process review action (accept/reject/skip) for a queued match"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
queue_id = int(data.get('queue_id'))
|
||||
action = data.get('action') # 'accept', 'reject', 'skip'
|
||||
selected_result_id = data.get('selected_result_id')
|
||||
|
||||
# Get queue entry
|
||||
match = ub.session.query(ub.HardcoverMatchQueue).filter(
|
||||
ub.HardcoverMatchQueue.id == queue_id
|
||||
).first()
|
||||
|
||||
if not match:
|
||||
return json.dumps({'success': False, 'error': 'Match not found'}), 404
|
||||
|
||||
if action == 'accept' and selected_result_id:
|
||||
# Apply the selected Hardcover ID to the book
|
||||
import json
|
||||
results = json.loads(match.hardcover_results)
|
||||
selected_result = next((r for r in results if str(r['id']) == str(selected_result_id)), None)
|
||||
|
||||
if not selected_result:
|
||||
return json.dumps({'success': False, 'error': 'Selected result not found'}), 400
|
||||
|
||||
# Get the book
|
||||
book = calibre_db.session.query(db.Books).filter(
|
||||
db.Books.id == match.book_id
|
||||
).first()
|
||||
|
||||
if not book:
|
||||
return json.dumps({'success': False, 'error': 'Book not found'}), 404
|
||||
|
||||
# Add identifiers
|
||||
try:
|
||||
identifiers_to_add = selected_result.get('identifiers', {})
|
||||
for id_type, id_value in identifiers_to_add.items():
|
||||
# Check if identifier already exists
|
||||
existing = calibre_db.session.query(db.Identifiers).filter(
|
||||
db.Identifiers.book == match.book_id,
|
||||
db.Identifiers.type == id_type
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
new_identifier = db.Identifiers(str(id_value), id_type, match.book_id)
|
||||
calibre_db.session.add(new_identifier)
|
||||
|
||||
calibre_db.session.commit()
|
||||
|
||||
# Mark as reviewed
|
||||
match.reviewed = 1
|
||||
match.selected_result_id = str(selected_result_id)
|
||||
match.review_action = 'accept'
|
||||
match.reviewed_at = datetime.datetime.utcnow().isoformat()
|
||||
match.reviewed_by = current_user.name
|
||||
ub.session.commit()
|
||||
|
||||
log.info(f"User {current_user.name} accepted Hardcover match for book {match.book_id}")
|
||||
return json.dumps({'success': True, 'message': _('Hardcover ID applied successfully')})
|
||||
|
||||
except Exception as e:
|
||||
calibre_db.session.rollback()
|
||||
ub.session.rollback()
|
||||
log.error(f"Error applying Hardcover ID: {e}")
|
||||
return json.dumps({'success': False, 'error': str(e)}), 500
|
||||
|
||||
elif action in ['reject', 'skip']:
|
||||
# Mark as reviewed with appropriate action
|
||||
match.reviewed = 1
|
||||
match.review_action = action
|
||||
match.reviewed_at = datetime.datetime.utcnow().isoformat()
|
||||
match.reviewed_by = current_user.name
|
||||
ub.session.commit()
|
||||
|
||||
log.info(f"User {current_user.name} {action}ed Hardcover match for book {match.book_id}")
|
||||
return json.dumps({'success': True, 'message': _('Match %(action)s', action=action)})
|
||||
|
||||
else:
|
||||
return json.dumps({'success': False, 'error': 'Invalid action'}), 400
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error processing review action: {e}")
|
||||
return json.dumps({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# method is available without login and not protected by CSRF to make it easy reachable, is per default switched off
|
||||
# needed for docker applications, as changes on metadata.db from host are not visible to application
|
||||
@admi.route("/reconnect", methods=['GET'])
|
||||
@@ -306,7 +496,7 @@ def db_configuration():
|
||||
def configuration():
|
||||
return render_title_template("config_edit.html",
|
||||
config=config,
|
||||
provider=oauthblueprints,
|
||||
provider=oauth_bb.oauthblueprints,
|
||||
feature_support=feature_support,
|
||||
title=_("Basic Configuration"), page="config")
|
||||
|
||||
@@ -659,7 +849,7 @@ def update_view_configuration():
|
||||
config.config_default_role = constants.selected_roles(to_save)
|
||||
config.config_default_role &= ~constants.ROLE_ANONYMOUS
|
||||
|
||||
config.config_default_show = sum(int(k[5:]) for k in to_save if k.startswith('show_'))
|
||||
config.config_default_show = sum(int(k[5:]) for k in to_save if k.startswith('show_') and not k.startswith('show_magic_shelf_') and not k.startswith('show_custom_shelf_'))
|
||||
if "Show_detail_random" in to_save:
|
||||
config.config_default_show |= constants.DETAIL_RANDOM
|
||||
|
||||
@@ -1208,7 +1398,7 @@ def _configuration_gdrive_helper(to_save):
|
||||
def _configuration_oauth_helper(to_save):
|
||||
reboot_required = False
|
||||
|
||||
for element in oauthblueprints:
|
||||
for element in oauth_bb.oauthblueprints:
|
||||
update = {}
|
||||
if element["provider_name"] == "generic":
|
||||
if to_save["config_generic_oauth_client_id"] != element["oauth_client_id"]:
|
||||
@@ -1438,7 +1628,7 @@ def new_user():
|
||||
return render_title_template("user_edit.html", new_user=1, content=content,
|
||||
config=config, translations=translations,
|
||||
languages=languages, title=_("Add New User"), page="newuser",
|
||||
kobo_support=kobo_support, registered_oauth=oauth_check)
|
||||
kobo_support=kobo_support, registered_oauth=oauth_bb.oauth_check)
|
||||
|
||||
|
||||
@admi.route("/admin/mailsettings", methods=["GET"])
|
||||
@@ -1582,6 +1772,30 @@ def edit_user(user_id):
|
||||
languages = calibre_db.speaking_language(return_all_languages=True)
|
||||
translations = get_available_locale()
|
||||
kobo_support = feature_support['kobo'] and config.config_kobo_sync
|
||||
|
||||
# Get system shelf templates and hidden templates for this user
|
||||
from . import magic_shelf
|
||||
system_shelf_templates = magic_shelf.SYSTEM_SHELF_TEMPLATES
|
||||
hidden_items = ub.session.query(
|
||||
ub.HiddenMagicShelfTemplate.template_key,
|
||||
ub.HiddenMagicShelfTemplate.shelf_id
|
||||
).filter(
|
||||
ub.HiddenMagicShelfTemplate.user_id == content.id
|
||||
).all()
|
||||
hidden_shelf_templates = {item.template_key for item in hidden_items if item.template_key}
|
||||
hidden_custom_shelf_ids = {item.shelf_id for item in hidden_items if item.shelf_id}
|
||||
|
||||
# Get ALL public custom shelves that user doesn't own (both hidden and visible)
|
||||
all_public_shelves = ub.session.query(ub.MagicShelf).filter(
|
||||
ub.MagicShelf.is_public == 1,
|
||||
ub.MagicShelf.user_id != content.id,
|
||||
ub.MagicShelf.is_system == False
|
||||
).all()
|
||||
|
||||
# Separate into hidden and visible
|
||||
hidden_custom_shelves = [s for s in all_public_shelves if s.id in hidden_custom_shelf_ids]
|
||||
visible_public_shelves = [s for s in all_public_shelves if s.id not in hidden_custom_shelf_ids]
|
||||
|
||||
if request.method == "POST":
|
||||
to_save = request.form.to_dict()
|
||||
resp = _handle_edit_user(to_save, content, languages, translations, kobo_support)
|
||||
@@ -1593,9 +1807,14 @@ def edit_user(user_id):
|
||||
new_user=0,
|
||||
content=content,
|
||||
config=config,
|
||||
registered_oauth=oauth_check,
|
||||
registered_oauth=oauth_bb.oauth_check,
|
||||
mail_configured=config.get_mail_server_configured(),
|
||||
kobo_support=kobo_support,
|
||||
system_shelf_templates=system_shelf_templates,
|
||||
hidden_shelf_templates=hidden_shelf_templates,
|
||||
hidden_custom_shelf_ids=hidden_custom_shelf_ids,
|
||||
hidden_custom_shelves=hidden_custom_shelves,
|
||||
visible_public_shelves=visible_public_shelves,
|
||||
title=_("Edit User %(nick)s", nick=content.name),
|
||||
page="edituser")
|
||||
|
||||
@@ -1945,6 +2164,7 @@ def _configuration_update_helper():
|
||||
_config_checkbox_int(to_save, "config_uploading")
|
||||
_config_checkbox_int(to_save, "config_unicode_filename")
|
||||
_config_checkbox_int(to_save, "config_embed_metadata")
|
||||
_config_checkbox(to_save, "config_fulltext_search")
|
||||
# Reboot on config_anonbrowse with enabled ldap, as decoraters are changed in this case
|
||||
reboot_required |= (_config_checkbox_int(to_save, "config_anonbrowse")
|
||||
and config.config_login_type == constants.LOGIN_LDAP)
|
||||
@@ -2059,6 +2279,7 @@ def _configuration_update_helper():
|
||||
|
||||
# security configuration
|
||||
_config_checkbox(to_save, "config_disable_standard_login")
|
||||
_config_checkbox(to_save, "config_enable_oauth_group_admin_management")
|
||||
_config_checkbox(to_save, "config_check_extensions")
|
||||
_config_checkbox(to_save, "config_password_policy")
|
||||
_config_checkbox(to_save, "config_password_number")
|
||||
@@ -2089,10 +2310,19 @@ def _configuration_update_helper():
|
||||
_configuration_result(_("Oops! Database Error: %(error)s.", error=e.orig))
|
||||
|
||||
config.save()
|
||||
|
||||
# Note: FTS initialization is now primarily managed via CWA Settings page
|
||||
# This just provides basic validation
|
||||
fts_warning = None
|
||||
if config.config_fulltext_search and not config.config_calibre_dir:
|
||||
fts_warning = _('Full Text Search enabled but no library path configured.')
|
||||
|
||||
if reboot_required:
|
||||
web_server.stop(True)
|
||||
|
||||
return _configuration_result(None, reboot_required, " ".join(filter(None, [unrar_warning, arch_warning])))
|
||||
# Combine all warnings
|
||||
combined_warnings = " ".join(filter(None, [unrar_warning, arch_warning, fts_warning]))
|
||||
return _configuration_result(None, reboot_required, combined_warnings if combined_warnings else None)
|
||||
|
||||
|
||||
def _configuration_result(error_flash=None, reboot=False, warning_flash=None):
|
||||
@@ -2174,7 +2404,7 @@ def _handle_new_user(to_save, content, languages, translations, kobo_support):
|
||||
config=config,
|
||||
translations=translations,
|
||||
languages=languages, title=_("Add new user"), page="newuser",
|
||||
kobo_support=kobo_support, registered_oauth=oauth_check)
|
||||
kobo_support=kobo_support, registered_oauth=oauth_bb.oauth_check)
|
||||
try:
|
||||
content.allowed_tags = config.config_allowed_tags
|
||||
content.denied_tags = config.config_denied_tags
|
||||
@@ -2252,7 +2482,7 @@ def _handle_edit_user(to_save, content, languages, translations, kobo_support):
|
||||
flash(_("No admin user remaining, can't remove admin role"), category="error")
|
||||
return redirect(url_for('admin.admin'))
|
||||
|
||||
val = [int(k[5:]) for k in to_save if k.startswith('show_')]
|
||||
val = [int(k[5:]) for k in to_save if k.startswith('show_') and not k.startswith('show_magic_shelf_') and not k.startswith('show_custom_shelf_')]
|
||||
sidebar, __ = get_sidebar_config()
|
||||
for element in sidebar:
|
||||
value = element['visibility']
|
||||
@@ -2276,6 +2506,65 @@ def _handle_edit_user(to_save, content, languages, translations, kobo_support):
|
||||
# Auto-send and metadata fetch settings
|
||||
content.auto_send_enabled = to_save.get("auto_send_enabled") == "on"
|
||||
content.auto_metadata_fetch = to_save.get("auto_metadata_fetch") == "on"
|
||||
|
||||
# Handle hidden magic shelf templates and custom shelves
|
||||
from . import magic_shelf
|
||||
if not content.is_anonymous:
|
||||
# Get all system template keys
|
||||
all_template_keys = set(magic_shelf.SYSTEM_SHELF_TEMPLATES.keys())
|
||||
# Get currently hidden items for this user
|
||||
current_hidden = ub.session.query(ub.HiddenMagicShelfTemplate).filter(
|
||||
ub.HiddenMagicShelfTemplate.user_id == content.id
|
||||
).all()
|
||||
current_hidden_template_keys = {h.template_key for h in current_hidden if h.template_key}
|
||||
current_hidden_shelf_ids = {h.shelf_id for h in current_hidden if h.shelf_id}
|
||||
|
||||
# Handle system templates
|
||||
visible_template_keys = {key for key in all_template_keys if to_save.get(f"show_magic_shelf_{key}") == "on"}
|
||||
should_be_hidden_templates = all_template_keys - visible_template_keys
|
||||
|
||||
# Add newly hidden templates
|
||||
for key in should_be_hidden_templates:
|
||||
if key not in current_hidden_template_keys:
|
||||
new_hidden = ub.HiddenMagicShelfTemplate(
|
||||
user_id=content.id,
|
||||
template_key=key
|
||||
)
|
||||
ub.session.add(new_hidden)
|
||||
log.info(f"User {content.id} hid system shelf template '{key}'")
|
||||
|
||||
# Remove templates that should no longer be hidden
|
||||
for hidden in current_hidden:
|
||||
if hidden.template_key and hidden.template_key in visible_template_keys:
|
||||
ub.session.delete(hidden)
|
||||
log.info(f"User {content.id} unhid system shelf template '{hidden.template_key}'")
|
||||
|
||||
# Handle custom public shelves - get all available ones
|
||||
all_public_shelves = ub.session.query(ub.MagicShelf).filter(
|
||||
ub.MagicShelf.is_public == 1,
|
||||
ub.MagicShelf.user_id != content.id,
|
||||
ub.MagicShelf.is_system == False
|
||||
).all()
|
||||
|
||||
# Check which ones should be visible (checked)
|
||||
visible_shelf_ids = {s.id for s in all_public_shelves if to_save.get(f"show_custom_shelf_{s.id}") == "on"}
|
||||
|
||||
# Hide shelves that are unchecked but not currently hidden
|
||||
for shelf in all_public_shelves:
|
||||
if shelf.id not in visible_shelf_ids and shelf.id not in current_hidden_shelf_ids:
|
||||
new_hidden = ub.HiddenMagicShelfTemplate(
|
||||
user_id=content.id,
|
||||
shelf_id=shelf.id
|
||||
)
|
||||
ub.session.add(new_hidden)
|
||||
log.info(f"User {content.id} hid custom shelf {shelf.id}")
|
||||
|
||||
# Unhide shelves that are checked but currently hidden
|
||||
for hidden in current_hidden:
|
||||
if hidden.shelf_id and hidden.shelf_id in visible_shelf_ids:
|
||||
ub.session.delete(hidden)
|
||||
log.info(f"User {content.id} unhid custom shelf {hidden.shelf_id}")
|
||||
|
||||
if to_save.get("default_language"):
|
||||
content.default_language = to_save["default_language"]
|
||||
if to_save.get("locale"):
|
||||
@@ -2316,7 +2605,7 @@ def _handle_edit_user(to_save, content, languages, translations, kobo_support):
|
||||
new_user=0,
|
||||
content=content,
|
||||
config=config,
|
||||
registered_oauth=oauth_check,
|
||||
registered_oauth=oauth_bb.oauth_check,
|
||||
title=_("Edit User %(nick)s", nick=content.name),
|
||||
page="edituser")
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import sqlite3
|
||||
|
||||
from cps import db, logger
|
||||
|
||||
log = logger.create()
|
||||
|
||||
DEFAULT_TITLE_SORT_REGEX = (
|
||||
r'^(A|The|An|Der|Die|Das|Den|Ein|Eine|Einen|Dem|Des|Einem|Eines|Le|La|Les|L\'|Un|Une)\s+'
|
||||
)
|
||||
|
||||
|
||||
class _MinimalConfig:
|
||||
def __init__(self, title_regex, calibre_dir):
|
||||
self.config_title_regex = title_regex
|
||||
self.config_calibre_dir = calibre_dir
|
||||
|
||||
|
||||
def init_calibre_db_from_config(config, settings_path):
|
||||
"""Initialize CalibreDB using an already-loaded config object."""
|
||||
if db.CalibreDB.session_factory and getattr(db.CalibreDB.config, "config_title_regex", None):
|
||||
return True
|
||||
db.CalibreDB.update_config(config)
|
||||
db.CalibreDB.setup_db(config.config_calibre_dir, settings_path)
|
||||
return db.CalibreDB.session_factory is not None
|
||||
|
||||
|
||||
def init_calibre_db_from_app_db(app_db_path="/config/app.db"):
|
||||
"""Initialize CalibreDB by reading config from app.db (for background workers)."""
|
||||
if db.CalibreDB.session_factory and getattr(db.CalibreDB.config, "config_title_regex", None):
|
||||
return True
|
||||
calibre_dir = None
|
||||
title_regex = None
|
||||
try:
|
||||
with sqlite3.connect(app_db_path, timeout=30) as con:
|
||||
cur = con.cursor()
|
||||
row = cur.execute(
|
||||
"SELECT config_calibre_dir, config_title_regex FROM settings LIMIT 1"
|
||||
).fetchone()
|
||||
if row:
|
||||
calibre_dir, title_regex = row[0], row[1]
|
||||
except Exception as e:
|
||||
log.error(f"Failed to read calibre settings from {app_db_path}: {e}")
|
||||
return False
|
||||
if not calibre_dir:
|
||||
log.error("Calibre library path missing in app.db; cannot initialize CalibreDB")
|
||||
return False
|
||||
title_regex = title_regex or DEFAULT_TITLE_SORT_REGEX
|
||||
db.CalibreDB.update_config(_MinimalConfig(title_regex, calibre_dir))
|
||||
db.CalibreDB.setup_db(calibre_dir, app_db_path)
|
||||
return db.CalibreDB.session_factory is not None
|
||||
+17
-9
@@ -88,6 +88,7 @@ class _Settings(_Base):
|
||||
config_remote_login = Column(Boolean, default=False)
|
||||
config_use_https = Column(Boolean, default=False)
|
||||
config_kobo_sync = Column(Boolean, default=False)
|
||||
config_kobo_sync_magic_shelves = Column(Boolean, default=False)
|
||||
|
||||
# Sync read progress to Hardcover - should this be renamed?
|
||||
config_hardcover_sync = Column(Boolean, default=False)
|
||||
@@ -145,6 +146,7 @@ class _Settings(_Base):
|
||||
config_upload_formats = Column(String, default=','.join(constants.EXTENSIONS_UPLOAD))
|
||||
config_unicode_filename = Column(Boolean, default=False)
|
||||
config_embed_metadata = Column(Boolean, default=True)
|
||||
config_fulltext_search = Column(Boolean, default=False)
|
||||
|
||||
config_updatechannel = Column(Integer, default=constants.UPDATE_STABLE)
|
||||
|
||||
@@ -154,6 +156,7 @@ class _Settings(_Base):
|
||||
config_ldap_auto_create_users = Column(Boolean, default=True)
|
||||
config_oauth_redirect_host = Column(String, default='')
|
||||
config_disable_standard_login = Column(Boolean, default=False)
|
||||
config_enable_oauth_group_admin_management = Column(Boolean, default=True)
|
||||
|
||||
schedule_start_time = Column(Integer, default=4)
|
||||
schedule_duration = Column(Integer, default=10)
|
||||
@@ -185,6 +188,7 @@ class ConfigSQL(object):
|
||||
# pylint: disable=no-member
|
||||
def __init__(self):
|
||||
self.__dict__["dirty"] = list()
|
||||
self.cli = None
|
||||
|
||||
def init_config(self, session, secret_key, cli):
|
||||
self._session = session
|
||||
@@ -235,21 +239,25 @@ class ConfigSQL(object):
|
||||
return self._settings
|
||||
|
||||
def get_config_certfile(self):
|
||||
if self.cli.certfilepath:
|
||||
return self.cli.certfilepath
|
||||
if self.cli.certfilepath == "":
|
||||
return None
|
||||
if self.cli:
|
||||
if self.cli.certfilepath:
|
||||
return self.cli.certfilepath
|
||||
if self.cli.certfilepath == "":
|
||||
return None
|
||||
return self.config_certfile
|
||||
|
||||
def get_config_keyfile(self):
|
||||
if self.cli.keyfilepath:
|
||||
return self.cli.keyfilepath
|
||||
if self.cli.certfilepath == "":
|
||||
return None
|
||||
if self.cli:
|
||||
if self.cli.keyfilepath:
|
||||
return self.cli.keyfilepath
|
||||
if self.cli.certfilepath == "":
|
||||
return None
|
||||
return self.config_keyfile
|
||||
|
||||
def get_config_ipaddress(self):
|
||||
return self.cli.ip_address or ""
|
||||
if self.cli:
|
||||
return self.cli.ip_address or ""
|
||||
return ""
|
||||
|
||||
def _has_role(self, role_flag):
|
||||
return constants.has_flag(self.config_default_role, role_flag)
|
||||
|
||||
@@ -30,6 +30,9 @@ def get_locale():
|
||||
preferred = list()
|
||||
if has_request_context() and request.accept_languages:
|
||||
for x in request.accept_languages.values():
|
||||
# Skip wildcard '*' from Accept-Language headers (common in internal API requests)
|
||||
if x == '*':
|
||||
continue
|
||||
try:
|
||||
preferred.append(str(Locale.parse(x.replace('-', '_'))))
|
||||
except (UnknownLocaleError, ValueError) as e:
|
||||
|
||||
@@ -21,6 +21,10 @@ from flask import session
|
||||
from flask import url_for
|
||||
from werkzeug.local import LocalProxy
|
||||
|
||||
import sys
|
||||
sys.path.insert(1, '/app/calibre-web-automated/scripts/')
|
||||
from cwa_db import CWA_DB
|
||||
|
||||
from .config import COOKIE_NAME
|
||||
from .config import EXEMPT_METHODS
|
||||
from .signals import user_logged_in
|
||||
@@ -208,6 +212,17 @@ def login_user(user, remember=False, duration=None, force=False, fresh=True):
|
||||
f"duration must be a datetime.timedelta, instead got: {duration}"
|
||||
) from e
|
||||
|
||||
# CWA Stats Logging
|
||||
try:
|
||||
cwa_db = CWA_DB()
|
||||
cwa_db.log_activity(
|
||||
user_id=user_id,
|
||||
user_name=getattr(user, 'nickname', 'Unknown'),
|
||||
event_type="LOGIN"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Failed to log login stats: {e}")
|
||||
|
||||
current_app.login_manager._update_request_context_with_user(user)
|
||||
user_logged_in.send(current_app._get_current_object(), user=_get_user())
|
||||
return True
|
||||
|
||||
+953
-9
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import quote
|
||||
import unidecode
|
||||
@@ -19,7 +21,7 @@ import sqlite3
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import Table, Column, ForeignKey, CheckConstraint
|
||||
from sqlalchemy import String, Integer, Boolean, TIMESTAMP, Float
|
||||
from sqlalchemy.orm import relationship, sessionmaker, scoped_session
|
||||
from sqlalchemy.orm import relationship, sessionmaker, scoped_session, joinedload
|
||||
from sqlalchemy.orm.collections import InstrumentedList
|
||||
from sqlalchemy.ext.declarative import DeclarativeMeta
|
||||
from sqlalchemy.exc import OperationalError
|
||||
@@ -531,6 +533,7 @@ class CalibreDB:
|
||||
# This is a WeakSet so that references here don't keep other CalibreDB
|
||||
# instances alive once they reach the end of their respective scopes
|
||||
instances = WeakSet()
|
||||
_reconnect_lock = threading.RLock() # Reentrant lock to prevent concurrent reconnect operations
|
||||
|
||||
def __init__(self, expire_on_commit=True, init=False):
|
||||
""" Initialize a new CalibreDB session
|
||||
@@ -546,6 +549,9 @@ class CalibreDB:
|
||||
self.instances.add(self)
|
||||
|
||||
def init_session(self, expire_on_commit=True):
|
||||
if self.session_factory is None:
|
||||
log.error("Cannot init session: session_factory is None")
|
||||
return
|
||||
self.session = self.session_factory()
|
||||
self.session.expire_on_commit = expire_on_commit
|
||||
self.create_functions(self.config)
|
||||
@@ -553,23 +559,42 @@ class CalibreDB:
|
||||
def ensure_session(self, expire_on_commit=True):
|
||||
"""Ensure a valid SQLAlchemy session exists.
|
||||
This protects against brief windows where dispose() nulled the session during a reconnect.
|
||||
Holds lock during entire recreation to prevent race conditions.
|
||||
"""
|
||||
try:
|
||||
if self.session is None:
|
||||
# Recreate a session from the factory if available
|
||||
if self.session_factory is not None:
|
||||
if self.session is not None:
|
||||
return # Fast path - session already exists
|
||||
|
||||
# Session is None - need to recreate it
|
||||
# Acquire lock to ensure atomic recreation (no interruption by dispose)
|
||||
with self._reconnect_lock:
|
||||
# Double-check after acquiring lock (another thread may have recreated it)
|
||||
if self.session is not None:
|
||||
return
|
||||
|
||||
# Try to recreate session from factory
|
||||
if self.session_factory is not None:
|
||||
try:
|
||||
self.init_session(expire_on_commit)
|
||||
else:
|
||||
# As a last resort, try to rebuild the setup if config is present
|
||||
if self.config and getattr(self.config, 'config_calibre_dir', None):
|
||||
try:
|
||||
self.setup_db(self.config.config_calibre_dir, ub.app_DB_path)
|
||||
self.init_session(expire_on_commit)
|
||||
except Exception as ex:
|
||||
log.error_or_exception(ex)
|
||||
except Exception:
|
||||
# Never let session recovery raise in callers; they will fail later with proper logging
|
||||
pass
|
||||
return # Success
|
||||
except Exception as ex:
|
||||
log.error(f"Failed to init session from factory: {ex}")
|
||||
|
||||
# Factory is None or init failed - try to rebuild entire database setup
|
||||
if self.config and getattr(self.config, 'config_calibre_dir', None):
|
||||
try:
|
||||
log.warning("Session factory unavailable, attempting to rebuild database setup")
|
||||
# Note: setup_db will call dispose() which is safe because we hold _reconnect_lock (RLock is reentrant)
|
||||
self.setup_db(self.config.config_calibre_dir, ub.app_DB_path)
|
||||
# After setup_db, session_factory should exist, try to init session
|
||||
if self.session is None and self.session_factory is not None:
|
||||
self.init_session(expire_on_commit)
|
||||
except Exception as ex:
|
||||
log.error(f"Failed to rebuild database setup in ensure_session: {ex}")
|
||||
|
||||
# If we still don't have a session, log warning
|
||||
# Don't raise exception - let caller handle AttributeError if they try to use None session
|
||||
if self.session is None:
|
||||
log.error("ensure_session: Unable to create session - session factory and config unavailable")
|
||||
|
||||
@classmethod
|
||||
def setup_db_cc_classes(cls, cc):
|
||||
@@ -684,63 +709,75 @@ class CalibreDB:
|
||||
|
||||
@classmethod
|
||||
def setup_db(cls, config_calibre_dir, app_db_path):
|
||||
cls.dispose()
|
||||
# Wrap entire method in lock to ensure atomic setup operation
|
||||
# RLock is reentrant, so nested calls (e.g., from reconnect_db) are safe
|
||||
with cls._reconnect_lock:
|
||||
# Always call dispose to clean up old sessions/connections
|
||||
cls.dispose()
|
||||
|
||||
if not config_calibre_dir:
|
||||
cls.config.invalidate()
|
||||
return None
|
||||
|
||||
dbpath = os.path.join(config_calibre_dir, "metadata.db")
|
||||
if not os.path.exists(dbpath):
|
||||
cls.config.invalidate()
|
||||
return None
|
||||
|
||||
try:
|
||||
cls.engine = create_engine('sqlite://',
|
||||
echo=False,
|
||||
isolation_level="SERIALIZABLE",
|
||||
connect_args={'check_same_thread': False, 'timeout': 30},
|
||||
poolclass=StaticPool)
|
||||
with cls.engine.begin() as connection:
|
||||
connection.execute(text("attach database '{}' as calibre;".format(dbpath)))
|
||||
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path)))
|
||||
# Try enabling WAL to improve concurrency unless running on a network share
|
||||
# Controlled by env var NETWORK_SHARE_MODE (default False)
|
||||
try:
|
||||
nsm = os.getenv('NETWORK_SHARE_MODE', 'False').lower() in ('1', 'true', 'yes', 'on')
|
||||
if not nsm:
|
||||
connection.execute(text("PRAGMA calibre.journal_mode=WAL"))
|
||||
connection.execute(text("PRAGMA app_settings.journal_mode=WAL"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
conn = cls.engine.connect()
|
||||
# conn.text_factory = lambda b: b.decode(errors = 'ignore') possible fix for #1302
|
||||
except Exception as ex:
|
||||
cls.config.invalidate(ex)
|
||||
return None
|
||||
|
||||
cls.config.db_configured = True
|
||||
|
||||
if not cc_classes:
|
||||
try:
|
||||
cc = conn.execute(text("SELECT id, datatype FROM custom_columns"))
|
||||
cls.setup_db_cc_classes(cc)
|
||||
except OperationalError as e:
|
||||
log.error_or_exception(e)
|
||||
if not config_calibre_dir:
|
||||
log.error("setup_db failed: config_calibre_dir is None or empty")
|
||||
if cls.config:
|
||||
cls.config.invalidate()
|
||||
return None
|
||||
|
||||
cls.session_factory = scoped_session(sessionmaker(autocommit=False,
|
||||
autoflush=True,
|
||||
bind=cls.engine, future=True))
|
||||
for inst in cls.instances:
|
||||
inst.init_session()
|
||||
dbpath = os.path.join(config_calibre_dir, "metadata.db")
|
||||
if not os.path.exists(dbpath):
|
||||
log.error(f"setup_db failed: metadata.db not found at {dbpath}")
|
||||
if cls.config:
|
||||
cls.config.invalidate()
|
||||
return None
|
||||
|
||||
# Ensure progress syncing tables exist in metadata.db (book checksums)
|
||||
from .progress_syncing.models import ensure_calibre_db_tables
|
||||
ensure_calibre_db_tables(conn)
|
||||
try:
|
||||
cls.engine = create_engine('sqlite://',
|
||||
echo=False,
|
||||
isolation_level="SERIALIZABLE",
|
||||
connect_args={'check_same_thread': False, 'timeout': 30},
|
||||
poolclass=StaticPool)
|
||||
with cls.engine.begin() as connection:
|
||||
connection.execute(text("attach database '{}' as calibre;".format(dbpath)))
|
||||
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path)))
|
||||
# Try enabling WAL to improve concurrency unless running on a network share
|
||||
# Controlled by env var NETWORK_SHARE_MODE (default False)
|
||||
try:
|
||||
nsm = os.getenv('NETWORK_SHARE_MODE', 'False').lower() in ('1', 'true', 'yes', 'on')
|
||||
if not nsm:
|
||||
connection.execute(text("PRAGMA calibre.journal_mode=WAL"))
|
||||
connection.execute(text("PRAGMA app_settings.journal_mode=WAL"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cls._init = True
|
||||
conn = cls.engine.connect()
|
||||
# conn.text_factory = lambda b: b.decode(errors = 'ignore') possible fix for #1302
|
||||
except Exception as ex:
|
||||
log.error(f"setup_db failed during engine creation: {ex}")
|
||||
if cls.config:
|
||||
cls.config.invalidate(ex)
|
||||
return None
|
||||
|
||||
if cls.config:
|
||||
cls.config.db_configured = True
|
||||
|
||||
if not cc_classes:
|
||||
try:
|
||||
cc = conn.execute(text("SELECT id, datatype FROM custom_columns"))
|
||||
cls.setup_db_cc_classes(cc)
|
||||
except OperationalError as e:
|
||||
log.error_or_exception(e)
|
||||
return None
|
||||
|
||||
cls.session_factory = scoped_session(sessionmaker(autocommit=False,
|
||||
autoflush=True,
|
||||
bind=cls.engine, future=True))
|
||||
for inst in cls.instances:
|
||||
inst.init_session()
|
||||
|
||||
# Ensure progress syncing tables exist in metadata.db (book checksums)
|
||||
from .progress_syncing.models import ensure_calibre_db_tables
|
||||
ensure_calibre_db_tables(conn)
|
||||
|
||||
cls._init = True
|
||||
# End of with cls._reconnect_lock
|
||||
|
||||
def get_book(self, book_id):
|
||||
self.ensure_session()
|
||||
@@ -748,8 +785,20 @@ class CalibreDB:
|
||||
|
||||
def get_filtered_book(self, book_id, allow_show_archived=False):
|
||||
self.ensure_session()
|
||||
return self.session.query(Books).filter(Books.id == book_id). \
|
||||
filter(self.common_filters(allow_show_archived)).first()
|
||||
# Eagerly load all relationships to prevent detached instance errors during editing
|
||||
return (self.session.query(Books)
|
||||
.options(joinedload(Books.authors),
|
||||
joinedload(Books.tags),
|
||||
joinedload(Books.comments),
|
||||
joinedload(Books.data),
|
||||
joinedload(Books.series),
|
||||
joinedload(Books.ratings),
|
||||
joinedload(Books.languages),
|
||||
joinedload(Books.publishers),
|
||||
joinedload(Books.identifiers))
|
||||
.filter(Books.id == book_id)
|
||||
.filter(self.common_filters(allow_show_archived))
|
||||
.first())
|
||||
|
||||
def get_book_read_archived(self, book_id, read_column, allow_show_archived=False):
|
||||
self.ensure_session()
|
||||
@@ -767,6 +816,8 @@ class CalibreDB:
|
||||
log.error("Custom Column No.{} does not exist in calibre database".format(read_column))
|
||||
# Skip linking read column and return None instead of read status
|
||||
bd = self.session.query(Books, None, ub.ArchivedBook.is_archived)
|
||||
# Eagerly load the data relationship to prevent session errors
|
||||
bd = bd.options(joinedload(Books.data))
|
||||
return (bd.filter(Books.id == book_id)
|
||||
.join(ub.ArchivedBook, and_(Books.id == ub.ArchivedBook.book_id,
|
||||
int(current_user.id) == ub.ArchivedBook.user_id), isouter=True)
|
||||
@@ -780,6 +831,22 @@ class CalibreDB:
|
||||
self.ensure_session()
|
||||
return self.session.query(Data).filter(Data.book == book_id).filter(Data.format == file_format).first()
|
||||
|
||||
def get_author_by_name(self, name):
|
||||
self.ensure_session()
|
||||
return self.session.query(Authors).filter(Authors.name == name).first()
|
||||
|
||||
def get_tag_by_name(self, name):
|
||||
self.ensure_session()
|
||||
return self.session.query(Tags).filter(Tags.name == name).first()
|
||||
|
||||
def get_series_by_name(self, name):
|
||||
self.ensure_session()
|
||||
return self.session.query(Series).filter(Series.name == name).first()
|
||||
|
||||
def get_publisher_by_name(self, name):
|
||||
self.ensure_session()
|
||||
return self.session.query(Publishers).filter(Publishers.name == name).first()
|
||||
|
||||
def set_metadata_dirty(self, book_id):
|
||||
self.ensure_session()
|
||||
if not self.session.query(Metadata_Dirtied).filter(Metadata_Dirtied.book == book_id).one_or_none():
|
||||
@@ -795,7 +862,7 @@ class CalibreDB:
|
||||
log.error("Database error: {}".format(e))
|
||||
|
||||
# Language and content filters for displaying in the UI
|
||||
def common_filters(self, allow_show_archived=False, return_all_languages=False):
|
||||
def common_filters(self, allow_show_archived=False, return_all_languages=False, viewing_tag_id=None):
|
||||
if not allow_show_archived:
|
||||
archived_books = (ub.session.query(ub.ArchivedBook)
|
||||
.filter(ub.ArchivedBook.user_id==int(current_user.id))
|
||||
@@ -813,6 +880,15 @@ class CalibreDB:
|
||||
negtags_list = current_user.list_denied_tags()
|
||||
postags_list = current_user.list_allowed_tags()
|
||||
neg_content_tags_filter = false() if negtags_list == [''] else Books.tags.any(Tags.name.in_(negtags_list))
|
||||
|
||||
# Issue #906: When viewing a specific tag category, include that tag in allowed tags
|
||||
if viewing_tag_id is not None and postags_list != ['']:
|
||||
# Get the tag name for the viewing_tag_id
|
||||
viewing_tag = self.session.query(Tags).filter(Tags.id == viewing_tag_id).first()
|
||||
if viewing_tag and viewing_tag.name not in postags_list:
|
||||
# Temporarily add the viewed tag to the allowed list for this query
|
||||
postags_list = postags_list + [viewing_tag.name]
|
||||
|
||||
pos_content_tags_filter = true() if postags_list == [''] else Books.tags.any(Tags.name.in_(postags_list))
|
||||
if self.config.config_restricted_column:
|
||||
try:
|
||||
@@ -881,18 +957,22 @@ class CalibreDB:
|
||||
|
||||
# Fill indexpage with all requested data from database
|
||||
def fill_indexpage(self, page, pagesize, database, db_filter, order,
|
||||
join_archive_read=False, config_read_column=0, *join):
|
||||
join_archive_read=False, config_read_column=0, *join, **kwargs):
|
||||
self.ensure_session()
|
||||
return self.fill_indexpage_with_archived_books(page, database, pagesize, db_filter, order, False,
|
||||
join_archive_read, config_read_column, *join)
|
||||
join_archive_read, config_read_column, *join, **kwargs)
|
||||
|
||||
def fill_indexpage_with_archived_books(self, page, database, pagesize, db_filter, order, allow_show_archived,
|
||||
join_archive_read, config_read_column, *join):
|
||||
join_archive_read, config_read_column, *join, **kwargs):
|
||||
self.ensure_session()
|
||||
viewing_tag_id = kwargs.get('viewing_tag_id')
|
||||
pagesize = pagesize or self.config.config_books_per_page
|
||||
if current_user.show_detail_random():
|
||||
random_query = self.generate_linked_query(config_read_column, database)
|
||||
randm = (random_query.filter(self.common_filters(allow_show_archived))
|
||||
# Eagerly load the data relationship for random books to prevent session errors
|
||||
if database == Books:
|
||||
random_query = random_query.options(joinedload(Books.data))
|
||||
randm = (random_query.filter(self.common_filters(allow_show_archived, viewing_tag_id=viewing_tag_id))
|
||||
.order_by(func.random())
|
||||
.limit(self.config.config_random_books).all())
|
||||
else:
|
||||
@@ -901,6 +981,11 @@ class CalibreDB:
|
||||
query = self.generate_linked_query(config_read_column, database)
|
||||
else:
|
||||
query = self.session.query(database)
|
||||
|
||||
# Eagerly load the data relationship to prevent DetachedInstanceError in templates
|
||||
if database == Books:
|
||||
query = query.options(joinedload(Books.data))
|
||||
|
||||
off = int(int(pagesize) * (page - 1))
|
||||
|
||||
indx = len(join)
|
||||
@@ -919,7 +1004,7 @@ class CalibreDB:
|
||||
indx -= 1
|
||||
element += 1
|
||||
query = query.filter(db_filter)\
|
||||
.filter(self.common_filters(allow_show_archived))
|
||||
.filter(self.common_filters(allow_show_archived, viewing_tag_id=viewing_tag_id))
|
||||
entries = list()
|
||||
pagination = list()
|
||||
try:
|
||||
@@ -946,10 +1031,13 @@ class CalibreDB:
|
||||
# error = False
|
||||
for auth in sort_authors:
|
||||
auth = strip_whitespaces(auth)
|
||||
# Skip empty author strings to prevent spurious errors
|
||||
if not auth:
|
||||
continue
|
||||
results = self.session.query(Authors).filter(Authors.sort == auth).all()
|
||||
# ToDo: How to handle not found author name
|
||||
if not len(results):
|
||||
log.error("Author {} not found to display name in right order".format(auth))
|
||||
log.error("Author '{}' not found to display name in right order".format(auth))
|
||||
# error = True
|
||||
break
|
||||
for r in results:
|
||||
@@ -1023,6 +1111,8 @@ class CalibreDB:
|
||||
getattr(Books,
|
||||
'custom_column_' + str(c.id)).any(
|
||||
func.lower(cc_classes[c.id].value).ilike("%" + term + "%")))
|
||||
# Eagerly load the data relationship to prevent session errors
|
||||
query = query.options(joinedload(Books.data))
|
||||
return query.filter(self.common_filters(True)).filter(or_(*filter_expression))
|
||||
|
||||
def get_cc_columns(self, config, filter_config_custom_read=False):
|
||||
@@ -1099,15 +1189,20 @@ class CalibreDB:
|
||||
|
||||
def create_functions(self, config=None):
|
||||
self.ensure_session()
|
||||
if self.session is None:
|
||||
log.error("create_functions: Cannot create functions because session is None")
|
||||
return
|
||||
|
||||
# user defined sort function for calibre databases (Series, etc.)
|
||||
def _title_sort(title):
|
||||
# calibre sort stuff
|
||||
title_pat = re.compile(config.config_title_regex, re.IGNORECASE)
|
||||
match = title_pat.search(title)
|
||||
if match:
|
||||
prep = match.group(1)
|
||||
title = title[len(prep):] + ', ' + prep
|
||||
return strip_whitespaces(title)
|
||||
if config:
|
||||
def _title_sort(title):
|
||||
# calibre sort stuff
|
||||
title_pat = re.compile(config.config_title_regex, re.IGNORECASE)
|
||||
match = title_pat.search(title)
|
||||
if match:
|
||||
prep = match.group(1)
|
||||
title = title[len(prep):] + ', ' + prep
|
||||
return strip_whitespaces(title)
|
||||
|
||||
try:
|
||||
# sqlalchemy <1.4.24 and sqlalchemy 2.0
|
||||
@@ -1126,28 +1221,29 @@ class CalibreDB:
|
||||
@classmethod
|
||||
def dispose(cls):
|
||||
# global session
|
||||
|
||||
for inst in cls.instances:
|
||||
old_session = inst.session
|
||||
inst.session = None
|
||||
if old_session:
|
||||
try:
|
||||
old_session.close()
|
||||
except Exception:
|
||||
pass
|
||||
if old_session.bind:
|
||||
# Use lock to prevent concurrent dispose/reconnect operations
|
||||
with cls._reconnect_lock:
|
||||
for inst in cls.instances:
|
||||
old_session = inst.session
|
||||
inst.session = None
|
||||
if old_session:
|
||||
try:
|
||||
old_session.bind.dispose()
|
||||
old_session.close()
|
||||
except Exception:
|
||||
pass
|
||||
if old_session.bind:
|
||||
try:
|
||||
old_session.bind.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for attr in list(Books.__dict__.keys()):
|
||||
if attr.startswith("custom_column_"):
|
||||
setattr(Books, attr, None)
|
||||
for attr in list(Books.__dict__.keys()):
|
||||
if attr.startswith("custom_column_"):
|
||||
setattr(Books, attr, None)
|
||||
|
||||
for db_class in cc_classes.values():
|
||||
Base.metadata.remove(db_class.__table__)
|
||||
cc_classes.clear()
|
||||
for db_class in cc_classes.values():
|
||||
Base.metadata.remove(db_class.__table__)
|
||||
cc_classes.clear()
|
||||
|
||||
for table in reversed(Base.metadata.sorted_tables):
|
||||
name = table.key
|
||||
@@ -1156,24 +1252,26 @@ class CalibreDB:
|
||||
Base.metadata.remove(table)
|
||||
|
||||
def reconnect_db(self, config, app_db_path):
|
||||
# Be resilient if database wasn't initialized yet
|
||||
try:
|
||||
self.dispose()
|
||||
except Exception:
|
||||
# Ignore dispose errors during reconnect
|
||||
pass
|
||||
# Use lock to ensure atomic reconnect operation
|
||||
with self._reconnect_lock:
|
||||
# Be resilient if database wasn't initialized yet
|
||||
try:
|
||||
self.dispose()
|
||||
except Exception:
|
||||
# Ignore dispose errors during reconnect
|
||||
pass
|
||||
|
||||
# engine is a class-level attribute that may be None before first setup
|
||||
try:
|
||||
if getattr(self, 'engine', None) is not None:
|
||||
self.engine.dispose()
|
||||
except Exception:
|
||||
# Ignore engine dispose errors; we'll rebuild below
|
||||
pass
|
||||
# engine is a class-level attribute that may be None before first setup
|
||||
try:
|
||||
if getattr(self, 'engine', None) is not None:
|
||||
self.engine.dispose()
|
||||
except Exception:
|
||||
# Ignore engine dispose errors; we'll rebuild below
|
||||
pass
|
||||
|
||||
# Rebuild engine/session factory and update config
|
||||
self.setup_db(config.config_calibre_dir, app_db_path)
|
||||
self.update_config(config)
|
||||
# Rebuild engine/session factory and update config
|
||||
self.setup_db(config.config_calibre_dir, app_db_path)
|
||||
self.update_config(config)
|
||||
|
||||
|
||||
def lcase(s):
|
||||
|
||||
+1520
-19
File diff suppressed because it is too large
Load Diff
+61
-10
@@ -6,6 +6,7 @@
|
||||
# See CONTRIBUTORS for full list of authors.
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from shutil import copyfile
|
||||
@@ -106,6 +107,12 @@ def upload():
|
||||
flash(_("Missing or invalid book id for format upload"), category="error")
|
||||
return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
|
||||
|
||||
# Validate that the book exists before creating manifest
|
||||
book = calibre_db.get_book(book_id)
|
||||
if not book:
|
||||
flash(_("Cannot upload format: Book no longer exists in library"), category="error")
|
||||
return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
|
||||
|
||||
for requested_file in request.files.getlist("btn-upload-format"):
|
||||
if not _validate_uploaded_file(requested_file):
|
||||
return Response(json.dumps({"location": url_for('edit-book.show_edit_book', book_id=book_id)}), mimetype='application/json')
|
||||
@@ -538,6 +545,7 @@ def delete_selected_books():
|
||||
if vals:
|
||||
for book_id in vals:
|
||||
delete_book_from_table(book_id, "", True)
|
||||
_queue_duplicate_scan_after_change()
|
||||
return json.dumps({'success': True})
|
||||
return ""
|
||||
|
||||
@@ -596,11 +604,36 @@ def merge_list_book():
|
||||
element.format,
|
||||
element.uncompressed_size,
|
||||
to_name))
|
||||
to_file.append(element.format)
|
||||
delete_book_from_table(from_book.id, "", True)
|
||||
return json.dumps({'success': True})
|
||||
calibre_db.session.commit()
|
||||
_queue_duplicate_scan_after_change()
|
||||
return json.dumps({'success': True})
|
||||
return ""
|
||||
|
||||
|
||||
def _queue_duplicate_scan_after_change():
|
||||
"""Queue a debounced duplicate scan after manual changes."""
|
||||
try:
|
||||
import requests
|
||||
sys.path.insert(1, '/app/calibre-web-automated/scripts/')
|
||||
from cwa_db import CWA_DB
|
||||
|
||||
cwa_db = CWA_DB()
|
||||
delay_seconds = int(cwa_db.cwa_settings.get('duplicate_scan_debounce_seconds', 30))
|
||||
delay_seconds = max(5, min(600, delay_seconds))
|
||||
url = helper.get_internal_api_url("/cwa-internal/queue-duplicate-scan")
|
||||
requests.post(
|
||||
url,
|
||||
json={"delay_seconds": delay_seconds},
|
||||
headers={"X-Forwarded-For": "127.0.0.1"},
|
||||
timeout=5,
|
||||
verify=False,
|
||||
)
|
||||
except Exception as e:
|
||||
log.error("Failed to queue duplicate scan after change: %s", str(e))
|
||||
|
||||
|
||||
@editbook.route("/ajax/xchange", methods=['POST'])
|
||||
@user_login_required
|
||||
@edit_required
|
||||
@@ -688,15 +721,15 @@ def do_edit_book(book_id, upload_formats=None):
|
||||
if not current_user.role_edit():
|
||||
edit_error = True
|
||||
flash(_("User has no rights to upload cover"), category="error")
|
||||
elif to_save["cover_url"].endswith('/static/generic_cover.jpg'):
|
||||
elif to_save["cover_url"].endswith('/static/generic_cover.svg'):
|
||||
book.has_cover = 0
|
||||
else:
|
||||
result, error = helper.save_cover_from_url(to_save["cover_url"].strip(), book.path)
|
||||
if result:
|
||||
book.has_cover = 1
|
||||
modify_date = True
|
||||
# Trigger thumbnail generation after successful cover fetch
|
||||
helper.trigger_thumbnail_generation_for_book(book.id)
|
||||
# Force thumbnail regeneration after successful cover fetch
|
||||
helper.replace_cover_thumbnail_cache(book.id)
|
||||
else:
|
||||
edit_error = True
|
||||
flash(error, category="error")
|
||||
@@ -1052,7 +1085,7 @@ def move_coverfile(meta, db_book):
|
||||
if meta.cover:
|
||||
cover_file = meta.cover
|
||||
else:
|
||||
cover_file = os.path.join(constants.STATIC_DIR, 'generic_cover.jpg')
|
||||
cover_file = os.path.join(constants.STATIC_DIR, 'generic_cover.svg')
|
||||
new_cover_path = os.path.join(config.get_book_path(), db_book.path)
|
||||
try:
|
||||
os.makedirs(new_cover_path, exist_ok=True)
|
||||
@@ -1165,6 +1198,18 @@ def delete_book_from_table(book_id, book_format, json_response, location=""):
|
||||
if book_format.upper() in ['KEPUB', 'EPUB', 'EPUB3']:
|
||||
kobo_sync_status.remove_synced_book(book.id, True)
|
||||
calibre_db.session.commit()
|
||||
|
||||
# Invalidate duplicate cache after book deletion
|
||||
try:
|
||||
import sys
|
||||
sys.path.insert(1, '/app/calibre-web-automated/scripts/')
|
||||
from cwa_db import CWA_DB
|
||||
cwa_db = CWA_DB()
|
||||
cwa_db.invalidate_duplicate_cache()
|
||||
cwa_db.close()
|
||||
except Exception as e:
|
||||
log.error("Failed to invalidate duplicate cache after deletion: %s", str(e))
|
||||
|
||||
except Exception as ex:
|
||||
log.error_or_exception(ex)
|
||||
calibre_db.session.rollback()
|
||||
@@ -1310,7 +1355,9 @@ def edit_book_comments(comments, book):
|
||||
modify_date = True
|
||||
else:
|
||||
if comments:
|
||||
book.comments.append(db.Comments(comment=comments, book=book.id))
|
||||
# Add comment via session instead of appending to collection during flush
|
||||
new_comment = db.Comments(comment=comments, book=book.id)
|
||||
calibre_db.session.add(new_comment)
|
||||
modify_date = True
|
||||
return modify_date
|
||||
|
||||
@@ -1605,7 +1652,8 @@ def upload_cover(cover_request, book):
|
||||
return False
|
||||
ret, message = helper.save_cover_with_thumbnail_update(requested_file, book.path, book.id)
|
||||
if ret is True:
|
||||
helper.replace_cover_thumbnail_cache(book.id)
|
||||
# Note: save_cover_with_thumbnail_update already triggers thumbnail generation
|
||||
# No need to call replace_cover_thumbnail_cache here (would create duplicate tasks)
|
||||
return True
|
||||
else:
|
||||
flash(message, category="error")
|
||||
@@ -1720,15 +1768,18 @@ def add_objects(db_book_object, db_object, db_session, db_type, add_elements):
|
||||
else: # db_type should be tag or language
|
||||
new_element = db_object(add_element)
|
||||
db_session.add(new_element)
|
||||
db_book_object.append(new_element)
|
||||
# Append new element (should not exist in collection, but check for safety)
|
||||
if new_element not in db_book_object:
|
||||
db_book_object.append(new_element)
|
||||
else:
|
||||
if len(db_element) == 1:
|
||||
db_element = create_objects_for_addition(db_element[0], add_element, db_type)
|
||||
else:
|
||||
db_el = db_session.query(db_object).filter(db_filter == add_element).first()
|
||||
db_element = db_element[0] if not db_el else db_el
|
||||
# add element to book
|
||||
db_book_object.append(db_element)
|
||||
# add element to book only if not already present (prevents UNIQUE constraint errors)
|
||||
if db_element not in db_book_object:
|
||||
db_book_object.append(db_element)
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
@@ -22,13 +22,14 @@ log = logger.create()
|
||||
# custom error page
|
||||
|
||||
def error_http(error):
|
||||
headers = {'WWW-Authenticate': f'Basic realm="{config.config_calibre_web_title or "calibre-web-automated"}"'} if error.code == 401 else {}
|
||||
return render_template('http_error.html',
|
||||
error_code="Error {0}".format(error.code),
|
||||
error_name=error.name,
|
||||
issue=False,
|
||||
unconfigured=not config.db_configured,
|
||||
instance=config.config_calibre_web_title
|
||||
), error.code
|
||||
), error.code, headers
|
||||
|
||||
|
||||
def internal_error(error):
|
||||
|
||||
+190
-33
@@ -48,6 +48,13 @@ from . import gdriveutils as gd
|
||||
from .constants import (STATIC_DIR as _STATIC_DIR, CACHE_TYPE_THUMBNAILS, THUMBNAIL_TYPE_COVER, THUMBNAIL_TYPE_SERIES,
|
||||
SUPPORTED_CALIBRE_BINARIES)
|
||||
from .subproc_wrapper import process_wait
|
||||
|
||||
# Track books with pending thumbnail generation to prevent duplicate tasks
|
||||
_pending_thumbnail_books = set()
|
||||
|
||||
import sys
|
||||
sys.path.insert(1, '/app/calibre-web-automated/scripts/')
|
||||
from cwa_db import CWA_DB
|
||||
from .services.worker import WorkerThread
|
||||
from .tasks.mail import TaskEmail
|
||||
from .tasks.thumbnail import TaskClearCoverThumbnailCache, TaskGenerateCoverThumbnails
|
||||
@@ -220,7 +227,7 @@ def send_mail(book_id, book_format, convert, ereader_mail, calibrepath, user_id,
|
||||
for entry in iter(book.data):
|
||||
if entry.format.upper() == book_format.upper():
|
||||
converted_file_name = entry.name + '.' + book_format.lower()
|
||||
link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book_id), escape(book.title))
|
||||
link = '<a href="/book/{}">{}</a>'.format(book_id, escape(book.title))
|
||||
email_text = N_("%(book)s send to eReader", book=link)
|
||||
for email in ereader_mail.split(','):
|
||||
email = strip_whitespaces(email)
|
||||
@@ -423,8 +430,51 @@ def rename_all_files_on_change(one_book, new_path, old_path, all_new_name, gdriv
|
||||
if not gdrive:
|
||||
if not os.path.exists(new_path):
|
||||
os.makedirs(new_path)
|
||||
shutil.move(os.path.join(old_path, file_format.name + '.' + file_format.format.lower()),
|
||||
os.path.join(new_path, all_new_name + '.' + file_format.format.lower()))
|
||||
|
||||
old_file = os.path.join(old_path, file_format.name + '.' + file_format.format.lower())
|
||||
new_file = os.path.join(new_path, all_new_name + '.' + file_format.format.lower())
|
||||
|
||||
# Skip if source and destination are the same
|
||||
if old_file == new_file:
|
||||
log.debug("Skipping file rename - source and destination are identical: %s", old_file)
|
||||
continue
|
||||
|
||||
# Check if source file exists
|
||||
if not os.path.exists(old_file):
|
||||
log.warning("Source file not found for rename: %s", old_file)
|
||||
# Check if the file already has the new name (perhaps from a previous partial operation)
|
||||
if os.path.exists(new_file):
|
||||
log.info("File already exists at destination: %s", new_file)
|
||||
file_format.name = all_new_name
|
||||
continue
|
||||
else:
|
||||
log.error("Neither old nor new file exists - cannot rename %s to %s", old_file, new_file)
|
||||
continue
|
||||
|
||||
# Check if destination already exists
|
||||
if os.path.exists(new_file) and old_file != new_file:
|
||||
log.warning("Destination file already exists, will overwrite: %s", new_file)
|
||||
try:
|
||||
os.remove(new_file)
|
||||
except OSError as ex:
|
||||
log.error("Could not remove existing destination file %s: %s", new_file, ex)
|
||||
|
||||
# Attempt to rename the file
|
||||
try:
|
||||
shutil.move(old_file, new_file)
|
||||
log.debug("Successfully renamed %s to %s", old_file, new_file)
|
||||
except OSError as ex:
|
||||
log.error("Failed to rename file from %s to %s: %s", old_file, new_file, ex)
|
||||
# Try copy+delete as fallback for permission issues (e.g., network shares)
|
||||
try:
|
||||
log.info("Attempting copy+delete fallback for %s", old_file)
|
||||
shutil.copy2(old_file, new_file)
|
||||
os.remove(old_file)
|
||||
log.info("Successfully copied and removed old file: %s", old_file)
|
||||
except (OSError, IOError) as fallback_ex:
|
||||
log.error("Copy+delete fallback also failed for %s: %s", old_file, fallback_ex)
|
||||
# Don't update the database name if we failed to rename the file
|
||||
continue
|
||||
else:
|
||||
g_file = gd.getFileFromEbooksFolder(old_path,
|
||||
file_format.name + '.' + file_format.format.lower())
|
||||
@@ -554,28 +604,64 @@ def move_files_on_change(calibre_path, new_author_dir, new_titledir, localbook,
|
||||
os.makedirs(new_path)
|
||||
try:
|
||||
shutil.move(original_filepath, os.path.join(new_path, db_filename))
|
||||
except OSError:
|
||||
log.error("Rename title from {} to {} failed with error, trying to "
|
||||
"move without metadata".format(path, new_path))
|
||||
shutil.move(original_filepath, os.path.join(new_path, db_filename), copy_function=shutil.copy)
|
||||
except OSError as ex:
|
||||
log.error("Rename title from %s to %s failed with error: %s, trying copy+delete fallback",
|
||||
path, new_path, ex)
|
||||
try:
|
||||
shutil.copy2(original_filepath, os.path.join(new_path, db_filename))
|
||||
os.remove(original_filepath)
|
||||
except (OSError, IOError) as fallback_ex:
|
||||
log.error("Copy+delete fallback also failed: %s", fallback_ex)
|
||||
raise
|
||||
log.debug("Moving title: %s to %s", original_filepath, new_path)
|
||||
else:
|
||||
# Check new path is not valid path
|
||||
if not os.path.exists(new_path):
|
||||
# move original path to new path
|
||||
log.debug("Moving title: %s to %s", path, new_path)
|
||||
shutil.move(path, new_path)
|
||||
try:
|
||||
shutil.move(path, new_path)
|
||||
except OSError as ex:
|
||||
log.error("Failed to move directory %s to %s: %s, trying copy tree approach", path, new_path, ex)
|
||||
# Fallback: try to copy tree and then remove original
|
||||
try:
|
||||
shutil.copytree(path, new_path, dirs_exist_ok=True)
|
||||
shutil.rmtree(path)
|
||||
except (OSError, IOError) as fallback_ex:
|
||||
log.error("Copy tree fallback also failed: %s", fallback_ex)
|
||||
raise
|
||||
else: # path is valid copy only files to new location (merge)
|
||||
log.info("Moving title: %s into existing: %s", path, new_path)
|
||||
# Take all files and subfolder from old path (strange command)
|
||||
for dir_name, __, file_list in os.walk(path):
|
||||
for file in file_list:
|
||||
shutil.move(os.path.join(dir_name, file), os.path.join(new_path + dir_name[len(path):], file))
|
||||
if not os.listdir(os.path.split(path)[0]):
|
||||
src_file = os.path.join(dir_name, file)
|
||||
dest_dir = new_path + dir_name[len(path):]
|
||||
dest_file = os.path.join(dest_dir, file)
|
||||
|
||||
# Create destination directory if it doesn't exist
|
||||
if not os.path.exists(dest_dir):
|
||||
os.makedirs(dest_dir)
|
||||
|
||||
try:
|
||||
shutil.move(src_file, dest_file)
|
||||
except OSError as ex:
|
||||
log.error("Failed to move file %s to %s: %s, trying copy+delete", src_file, dest_file, ex)
|
||||
try:
|
||||
shutil.copy2(src_file, dest_file)
|
||||
os.remove(src_file)
|
||||
except (OSError, IOError) as fallback_ex:
|
||||
log.error("Copy+delete fallback failed for %s: %s", src_file, fallback_ex)
|
||||
# Continue with other files even if one fails
|
||||
continue
|
||||
|
||||
# Try to remove old author directory if empty
|
||||
if os.path.exists(os.path.split(path)[0]) and not os.listdir(os.path.split(path)[0]):
|
||||
try:
|
||||
shutil.rmtree(os.path.split(path)[0])
|
||||
except (IOError, OSError) as ex:
|
||||
log.error("Deleting authorpath for book %s failed: %s", localbook.id, ex)
|
||||
|
||||
# change location in database to new author/title path
|
||||
localbook.path = os.path.join(new_author_dir, new_titledir).replace('\\', '/')
|
||||
except OSError as ex:
|
||||
@@ -755,9 +841,9 @@ def delete_book(book, calibrepath, book_format):
|
||||
|
||||
def get_cover_on_failure():
|
||||
try:
|
||||
return send_from_directory(_STATIC_DIR, "generic_cover.jpg")
|
||||
return send_from_directory(_STATIC_DIR, "generic_cover.svg")
|
||||
except PermissionError:
|
||||
log.error("No permission to access generic_cover.jpg file.")
|
||||
log.error("No permission to access generic_cover.svg file.")
|
||||
abort(403)
|
||||
|
||||
|
||||
@@ -802,17 +888,24 @@ def get_book_cover_internal(book, resolution=None):
|
||||
|
||||
if not is_kobo_request and use_IM:
|
||||
from .tasks.thumbnail import TaskGenerateCoverThumbnails
|
||||
# Create and run thumbnail generation for this book
|
||||
thumbnail_task = TaskGenerateCoverThumbnails(book_id=book.id)
|
||||
thumbnail_task.create_book_cover_thumbnails(book)
|
||||
|
||||
# Refresh thumbnail references after generation
|
||||
webp_thumb = get_book_cover_thumbnail_by_format(book, resolution, 'webp')
|
||||
jpg_thumb = get_book_cover_thumbnail_by_format(book, resolution, 'jpg')
|
||||
webp_exists = webp_thumb and cache.get_cache_file_exists(webp_thumb.filename, CACHE_TYPE_THUMBNAILS)
|
||||
jpg_exists = jpg_thumb and cache.get_cache_file_exists(jpg_thumb.filename, CACHE_TYPE_THUMBNAILS)
|
||||
from .services.worker import WorkerThread
|
||||
|
||||
# Queue thumbnail generation task if not already pending (prevents duplicate tasks)
|
||||
if book.id not in _pending_thumbnail_books:
|
||||
thumbnail_task = TaskGenerateCoverThumbnails(book_id=book.id)
|
||||
try:
|
||||
WorkerThread.add(None, thumbnail_task, hidden=True)
|
||||
# CRITICAL: Only add to pending set AFTER successful queue
|
||||
_pending_thumbnail_books.add(book.id)
|
||||
log.debug(f'Queued background thumbnail generation for book {book.id}')
|
||||
except Exception as queue_ex:
|
||||
# If queueing fails, don't add to pending set
|
||||
log.error(f'Failed to queue thumbnail task for book {book.id}: {queue_ex}')
|
||||
|
||||
# Note: Thumbnails will be generated in background
|
||||
# Current request will fall back to serving original cover.jpg
|
||||
except Exception as ex:
|
||||
log.debug(f'Failed to generate thumbnail on-demand for book {book.id}: {ex}')
|
||||
log.debug(f'Failed to prepare thumbnail generation for book {book.id}: {ex}')
|
||||
|
||||
# Determine which thumbnail format to serve based on request context
|
||||
try:
|
||||
@@ -1027,21 +1120,31 @@ def trigger_thumbnail_generation_for_book(book_id):
|
||||
from .tasks.thumbnail import TaskGenerateCoverThumbnails
|
||||
|
||||
if use_IM:
|
||||
# Queue thumbnail generation task
|
||||
thumbnail_task = TaskGenerateCoverThumbnails(book_id=book_id, task_message="Generating thumbnails after cover update")
|
||||
WorkerThread.add(current_user.name, thumbnail_task, hidden=True)
|
||||
log.debug(f'Queued thumbnail generation for book {book_id}')
|
||||
# Skip if already pending (prevents duplicate tasks)
|
||||
if book_id not in _pending_thumbnail_books:
|
||||
# Queue thumbnail generation task
|
||||
thumbnail_task = TaskGenerateCoverThumbnails(book_id=book_id, task_message="Generating thumbnails after cover update")
|
||||
try:
|
||||
WorkerThread.add(current_user.name, thumbnail_task, hidden=True)
|
||||
# CRITICAL: Only add to pending set AFTER successful queue
|
||||
# Task's finally block will discard when complete
|
||||
_pending_thumbnail_books.add(book_id)
|
||||
log.debug(f'Queued thumbnail generation for book {book_id}')
|
||||
except Exception as queue_ex:
|
||||
log.error(f'Failed to queue thumbnail task for book {book_id}: {queue_ex}')
|
||||
# If queueing fails, don't add to pending set
|
||||
except Exception as ex:
|
||||
log.debug(f'Failed to queue thumbnail generation for book {book_id}: {ex}')
|
||||
log.error(f'Failed to prepare thumbnail generation for book {book_id}: {ex}')
|
||||
|
||||
|
||||
def save_cover_with_thumbnail_update(img, book_path, book_id=None):
|
||||
"""Save cover and trigger thumbnail generation."""
|
||||
"""Save cover and force thumbnail regeneration."""
|
||||
result, message = save_cover(img, book_path)
|
||||
|
||||
# If cover save was successful and we have a book_id, generate thumbnails
|
||||
# If cover save was successful and we have a book_id, force thumbnail regeneration
|
||||
# Use replace_cover_thumbnail_cache to ensure fresh thumbnails even if generation is pending
|
||||
if result and book_id:
|
||||
trigger_thumbnail_generation_for_book(book_id)
|
||||
replace_cover_thumbnail_cache(book_id)
|
||||
|
||||
return result, message
|
||||
|
||||
@@ -1280,6 +1383,40 @@ def get_download_link(book_id, book_format, client):
|
||||
# collect downloaded books only for registered user and not for anonymous user
|
||||
if current_user.is_authenticated:
|
||||
ub.update_download(book_id, int(current_user.id))
|
||||
# CWA Stats Logging
|
||||
try:
|
||||
import json
|
||||
from flask import request
|
||||
|
||||
# Detect source of download
|
||||
source = request.args.get('from', 'direct')
|
||||
referer = request.headers.get('Referer', '')
|
||||
if not source or source == 'direct':
|
||||
if '/search' in referer:
|
||||
source = 'search'
|
||||
elif '/series' in referer:
|
||||
source = 'series'
|
||||
elif '/author' in referer:
|
||||
source = 'author'
|
||||
elif '/category' in referer:
|
||||
source = 'category'
|
||||
elif '/book/' in referer:
|
||||
source = 'book_detail'
|
||||
elif '/shelf' in referer:
|
||||
source = 'shelf'
|
||||
|
||||
cwa_db = CWA_DB()
|
||||
cwa_db.log_activity(
|
||||
user_id=current_user.id,
|
||||
user_name=current_user.name,
|
||||
event_type="DOWNLOAD",
|
||||
item_id=book_id,
|
||||
item_title=book.title,
|
||||
extra_data=json.dumps({'format': book_format, 'source': source})
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to log download stats: {e}")
|
||||
|
||||
file_name = book.title
|
||||
if len(book.authors) > 0:
|
||||
file_name = file_name + ' - ' + book.authors[0].name
|
||||
@@ -1293,13 +1430,26 @@ def get_download_link(book_id, book_format, client):
|
||||
|
||||
def clear_cover_thumbnail_cache(book_id):
|
||||
# Always allow clearing thumbnail cache
|
||||
# Remove from pending set when clearing cache (e.g., during book deletion)
|
||||
_pending_thumbnail_books.discard(book_id)
|
||||
WorkerThread.add(None, TaskClearCoverThumbnailCache(book_id), hidden=True)
|
||||
|
||||
|
||||
def replace_cover_thumbnail_cache(book_id):
|
||||
# Always allow replacing thumbnail cache
|
||||
WorkerThread.add(None, TaskClearCoverThumbnailCache(book_id), hidden=True)
|
||||
WorkerThread.add(None, TaskGenerateCoverThumbnails(book_id), hidden=True)
|
||||
# Remove from pending set to allow regeneration
|
||||
_pending_thumbnail_books.discard(book_id)
|
||||
# Try to queue clear task (not critical if it fails)
|
||||
try:
|
||||
WorkerThread.add(None, TaskClearCoverThumbnailCache(book_id), hidden=True)
|
||||
except Exception as e:
|
||||
log.error(f'Failed to queue thumbnail clear for book {book_id}: {e}')
|
||||
# Queue generation task and add to pending set only if successful
|
||||
try:
|
||||
WorkerThread.add(None, TaskGenerateCoverThumbnails(book_id), hidden=True)
|
||||
_pending_thumbnail_books.add(book_id)
|
||||
except Exception as e:
|
||||
log.error(f'Failed to queue thumbnail generation for book {book_id}: {e}')
|
||||
|
||||
|
||||
def delete_thumbnail_cache():
|
||||
@@ -1308,7 +1458,14 @@ def delete_thumbnail_cache():
|
||||
|
||||
def add_book_to_thumbnail_cache(book_id):
|
||||
# Always generate thumbnails for new books
|
||||
WorkerThread.add(None, TaskGenerateCoverThumbnails(book_id), hidden=True)
|
||||
# Ensure not in pending set (shouldn't be for new books, but defensive)
|
||||
_pending_thumbnail_books.discard(book_id)
|
||||
# Queue generation task and add to pending set only if successful
|
||||
try:
|
||||
WorkerThread.add(None, TaskGenerateCoverThumbnails(book_id), hidden=True)
|
||||
_pending_thumbnail_books.add(book_id)
|
||||
except Exception as e:
|
||||
log.error(f'Failed to queue thumbnail generation for book {book_id}: {e}')
|
||||
|
||||
|
||||
def update_thumbnail_cache():
|
||||
|
||||
+44
-7
@@ -96,18 +96,21 @@ def yesno(value, yes, no):
|
||||
|
||||
@jinjia.app_template_filter('formatfloat')
|
||||
def formatfloat(value, decimals=1):
|
||||
if not value:
|
||||
return value
|
||||
# Handle None and empty string cases
|
||||
if value is None or (isinstance(value, str) and value.strip() == ''):
|
||||
return ''
|
||||
|
||||
try:
|
||||
# Convert to float if it's a string (series_index is stored as String in DB)
|
||||
float_value = float(value) if isinstance(value, str) else value
|
||||
formated_value = ('{0:.' + str(decimals) + 'f}').format(float_value)
|
||||
if formated_value.endswith('.' + "0" * decimals):
|
||||
formated_value = formated_value.rstrip('0').rstrip('.')
|
||||
# Remove trailing zeros and unnecessary decimal point
|
||||
formated_value = formated_value.rstrip('0').rstrip('.')
|
||||
return formated_value
|
||||
except (ValueError, TypeError):
|
||||
# If conversion fails, return the original value
|
||||
return value
|
||||
except (ValueError, TypeError) as e:
|
||||
# If conversion fails, log the error and return empty string for safety
|
||||
log.debug(f'formatfloat filter error: Cannot convert value "{value}" to float: {e}')
|
||||
return ''
|
||||
|
||||
|
||||
'''@jinjia.app_template_filter('formatseriesindex')
|
||||
@@ -177,6 +180,40 @@ def get_cover_srcset(series):
|
||||
return ', '.join(srcset)
|
||||
|
||||
|
||||
@jinjia.app_template_filter('filesizeformat_binary')
|
||||
def filesizeformat_binary(num_bytes):
|
||||
"""
|
||||
Format bytes to human-readable binary (power-of-2) file size.
|
||||
Uses KiB, MiB, GiB notation (1024-based) to match internal storage.
|
||||
This ensures consistency with email size limits and file system reporting.
|
||||
"""
|
||||
if num_bytes is None:
|
||||
return '0 B'
|
||||
|
||||
try:
|
||||
num_bytes = float(num_bytes)
|
||||
except (ValueError, TypeError):
|
||||
return '0 B'
|
||||
|
||||
# Binary (power-of-2) units
|
||||
units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']
|
||||
unit_index = 0
|
||||
size = float(num_bytes)
|
||||
|
||||
while size >= 1024.0 and unit_index < len(units) - 1:
|
||||
size /= 1024.0
|
||||
unit_index += 1
|
||||
|
||||
# Format with 1 decimal place, but remove if .0
|
||||
if unit_index == 0: # Bytes - no decimal
|
||||
return f"{int(size)} {units[unit_index]}"
|
||||
else:
|
||||
formatted = f"{size:.1f}"
|
||||
if formatted.endswith('.0'):
|
||||
formatted = formatted[:-2]
|
||||
return f"{formatted} {units[unit_index]}"
|
||||
|
||||
|
||||
@jinjia.app_template_filter('music')
|
||||
def contains_music(book_formats):
|
||||
result = False
|
||||
|
||||
+83
-1
@@ -34,7 +34,7 @@ from sqlalchemy.exc import StatementError
|
||||
from sqlalchemy.sql import select
|
||||
import requests
|
||||
|
||||
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status
|
||||
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status, magic_shelf
|
||||
from . import isoLanguages
|
||||
from .epub import get_epub_layout
|
||||
from .constants import COVER_THUMBNAIL_SMALL, COVER_THUMBNAIL_MEDIUM, COVER_THUMBNAIL_LARGE
|
||||
@@ -326,6 +326,52 @@ def HandleSyncRequest():
|
||||
|
||||
sync_shelves(sync_token, sync_results, only_kobo_shelves)
|
||||
|
||||
# Add magic shelves as collections
|
||||
if config.config_kobo_sync_magic_shelves:
|
||||
|
||||
for shelf in ub.session.query(ub.MagicShelf)\
|
||||
.filter_by(user_id=current_user.id, kobo_sync=False)\
|
||||
.all():
|
||||
|
||||
sync_results.append({
|
||||
"DeletedTag": {
|
||||
"Tag": {
|
||||
"Id": shelf.uuid,
|
||||
"LastModified": convert_to_kobo_timestamp_string(shelf.last_modified)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
magic_shelves = ub.session.query(ub.MagicShelf)\
|
||||
.filter_by(user_id=current_user.id, kobo_sync=True)\
|
||||
.all()
|
||||
|
||||
new_tags_last_modified = sync_token.tags_last_modified
|
||||
|
||||
for shelf in magic_shelves:
|
||||
books, _ = magic_shelf.get_books_for_magic_shelf(
|
||||
shelf.id, page=1, page_size=1000
|
||||
)
|
||||
|
||||
new_tags_last_modified = max(shelf.last_modified, new_tags_last_modified)
|
||||
|
||||
tag = create_kobo_tag_magic(shelf, books)
|
||||
if not tag:
|
||||
continue
|
||||
|
||||
if shelf.created > sync_token.tags_last_modified:
|
||||
log.debug("Syncing new magic shelf %s to Kobo device", shelf.name)
|
||||
sync_results.append({
|
||||
"NewTag": tag
|
||||
})
|
||||
else:
|
||||
log.debug("Syncing changed magic shelf %s to Kobo device", shelf.name)
|
||||
sync_results.append({
|
||||
"ChangedTag": tag
|
||||
})
|
||||
|
||||
sync_token.tags_last_modified = new_tags_last_modified
|
||||
|
||||
# update last created timestamp to distinguish between new and changed entitlements
|
||||
if not cont_sync:
|
||||
sync_token.books_last_created = new_books_last_created
|
||||
@@ -356,6 +402,23 @@ def generate_sync_response(sync_token, sync_results, set_cont=False):
|
||||
extra_headers["x-kobo-sync"] = "continue"
|
||||
sync_token.to_headers(extra_headers)
|
||||
|
||||
# Track Kobo sync activity
|
||||
try:
|
||||
from scripts.cwa_db import CWA_DB
|
||||
import json as json_lib
|
||||
cwa_db = CWA_DB()
|
||||
cwa_db.log_activity(
|
||||
user_id=int(current_user.id),
|
||||
user_name=current_user.name,
|
||||
event_type='KOBO_SYNC',
|
||||
extra_data=json_lib.dumps({
|
||||
'books_synced': len(sync_results),
|
||||
'endpoint': '/v1/library/sync'
|
||||
})
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to log Kobo sync activity: {e}")
|
||||
|
||||
# log.debug("Kobo Sync Content: {}".format(sync_results))
|
||||
# jsonify decodes the unicode string different to what kobo expects
|
||||
response = make_response(json.dumps(sync_results), extra_headers)
|
||||
@@ -784,6 +847,25 @@ def create_kobo_tag(shelf):
|
||||
)
|
||||
return {"Tag": tag}
|
||||
|
||||
# Creates a Kobo "Tag" object from a ub.MagicShelf object
|
||||
def create_kobo_tag_magic(shelf, books):
|
||||
tag = {
|
||||
"Created": convert_to_kobo_timestamp_string(shelf.created),
|
||||
"Id": shelf.uuid,
|
||||
"Items": [],
|
||||
"LastModified": convert_to_kobo_timestamp_string(shelf.last_modified),
|
||||
"Name": shelf.name,
|
||||
"Type": "UserTag"
|
||||
}
|
||||
for book in books:
|
||||
tag["Items"].append(
|
||||
{
|
||||
"RevisionId": book.uuid,
|
||||
"Type": "ProductRevisionTagItem"
|
||||
}
|
||||
)
|
||||
return {"Tag": tag}
|
||||
|
||||
|
||||
@csrf.exempt
|
||||
@kobo.route("/v1/library/<book_uuid>/state", methods=["GET", "PUT"])
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated or g.allow_anonymous %}
|
||||
<li class="nav-head hidden-xs public-shelves">{{_('Shelves')}}</li>
|
||||
{% for shelf in g.shelves_access %}
|
||||
<li><a href="{{url_for('shelf.show_shelf', shelf_id=shelf.id)}}"><span class="glyphicon glyphicon-list shelf"></span> {{shelf.name|shortentitle(40)}}{% if shelf.is_public == 1 %} {{_('(Public)')}}{% endif %} <span style="font-size: 80%; color: #888;">({{shelf.books.all()|length}})</span></a></li>
|
||||
{% endfor %}
|
||||
<li class="nav-head hidden-xs public-shelves">{{_('Magic Shelves')}}</li>
|
||||
{% for shelf in g.magic_shelves_access %}
|
||||
<li><a href="{{url_for('web.books_list', data='magicshelf', sort_param='stored', book_id=shelf.id)}}"><i class="fa {{ shelf.icon }}"></i> {{shelf.name|shortentitle(40)}}</a></li>
|
||||
{% endfor %}
|
||||
{% if not current_user.is_anonymous %}
|
||||
<li id="nav_createshelf" class="create-shelf"><a href="{{url_for('shelf.create_shelf')}}">{{_('Create a Shelf')}}</a></li>
|
||||
<li id="nav_createmagicshelf" class="create-shelf"><a href="{{url_for('web.create_magic_shelf')}}">{{_('Create a Magic Shelf')}}</a></li>
|
||||
<li id="nav_about" {% if page == 'stat' %}class="active"{% endif %}><a href="{{url_for('about.stats')}}"><span class="glyphicon glyphicon-info-sign"></span> {{_('About')}}</a></li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
</ul>
|
||||
@@ -1,6 +1,637 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Calibre-Web Automated – fork of Calibre-Web
|
||||
# Copyright (C) 2018-2025 Calibre-Web contributors
|
||||
# Copyright (C) 2024-2025 Calibre-Web Automated contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# See CONTRIBUTORS for full list of authors.
|
||||
|
||||
from . import db, ub, logger
|
||||
from .cw_login import current_user
|
||||
from sqlalchemy import and_, or_, not_
|
||||
from sqlalchemy.sql.expression import func
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
log = logger.create()
|
||||
|
||||
# System Magic Shelf Templates
|
||||
# These are pre-built shelves that can be created for users as examples/templates
|
||||
SYSTEM_SHELF_TEMPLATES = {
|
||||
'recently_added': {
|
||||
'name': 'Recently Added',
|
||||
'icon': '⏰',
|
||||
'description': 'Books added to your library in the last 30 days',
|
||||
'rules': {
|
||||
'condition': 'AND',
|
||||
'rules': [
|
||||
{
|
||||
'id': 'timestamp',
|
||||
'field': 'timestamp',
|
||||
'type': 'date',
|
||||
'input': 'text',
|
||||
'operator': 'greater',
|
||||
'value': (datetime.now(timezone.utc) - timedelta(days=30)).strftime('%Y-%m-%d')
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
'highly_rated': {
|
||||
'name': 'Highly Rated',
|
||||
'icon': '⭐',
|
||||
'description': 'Books with a rating of 8 or higher',
|
||||
'rules': {
|
||||
'condition': 'AND',
|
||||
'rules': [
|
||||
{
|
||||
'id': 'rating',
|
||||
'field': 'rating',
|
||||
'type': 'integer',
|
||||
'input': 'select',
|
||||
'operator': 'greater_or_equal',
|
||||
'value': 8
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
# 'no_cover': {
|
||||
# 'name': 'Books Without Covers',
|
||||
# 'icon': '🗒️',
|
||||
# 'description': 'Books that are missing cover images',
|
||||
# 'rules': {
|
||||
# 'condition': 'AND',
|
||||
# 'rules': [
|
||||
# {
|
||||
# 'id': 'has_cover',
|
||||
# 'field': 'has_cover',
|
||||
# 'type': 'boolean',
|
||||
# 'input': 'radio',
|
||||
# 'operator': 'equal',
|
||||
# 'value': 0
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# },
|
||||
'yet_to_read': {
|
||||
'name': 'Yet to Read',
|
||||
'icon': '📖',
|
||||
'description': 'Books you haven\'t read yet',
|
||||
'rules': {
|
||||
'condition': 'AND',
|
||||
'rules': [{
|
||||
'id': 'read_status',
|
||||
'field': 'read_status',
|
||||
'type': 'integer',
|
||||
'input': 'radio',
|
||||
'operator': 'equal',
|
||||
'value': 0 # Just check for unread
|
||||
}]
|
||||
}
|
||||
},
|
||||
'recent_publications': {
|
||||
'name': 'Recent Publications',
|
||||
'icon': '🌱',
|
||||
'description': 'Books published in the last 2 years',
|
||||
'rules': {
|
||||
'condition': 'AND',
|
||||
'rules': [
|
||||
{
|
||||
'id': 'pubdate',
|
||||
'field': 'pubdate',
|
||||
'type': 'date',
|
||||
'input': 'text',
|
||||
'operator': 'greater',
|
||||
'value': (datetime.now(timezone.utc) - timedelta(days=730)).strftime('%Y-%m-%d')
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
# 'series_incomplete': {
|
||||
# 'name': 'Incomplete Series',
|
||||
# 'icon': '📚',
|
||||
# 'description': 'Books that are part of a series',
|
||||
# 'rules': {
|
||||
# 'condition': 'AND',
|
||||
# 'rules': [
|
||||
# {
|
||||
# 'id': 'series',
|
||||
# 'field': 'series',
|
||||
# 'type': 'string',
|
||||
# 'input': 'text',
|
||||
# 'operator': 'is_not_empty',
|
||||
# 'value': None
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# }
|
||||
|
||||
}
|
||||
|
||||
# Mapping from UI field names to database models and columns
|
||||
FIELD_MAP = {
|
||||
'title': (db.Books, 'title'),
|
||||
'author': (db.Authors, 'name'),
|
||||
'tag': (db.Tags, 'name'),
|
||||
'series': (db.Series, 'name'),
|
||||
'publisher': (db.Publishers, 'name'),
|
||||
'rating': (db.Ratings, 'rating'),
|
||||
'language': (db.Languages, 'lang_code'),
|
||||
'pubdate': (db.Books, 'pubdate'),
|
||||
'timestamp': (db.Books, 'timestamp'),
|
||||
'has_cover': (db.Books, 'has_cover'),
|
||||
'series_index': (db.Books, 'series_index'),
|
||||
'comments': (db.Comments, 'text'), # Fixed: Points to actual text column, not relationship
|
||||
'read_status': ('custom_column', 'read_status'), # Special handling - uses config.config_read_column
|
||||
'hardcover_id': ('identifier', 'hardcover-id'), # Special handling - checks Identifiers table
|
||||
}
|
||||
|
||||
# Mapping from UI operators to SQLAlchemy functions/operators
|
||||
OPERATOR_MAP = {
|
||||
# 'equals': lambda col, val: col == val, # Not used by QueryBuilder
|
||||
'equal': lambda col, val: col == val,
|
||||
# 'not_equals': lambda col, val: col != val, # Not used by QueryBuilder
|
||||
'not_equal': lambda col, val: col != val,
|
||||
'less': lambda col, val: col < val,
|
||||
# 'less_than': lambda col, val: col < val, # Not used by QueryBuilder
|
||||
'less_or_equal': lambda col, val: col <= val,
|
||||
# 'less_than_equal_to': lambda col, val: col <= val, # Not used by QueryBuilder
|
||||
'greater': lambda col, val: col > val,
|
||||
# 'greater_than': lambda col, val: col > val, # Not used by QueryBuilder
|
||||
'greater_or_equal': lambda col, val: col >= val,
|
||||
# 'greater_than_equal_to': lambda col, val: col >= val, # Not used by QueryBuilder
|
||||
'between': lambda col, val: col.between(*val) if isinstance(val, (list, tuple)) and len(val) == 2 else None,
|
||||
'not_between': lambda col, val: ~col.between(*val) if isinstance(val, (list, tuple)) and len(val) == 2 else None,
|
||||
'contains': lambda col, val: col.ilike(f'%{val}%') if val is not None else None,
|
||||
'not_contains': lambda col, val: ~col.ilike(f'%{val}%') if val is not None else None,
|
||||
'begins_with': lambda col, val: col.ilike(f'{val}%') if val is not None else None,
|
||||
'not_begins_with': lambda col, val: ~col.ilike(f'{val}%') if val is not None else None,
|
||||
'starts_with': lambda col, val: col.ilike(f'{val}%') if val is not None else None, # QueryBuilder emits 'begins_with', but keep for legacy
|
||||
'ends_with': lambda col, val: col.ilike(f'%{val}') if val is not None else None,
|
||||
'not_ends_with': lambda col, val: ~col.ilike(f'%{val}') if val is not None else None,
|
||||
'is_empty': lambda col, val: col == None,
|
||||
'is_not_empty': lambda col, val: col != None,
|
||||
'is_null': lambda col, val: col == None,
|
||||
'is_not_null': lambda col, val: col != None,
|
||||
'in': lambda col, val: col.in_(val if isinstance(val, list) else [val]),
|
||||
'not_in': lambda col, val: ~col.in_(val if isinstance(val, list) else [val]),
|
||||
}
|
||||
|
||||
RELATIONSHIP_MAP = {
|
||||
'author': 'authors',
|
||||
'tag': 'tags',
|
||||
'series': 'series',
|
||||
'publisher': 'publishers',
|
||||
'rating': 'ratings',
|
||||
'language': 'languages',
|
||||
'comments': 'comments', # For description field - requires join to Comments table
|
||||
}
|
||||
|
||||
def build_filter_from_rule(rule, user_id=None):
|
||||
"""Builds a SQLAlchemy filter condition from a single rule."""
|
||||
from . import config
|
||||
|
||||
field_name = rule.get('id')
|
||||
operator_name = rule.get('operator')
|
||||
value = rule.get('value')
|
||||
|
||||
if not all([field_name, operator_name]):
|
||||
return None
|
||||
|
||||
field_info = FIELD_MAP.get(field_name)
|
||||
if not field_info:
|
||||
return None
|
||||
|
||||
model, column_name = field_info
|
||||
|
||||
# Special handling for hardcover_id identifier
|
||||
if model == 'identifier' and column_name == 'hardcover-id':
|
||||
# Value is 1 (has hardcover ID) or 0 (doesn't have hardcover ID)
|
||||
# Similar to has_cover boolean handling
|
||||
try:
|
||||
has_hardcover = bool(int(value)) if value is not None else True
|
||||
except (ValueError, TypeError):
|
||||
has_hardcover = True
|
||||
|
||||
hardcover_condition = db.Books.identifiers.any(
|
||||
or_(
|
||||
db.Identifiers.type == 'hardcover-id',
|
||||
db.Identifiers.type == 'hardcover-slug',
|
||||
db.Identifiers.type == 'hardcover-edition'
|
||||
)
|
||||
)
|
||||
|
||||
if operator_name == 'equal':
|
||||
# Equal to 1 (Yes) = has hardcover ID
|
||||
# Equal to 0 (No) = doesn't have hardcover ID
|
||||
return hardcover_condition if has_hardcover else ~hardcover_condition
|
||||
elif operator_name == 'not_equal':
|
||||
# Opposite of equal
|
||||
return ~hardcover_condition if has_hardcover else hardcover_condition
|
||||
else:
|
||||
# For any other operator (shouldn't happen with boolean type), default to equal
|
||||
return hardcover_condition if has_hardcover else ~hardcover_condition
|
||||
|
||||
# Special handling for read_status custom column
|
||||
if model == 'custom_column' and column_name == 'read_status':
|
||||
use_custom_column = False
|
||||
if config.config_read_column and config.config_read_column != 0:
|
||||
if config.config_read_column in db.cc_classes:
|
||||
use_custom_column = True
|
||||
else:
|
||||
log.warning(f"Read status column {config.config_read_column} not found in cc_classes")
|
||||
|
||||
if not use_custom_column:
|
||||
if user_id is not None:
|
||||
# Fallback to built-in read status
|
||||
# Get read books for user (STATUS_FINISHED = 1)
|
||||
read_books = ub.session.query(ub.ReadBook).filter(
|
||||
ub.ReadBook.user_id == user_id,
|
||||
ub.ReadBook.read_status == ub.ReadBook.STATUS_FINISHED
|
||||
).all()
|
||||
read_book_ids = [rb.book_id for rb in read_books]
|
||||
|
||||
operator = OPERATOR_MAP.get(operator_name)
|
||||
if not operator:
|
||||
return None
|
||||
|
||||
# Value is 0 (Unread) or 1 (Read)
|
||||
try:
|
||||
is_checking_read = (int(value) == 1)
|
||||
except (ValueError, TypeError):
|
||||
is_checking_read = False
|
||||
|
||||
if operator_name == 'equal':
|
||||
if is_checking_read:
|
||||
return db.Books.id.in_(read_book_ids)
|
||||
else:
|
||||
return ~db.Books.id.in_(read_book_ids)
|
||||
elif operator_name == 'not_equal':
|
||||
if is_checking_read:
|
||||
return ~db.Books.id.in_(read_book_ids)
|
||||
else:
|
||||
return db.Books.id.in_(read_book_ids)
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
log.debug("Read status column not configured and no user_id provided, skipping read_status filter")
|
||||
return None
|
||||
|
||||
read_col_class = db.cc_classes[config.config_read_column]
|
||||
column = read_col_class.value
|
||||
|
||||
# Get the operator
|
||||
operator = OPERATOR_MAP.get(operator_name)
|
||||
if not operator:
|
||||
return None
|
||||
|
||||
# Convert integer value (0/1) to boolean (False/True) for proper comparison
|
||||
# QueryBuilder sends integers from radio buttons, but custom column expects boolean
|
||||
if isinstance(value, int):
|
||||
value = bool(value)
|
||||
|
||||
# Read status custom columns are joined via relationship - get the dynamic relationship name
|
||||
cc_relationship = f'custom_column_{config.config_read_column}'
|
||||
if hasattr(db.Books, cc_relationship):
|
||||
return getattr(db.Books, cc_relationship).any(operator(column, value))
|
||||
else:
|
||||
log.error(f"Books model does not have relationship '{cc_relationship}'")
|
||||
return None
|
||||
else:
|
||||
if not model:
|
||||
return None
|
||||
column = getattr(model, column_name)
|
||||
|
||||
operator = OPERATOR_MAP.get(operator_name)
|
||||
|
||||
if not operator:
|
||||
return None
|
||||
|
||||
# Handle relationships using .any()
|
||||
relationship_name = RELATIONSHIP_MAP.get(field_name)
|
||||
try:
|
||||
if relationship_name:
|
||||
# Special handling for is_empty/is_null on relationships:
|
||||
# These check for absence of relationships, not null values in related records
|
||||
if operator_name in ['is_empty', 'is_null']:
|
||||
return ~getattr(db.Books, relationship_name).any()
|
||||
elif operator_name in ['is_not_empty', 'is_not_null']:
|
||||
return getattr(db.Books, relationship_name).any()
|
||||
else:
|
||||
filter_expr = operator(column, value)
|
||||
if filter_expr is None:
|
||||
return None
|
||||
return getattr(db.Books, relationship_name).any(filter_expr)
|
||||
else:
|
||||
filter_expr = operator(column, value)
|
||||
return filter_expr
|
||||
except Exception as e:
|
||||
log.error(f"Error building filter for field '{field_name}', operator '{operator_name}', value '{value}': {str(e)}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def build_query_from_rules(rules_json, user_id=None):
|
||||
"""
|
||||
Recursively builds a SQLAlchemy query filter from a JSON rule structure.
|
||||
"""
|
||||
if not rules_json or not rules_json.get('rules'):
|
||||
return None
|
||||
|
||||
condition = rules_json.get('condition', 'AND').upper()
|
||||
rules = rules_json.get('rules', [])
|
||||
|
||||
filters = []
|
||||
for rule in rules:
|
||||
# If 'condition' is present, it's a group, recurse
|
||||
if 'condition' in rule:
|
||||
sub_filter = build_query_from_rules(rule, user_id)
|
||||
if sub_filter is not None:
|
||||
filters.append(sub_filter)
|
||||
# Otherwise, it's a rule
|
||||
else:
|
||||
rule_filter = build_filter_from_rule(rule, user_id)
|
||||
if rule_filter is not None:
|
||||
filters.append(rule_filter)
|
||||
|
||||
if not filters:
|
||||
return None
|
||||
|
||||
if condition == 'AND':
|
||||
return and_(*filters)
|
||||
elif condition == 'OR':
|
||||
return or_(*filters)
|
||||
|
||||
return None
|
||||
|
||||
def get_books_for_magic_shelf(shelf_id, page=1, page_size=None, sort_order=None, sort_param='stored', bypass_cache=False):
|
||||
"""
|
||||
Takes a MagicShelf ID and returns a paginated list of book objects that match its rules.
|
||||
|
||||
Args:
|
||||
shelf_id: ID of the magic shelf
|
||||
page: Page number (1-indexed)
|
||||
page_size: Number of books per page (None = all books)
|
||||
sort_order: SQLAlchemy order_by expression
|
||||
sort_param: String identifier for the sort order (used for cache key)
|
||||
bypass_cache: If True, forces a database query and cache update
|
||||
|
||||
Returns:
|
||||
tuple: (books, total_count)
|
||||
"""
|
||||
try:
|
||||
magic_shelf = ub.session.query(ub.MagicShelf).get(shelf_id)
|
||||
if not magic_shelf:
|
||||
log.warning(f"Magic shelf with ID {shelf_id} not found")
|
||||
return [], 0
|
||||
|
||||
# 1. Check Cache
|
||||
if not bypass_cache and current_user.is_authenticated:
|
||||
cache = ub.session.query(ub.MagicShelfCache).filter_by(
|
||||
shelf_id=shelf_id,
|
||||
user_id=current_user.id,
|
||||
sort_param=sort_param
|
||||
).first()
|
||||
|
||||
# Check TTL (e.g. 30 minutes)
|
||||
if cache:
|
||||
# Handle potential naive/aware datetime issues
|
||||
created_at = cache.created_at
|
||||
if created_at.tzinfo is None:
|
||||
created_at = created_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
is_expired = (datetime.now(timezone.utc) - created_at) > timedelta(minutes=30)
|
||||
|
||||
if not is_expired:
|
||||
all_ids = cache.book_ids
|
||||
total_count = cache.total_count
|
||||
|
||||
# Slice for pagination
|
||||
if page_size is not None and page_size > 0:
|
||||
start = (page - 1) * page_size
|
||||
page_ids = all_ids[start : start + page_size]
|
||||
else:
|
||||
page_ids = all_ids
|
||||
|
||||
if not page_ids:
|
||||
return [], total_count
|
||||
|
||||
# Fetch objects (must preserve order!)
|
||||
cdb = db.CalibreDB(init=True)
|
||||
books = cdb.session.query(db.Books).filter(db.Books.id.in_(page_ids)).all()
|
||||
book_map = {b.id: b for b in books}
|
||||
ordered_books = [book_map[bid] for bid in page_ids if bid in book_map]
|
||||
|
||||
log.debug(f"Magic shelf {shelf_id} served from cache ({len(ordered_books)} books)")
|
||||
return ordered_books, total_count
|
||||
else:
|
||||
log.debug(f"Magic shelf {shelf_id} cache expired")
|
||||
|
||||
rules = magic_shelf.rules
|
||||
log.debug(f"Loading magic shelf '{magic_shelf.name}' (ID: {shelf_id}) with {len(rules.get('rules', [])) if rules else 0} rules")
|
||||
|
||||
if not rules or not rules.get('rules'):
|
||||
log.debug(f"No rules defined for magic shelf {shelf_id}")
|
||||
return [], 0
|
||||
|
||||
cdb = db.CalibreDB(init=True)
|
||||
query_filter = build_query_from_rules(rules, user_id=magic_shelf.user_id)
|
||||
|
||||
if query_filter is None:
|
||||
log.warning(f"Failed to build query filter for magic shelf {shelf_id}")
|
||||
return [], 0
|
||||
|
||||
# Build base query
|
||||
query = cdb.session.query(db.Books)
|
||||
query = query.filter(query_filter)
|
||||
|
||||
# Apply standard user permissions filters
|
||||
query = query.filter(cdb.common_filters())
|
||||
|
||||
# Apply sorting
|
||||
if sort_order is not None:
|
||||
if isinstance(sort_order, list):
|
||||
for order_expr in sort_order:
|
||||
query = query.order_by(order_expr)
|
||||
else:
|
||||
query = query.order_by(sort_order)
|
||||
|
||||
# Execute Full Query for Cache
|
||||
try:
|
||||
# Fetch ALL IDs
|
||||
all_ids_tuples = query.with_entities(db.Books.id).all()
|
||||
all_ids = [x[0] for x in all_ids_tuples]
|
||||
total_count = len(all_ids)
|
||||
|
||||
# Update Cache
|
||||
if current_user.is_authenticated:
|
||||
# Delete old cache for this specific combo
|
||||
ub.session.query(ub.MagicShelfCache).filter_by(
|
||||
shelf_id=shelf_id,
|
||||
user_id=current_user.id,
|
||||
sort_param=sort_param
|
||||
).delete()
|
||||
|
||||
new_cache = ub.MagicShelfCache(
|
||||
shelf_id=shelf_id,
|
||||
user_id=current_user.id,
|
||||
sort_param=sort_param,
|
||||
book_ids=all_ids,
|
||||
total_count=total_count
|
||||
)
|
||||
ub.session.add(new_cache)
|
||||
ub.session.commit()
|
||||
log.debug(f"Magic shelf {shelf_id} cache updated ({total_count} items)")
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
log.error(f"Error executing query for magic shelf {shelf_id}: {e}")
|
||||
return [], 0
|
||||
|
||||
# Apply pagination to the list of IDs we just fetched
|
||||
if page_size is not None and page_size > 0:
|
||||
start = (page - 1) * page_size
|
||||
page_ids = all_ids[start : start + page_size]
|
||||
else:
|
||||
page_ids = all_ids
|
||||
|
||||
if not page_ids:
|
||||
return [], total_count
|
||||
|
||||
# Fetch objects for the current page
|
||||
books = cdb.session.query(db.Books).filter(db.Books.id.in_(page_ids)).all()
|
||||
book_map = {b.id: b for b in books}
|
||||
ordered_books = [book_map[bid] for bid in page_ids if bid in book_map]
|
||||
|
||||
return ordered_books, total_count
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
log.error(f"Database error retrieving books for magic shelf {shelf_id}: {e}")
|
||||
return [], 0
|
||||
except Exception as e:
|
||||
log.error(f"Unexpected error retrieving books for magic shelf {shelf_id}: {e}")
|
||||
return [], 0
|
||||
|
||||
|
||||
def get_book_count_for_magic_shelf(shelf_id):
|
||||
"""
|
||||
Efficiently gets the total count of books for a magic shelf.
|
||||
|
||||
Args:
|
||||
shelf_id: ID of the magic shelf
|
||||
|
||||
Returns:
|
||||
int: Total count of matching books
|
||||
"""
|
||||
try:
|
||||
magic_shelf = ub.session.query(ub.MagicShelf).get(shelf_id)
|
||||
if not magic_shelf:
|
||||
return 0
|
||||
|
||||
rules = magic_shelf.rules
|
||||
if not rules or not rules.get('rules'):
|
||||
return 0
|
||||
|
||||
cdb = db.CalibreDB(init=True)
|
||||
query_filter = build_query_from_rules(rules, user_id=magic_shelf.user_id)
|
||||
|
||||
if query_filter is None:
|
||||
return 0
|
||||
|
||||
# Build base query
|
||||
query = cdb.session.query(db.Books)
|
||||
query = query.filter(query_filter)
|
||||
|
||||
# Apply standard user permissions filters
|
||||
query = query.filter(cdb.common_filters())
|
||||
|
||||
return query.count()
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error counting books for magic shelf {shelf_id}: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def create_system_magic_shelves(user_id, template_keys=None):
|
||||
"""
|
||||
Create system magic shelves for a user from templates.
|
||||
|
||||
Args:
|
||||
user_id: ID of the user to create shelves for
|
||||
template_keys: List of template keys to create (None = create all)
|
||||
|
||||
Returns:
|
||||
int: Number of shelves created
|
||||
"""
|
||||
if template_keys is None:
|
||||
template_keys = SYSTEM_SHELF_TEMPLATES.keys()
|
||||
|
||||
created_count = 0
|
||||
|
||||
for key in template_keys:
|
||||
if key not in SYSTEM_SHELF_TEMPLATES:
|
||||
log.warning(f"Unknown system shelf template: {key}")
|
||||
continue
|
||||
|
||||
template = SYSTEM_SHELF_TEMPLATES[key]
|
||||
|
||||
try:
|
||||
# Check if user already has this system shelf
|
||||
existing = ub.session.query(ub.MagicShelf).filter(
|
||||
ub.MagicShelf.user_id == user_id,
|
||||
ub.MagicShelf.name == template['name'],
|
||||
ub.MagicShelf.is_system == True
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
log.debug(f"User {user_id} already has system shelf '{template['name']}'")
|
||||
continue
|
||||
|
||||
# Create new system shelf
|
||||
new_shelf = ub.MagicShelf(
|
||||
user_id=user_id,
|
||||
name=template['name'],
|
||||
icon=template['icon'],
|
||||
rules=template['rules'],
|
||||
is_system=True,
|
||||
is_public=0
|
||||
)
|
||||
|
||||
ub.session.add(new_shelf)
|
||||
created_count += 1
|
||||
log.info(f"Created system magic shelf '{template['name']}' for user {user_id}")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error creating system shelf '{template.get('name')}' for user {user_id}: {e}")
|
||||
ub.session.rollback()
|
||||
continue
|
||||
|
||||
if created_count > 0:
|
||||
try:
|
||||
ub.session.commit()
|
||||
log.info(f"Successfully created {created_count} system magic shelves for user {user_id}")
|
||||
except Exception as e:
|
||||
log.error(f"Error committing system shelves for user {user_id}: {e}")
|
||||
ub.session.rollback()
|
||||
return 0
|
||||
|
||||
return created_count
|
||||
|
||||
|
||||
def get_system_shelf_template(template_key):
|
||||
"""
|
||||
Get a system shelf template by key.
|
||||
|
||||
Args:
|
||||
template_key: Key of the template to retrieve
|
||||
|
||||
Returns:
|
||||
dict: Template data or None if not found
|
||||
"""
|
||||
return SYSTEM_SHELF_TEMPLATES.get(template_key)
|
||||
|
||||
|
||||
def list_system_shelf_templates():
|
||||
"""
|
||||
Get all available system shelf templates.
|
||||
|
||||
Returns:
|
||||
dict: All system shelf templates
|
||||
"""
|
||||
return SYSTEM_SHELF_TEMPLATES
|
||||
|
||||
+2
-1
@@ -9,7 +9,7 @@ import sys
|
||||
|
||||
from . import create_app, limiter
|
||||
from .jinjia import jinjia
|
||||
from flask import request
|
||||
from flask import request, g
|
||||
|
||||
|
||||
def request_username():
|
||||
@@ -54,6 +54,7 @@ def main():
|
||||
from . import web_server
|
||||
init_errorhandler()
|
||||
|
||||
|
||||
# CWA Blueprints
|
||||
app.register_blueprint(switch_theme)
|
||||
app.register_blueprint(library_refresh)
|
||||
|
||||
+42
-16
@@ -6,10 +6,8 @@
|
||||
# See CONTRIBUTORS for full list of authors.
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
from cps import logger, calibre_db, db, constants
|
||||
from cps import logger, db
|
||||
from cps.search_metadata import cl as metadata_providers
|
||||
import sys
|
||||
sys.path.insert(1, '/app/calibre-web-automated/scripts/')
|
||||
@@ -17,7 +15,6 @@ from cwa_db import CWA_DB
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
def fetch_and_apply_metadata(book_id: int, user_enabled: bool = False) -> bool:
|
||||
"""
|
||||
Fetch metadata for a newly ingested book and apply it if settings allow.
|
||||
@@ -30,6 +27,10 @@ def fetch_and_apply_metadata(book_id: int, user_enabled: bool = False) -> bool:
|
||||
bool: True if metadata was successfully fetched and applied, False otherwise
|
||||
"""
|
||||
try:
|
||||
if not db.CalibreDB.session_factory:
|
||||
log.error("CalibreDB not initialized; skipping metadata fetch")
|
||||
return False
|
||||
|
||||
# Check global settings (admin-controlled only)
|
||||
cwa_db = CWA_DB()
|
||||
cwa_settings = cwa_db.get_cwa_settings()
|
||||
@@ -60,8 +61,7 @@ def fetch_and_apply_metadata(book_id: int, user_enabled: bool = False) -> bool:
|
||||
provider_hierarchy = ["google", "douban", "dnb", "ibdb", "comicvine"]
|
||||
|
||||
# Global provider enablement map
|
||||
from cps.cwa_functions import parse_metadata_providers_enabled
|
||||
enabled_map = parse_metadata_providers_enabled(
|
||||
enabled_map = _parse_metadata_providers_enabled(
|
||||
cwa_settings.get('metadata_providers_enabled', '{}')
|
||||
)
|
||||
|
||||
@@ -108,7 +108,7 @@ def fetch_and_apply_metadata(book_id: int, user_enabled: bool = False) -> bool:
|
||||
return metadata_found
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error in fetch_and_apply_metadata: {e}")
|
||||
log.error(f"Error in fetch_and_apply_metadata: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ def _apply_metadata_to_book(book, metadata, calibre_db_instance) -> bool:
|
||||
if author_name and author_name.strip():
|
||||
author = calibre_db_instance.get_author_by_name(author_name.strip())
|
||||
if not author:
|
||||
author = db.Authors(name=author_name.strip(), sort=author_name.strip())
|
||||
author = db.Authors(author_name.strip(), author_name.strip())
|
||||
calibre_db_instance.session.add(author)
|
||||
book.authors.append(author)
|
||||
updated = True
|
||||
@@ -166,14 +166,14 @@ def _apply_metadata_to_book(book, metadata, calibre_db_instance) -> bool:
|
||||
if book.comments:
|
||||
book.comments[0].text = metadata.description.strip()
|
||||
else:
|
||||
comment = db.Comments(text=metadata.description.strip(), book=book.id)
|
||||
comment = db.Comments(metadata.description.strip(), book.id)
|
||||
calibre_db_instance.session.add(comment)
|
||||
updated = True
|
||||
else:
|
||||
if book.comments:
|
||||
book.comments[0].text = metadata.description.strip()
|
||||
else:
|
||||
comment = db.Comments(text=metadata.description.strip(), book=book.id)
|
||||
comment = db.Comments(metadata.description.strip(), book.id)
|
||||
calibre_db_instance.session.add(comment)
|
||||
updated = True
|
||||
|
||||
@@ -184,7 +184,7 @@ def _apply_metadata_to_book(book, metadata, calibre_db_instance) -> bool:
|
||||
if not book.publishers or len(book.publishers) == 0:
|
||||
publisher = calibre_db_instance.get_publisher_by_name(metadata.publisher.strip())
|
||||
if not publisher:
|
||||
publisher = db.Publishers(name=metadata.publisher.strip())
|
||||
publisher = db.Publishers(metadata.publisher.strip(), metadata.publisher.strip())
|
||||
calibre_db_instance.session.add(publisher)
|
||||
book.publishers = [publisher]
|
||||
updated = True
|
||||
@@ -193,7 +193,7 @@ def _apply_metadata_to_book(book, metadata, calibre_db_instance) -> bool:
|
||||
book.publishers.clear()
|
||||
publisher = calibre_db_instance.get_publisher_by_name(metadata.publisher.strip())
|
||||
if not publisher:
|
||||
publisher = db.Publishers(name=metadata.publisher.strip())
|
||||
publisher = db.Publishers(metadata.publisher.strip(), metadata.publisher.strip())
|
||||
calibre_db_instance.session.add(publisher)
|
||||
book.publishers = [publisher]
|
||||
updated = True
|
||||
@@ -216,7 +216,7 @@ def _apply_metadata_to_book(book, metadata, calibre_db_instance) -> bool:
|
||||
hasattr(metadata, 'series') and metadata.series and metadata.series.strip()):
|
||||
series = calibre_db_instance.get_series_by_name(metadata.series.strip())
|
||||
if not series:
|
||||
series = db.Series(name=metadata.series.strip(), sort=metadata.series.strip())
|
||||
series = db.Series(metadata.series.strip(), metadata.series.strip())
|
||||
calibre_db_instance.session.add(series)
|
||||
book.series.clear()
|
||||
book.series.append(series)
|
||||
@@ -224,9 +224,11 @@ def _apply_metadata_to_book(book, metadata, calibre_db_instance) -> bool:
|
||||
# Set series index if available
|
||||
if hasattr(metadata, 'series_index') and metadata.series_index:
|
||||
try:
|
||||
book.series_index = float(metadata.series_index)
|
||||
# Convert to float first to validate, then store as string (DB column is String)
|
||||
float_value = float(metadata.series_index)
|
||||
book.series_index = str(float_value)
|
||||
except (ValueError, TypeError):
|
||||
book.series_index = 1.0
|
||||
book.series_index = '1.0'
|
||||
updated = True
|
||||
|
||||
# Update published date if available and enabled in settings
|
||||
@@ -278,7 +280,7 @@ def _apply_metadata_to_book(book, metadata, calibre_db_instance) -> bool:
|
||||
existing = True
|
||||
break
|
||||
if not existing:
|
||||
new_identifier = db.Identifiers(type=identifier_type, val=identifier_value, book=book.id)
|
||||
new_identifier = db.Identifiers(identifier_value, identifier_type, book.id)
|
||||
calibre_db_instance.session.add(new_identifier)
|
||||
book.identifiers.append(new_identifier)
|
||||
updated = True
|
||||
@@ -301,3 +303,27 @@ def _apply_metadata_to_book(book, metadata, calibre_db_instance) -> bool:
|
||||
log.error(f"Error applying metadata to book {getattr(book, 'id', 'unknown')}: {e}")
|
||||
calibre_db_instance.session.rollback()
|
||||
return False
|
||||
|
||||
|
||||
def _parse_metadata_providers_enabled(raw_value):
|
||||
"""Lightweight parser for metadata_providers_enabled without importing cwa_functions."""
|
||||
try:
|
||||
if raw_value is None:
|
||||
return {}
|
||||
if isinstance(raw_value, bytes):
|
||||
raw_value = raw_value.decode('utf-8', errors='ignore')
|
||||
if isinstance(raw_value, str):
|
||||
s = raw_value.strip()
|
||||
if not s:
|
||||
return {}
|
||||
if s.startswith("'") and s.endswith("'"):
|
||||
s = s[1:-1]
|
||||
if not s:
|
||||
return {}
|
||||
data = json.loads(s)
|
||||
return data if isinstance(data, dict) else {}
|
||||
if isinstance(raw_value, dict):
|
||||
return raw_value
|
||||
return {}
|
||||
except (json.JSONDecodeError, ValueError, TypeError, AttributeError):
|
||||
return {}
|
||||
|
||||
@@ -15,6 +15,25 @@ from typing import Dict, List, Optional, Union
|
||||
import requests
|
||||
from os import getenv
|
||||
|
||||
# Import text similarity utilities
|
||||
try:
|
||||
from cps.utils.text_similarity import (
|
||||
normalized_levenshtein_similarity,
|
||||
author_list_similarity,
|
||||
normalize_string,
|
||||
calculate_year_similarity
|
||||
)
|
||||
except ImportError:
|
||||
# Fallback for CLI usage
|
||||
def normalized_levenshtein_similarity(s1: str, s2: str) -> float:
|
||||
return 1.0 if s1.lower() == s2.lower() else 0.5
|
||||
def author_list_similarity(a1: List[str], a2: List[str]) -> tuple[float, bool]:
|
||||
return (0.5, False)
|
||||
def normalize_string(s: str) -> str:
|
||||
return s.lower()
|
||||
def calculate_year_similarity(y1: str, y2: str) -> float:
|
||||
return 1.0 if y1 == y2 else 0.0
|
||||
|
||||
# Try importing from full app; if unavailable (CLI), use light fallbacks
|
||||
try: # pragma: no cover - normal app path
|
||||
from cps import logger, config, constants # type: ignore
|
||||
@@ -376,6 +395,117 @@ class Hardcover(Metadata):
|
||||
except (TypeError, KeyError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def calculate_confidence_score(
|
||||
result: MetaRecord,
|
||||
query_title: str,
|
||||
query_authors: Optional[List[str]] = None,
|
||||
query_isbn: Optional[str] = None,
|
||||
query_series: Optional[str] = None,
|
||||
query_series_index: Optional[Union[int, float, str]] = None,
|
||||
query_publisher: Optional[str] = None,
|
||||
query_year: Optional[str] = None
|
||||
) -> tuple[float, str]:
|
||||
"""
|
||||
Calculate confidence score for a metadata match.
|
||||
|
||||
Args:
|
||||
result: The MetaRecord from Hardcover search
|
||||
query_title: Title to match against
|
||||
query_authors: Authors to match against
|
||||
query_isbn: ISBN to match against
|
||||
query_series: Series name to match against
|
||||
query_series_index: Series position to match against
|
||||
query_publisher: Publisher to match against
|
||||
query_year: Publication year to match against
|
||||
|
||||
Returns:
|
||||
tuple: (confidence_score, match_reason)
|
||||
- confidence_score: 0.0 to 1.0
|
||||
- match_reason: Human-readable explanation
|
||||
"""
|
||||
score = 0.0
|
||||
reasons = []
|
||||
|
||||
# ISBN match (if available) - highest confidence
|
||||
if query_isbn and result.identifiers.get('isbn'):
|
||||
result_isbn = str(result.identifiers.get('isbn', '')).replace('-', '').replace(' ', '')
|
||||
query_isbn_clean = query_isbn.replace('-', '').replace(' ', '')
|
||||
if result_isbn == query_isbn_clean:
|
||||
return (1.0, "ISBN exact match")
|
||||
|
||||
# Title similarity (base score: 0.5-0.95)
|
||||
if query_title and result.title:
|
||||
title_similarity = normalized_levenshtein_similarity(query_title, result.title)
|
||||
score += title_similarity * 0.5
|
||||
if title_similarity >= 0.9:
|
||||
reasons.append(f"title exact match ({title_similarity:.2f})")
|
||||
elif title_similarity >= 0.7:
|
||||
reasons.append(f"title close match ({title_similarity:.2f})")
|
||||
else:
|
||||
reasons.append(f"title partial match ({title_similarity:.2f})")
|
||||
|
||||
# Author similarity (base score: 0.0-0.45)
|
||||
if query_authors and result.authors:
|
||||
author_similarity, is_and_match = author_list_similarity(query_authors, result.authors)
|
||||
# Weight AND matches (all authors match) higher than OR matches (some match)
|
||||
if is_and_match:
|
||||
score += author_similarity * 0.45 # Higher weight for AND matches
|
||||
reasons.append(f"all authors match ({author_similarity:.2f})")
|
||||
else:
|
||||
score += author_similarity * 0.35 # Lower weight for partial matches
|
||||
reasons.append(f"some authors match ({author_similarity:.2f})")
|
||||
|
||||
# Series matching (bonus: +0.0 to +0.15)
|
||||
if query_series and result.series:
|
||||
series_similarity = normalized_levenshtein_similarity(query_series, result.series)
|
||||
if series_similarity >= 0.8:
|
||||
bonus = 0.1 * series_similarity
|
||||
score += bonus
|
||||
reasons.append(f"series match ({series_similarity:.2f})")
|
||||
|
||||
# Series index exact match (additional bonus: +0.05)
|
||||
if query_series_index is not None and result.series_index:
|
||||
try:
|
||||
query_idx = float(query_series_index) if query_series_index else 0
|
||||
result_idx = float(result.series_index) if result.series_index else 0
|
||||
if query_idx > 0 and result_idx > 0 and abs(query_idx - result_idx) < 0.1:
|
||||
score += 0.05
|
||||
reasons.append(f"series position {query_idx} matches")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Publisher match (bonus: +0.1)
|
||||
if query_publisher and result.publisher:
|
||||
publisher_similarity = normalized_levenshtein_similarity(query_publisher, result.publisher)
|
||||
if publisher_similarity >= 0.8:
|
||||
score += 0.1
|
||||
reasons.append("publisher match")
|
||||
|
||||
# Publication year match (bonus: +0.05 for exact, +0.025 for ±1 year)
|
||||
if query_year and result.publishedDate:
|
||||
year_similarity = calculate_year_similarity(query_year, result.publishedDate)
|
||||
score += year_similarity * 0.05
|
||||
if year_similarity > 0:
|
||||
reasons.append(f"year match ({year_similarity:.2f})")
|
||||
|
||||
# Cap score at 1.0
|
||||
score = min(score, 1.0)
|
||||
|
||||
# Generate match reason string
|
||||
if score >= 0.95:
|
||||
reason = "Excellent match: " + ", ".join(reasons)
|
||||
elif score >= 0.85:
|
||||
reason = "High confidence: " + ", ".join(reasons)
|
||||
elif score >= 0.7:
|
||||
reason = "Good match: " + ", ".join(reasons)
|
||||
elif score >= 0.5:
|
||||
reason = "Possible match: " + ", ".join(reasons)
|
||||
else:
|
||||
reason = "Low confidence: " + ", ".join(reasons)
|
||||
|
||||
return (score, reason)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Lightweight CLI for manual testing of Hardcover searches
|
||||
|
||||
@@ -25,7 +25,7 @@ class IBDb(Metadata):
|
||||
DESCRIPTION = "Internet Book Database"
|
||||
META_URL = "https://ibdb.dev/"
|
||||
BOOK_URL = "https://ibdb.dev/book/"
|
||||
SEARCH_URL = "https://ibdb.dev/search?q="
|
||||
SEARCH_URL = "https://ibdb.dev/api/search?q="
|
||||
|
||||
def search(
|
||||
self, query: str, generic_cover: str = "", locale: str = "en"
|
||||
@@ -40,6 +40,13 @@ class IBDb(Metadata):
|
||||
try:
|
||||
results = requests.get(IBDb.SEARCH_URL + query, timeout=15)
|
||||
results.raise_for_status()
|
||||
except requests.HTTPError as e:
|
||||
status_code = getattr(e.response, "status_code", None)
|
||||
if status_code == 501:
|
||||
log.debug("IBDb search not implemented (501); skipping provider.")
|
||||
return []
|
||||
log.warning(e)
|
||||
return []
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return []
|
||||
|
||||
+54
-20
@@ -322,12 +322,16 @@ def register_user_from_generic_oauth(token=None):
|
||||
# Apply default configuration settings for new OAuth users (Issue #660)
|
||||
# Match the same pattern as normal user creation in admin.py
|
||||
|
||||
# Set role: admin group overrides default role, otherwise use configured default
|
||||
if should_be_admin:
|
||||
# Set role: admin group overrides default role (only if group management enabled), otherwise use configured default
|
||||
if should_be_admin and config.config_enable_oauth_group_admin_management:
|
||||
user.role = constants.ROLE_ADMIN
|
||||
log.info("New OAuth user '%s' granted admin role via group '%s'", provider_username, admin_group)
|
||||
log.info("New OAuth user '%s' granted admin role via group '%s' (groups: %s)",
|
||||
provider_username, admin_group, user_groups)
|
||||
else:
|
||||
user.role = config.config_default_role
|
||||
if should_be_admin and not config.config_enable_oauth_group_admin_management:
|
||||
log.debug("New OAuth user '%s' not granted admin role - group-based management disabled",
|
||||
provider_username)
|
||||
|
||||
# Apply default user settings (same as normal user registration)
|
||||
user.sidebar_view = getattr(config, 'config_default_show', 1)
|
||||
@@ -360,16 +364,23 @@ def register_user_from_generic_oauth(token=None):
|
||||
else:
|
||||
# Existing user: update admin role based on current group membership (Issue #715)
|
||||
# This ensures that users who are added to or removed from admin groups get proper access
|
||||
# Only enforce if group-based admin management is enabled (global setting)
|
||||
current_is_admin = user.role_admin()
|
||||
|
||||
if should_be_admin and not current_is_admin:
|
||||
# User was added to admin group - grant admin role
|
||||
user.role |= constants.ROLE_ADMIN
|
||||
log.info("OAuth user '%s' will be granted admin role via group '%s'", provider_username, admin_group)
|
||||
elif not should_be_admin and current_is_admin:
|
||||
# User was removed from admin group - revoke admin role (but keep other roles)
|
||||
user.role &= ~constants.ROLE_ADMIN
|
||||
log.info("OAuth user '%s' admin role will be revoked (not in group '%s')", provider_username, admin_group)
|
||||
if config.config_enable_oauth_group_admin_management:
|
||||
if should_be_admin and not current_is_admin:
|
||||
# User was added to admin group - grant admin role
|
||||
user.role |= constants.ROLE_ADMIN
|
||||
log.info("OAuth user '%s' granted admin role via group '%s' (groups: %s)",
|
||||
provider_username, admin_group, user_groups)
|
||||
elif not should_be_admin and current_is_admin:
|
||||
# User was removed from admin group - revoke admin role (but keep other roles)
|
||||
user.role &= ~constants.ROLE_ADMIN
|
||||
log.warning("OAuth user '%s' admin role revoked - not in required group '%s' (user groups: %s)",
|
||||
provider_username, admin_group, user_groups)
|
||||
else:
|
||||
log.debug("OAuth group-based admin management disabled - preserving manual role assignments for '%s'",
|
||||
provider_username)
|
||||
# Note: Changes are not committed yet - will be committed with OAuth entry below
|
||||
|
||||
oauth = ub.session.query(ub.OAuth).filter_by(
|
||||
@@ -394,8 +405,8 @@ def register_user_from_generic_oauth(token=None):
|
||||
# Commit all changes together: OAuth entry + Token + User + Role updates
|
||||
try:
|
||||
ub.session_commit()
|
||||
# Log role changes after successful commit
|
||||
if user.role_admin() and should_be_admin:
|
||||
# Log role changes after successful commit (only if group management is enabled)
|
||||
if user.role_admin() and should_be_admin and config.config_enable_oauth_group_admin_management:
|
||||
log.info("OAuth user '%s' has admin role via group '%s'", provider_username, admin_group)
|
||||
except Exception as ex:
|
||||
log.error("Failed to save OAuth session for user '%s': %s", provider_username, ex)
|
||||
@@ -415,8 +426,8 @@ def register_user_from_generic_oauth(token=None):
|
||||
if key in session:
|
||||
try:
|
||||
session.pop(key)
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to clean session key '{key}': {e}")
|
||||
# We assume the user is about to be logged in by bind_oauth_or_register,
|
||||
# but we set the user_id in session just in case, matching oauth_update_token behavior.
|
||||
session[provider_id + "_oauth_user_id"] = provider_user_id
|
||||
@@ -448,8 +459,8 @@ def oauth_update_token(provider_id, token, provider_user_id):
|
||||
if key in session:
|
||||
try:
|
||||
session.pop(key)
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to clean session key '{key}': {e}")
|
||||
|
||||
session[provider_id + "_oauth_user_id"] = provider_user_id
|
||||
# Do NOT store token in session - it's too big and causes cookie drop
|
||||
@@ -754,7 +765,21 @@ def generate_oauth_blueprints():
|
||||
return oauthblueprints
|
||||
|
||||
|
||||
if ub.oauth_support:
|
||||
def init_oauth_blueprints():
|
||||
"""
|
||||
Initialize OAuth blueprints and register signal handlers.
|
||||
|
||||
This function MUST be called after babel.init_app() in __init__.py to ensure
|
||||
translations are properly loaded. When OAuth blueprints are generated during
|
||||
module import (before babel init), babel.list_translations() returns an empty
|
||||
list, causing the language selection dropdown to only show English.
|
||||
|
||||
Issue: https://discord.com/channels/.../... (BortS 01/01/2026)
|
||||
"""
|
||||
if not ub.oauth_support:
|
||||
return []
|
||||
|
||||
global oauthblueprints
|
||||
oauthblueprints = generate_oauth_blueprints()
|
||||
|
||||
@oauth_authorized.connect_via(oauthblueprints[0]['blueprint'])
|
||||
@@ -830,8 +855,7 @@ if ub.oauth_support:
|
||||
# FUNCTION NOW RETURNS A RESPONSE OBJECT (Redirect)
|
||||
response = register_user_from_generic_oauth(token)
|
||||
if response:
|
||||
# DIRECT LOGIN: Abort immediately to proper redirect
|
||||
abort(response)
|
||||
return response
|
||||
|
||||
# If no response, something failed silently (already logged)
|
||||
return False
|
||||
@@ -901,12 +925,20 @@ if ub.oauth_support:
|
||||
flash(msg, category="error")
|
||||
log.error("Generic OAuth error: %s", msg)
|
||||
|
||||
return oauthblueprints
|
||||
|
||||
|
||||
# Initialize empty oauthblueprints list at module level
|
||||
# This will be populated when init_oauth_blueprints() is called
|
||||
oauthblueprints = []
|
||||
|
||||
|
||||
@oauth.route('/link/github')
|
||||
@oauth_required
|
||||
def github_login():
|
||||
# This route is now only a fallback if the direct login hijack fails
|
||||
# or if the user navigates here manually.
|
||||
log.warning("Fallback OAuth route '/link/github' accessed - direct login may have failed")
|
||||
if not github.authorized:
|
||||
return redirect(url_for('github.login'))
|
||||
try:
|
||||
@@ -936,6 +968,7 @@ def github_login_unlink():
|
||||
@oauth.route('/link/google')
|
||||
@oauth_required
|
||||
def google_login():
|
||||
log.warning("Fallback OAuth route '/link/google' accessed - direct login may have failed")
|
||||
# Try to find token in session using the provider ID key
|
||||
provider_id = str(oauthblueprints[1]['id'])
|
||||
user_id_key = provider_id + "_oauth_user_id"
|
||||
@@ -995,6 +1028,7 @@ def google_login_unlink():
|
||||
@oauth.route('/link/generic')
|
||||
@oauth_required
|
||||
def generic_login():
|
||||
log.warning("Fallback OAuth route '/link/generic' accessed - direct login may have failed")
|
||||
# This route is now only a fallback if the direct login hijack fails
|
||||
# or if the user navigates here manually.
|
||||
if not oauthblueprints[2]['blueprint'].session.authorized:
|
||||
|
||||
+24
@@ -29,6 +29,30 @@ opds = Blueprint('opds', __name__)
|
||||
log = logger.create()
|
||||
|
||||
|
||||
@opds.before_request
|
||||
def track_opds_access():
|
||||
"""Track OPDS feed access for analytics"""
|
||||
try:
|
||||
from scripts.cwa_db import CWA_DB
|
||||
from .cw_login import current_user
|
||||
import json as json_lib
|
||||
|
||||
# Only track if user is authenticated
|
||||
if current_user and hasattr(current_user, 'is_authenticated') and current_user.is_authenticated:
|
||||
cwa_db = CWA_DB()
|
||||
cwa_db.log_activity(
|
||||
user_id=int(current_user.id),
|
||||
user_name=current_user.name,
|
||||
event_type='OPDS_ACCESS',
|
||||
extra_data=json_lib.dumps({
|
||||
'endpoint': request.path,
|
||||
'method': request.method
|
||||
})
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to log OPDS access: {e}")
|
||||
|
||||
|
||||
@opds.route("/opds/")
|
||||
@opds.route("/opds")
|
||||
@requires_basic_auth_if_no_ano
|
||||
|
||||
+119
-3
@@ -8,13 +8,14 @@
|
||||
import datetime
|
||||
|
||||
from . import config, constants
|
||||
from .services.background_scheduler import BackgroundScheduler, CronTrigger, use_APScheduler, DateTrigger
|
||||
from .services.background_scheduler import BackgroundScheduler, CronTrigger, IntervalTrigger, use_APScheduler, DateTrigger
|
||||
from .tasks.database import TaskReconnectDatabase
|
||||
from .tasks.clean import TaskClean
|
||||
from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails, TaskClearCoverThumbnailCache
|
||||
from .tasks.thumbnail_migration import check_and_migrate_thumbnails
|
||||
from .services.worker import WorkerThread
|
||||
from .tasks.metadata_backup import TaskBackupMetadata
|
||||
from .tasks.auto_hardcover_id import TaskAutoHardcoverID
|
||||
|
||||
def get_scheduled_tasks(reconnect=True):
|
||||
tasks = list()
|
||||
@@ -61,12 +62,15 @@ def register_scheduled_tasks(reconnect=True):
|
||||
# Register scheduled tasks
|
||||
timezone_info = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo
|
||||
scheduler.schedule_tasks(tasks=get_scheduled_tasks(reconnect), trigger=CronTrigger(hour=start,
|
||||
timezone=timezone_info))
|
||||
timezone=timezone_info))
|
||||
_schedule_duplicate_scan(scheduler, timezone_info)
|
||||
end_time = calclulate_end_time(start, duration)
|
||||
scheduler.schedule(func=end_scheduled_tasks, trigger=CronTrigger(hour=end_time.hour, minute=end_time.minute,
|
||||
timezone=timezone_info),
|
||||
name="end scheduled task")
|
||||
|
||||
_schedule_hardcover_auto_fetch(scheduler, timezone_info)
|
||||
|
||||
# Kick-off tasks, if they should currently be running
|
||||
if should_task_be_running(start, duration):
|
||||
scheduler.schedule_tasks_immediately(tasks=get_scheduled_tasks(reconnect))
|
||||
@@ -97,6 +101,7 @@ def register_startup_tasks():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
db = CWA_DB()
|
||||
delay_minutes = int(db.cwa_settings.get('auto_send_delay_minutes', 0) or 0)
|
||||
pending = db.scheduled_get_pending_autosend()
|
||||
for row in pending:
|
||||
try:
|
||||
@@ -117,7 +122,7 @@ def register_startup_tasks():
|
||||
except Exception:
|
||||
pass
|
||||
if should_enqueue and bid is not None and uid is not None:
|
||||
WorkerThread.add(u, TaskAutoSend(f"Auto-sending '{t}' to user's eReader(s)", bid, uid, config.auto_send_delay_minutes), hidden=False)
|
||||
WorkerThread.add(u, TaskAutoSend(f"Auto-sending '{t}' to user's eReader(s)", bid, uid, delay_minutes), hidden=False)
|
||||
|
||||
job = scheduler.schedule(func=_rehydrate_enqueue, trigger=DateTrigger(run_date=run_at_local), name=f"rehydrated auto-send {schedule_id}")
|
||||
try:
|
||||
@@ -198,3 +203,114 @@ def calclulate_end_time(start, duration):
|
||||
start_time = datetime.datetime.now().replace(hour=start, minute=0)
|
||||
return start_time + datetime.timedelta(hours=duration // 60, minutes=duration % 60)
|
||||
|
||||
|
||||
def _schedule_duplicate_scan(scheduler, timezone_info):
|
||||
"""Schedule background duplicate scan based on CWA settings."""
|
||||
try:
|
||||
import sys as _sys
|
||||
if '/app/calibre-web-automated/scripts/' not in _sys.path:
|
||||
_sys.path.insert(1, '/app/calibre-web-automated/scripts/')
|
||||
from cwa_db import CWA_DB
|
||||
from .tasks.duplicate_scan import TaskDuplicateScan
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
db = CWA_DB()
|
||||
enabled = bool(db.cwa_settings.get('duplicate_scan_enabled', 0))
|
||||
cron_expr = (db.cwa_settings.get('duplicate_scan_cron') or '').strip()
|
||||
|
||||
if not enabled:
|
||||
return
|
||||
|
||||
if cron_expr:
|
||||
trigger = CronTrigger.from_crontab(cron_expr, timezone=timezone_info)
|
||||
else:
|
||||
# manual/after_import handled elsewhere
|
||||
return
|
||||
|
||||
scheduler.schedule_task(lambda: TaskDuplicateScan(full_scan=True, trigger_type='scheduled'),
|
||||
user='System', trigger=trigger, name='duplicate scan', hidden=False)
|
||||
except Exception:
|
||||
# Scheduling is best-effort; never block startup
|
||||
pass
|
||||
|
||||
|
||||
def _schedule_hardcover_auto_fetch(scheduler, timezone_info):
|
||||
"""Schedule background Hardcover auto-fetch based on CWA settings."""
|
||||
try:
|
||||
import sys as _sys
|
||||
if '/app/calibre-web-automated/scripts/' not in _sys.path:
|
||||
_sys.path.insert(1, '/app/calibre-web-automated/scripts/')
|
||||
from cwa_db import CWA_DB
|
||||
from .tasks.auto_hardcover_id import TaskAutoHardcoverID
|
||||
from os import getenv
|
||||
|
||||
db = CWA_DB()
|
||||
cwa_settings = db.get_cwa_settings()
|
||||
|
||||
# Check if enabled and token available
|
||||
enabled = bool(cwa_settings.get('hardcover_auto_fetch_enabled', False))
|
||||
token_available = bool(
|
||||
getattr(config, "config_hardcover_token", None) or
|
||||
getenv("HARDCOVER_TOKEN")
|
||||
)
|
||||
|
||||
if not enabled or not token_available:
|
||||
return
|
||||
|
||||
schedule_type = cwa_settings.get('hardcover_auto_fetch_schedule', 'weekly')
|
||||
schedule_day = cwa_settings.get('hardcover_auto_fetch_schedule_day', 'sunday')
|
||||
schedule_hour = int(cwa_settings.get('hardcover_auto_fetch_schedule_hour', 2))
|
||||
min_confidence = float(cwa_settings.get('hardcover_auto_fetch_min_confidence', 0.85))
|
||||
batch_size = int(cwa_settings.get('hardcover_auto_fetch_batch_size', 50))
|
||||
rate_limit = float(cwa_settings.get('hardcover_auto_fetch_rate_limit', 5.0))
|
||||
|
||||
# Create lambda that returns task instance with configured settings
|
||||
task_lambda = lambda: TaskAutoHardcoverID(
|
||||
min_confidence=min_confidence,
|
||||
batch_size=batch_size,
|
||||
rate_limit_delay=rate_limit
|
||||
)
|
||||
|
||||
# Map day names to APScheduler format
|
||||
day_map = {
|
||||
'monday': 'mon', 'tuesday': 'tue', 'wednesday': 'wed',
|
||||
'thursday': 'thu', 'friday': 'fri', 'saturday': 'sat', 'sunday': 'sun'
|
||||
}
|
||||
|
||||
# Determine trigger based on schedule type
|
||||
trigger = None
|
||||
name = "hardcover auto-fetch"
|
||||
|
||||
if schedule_type == '15min':
|
||||
trigger = IntervalTrigger(minutes=15, timezone=timezone_info)
|
||||
elif schedule_type == '30min':
|
||||
trigger = IntervalTrigger(minutes=30, timezone=timezone_info)
|
||||
elif schedule_type == '1hour':
|
||||
trigger = IntervalTrigger(hours=1, timezone=timezone_info)
|
||||
elif schedule_type == '2hours':
|
||||
trigger = IntervalTrigger(hours=2, timezone=timezone_info)
|
||||
elif schedule_type == '4hours':
|
||||
trigger = IntervalTrigger(hours=4, timezone=timezone_info)
|
||||
elif schedule_type == '6hours':
|
||||
trigger = IntervalTrigger(hours=6, timezone=timezone_info)
|
||||
elif schedule_type == '12hours':
|
||||
trigger = IntervalTrigger(hours=12, timezone=timezone_info)
|
||||
elif schedule_type == 'daily':
|
||||
trigger = CronTrigger(hour=schedule_hour, minute=0, timezone=timezone_info)
|
||||
elif schedule_type == 'weekly':
|
||||
day_abbr = day_map.get(schedule_day.lower(), 'sun')
|
||||
trigger = CronTrigger(day_of_week=day_abbr, hour=schedule_hour, minute=0, timezone=timezone_info)
|
||||
elif schedule_type == 'monthly':
|
||||
# For monthly, schedule_day contains day of month (1-28)
|
||||
try:
|
||||
day_of_month = int(schedule_day) if str(schedule_day).isdigit() else 1
|
||||
day_of_month = max(1, min(28, day_of_month)) # Clamp to 1-28
|
||||
except (ValueError, TypeError):
|
||||
day_of_month = 1
|
||||
trigger = CronTrigger(day=day_of_month, hour=schedule_hour, minute=0, timezone=timezone_info)
|
||||
|
||||
if trigger:
|
||||
scheduler.schedule_task(task_lambda, user='System', trigger=trigger, name=name, hidden=False)
|
||||
except Exception:
|
||||
# Scheduling is best-effort; never block startup
|
||||
pass
|
||||
|
||||
+113
-6
@@ -5,6 +5,7 @@
|
||||
# See CONTRIBUTORS for full list of authors.
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, request, redirect, url_for, flash
|
||||
@@ -12,6 +13,7 @@ from flask import session as flask_session
|
||||
from .cw_login import current_user
|
||||
from flask_babel import format_date
|
||||
from flask_babel import gettext as _
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.sql.expression import func, not_, and_, or_, text, true
|
||||
from sqlalchemy.sql.functions import coalesce
|
||||
|
||||
@@ -32,6 +34,19 @@ log = logger.create()
|
||||
def simple_search():
|
||||
term = request.args.get("query")
|
||||
if term:
|
||||
# Track search activity
|
||||
if current_user.is_authenticated:
|
||||
try:
|
||||
from scripts.cwa_db import CWA_DB
|
||||
cwa_db = CWA_DB()
|
||||
cwa_db.log_activity(
|
||||
user_id=int(current_user.id),
|
||||
user_name=current_user.name,
|
||||
event_type='SEARCH',
|
||||
extra_data=term[:100] # Limit search term length
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to log search activity: {e}")
|
||||
return redirect(url_for('web.books_list', data="search", sort_param='stored', query=term.strip()))
|
||||
else:
|
||||
return render_title_template('search.html',
|
||||
@@ -406,12 +421,104 @@ def render_prepare_search_form(cc):
|
||||
def render_search_results(term, offset=None, order=None, limit=None):
|
||||
if term:
|
||||
join = db.books_series_link, db.Books.id == db.books_series_link.c.book, db.Series
|
||||
entries, result_count, pagination = calibre_db.get_search_results(term,
|
||||
config,
|
||||
offset,
|
||||
order,
|
||||
limit,
|
||||
*join)
|
||||
entries = []
|
||||
result_count = 0
|
||||
pagination = None
|
||||
order_by = order[0] if order else [db.Books.sort]
|
||||
|
||||
fts_ids = None
|
||||
fts_term = strip_whitespaces(term)
|
||||
fts_query = None
|
||||
if fts_term:
|
||||
tokens = [token for token in fts_term.split() if token]
|
||||
if tokens:
|
||||
fts_query = " AND ".join(
|
||||
f'"{token.replace("\"", "\"\"")}"' for token in tokens
|
||||
)
|
||||
else:
|
||||
fts_query = fts_term
|
||||
fts_db_path = None
|
||||
if config.config_calibre_dir:
|
||||
fts_db_path = os.path.join(config.config_calibre_dir, "full-text-search.db")
|
||||
|
||||
if config.config_fulltext_search and fts_query and fts_db_path and os.path.exists(fts_db_path):
|
||||
try:
|
||||
fts_engine = create_engine(
|
||||
f"sqlite:///{fts_db_path}",
|
||||
echo=False,
|
||||
connect_args={'check_same_thread': False}
|
||||
)
|
||||
try:
|
||||
with fts_engine.connect() as fts_conn:
|
||||
try:
|
||||
try:
|
||||
raw_conn = fts_conn.connection.driver_connection
|
||||
except AttributeError:
|
||||
raw_conn = fts_conn.connection
|
||||
|
||||
# Enable extension loading, load Calibre tokenizer, then disable for security
|
||||
raw_conn.enable_load_extension(True)
|
||||
# Note: load_extension auto-appends .so on Linux, so omit it from path
|
||||
raw_conn.load_extension(
|
||||
"/app/calibre/lib/calibre-extensions/sqlite_extension"
|
||||
)
|
||||
raw_conn.enable_load_extension(False)
|
||||
except Exception as ex:
|
||||
log.debug("FTS extension load failed: %s", ex)
|
||||
|
||||
fts_rows = fts_conn.execute(
|
||||
text("""
|
||||
SELECT
|
||||
books_text.book,
|
||||
bm25(books_fts) AS rank
|
||||
FROM books_fts
|
||||
JOIN books_text ON books_text.id = books_fts.rowid
|
||||
WHERE books_fts MATCH :term
|
||||
ORDER BY rank
|
||||
"""),
|
||||
{"term": fts_query}
|
||||
).fetchall()
|
||||
if fts_rows:
|
||||
fts_ids = list(dict.fromkeys(row[0] for row in fts_rows))
|
||||
finally:
|
||||
fts_engine.dispose()
|
||||
except Exception as ex:
|
||||
log.debug("FTS search failed, falling back to default: %s", ex)
|
||||
|
||||
if fts_ids:
|
||||
query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
|
||||
query = (query.outerjoin(db.books_series_link, db.Books.id == db.books_series_link.c.book)
|
||||
.outerjoin(db.Series)
|
||||
.filter(calibre_db.common_filters(True))
|
||||
.filter(db.Books.id.in_(fts_ids))
|
||||
.order_by(*order_by))
|
||||
|
||||
if offset is not None and limit is not None:
|
||||
offset = int(offset)
|
||||
limit_int = int(limit)
|
||||
result = query.limit(offset + limit_int + 1).all()
|
||||
|
||||
has_more = len(result) > (offset + limit_int)
|
||||
if has_more:
|
||||
result_count = offset + limit_int + 1
|
||||
else:
|
||||
result_count = len(result)
|
||||
|
||||
result = result[offset:offset + limit_int]
|
||||
pagination = Pagination((offset / limit_int + 1), limit_int, result_count)
|
||||
else:
|
||||
result = query.all()
|
||||
result_count = len(result)
|
||||
|
||||
ub.store_combo_ids(result)
|
||||
entries = calibre_db.order_authors(result, list_return=True, combined=True)
|
||||
else:
|
||||
entries, result_count, pagination = calibre_db.get_search_results(term,
|
||||
config,
|
||||
offset,
|
||||
order,
|
||||
limit,
|
||||
*join)
|
||||
else:
|
||||
entries = list()
|
||||
order = [None, None]
|
||||
|
||||
@@ -153,7 +153,7 @@ def metadata_search():
|
||||
locale = get_locale()
|
||||
global_enabled = _get_global_provider_enabled_map()
|
||||
if query:
|
||||
static_cover = url_for("static", filename="generic_cover.jpg")
|
||||
static_cover = url_for("static", filename="generic_cover.svg")
|
||||
# ret = cl[0].search(query, static_cover, locale)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||
meta = {
|
||||
|
||||
@@ -28,7 +28,7 @@ class MetaRecord:
|
||||
authors: List[str]
|
||||
url: str
|
||||
source: MetaSourceInfo
|
||||
cover: str = os.path.join(constants.STATIC_DIR, 'generic_cover.jpg')
|
||||
cover: str = os.path.join(constants.STATIC_DIR, 'generic_cover.svg')
|
||||
description: Optional[str] = ""
|
||||
series: Optional[str] = None
|
||||
series_index: Optional[Union[int, float]] = 0
|
||||
@@ -39,6 +39,8 @@ class MetaRecord:
|
||||
languages: Optional[List[str]] = dataclasses.field(default_factory=list)
|
||||
tags: Optional[List[str]] = dataclasses.field(default_factory=list)
|
||||
format: Optional[str] = None
|
||||
confidence_score: Optional[float] = None
|
||||
match_reason: Optional[str] = ""
|
||||
|
||||
|
||||
class Metadata:
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
# See CONTRIBUTORS for full list of authors.
|
||||
|
||||
import atexit
|
||||
import threading
|
||||
|
||||
from .. import logger
|
||||
from .worker import WorkerThread
|
||||
@@ -14,6 +15,7 @@ try:
|
||||
from apscheduler.schedulers.background import BackgroundScheduler as BScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.date import DateTrigger
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
use_APScheduler = True
|
||||
except (ImportError, RuntimeError) as e:
|
||||
use_APScheduler = False
|
||||
@@ -34,6 +36,7 @@ class BackgroundScheduler:
|
||||
logger.logging.getLogger('tzlocal').setLevel(logger.logging.WARNING)
|
||||
cls.scheduler = BScheduler()
|
||||
cls.scheduler.start()
|
||||
cls._schedule_lock = threading.Lock() # Prevent concurrent task scheduling
|
||||
|
||||
return cls._instance
|
||||
|
||||
@@ -74,8 +77,10 @@ class BackgroundScheduler:
|
||||
# Expects a list of lambda expressions for the tasks
|
||||
def schedule_tasks_immediately(self, tasks, user=None):
|
||||
if use_APScheduler:
|
||||
for task in tasks:
|
||||
self.schedule_task_immediately(task[0], user, name="immediately " + task[1], hidden=task[2])
|
||||
# Use lock to prevent "Set changed size during iteration" when tasks are scheduled simultaneously
|
||||
with self._schedule_lock:
|
||||
for task in tasks:
|
||||
self.schedule_task_immediately(task[0], user, name="immediately " + task[1], hidden=task[2])
|
||||
|
||||
# Remove all jobs
|
||||
def remove_all_jobs(self):
|
||||
|
||||
@@ -526,6 +526,9 @@ class HardcoverClient:
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
# 401 Unauthorized means invalid/missing token - treat as missing token
|
||||
if response.status_code == 401:
|
||||
raise MissingHardcoverToken("Invalid or expired Hardcover API token")
|
||||
raise Exception(f"HTTP error occurred: {e}")
|
||||
result = response.json()
|
||||
if "errors" in result:
|
||||
|
||||
@@ -149,6 +149,52 @@ class WorkerThread(threading.Thread):
|
||||
if str(task.id) == str(task_id) and task.is_cancellable:
|
||||
task.stat = STAT_CANCELLED if task.stat == STAT_WAITING else STAT_ENDED
|
||||
|
||||
def cancel_tasks_for_book(self, book_id):
|
||||
"""Cancel all pending tasks associated with a specific book ID
|
||||
|
||||
Args:
|
||||
book_id: The book ID whose tasks should be cancelled
|
||||
|
||||
Returns:
|
||||
int: Number of tasks cancelled
|
||||
"""
|
||||
cancelled_count = 0
|
||||
ins = self.get_instance()
|
||||
|
||||
try:
|
||||
with ins.doLock:
|
||||
# Access queue and dequeued directly to avoid recursive lock from .tasks property
|
||||
tasks_snapshot = list(ins.queue.to_list() + ins.dequeued)
|
||||
except Exception as e:
|
||||
log.warning("[worker] Could not get tasks snapshot: %s", str(e))
|
||||
return 0
|
||||
|
||||
# Process outside the lock to avoid deadlock
|
||||
tasks_to_cancel = []
|
||||
for queued_task in tasks_snapshot:
|
||||
task = queued_task.task
|
||||
# Check if task has a book_id attribute and it matches
|
||||
if hasattr(task, 'book_id') and task.book_id == book_id:
|
||||
# Only cancel if task is waiting or scheduled
|
||||
if task.stat in (STAT_WAITING,) and task.is_cancellable:
|
||||
tasks_to_cancel.append((task, 'book_id'))
|
||||
# Also check for scheduled tasks with bookId attribute (some tasks use different naming)
|
||||
elif hasattr(task, 'bookId') and task.bookId == book_id:
|
||||
if task.stat in (STAT_WAITING,) and task.is_cancellable:
|
||||
tasks_to_cancel.append((task, 'bookId'))
|
||||
|
||||
# Cancel tasks without holding the main lock
|
||||
for task, attr_name in tasks_to_cancel:
|
||||
try:
|
||||
task.stat = STAT_CANCELLED
|
||||
task.error = f"Cancelled: Book {book_id} was removed from library"
|
||||
log.info("[worker] Cancelled task %s for book %s", task.name, book_id)
|
||||
cancelled_count += 1
|
||||
except Exception as e:
|
||||
log.warning("[worker] Failed to cancel task %s: %s", task.name, str(e))
|
||||
|
||||
return cancelled_count
|
||||
|
||||
|
||||
class CalibreTask:
|
||||
__metaclass__ = abc.ABCMeta
|
||||
|
||||
@@ -70,6 +70,23 @@ def add_to_shelf(shelf_id, book_id):
|
||||
try:
|
||||
ub.session.merge(shelf)
|
||||
ub.session.commit()
|
||||
|
||||
# Track shelf activity
|
||||
try:
|
||||
from scripts.cwa_db import CWA_DB
|
||||
import json
|
||||
cwa_db = CWA_DB()
|
||||
cwa_db.log_activity(
|
||||
user_id=int(current_user.id),
|
||||
user_name=current_user.name,
|
||||
event_type='SHELF_ADD',
|
||||
item_id=book_id,
|
||||
item_title=book.title if book else None,
|
||||
extra_data=json.dumps({'shelf_name': shelf.name})
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to log shelf activity: {e}")
|
||||
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
ub.session.rollback()
|
||||
log.error_or_exception("Settings Database error: {}".format(e))
|
||||
@@ -187,6 +204,24 @@ def remove_from_shelf(shelf_id, book_id):
|
||||
ub.session.delete(book_shelf)
|
||||
shelf.last_modified = datetime.now(timezone.utc)
|
||||
ub.session.commit()
|
||||
|
||||
# Track shelf activity
|
||||
try:
|
||||
from scripts.cwa_db import CWA_DB
|
||||
import json
|
||||
book = calibre_db.session.query(db.Books).filter(db.Books.id == book_id).one_or_none()
|
||||
cwa_db = CWA_DB()
|
||||
cwa_db.log_activity(
|
||||
user_id=int(current_user.id),
|
||||
user_name=current_user.name,
|
||||
event_type='SHELF_REMOVE',
|
||||
item_id=book_id,
|
||||
item_title=book.title if book else None,
|
||||
extra_data=json.dumps({'shelf_name': shelf.name})
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to log shelf activity: {e}")
|
||||
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
ub.session.rollback()
|
||||
log.error_or_exception("Settings Database error: {}".format(e))
|
||||
|
||||
@@ -2313,7 +2313,8 @@ body.advsearch > div.container-fluid > div > div.col-sm-10 > div.col-md-10.col-l
|
||||
}
|
||||
|
||||
body.me > div.container-fluid > div > div.col-sm-10 > div.discover:before {
|
||||
content: "My Profile"
|
||||
content: "My Profile";
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
.well {
|
||||
|
||||
@@ -356,6 +356,11 @@ form#archived_form > label.block-label::before {
|
||||
z-index: -1 !important;
|
||||
}
|
||||
|
||||
/* Fixes Create Magic Shelf spacing in sidebar */
|
||||
li#nav_createmagicshelf {
|
||||
margin-left: 1rem !important;
|
||||
}
|
||||
|
||||
/* Fixes close buttons colour */
|
||||
button.close > span {
|
||||
color: whitesmoke;
|
||||
@@ -402,3 +407,13 @@ a#advanced_search > span.hidden-sm {
|
||||
width: auto !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cwa-settings-tip {
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem;
|
||||
background-color: rgba(255, 193, 7, 0.1);
|
||||
border-left: 4px solid #ffc107;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 2rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/* Calibre-Web Automated – Modern Duplicates Notification System
|
||||
* Copyright (C) 2024-2025 Calibre-Web Automated contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
/* Backdrop Overlay */
|
||||
.duplicate-notification-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
z-index: 9998;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
}
|
||||
|
||||
.duplicate-notification-backdrop.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Modal Container */
|
||||
.duplicate-notification-modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
width: 90%;
|
||||
max-width: 550px;
|
||||
background: #1c28328a;
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.duplicate-notification-modal.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
/* Dark theme adjustments */
|
||||
body.caliblur-theme .duplicate-notification-modal {
|
||||
background: rgba(30, 30, 35, 0.98);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.6),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Modal Header */
|
||||
.duplicate-notification-header {
|
||||
background: linear-gradient(135deg, #ff9800 0%, #ff6f00 100%);
|
||||
color: white;
|
||||
padding: 28px 32px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.duplicate-notification-header h2 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.duplicate-notification-header p {
|
||||
margin: 0;
|
||||
opacity: 0.95;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Close Button */
|
||||
.duplicate-notification-close {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.duplicate-notification-close:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Modal Body */
|
||||
.duplicate-notification-body {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.duplicate-count-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.duplicate-count-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #ff9800 0%, #ff6f00 100%);
|
||||
color: white;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 8px 24px rgba(255, 152, 0, 0.4);
|
||||
}
|
||||
|
||||
.duplicate-notification-body h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
body.caliblur-theme .duplicate-notification-body h3 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.duplicate-preview-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 24px 0;
|
||||
}
|
||||
|
||||
.duplicate-preview-item {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
background: #34455185;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #ff9800;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
body.caliblur-theme .duplicate-preview-item {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.duplicate-preview-item:hover {
|
||||
background: #edf2f7;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
body.caliblur-theme .duplicate-preview-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.duplicate-preview-item strong {
|
||||
display: block;
|
||||
color: white;
|
||||
font-size: 15px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
body.caliblur-theme .duplicate-preview-item strong {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.duplicate-preview-item small {
|
||||
color: #718096;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
body.caliblur-theme .duplicate-preview-item small {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
/* Modal Footer */
|
||||
.duplicate-notification-footer {
|
||||
padding: 0 32px 32px 32px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.duplicate-notification-btn {
|
||||
flex: 1;
|
||||
padding: 14px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.duplicate-notification-btn-primary {
|
||||
background: linear-gradient(135deg, #ff9800 0%, #ff6f00 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(255, 152, 0, 0.3);
|
||||
}
|
||||
|
||||
.duplicate-notification-btn-primary:hover {
|
||||
box-shadow: 0 6px 20px rgba(255, 152, 0, 0.4);
|
||||
transform: translateY(-2px);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.duplicate-notification-btn-secondary {
|
||||
background: #34455185;
|
||||
color: white;
|
||||
}
|
||||
|
||||
body.caliblur-theme .duplicate-notification-btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #cbd5e0;
|
||||
}
|
||||
|
||||
.duplicate-notification-btn-secondary:hover {
|
||||
background: #cbd5e0;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
body.caliblur-theme .duplicate-notification-btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Sidebar Badge */
|
||||
.duplicate-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: linear-gradient(135deg, #ff9800 0%, #ff6f00 100%);
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 4px 7px;
|
||||
border-radius: 12px;
|
||||
line-height: 1;
|
||||
box-shadow: 2px 2px 2px rgb(0 0 0 / 18%);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 600px) {
|
||||
.duplicate-notification-modal {
|
||||
width: 95%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.duplicate-notification-header {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.duplicate-notification-body {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.duplicate-notification-footer {
|
||||
padding: 0 20px 24px 20px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.duplicate-notification-header h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.duplicate-count-badge {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
font-size: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -48%) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
.duplicate-notification-modal:focus {
|
||||
outline: 2px solid #ff9800;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.duplicate-notification-btn:focus {
|
||||
outline: 2px solid #ff9800;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.duplicate-notification-loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.duplicate-notification-loading::after {
|
||||
content: "...";
|
||||
animation: ellipsis 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes ellipsis {
|
||||
0%, 20% { content: "."; }
|
||||
40% { content: ".."; }
|
||||
60%, 100% { content: "..."; }
|
||||
}
|
||||
Binary file not shown.
+13
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Minified by jsDelivr using clean-css v5.3.3.
|
||||
* Original file: /npm/jQuery-QueryBuilder@3.0.0/dist/css/query-builder.default.css
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
/*!
|
||||
* jQuery QueryBuilder 3.0.0
|
||||
* Copyright 2014-2024 Damien "Mistic" Sorel (http://www.strangeplanet.fr)
|
||||
* Licensed under MIT (https://opensource.org/licenses/MIT)
|
||||
*/
|
||||
.query-builder .rule-container,.query-builder .rule-placeholder,.query-builder .rules-group-container{position:relative;margin:4px 0;border-radius:5px;padding:5px;border:1px solid #eee;background:rgba(255,255,255,.9)}.query-builder .drag-handle,.query-builder .error-container,.query-builder .rule-container .rule-filter-container,.query-builder .rule-container .rule-operator-container,.query-builder .rule-container .rule-value-container{display:inline-block;margin:0 5px 0 0;vertical-align:middle}.query-builder .rules-group-container{padding:10px;padding-bottom:6px;border:1px solid #dcc896;background:rgba(250,240,210,.5)}.query-builder .rules-group-header{margin-bottom:10px}.query-builder .rules-group-header .group-conditions .btn.readonly:not(.active),.query-builder .rules-group-header .group-conditions input[name$=_cond]{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px;white-space:nowrap}.query-builder .rules-group-header .group-conditions .btn.readonly{border-radius:3px}.query-builder .rules-list{list-style:none;padding:0 0 0 15px;margin:0}.query-builder .rule-value-container{border-left:1px solid #ddd;padding-left:5px}.query-builder .rule-value-container label{margin-bottom:0;font-weight:400}.query-builder .rule-value-container label.block{display:block}.query-builder .error-container{display:none;cursor:help;color:red}.query-builder .has-error{background-color:#fdd;border-color:#f99}.query-builder .has-error .error-container{display:inline-block!important}.query-builder .rules-list>::after,.query-builder .rules-list>::before{content:"";position:absolute;left:-10px;width:10px;height:calc(50% + 4px);border-color:#ccc;border-style:solid}.query-builder .rules-list>::before{top:-4px;border-width:0 0 2px 2px}.query-builder .rules-list>::after{top:50%;border-width:0 0 0 2px}.query-builder .rules-list>:first-child::before{top:-12px;height:calc(50% + 14px)}.query-builder .rules-list>:last-child::before{border-radius:0 0 0 4px}.query-builder .rules-list>:last-child::after{display:none}.query-builder.bt-checkbox-bootstrap-icons .checkbox input[type=checkbox]+label::before{outline:0}.query-builder.bt-checkbox-bootstrap-icons .checkbox input[type=checkbox]:checked+label::after{font-family:bootstrap-icons;content:"\f633"}.query-builder .error-container+.tooltip .tooltip-inner{color:#f99!important}.query-builder p.filter-description{margin:5px 0 0 0;background:#d9edf7;border:1px solid #bce8f1;color:#31708f;border-radius:5px;padding:2.5px 5px;font-size:.8em}.query-builder .rules-group-header [data-invert]{margin-left:5px}.query-builder .drag-handle{cursor:move;vertical-align:middle;margin-left:5px}.query-builder .dragging{position:fixed;opacity:.5;z-index:100}.query-builder .dragging::after,.query-builder .dragging::before{display:none}.query-builder .rule-placeholder{border:1px dashed #bbb;opacity:.7}
|
||||
/*# sourceMappingURL=/sm/dee7df6890261b76e2d840e56811c2a63ad6643f0991b479bb685b044c2d272d.map */
|
||||
@@ -0,0 +1,217 @@
|
||||
.query-builder .rule-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
background-color: #3f3f3fad;
|
||||
border: 1px solid transparent;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.btn-group.group-conditions {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn-group.float-end.group-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.rules-group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.rule-group-container, .rule-container {
|
||||
border-left: 2px solid #ccc;
|
||||
padding-left: 15px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.group-actions > button.btn.btn-sm.btn-success {
|
||||
background-color: #cc7b19;
|
||||
}
|
||||
.group-actions > button.btn.btn-sm.btn-success:hover {
|
||||
background-color: #df9844;
|
||||
}
|
||||
|
||||
.group-actions > button.btn.btn-sm.btn-success {
|
||||
background-color: #cc7b19;
|
||||
}
|
||||
.group-actions > button.btn.btn-sm.btn-success:hover {
|
||||
background-color: #df9844;
|
||||
}
|
||||
.rules-list > .rule-container > .rule-filter-container > select {
|
||||
background-color: rgb(187 187 187 / 34%);
|
||||
color: whitesmoke;
|
||||
text-align: center;
|
||||
padding: 0.3rem;
|
||||
border-radius: 6px;
|
||||
width: --webkit-fill-available;
|
||||
}
|
||||
.rules-list > .rule-container > .rule-operator-container > select {
|
||||
background-color: rgb(187 187 187 / 34%);
|
||||
color: whitesmoke;
|
||||
text-align: center;
|
||||
padding: 0.3rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.rule-actions > button.btn.btn-sm.btn-danger {
|
||||
border-radius: 6px;
|
||||
margin: 0.3rem;
|
||||
padding: 0.3rem 1.6rem;
|
||||
}
|
||||
.rule-value-container {
|
||||
border-left: none !important;
|
||||
}
|
||||
|
||||
.query-builder .rules-group-container {
|
||||
padding: 10px;
|
||||
padding-bottom: 6px;
|
||||
border: 1px solid transparent;
|
||||
background: rgb(196 200 202 / 21%);
|
||||
}
|
||||
|
||||
.rule-value-container > select.form-select {
|
||||
background-color: #1a252ea3;
|
||||
padding: 0.35rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
.rules-list > .rule-container > .rule-operator-container > select > option {
|
||||
background-color: #3c4d55;
|
||||
text-align: left;
|
||||
}
|
||||
.rules-list > .rule-container > .rule-operator-container > select > optgroup > option {
|
||||
background-color: #4f565bad;
|
||||
margin-left: 0px !important;
|
||||
}
|
||||
.rules-list > .rule-container > .rule-operator-container > select > optgroup {
|
||||
background-color: #384850;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.rules-list > .rule-container > .rule-filter-container > select > option {
|
||||
background-color: #3c4d55;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
a.btn.btn-secondary {
|
||||
background-color: #cc7b19;
|
||||
color: whitesmoke;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
a.btn.btn-secondary:hover {
|
||||
background-color: #1a252e;
|
||||
color: whitesmoke;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
/* Mobile Responsive Enhancements */
|
||||
@media (max-width: 767px) {
|
||||
.query-builder .rule-container {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.query-builder .rules-group-container {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.rule-group-container, .rule-container {
|
||||
padding-left: 10px;
|
||||
border-left: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.rules-group-header {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-group.group-conditions,
|
||||
.btn-group.float-end.group-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.btn-group.group-conditions > .btn,
|
||||
.btn-group.group-actions > .btn {
|
||||
flex: 1;
|
||||
min-height: 44px; /* Touch-friendly target size */
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rule-actions > button {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
select.form-control,
|
||||
input.form-control {
|
||||
min-height: 44px;
|
||||
font-size: 16px; /* Prevents iOS zoom on focus */
|
||||
}
|
||||
|
||||
.rules-list > .rule-container > .rule-filter-container > select,
|
||||
.rules-list > .rule-container > .rule-operator-container > select,
|
||||
.rule-value-container > select.form-select {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet Adjustments */
|
||||
@media (min-width: 768px) and (max-width: 991px) {
|
||||
.btn-group.group-conditions > .btn,
|
||||
.btn-group.group-actions > .btn {
|
||||
min-height: 38px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Improved Visual Hierarchy */
|
||||
.query-builder .rules-group-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.query-builder .rules-group-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: #cc7b19;
|
||||
}
|
||||
|
||||
/* Better Focus States */
|
||||
.query-builder select:focus,
|
||||
.query-builder input:focus {
|
||||
outline: 2px solid #cc7b19;
|
||||
outline-offset: 2px;
|
||||
border-color: #cc7b19;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.query-builder.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Empty State Message */
|
||||
.query-builder-empty-message {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -390,9 +390,6 @@ fieldset[disabled] .btn-primary.active {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.spinner {
|
||||
margin: 0 41%;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.3 KiB |
@@ -1060,6 +1060,9 @@ $(function() {
|
||||
$.ajax({
|
||||
url: window.scriptRoot + '/ajax/toggleread/' + bookId,
|
||||
type: 'POST',
|
||||
data: {
|
||||
csrf_token: $("input[name='csrf_token']").val()
|
||||
},
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/* Calibre-Web Automated – Modern Duplicates Notification System
|
||||
* Copyright (C) 2024-2025 Calibre-Web Automated contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const STORAGE_KEY = 'cwa_duplicates_notification_shown';
|
||||
// Removed automatic polling - badge updates only on cache invalidation events
|
||||
|
||||
let currentDuplicateCount = 0;
|
||||
|
||||
/**
|
||||
* Check if notification was already shown in this session
|
||||
*/
|
||||
function wasNotificationShown() {
|
||||
return sessionStorage.getItem(STORAGE_KEY) === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notification as shown for this session
|
||||
*/
|
||||
function markNotificationShown() {
|
||||
sessionStorage.setItem(STORAGE_KEY, 'true');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the duplicate count badge in sidebar
|
||||
*/
|
||||
function updateBadge(count) {
|
||||
currentDuplicateCount = count;
|
||||
const badge = document.getElementById('duplicate-count-badge');
|
||||
|
||||
if (badge) {
|
||||
if (count > 0) {
|
||||
badge.textContent = count > 99 ? '99+' : count;
|
||||
badge.style.display = 'inline-block';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch duplicate status from API
|
||||
*/
|
||||
function fetchDuplicateStatus() {
|
||||
const basePath = (typeof getPath === 'function') ? getPath() : '';
|
||||
const statusUrl = basePath + '/duplicates/status';
|
||||
return fetch(statusUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.catch(error => {
|
||||
console.error('[CWA Duplicates] Error fetching status:', error);
|
||||
return { success: false, count: 0, preview: [], enabled: false };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the notification modal
|
||||
*/
|
||||
function showNotificationModal(data) {
|
||||
const { count, preview } = data;
|
||||
|
||||
// Don't show if already shown this session
|
||||
if (wasNotificationShown()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update count in modal
|
||||
const countBadge = document.getElementById('duplicate-notification-count');
|
||||
if (countBadge) {
|
||||
countBadge.textContent = count;
|
||||
}
|
||||
|
||||
// Update preview list
|
||||
const previewList = document.getElementById('duplicate-notification-preview');
|
||||
if (previewList && preview && preview.length > 0) {
|
||||
previewList.innerHTML = preview.map(item => `
|
||||
<li class="duplicate-preview-item">
|
||||
<strong>${escapeHtml(item.title)}</strong>
|
||||
<small>${escapeHtml(item.author)} - ${item.count} copies</small>
|
||||
</li>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Show modal and backdrop
|
||||
const modal = document.getElementById('duplicate-notification-modal');
|
||||
const backdrop = document.getElementById('duplicate-notification-backdrop');
|
||||
|
||||
if (modal && backdrop) {
|
||||
// Small delay for smooth animation
|
||||
setTimeout(() => {
|
||||
backdrop.classList.add('active');
|
||||
modal.classList.add('active');
|
||||
|
||||
// Focus trap
|
||||
modal.focus();
|
||||
|
||||
// Mark as shown
|
||||
markNotificationShown();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the notification modal
|
||||
*/
|
||||
function hideNotificationModal() {
|
||||
const modal = document.getElementById('duplicate-notification-modal');
|
||||
const backdrop = document.getElementById('duplicate-notification-backdrop');
|
||||
|
||||
if (modal && backdrop) {
|
||||
modal.classList.remove('active');
|
||||
backdrop.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize event listeners
|
||||
*/
|
||||
function initializeEventListeners() {
|
||||
// Close button
|
||||
const closeBtn = document.getElementById('duplicate-notification-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', hideNotificationModal);
|
||||
}
|
||||
|
||||
// Remind me later button
|
||||
const remindBtn = document.getElementById('duplicate-notification-remind');
|
||||
if (remindBtn) {
|
||||
remindBtn.addEventListener('click', hideNotificationModal);
|
||||
}
|
||||
|
||||
// Click outside to close
|
||||
const backdrop = document.getElementById('duplicate-notification-backdrop');
|
||||
if (backdrop) {
|
||||
backdrop.addEventListener('click', hideNotificationModal);
|
||||
}
|
||||
|
||||
// Escape key to close
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
hideNotificationModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Main initialization function
|
||||
*/
|
||||
function init() {
|
||||
// Check if user has permission (admin or edit)
|
||||
const userHasPermission = document.getElementById('duplicate-notification-modal');
|
||||
if (!userHasPermission) {
|
||||
return; // Modal not rendered, user doesn't have permission
|
||||
}
|
||||
|
||||
// Initialize event listeners
|
||||
initializeEventListeners();
|
||||
|
||||
// Fetch initial status once on page load
|
||||
// No periodic updates - badge refreshes after ingest operations only
|
||||
fetchDuplicateStatus().then(data => {
|
||||
if (data.success) {
|
||||
// Update badge
|
||||
updateBadge(data.count);
|
||||
|
||||
// Show notification modal if there are duplicates and notifications are enabled
|
||||
if (data.count > 0 && data.enabled) {
|
||||
showNotificationModal(data);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Expose functions globally for use by other scripts
|
||||
window.CWADuplicates = {
|
||||
updateBadge: updateBadge,
|
||||
fetchStatus: fetchDuplicateStatus,
|
||||
hideModal: hideNotificationModal
|
||||
};
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
})();
|
||||
+463
-16
@@ -26,11 +26,17 @@ $(document).ready(function() {
|
||||
function updateSelectionCount() {
|
||||
var count = selectedBooks.length;
|
||||
if (count === 0) {
|
||||
$('#selection_count').text('');
|
||||
$('#selection_count').text('0 BOOKS SELECTED');
|
||||
$('#delete_selected').addClass('disabled').attr('aria-disabled', true);
|
||||
} else {
|
||||
$('#selection_count').text(count + ' book' + (count > 1 ? 's' : '') + ' selected');
|
||||
$('.merge-selected-btn').addClass('disabled').attr('aria-disabled', true);
|
||||
} else if (count === 1) {
|
||||
$('#selection_count').text('1 BOOK SELECTED');
|
||||
$('#delete_selected').removeClass('disabled').attr('aria-disabled', false);
|
||||
$('.merge-selected-btn').addClass('disabled').attr('aria-disabled', true);
|
||||
} else {
|
||||
$('#selection_count').text(count + ' BOOKS SELECTED');
|
||||
$('#delete_selected').removeClass('disabled').attr('aria-disabled', false);
|
||||
$('.merge-selected-btn').removeClass('disabled').attr('aria-disabled', false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +101,63 @@ $(document).ready(function() {
|
||||
updateBookItemVisuals();
|
||||
});
|
||||
|
||||
// Merge Selected button (delegated for multiple buttons)
|
||||
$(document).on('click', '.merge-selected-btn', function(event) {
|
||||
if ($(this).hasClass('disabled')) {
|
||||
event.stopPropagation();
|
||||
} else {
|
||||
// Check if at least 2 books are selected
|
||||
if (selectedBooks.length < 2) {
|
||||
$('#error_modal_message').text('Please select at least 2 books to merge.');
|
||||
$('#error_modal').modal('show');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use relative URL like table.js to respect base paths
|
||||
var relativeUrl = window.location.pathname + "/../ajax/displayselectedbooks";
|
||||
|
||||
$('#merge_selected_modal').modal('show');
|
||||
|
||||
// Convert book IDs to integers (same as table.js)
|
||||
var bookIds = selectedBooks.map(function(id) { return parseInt(id, 10); });
|
||||
|
||||
// Show list of books to be merged (no CSRF - match table.js exactly)
|
||||
var ajaxData = {"selections": bookIds};
|
||||
|
||||
$.ajax({
|
||||
method: 'post',
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
dataType: 'json',
|
||||
url: relativeUrl,
|
||||
data: JSON.stringify(ajaxData),
|
||||
beforeSend: function(xhr) {
|
||||
// Add CSRF token as header (like table.js)
|
||||
if (csrfToken) {
|
||||
xhr.setRequestHeader('X-CSRFToken', csrfToken);
|
||||
}
|
||||
},
|
||||
success: function(response) {
|
||||
$('#display-merge-target-book').empty();
|
||||
$('#display-merge-source-books').empty();
|
||||
|
||||
// First book is the target (kept)
|
||||
if (response.books && response.books.length > 0) {
|
||||
$("<span>✓ " + response.books[0] + "</span>").appendTo('#display-merge-target-book');
|
||||
|
||||
// Rest are source books (merged and deleted)
|
||||
for (var i = 1; i < response.books.length; i++) {
|
||||
$("<span>- " + response.books[i] + "</span><p></p>").appendTo('#display-merge-source-books');
|
||||
}
|
||||
}
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
$('#error_modal_message').text('Error loading book list for merge confirmation. Status: ' + xhr.status + '. Check browser console for details.');
|
||||
$('#error_modal').modal('show');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Delete Selected button
|
||||
$('#delete_selected').click(function(event) {
|
||||
if ($(this).hasClass('disabled')) {
|
||||
@@ -107,8 +170,8 @@ $(document).ready(function() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use absolute URL like working table.js
|
||||
var relativeUrl = "/ajax/displayselectedbooks";
|
||||
// Use relative URL like table.js to respect base paths
|
||||
var relativeUrl = window.location.pathname + "/../ajax/displayselectedbooks";
|
||||
|
||||
$('#delete_selected_modal').modal('show');
|
||||
|
||||
@@ -130,23 +193,73 @@ $(document).ready(function() {
|
||||
xhr.setRequestHeader('X-CSRFToken', csrfToken);
|
||||
}
|
||||
},
|
||||
success: function(response) {
|
||||
$('#display-delete-selected-books').empty();
|
||||
$.each(response.books, function(i, item) {
|
||||
$("<span>- " + item + "</span><p></p>").appendTo("#display-delete-selected-books");
|
||||
});
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
$('#error_modal_message').text("Error loading book list for confirmation. Status: " + xhr.status + ". Check browser console for details.");
|
||||
$('#error_modal').modal('show');
|
||||
}
|
||||
success: function(response) {
|
||||
$('#display-delete-selected-books').empty();
|
||||
$.each(response.books, function(i, item) {
|
||||
$("<span>- " + item + "</span><p></p>").appendTo("#display-delete-selected-books");
|
||||
});
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
$('#error_modal_message').text("Error loading book list for confirmation. Status: " + xhr.status + ". Check browser console for details.");
|
||||
$('#error_modal').modal('show');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Confirm merge
|
||||
$('#merge_selected_confirm').click(function() {
|
||||
var mergeUrl = window.location.pathname + "/../ajax/mergebooks";
|
||||
|
||||
// Convert book IDs to integers (same as table.js)
|
||||
var bookIds = selectedBooks.map(function(id) { return parseInt(id, 10); });
|
||||
|
||||
// First book in array is target, rest are merged into it
|
||||
var mergeData = {"Merge_books": bookIds};
|
||||
|
||||
$.ajax({
|
||||
method: 'post',
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
dataType: 'json',
|
||||
url: mergeUrl,
|
||||
data: JSON.stringify(mergeData),
|
||||
beforeSend: function(xhr) {
|
||||
// Add CSRF token as header (like table.js)
|
||||
if (csrfToken) {
|
||||
xhr.setRequestHeader('X-CSRFToken', csrfToken);
|
||||
}
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
// Close the merge confirmation modal
|
||||
$('#merge_selected_modal').modal('hide');
|
||||
|
||||
// Show success modal
|
||||
$('#success_modal_message').text('Selected books have been merged successfully!');
|
||||
$('#success_modal').modal('show');
|
||||
} else {
|
||||
// Close the merge confirmation modal
|
||||
$('#merge_selected_modal').modal('hide');
|
||||
|
||||
// Show error modal
|
||||
$('#error_modal_message').text('Error: ' + (response.error || 'Unknown error occurred during merge'));
|
||||
$('#error_modal').modal('show');
|
||||
}
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
// Close the merge confirmation modal
|
||||
$('#merge_selected_modal').modal('hide');
|
||||
|
||||
// Show error modal
|
||||
$('#error_modal_message').text('An error occurred while merging books. Check browser console for details.');
|
||||
$('#error_modal').modal('show');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Confirm delete
|
||||
$('#delete_selected_confirm').click(function() {
|
||||
var deleteUrl = "/ajax/deleteselectedbooks";
|
||||
var deleteUrl = window.location.pathname + "/../ajax/deleteselectedbooks";
|
||||
|
||||
// Convert book IDs to integers (same as table.js)
|
||||
var bookIds = selectedBooks.map(function(id) { return parseInt(id, 10); });
|
||||
@@ -199,6 +312,340 @@ $(document).ready(function() {
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
// Dismiss/Undismiss duplicate group handlers
|
||||
$(document).on('click', '.dismiss-duplicate-btn', function(e) {
|
||||
e.preventDefault();
|
||||
var btn = $(this);
|
||||
var groupHash = btn.data('group-hash');
|
||||
var isDismissed = btn.data('dismissed');
|
||||
var groupContainer = btn.closest('.duplicate-group');
|
||||
|
||||
// Determine action
|
||||
var action = isDismissed ? 'undismiss' : 'dismiss';
|
||||
var endpoint = '/duplicates/' + action + '/' + groupHash;
|
||||
|
||||
// Disable button during request
|
||||
btn.prop('disabled', true);
|
||||
|
||||
// Make AJAX request
|
||||
$.ajax({
|
||||
url: endpoint,
|
||||
type: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
dataType: 'json',
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
// Update button state
|
||||
if (action === 'dismiss') {
|
||||
btn.data('dismissed', true);
|
||||
btn.html('<span class="glyphicon glyphicon-eye-open"></span> Show');
|
||||
btn.attr('title', 'Show this duplicate group');
|
||||
btn.css('background', 'rgba(46, 204, 113, 0.2)');
|
||||
btn.css('border-color', 'rgba(46, 204, 113, 0.4)');
|
||||
|
||||
// Fade out the group
|
||||
groupContainer.fadeOut(300);
|
||||
} else {
|
||||
btn.data('dismissed', false);
|
||||
btn.html('<span class="glyphicon glyphicon-eye-close"></span> Dismiss');
|
||||
btn.attr('title', 'Dismiss this duplicate group');
|
||||
btn.css('background', 'rgba(255,255,255,0.15)');
|
||||
btn.css('border-color', 'rgba(255,255,255,0.3)');
|
||||
}
|
||||
|
||||
// Update badge count in real-time
|
||||
if (window.CWADuplicates && window.CWADuplicates.updateBadge) {
|
||||
window.CWADuplicates.updateBadge(response.count);
|
||||
}
|
||||
|
||||
// Show success message (optional)
|
||||
console.log('[CWA Duplicates] ' + response.message);
|
||||
} else {
|
||||
// Show error
|
||||
alert('Error: ' + response.error);
|
||||
}
|
||||
|
||||
// Re-enable button
|
||||
btn.prop('disabled', false);
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('[CWA Duplicates] Error ' + action + 'ing duplicate group:', error);
|
||||
alert('Error: Failed to update duplicate group');
|
||||
|
||||
// Re-enable button
|
||||
btn.prop('disabled', false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let activeScanTaskId = null;
|
||||
|
||||
function updateProgressUI(progress, message) {
|
||||
const pct = Math.round((progress || 0) * 100);
|
||||
$('#scan_progress_container').show();
|
||||
$('#scan_progress_bar').css('width', pct + '%');
|
||||
$('#scan_progress_label').text(pct + '%');
|
||||
$('#scan_progress_message').text(message || '');
|
||||
$('#cancel_scan').show();
|
||||
}
|
||||
|
||||
function resetProgressUI() {
|
||||
$('#scan_progress_container').hide();
|
||||
$('#scan_progress_bar').css('width', '0%');
|
||||
$('#scan_progress_label').text('0%');
|
||||
$('#scan_progress_message').text('');
|
||||
$('#cancel_scan').hide();
|
||||
activeScanTaskId = null;
|
||||
}
|
||||
|
||||
function pollScanProgress(taskId, btn) {
|
||||
activeScanTaskId = taskId;
|
||||
const pollInterval = 2000;
|
||||
function pollOnce() {
|
||||
$.ajax({
|
||||
url: '/duplicates/scan-progress/' + taskId,
|
||||
type: 'GET',
|
||||
dataType: 'json',
|
||||
success: function(response) {
|
||||
if (!response.success) {
|
||||
clearInterval(poller);
|
||||
btn.prop('disabled', false);
|
||||
btn.html('<span class="glyphicon glyphicon-refresh"></span> Scan for Duplicates Now');
|
||||
resetProgressUI();
|
||||
alert('Scan failed: ' + (response.error || 'Unknown error'));
|
||||
return;
|
||||
}
|
||||
|
||||
updateProgressUI(response.progress, response.message);
|
||||
|
||||
if (response.status === 'completed') {
|
||||
clearInterval(poller);
|
||||
btn.prop('disabled', false);
|
||||
btn.html('<span class="glyphicon glyphicon-refresh"></span> Scan for Duplicates Now');
|
||||
const resultCount = (response.result_count !== null && response.result_count !== undefined)
|
||||
? response.result_count
|
||||
: null;
|
||||
const doneMessage = resultCount !== null
|
||||
? 'Scan completed. Found ' + resultCount + ' duplicate groups.'
|
||||
: 'Scan completed.';
|
||||
updateProgressUI(1, doneMessage + ' Refreshing...');
|
||||
alert(doneMessage);
|
||||
setTimeout(function() {
|
||||
resetProgressUI();
|
||||
location.reload();
|
||||
}, 800);
|
||||
} else if (response.status === 'failed') {
|
||||
clearInterval(poller);
|
||||
btn.prop('disabled', false);
|
||||
btn.html('<span class="glyphicon glyphicon-refresh"></span> Scan for Duplicates Now');
|
||||
resetProgressUI();
|
||||
alert('Scan failed: ' + (response.message || 'Unknown error'));
|
||||
} else if (response.status === 'cancelled') {
|
||||
clearInterval(poller);
|
||||
btn.prop('disabled', false);
|
||||
btn.html('<span class="glyphicon glyphicon-refresh"></span> Scan for Duplicates Now');
|
||||
resetProgressUI();
|
||||
alert('Scan cancelled');
|
||||
}
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('[CWA Duplicates] Error polling scan progress:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const poller = setInterval(pollOnce, pollInterval);
|
||||
pollOnce();
|
||||
}
|
||||
|
||||
// Manual scan trigger
|
||||
$('#trigger_scan').on('click', function() {
|
||||
var btn = $(this);
|
||||
btn.prop('disabled', true);
|
||||
btn.html('<span class="glyphicon glyphicon-refresh glyphicon-spin"></span> Scanning...');
|
||||
|
||||
$.ajax({
|
||||
url: '/duplicates/trigger-scan',
|
||||
type: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
dataType: 'json',
|
||||
success: function(response) {
|
||||
if (response.success && response.task_id) {
|
||||
updateProgressUI(0, 'Queued...');
|
||||
pollScanProgress(response.task_id, btn);
|
||||
} else if (response.success) {
|
||||
if (response.queued === false && response.fallback_reason) {
|
||||
console.warn('[CWA Duplicates] Background queue failed, fallback used:', response.fallback_reason);
|
||||
}
|
||||
const count = (response.count !== undefined && response.count !== null)
|
||||
? response.count
|
||||
: null;
|
||||
const message = count !== null
|
||||
? 'Scan completed. Found ' + count + ' duplicate groups.'
|
||||
: 'Scan completed.';
|
||||
alert(message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Scan failed: ' + response.error);
|
||||
}
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('[CWA Duplicates] Error triggering scan:', error);
|
||||
alert('Error: Failed to trigger duplicate scan');
|
||||
},
|
||||
complete: function() {
|
||||
// Button state will be restored by poller
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Cancel scan
|
||||
$('#cancel_scan').on('click', function() {
|
||||
if (!activeScanTaskId) {
|
||||
return;
|
||||
}
|
||||
$.ajax({
|
||||
url: '/duplicates/cancel-scan/' + activeScanTaskId,
|
||||
type: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
dataType: 'json',
|
||||
success: function(response) {
|
||||
if (!response.success) {
|
||||
alert('Cancel failed: ' + response.error);
|
||||
}
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('[CWA Duplicates] Error cancelling scan:', error);
|
||||
alert('Error: Failed to cancel duplicate scan');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-resolution preview
|
||||
$('#preview_resolution').on('click', function() {
|
||||
var strategy = $('#resolution_strategy').val();
|
||||
var btn = $(this);
|
||||
btn.prop('disabled', true);
|
||||
btn.html('<span class="glyphicon glyphicon-refresh glyphicon-spin"></span> Loading...');
|
||||
|
||||
$.ajax({
|
||||
url: '/duplicates/preview-resolution',
|
||||
method: 'POST',
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
data: JSON.stringify({ strategy: strategy }),
|
||||
success: function(data) {
|
||||
if (data.success && data.preview) {
|
||||
showResolutionPreview(data);
|
||||
$('#execute_resolution').removeClass('disabled').prop('aria-disabled', false);
|
||||
} else {
|
||||
alert('Error generating preview: ' + (data.errors || []).join(', '));
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
alert('Failed to generate preview');
|
||||
},
|
||||
complete: function() {
|
||||
btn.prop('disabled', false);
|
||||
btn.html('<span class="glyphicon glyphicon-eye-open"></span> Preview');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Execute resolution
|
||||
$('#execute_resolution').on('click', function() {
|
||||
if ($(this).hasClass('disabled')) return;
|
||||
|
||||
if (!confirm('Are you sure you want to execute auto-resolution? This will permanently delete duplicate books.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var strategy = $('#resolution_strategy').val();
|
||||
var btn = $(this);
|
||||
btn.addClass('disabled').html('<span class="glyphicon glyphicon-refresh glyphicon-spin"></span> Executing...');
|
||||
|
||||
$.ajax({
|
||||
url: '/duplicates/execute-resolution',
|
||||
method: 'POST',
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
data: JSON.stringify({ strategy: strategy }),
|
||||
success: function(data) {
|
||||
if (data.success) {
|
||||
alert('Resolution complete!\n\nResolved: ' + data.resolved_count + ' groups\nKept: ' + data.kept_count + ' books\nDeleted: ' + data.deleted_count + ' books');
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Errors occurred:\n' + (data.errors || []).join('\n'));
|
||||
btn.removeClass('disabled').html('<span class="glyphicon glyphicon-flash"></span> Execute Resolution');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
alert('Failed to execute resolution');
|
||||
btn.removeClass('disabled').html('<span class="glyphicon glyphicon-flash"></span> Execute Resolution');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function showResolutionPreview(data) {
|
||||
var html = '<div style="margin-bottom: 20px; padding: 15px; background: #6d6d6d66; border-left: 4px solid #4caf50; border-radius: 4px; color: white">' +
|
||||
'<h4 style="color: #98f99c; margin-top: 0;">Summary</h4>' +
|
||||
'<p><strong>Groups to resolve:</strong> ' + data.resolved_count + '</p>' +
|
||||
'<p><strong>Books to keep:</strong> ' + data.kept_count + '</p>' +
|
||||
'<p><strong>Books to delete:</strong> ' + data.deleted_count + '</p>' +
|
||||
'</div>';
|
||||
|
||||
if (data.preview && data.preview.length > 0) {
|
||||
html += '<div style="margin-top: 20px;"><h4>Preview:</h4>';
|
||||
|
||||
data.preview.forEach(function(group) {
|
||||
html += '<div style="padding: 15px; border: none; border-radius: 6px; background: #6d6d6d66;">' +
|
||||
'<h5 style="color: white; margin-top: 0;">' + escapeHtml(group.title) + ' <small>by ' + escapeHtml(group.author) + '</small></h5>' +
|
||||
'<div style="margin-bottom: 15px; padding: 10px; background: #d4edda; border-left: 3px solid #28a745; border-radius: 4px; color: #1c2832">' +
|
||||
'<strong style="color: #155724;">✓ KEEP:</strong> Book ID ' + group.kept_book_id + ' ' +
|
||||
'<small style="color: #666;">(Added: ' + group.kept_book_timestamp + ', Formats: ' + group.kept_book_formats.join(', ') + ')</small>' +
|
||||
'</div>' +
|
||||
'<div style="padding: 10px; background: #f8d7da; border-left: 3px solid #dc3545; border-radius: 4px;">' +
|
||||
'<strong style="color: #721c24;">✗ DELETE:</strong>' +
|
||||
'<ul style="margin: 5px 0 0 20px; padding: 0; color: #1c2832">';
|
||||
|
||||
group.deleted_books_info.forEach(function(book) {
|
||||
html += '<li style="margin: 5px 0;">' +
|
||||
'Book ID ' + book.id + ' ' +
|
||||
'<small style="color: #666;">(Added: ' + book.timestamp + ', Formats: ' + book.formats.join(', ') + ')</small>' +
|
||||
'</li>';
|
||||
});
|
||||
|
||||
html += '</ul></div></div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
$('#resolution_preview_body').html(html);
|
||||
$('#resolution_preview_modal').modal('show');
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
var map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return text.replace(/[&<>"']/g, function(m) { return map[m]; });
|
||||
}
|
||||
|
||||
// Initialize
|
||||
updateSelectionCount();
|
||||
updateBookItemVisuals();
|
||||
|
||||
Vendored
+45
File diff suppressed because one or more lines are too long
Vendored
+4
-2
File diff suppressed because one or more lines are too long
@@ -692,6 +692,32 @@ $(function() {
|
||||
}
|
||||
});
|
||||
});
|
||||
$("#hardcover_auto_fetch").click(function() {
|
||||
$("#DialogHeader").addClass("hidden");
|
||||
$("#DialogFinished").addClass("hidden");
|
||||
$("#DialogContent").html("");
|
||||
$("#spinner2").show();
|
||||
$.ajax({
|
||||
method: "post",
|
||||
contentType: "application/json; charset=utf-8",
|
||||
dataType: "json",
|
||||
url: getPath() + "/hardcover_auto_fetch",
|
||||
success: function success(data) {
|
||||
$("#spinner2").hide();
|
||||
$("#DialogContent").html(data.text);
|
||||
$("#DialogFinished").removeClass("hidden");
|
||||
},
|
||||
error: function error(xhr) {
|
||||
$("#spinner2").hide();
|
||||
var errorMessage = "Error starting Hardcover auto-fetch";
|
||||
if (xhr.responseJSON && xhr.responseJSON.text) {
|
||||
errorMessage = xhr.responseJSON.text;
|
||||
}
|
||||
$("#DialogContent").html(errorMessage);
|
||||
$("#DialogFinished").removeClass("hidden");
|
||||
}
|
||||
});
|
||||
});
|
||||
$("#perform_update").click(function() {
|
||||
$("#DialogHeader").removeClass("hidden");
|
||||
$("#spinner2").show();
|
||||
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
@@ -44,6 +44,22 @@ $(function() {
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
if ($('#upcomingtable').length) {
|
||||
$('#upcomingtable').bootstrapTable({
|
||||
formatNoMatches: function () {
|
||||
return '';
|
||||
},
|
||||
striped: true
|
||||
});
|
||||
}
|
||||
if ($('#upcomingopstable').length) {
|
||||
$('#upcomingopstable').bootstrapTable({
|
||||
formatNoMatches: function () {
|
||||
return '';
|
||||
},
|
||||
striped: true
|
||||
});
|
||||
}
|
||||
|
||||
$(document).on('click', '#select_all', function() {
|
||||
$('#books-table').bootstrapTable('checkAll');
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Calibre-Web Automated – fork of Calibre-Web
|
||||
# Copyright (C) 2018-2025 Calibre-Web contributors
|
||||
# Copyright (C) 2024-2025 Calibre-Web Automated contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# See CONTRIBUTORS for full list of authors.
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
from os import getenv
|
||||
from typing import List, Optional
|
||||
|
||||
from cps import config, db, logger, ub
|
||||
from cps.services.worker import CalibreTask, STAT_FAIL, STAT_FINISH_SUCCESS
|
||||
from flask_babel import lazy_gettext as N_
|
||||
from sqlalchemy import not_
|
||||
|
||||
# Import the Hardcover provider
|
||||
try:
|
||||
from cps.metadata_provider.hardcover import Hardcover
|
||||
except ImportError:
|
||||
Hardcover = None
|
||||
|
||||
|
||||
class TaskAutoHardcoverID(CalibreTask):
|
||||
"""
|
||||
Background task to automatically fetch Hardcover IDs for books in the library.
|
||||
|
||||
This task:
|
||||
1. Queries all books without hardcover-id, hardcover-slug, or hardcover-edition identifiers
|
||||
2. Processes books in configurable batches with rate limiting
|
||||
3. Searches Hardcover API for each book using title + authors
|
||||
4. Calculates confidence scores for matches
|
||||
5. Auto-applies high-confidence matches (>=threshold, default 0.85)
|
||||
6. Queues low-confidence matches for manual review
|
||||
7. Implements exponential backoff on API errors
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
min_confidence: float = 0.85,
|
||||
batch_size: int = 50,
|
||||
rate_limit_delay: float = 5.0,
|
||||
max_backoff_errors: int = 5,
|
||||
task_message=N_('Auto-fetching Hardcover IDs')):
|
||||
super(TaskAutoHardcoverID, self).__init__(task_message)
|
||||
self.log = logger.create()
|
||||
self.calibre_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||
self.min_confidence = min_confidence
|
||||
self.batch_size = batch_size
|
||||
self.rate_limit_delay = rate_limit_delay
|
||||
self.max_backoff_errors = max_backoff_errors
|
||||
|
||||
# Stats tracking
|
||||
self.books_processed = 0
|
||||
self.auto_matched = 0
|
||||
self.queued_for_review = 0
|
||||
self.skipped_no_results = 0
|
||||
self.errors = 0
|
||||
self.total_confidence = 0.0
|
||||
|
||||
# Error tracking for exponential backoff
|
||||
self.consecutive_errors = 0
|
||||
self.current_delay = rate_limit_delay
|
||||
|
||||
def run(self, worker_thread):
|
||||
# Check if Hardcover provider is available
|
||||
if Hardcover is None:
|
||||
self._handleError("Hardcover provider not available")
|
||||
return
|
||||
|
||||
# Check if valid token exists
|
||||
token = self._get_hardcover_token()
|
||||
if not token:
|
||||
self._handleError("No valid Hardcover token found. Set HARDCOVER_TOKEN environment variable or configure token in settings.")
|
||||
return
|
||||
|
||||
try:
|
||||
# Query books without hardcover identifiers
|
||||
books = self._get_books_without_hardcover_id()
|
||||
total_books = len(books)
|
||||
|
||||
if total_books == 0:
|
||||
self.log.info("No books found without Hardcover IDs")
|
||||
self._handleSuccess()
|
||||
return
|
||||
|
||||
self.log.info(f"Found {total_books} books without Hardcover IDs. Processing in batches of {self.batch_size}...")
|
||||
|
||||
# Process books in batches
|
||||
batch_count = (total_books + self.batch_size - 1) // self.batch_size
|
||||
for batch_num in range(batch_count):
|
||||
# Check if task was cancelled
|
||||
if self.stat == 5: # STAT_CANCELLED
|
||||
self.log.info("Task cancelled by user")
|
||||
return
|
||||
|
||||
start_idx = batch_num * self.batch_size
|
||||
end_idx = min(start_idx + self.batch_size, total_books)
|
||||
batch = books[start_idx:end_idx]
|
||||
|
||||
self.log.info(f"Processing batch {batch_num + 1}/{batch_count} ({len(batch)} books)")
|
||||
|
||||
for book in batch:
|
||||
# Check if cancelled
|
||||
if self.stat == 5:
|
||||
self.log.info("Task cancelled by user")
|
||||
return
|
||||
|
||||
# Check if we've hit too many consecutive errors
|
||||
if self.consecutive_errors >= self.max_backoff_errors:
|
||||
error_msg = f"Exceeded maximum consecutive errors ({self.max_backoff_errors}). Stopping to protect API key."
|
||||
self.log.error(error_msg)
|
||||
self._save_stats()
|
||||
self._handleError(error_msg)
|
||||
return
|
||||
|
||||
try:
|
||||
self._process_book(book)
|
||||
self.books_processed += 1
|
||||
|
||||
# Reset consecutive errors on success
|
||||
self.consecutive_errors = 0
|
||||
self.current_delay = self.rate_limit_delay
|
||||
|
||||
# Update progress
|
||||
self.progress = self.books_processed / total_books
|
||||
|
||||
# Rate limiting: wait between requests
|
||||
if self.books_processed < total_books:
|
||||
time.sleep(self.current_delay)
|
||||
|
||||
except Exception as e:
|
||||
self.log.error(f"Error processing book {book.id} '{book.title}': {e}")
|
||||
self.errors += 1
|
||||
self.consecutive_errors += 1
|
||||
|
||||
# Exponential backoff
|
||||
self.current_delay = min(self.current_delay * 2, 60.0)
|
||||
self.log.warning(f"Consecutive errors: {self.consecutive_errors}. Increasing delay to {self.current_delay}s")
|
||||
time.sleep(self.current_delay)
|
||||
|
||||
# Save final stats
|
||||
self._save_stats()
|
||||
|
||||
# Log summary
|
||||
self.log.info(f"Hardcover auto-fetch completed: {self.books_processed} processed, "
|
||||
f"{self.auto_matched} auto-matched, {self.queued_for_review} queued for review, "
|
||||
f"{self.skipped_no_results} skipped (no results), {self.errors} errors")
|
||||
|
||||
self._handleSuccess()
|
||||
|
||||
except Exception as ex:
|
||||
self.log.error(f"Fatal error in TaskAutoHardcoverID: {ex}")
|
||||
self._handleError(str(ex))
|
||||
finally:
|
||||
self.calibre_db.session.close()
|
||||
|
||||
def _get_hardcover_token(self) -> Optional[str]:
|
||||
"""Get Hardcover token from environment or config"""
|
||||
token = (
|
||||
getattr(config, "config_hardcover_token", None)
|
||||
or getenv("HARDCOVER_TOKEN")
|
||||
)
|
||||
return token
|
||||
|
||||
def _get_books_without_hardcover_id(self) -> List[db.Books]:
|
||||
"""
|
||||
Query all books that don't have any Hardcover identifiers.
|
||||
Excludes books with hardcover-id, hardcover-slug, or hardcover-edition.
|
||||
"""
|
||||
books = self.calibre_db.session.query(db.Books).filter(
|
||||
~db.Books.identifiers.any(
|
||||
db.Identifiers.type.in_(['hardcover-id', 'hardcover-slug', 'hardcover-edition'])
|
||||
)
|
||||
).limit(10000).all() # Safety limit
|
||||
|
||||
return books
|
||||
|
||||
def _process_book(self, book: db.Books):
|
||||
"""
|
||||
Process a single book: search Hardcover API, calculate confidence, apply or queue.
|
||||
"""
|
||||
# Build search query from book metadata
|
||||
authors = [author.name for author in book.authors] if book.authors else []
|
||||
author_str = ", ".join(authors[:3]) if authors else "" # Limit to first 3 authors
|
||||
|
||||
# Build search query
|
||||
if author_str:
|
||||
search_query = f"{book.title} {author_str}"
|
||||
else:
|
||||
search_query = book.title
|
||||
|
||||
self.log.debug(f"Searching Hardcover for: {search_query}")
|
||||
|
||||
# Initialize Hardcover provider
|
||||
provider = Hardcover()
|
||||
|
||||
# Search Hardcover API
|
||||
results = provider.search(search_query)
|
||||
|
||||
if not results:
|
||||
self.log.debug(f"No Hardcover results for book {book.id} '{book.title}'")
|
||||
self.skipped_no_results += 1
|
||||
return
|
||||
|
||||
self.log.debug(f"Found {len(results)} Hardcover results for book {book.id}")
|
||||
|
||||
# Calculate confidence scores for each result
|
||||
scored_results = []
|
||||
for result in results[:10]: # Limit to top 10 results
|
||||
# Get book's ISBN for matching
|
||||
book_isbn = None
|
||||
for identifier in book.identifiers:
|
||||
if identifier.type.lower() == 'isbn':
|
||||
book_isbn = identifier.val
|
||||
break
|
||||
|
||||
# Get book's series info
|
||||
book_series = book.series[0].name if book.series else None
|
||||
book_series_index = book.series_index if book.series else None
|
||||
|
||||
# Get publisher
|
||||
book_publisher = book.publishers[0].name if book.publishers else None
|
||||
|
||||
# Get publication year
|
||||
book_year = str(book.pubdate)[:4] if book.pubdate else None
|
||||
|
||||
# Calculate confidence score
|
||||
score, reason = Hardcover.calculate_confidence_score(
|
||||
result=result,
|
||||
query_title=book.title,
|
||||
query_authors=authors,
|
||||
query_isbn=book_isbn,
|
||||
query_series=book_series,
|
||||
query_series_index=book_series_index,
|
||||
query_publisher=book_publisher,
|
||||
query_year=book_year
|
||||
)
|
||||
|
||||
scored_results.append({
|
||||
'result': result,
|
||||
'score': score,
|
||||
'reason': reason
|
||||
})
|
||||
|
||||
# Sort by confidence score (highest first)
|
||||
scored_results.sort(key=lambda x: x['score'], reverse=True)
|
||||
|
||||
if not scored_results:
|
||||
self.skipped_no_results += 1
|
||||
return
|
||||
|
||||
# Get best match
|
||||
best_match = scored_results[0]
|
||||
best_score = best_match['score']
|
||||
best_result = best_match['result']
|
||||
|
||||
self.log.debug(f"Best match for book {book.id}: score={best_score:.3f}, reason={best_match['reason']}")
|
||||
|
||||
# Track average confidence
|
||||
self.total_confidence += best_score
|
||||
|
||||
# Auto-apply if confidence is high enough
|
||||
if best_score >= self.min_confidence:
|
||||
self._apply_hardcover_id(book, best_result)
|
||||
self.auto_matched += 1
|
||||
self.log.info(f"Auto-matched book {book.id} '{book.title}' to Hardcover ID {best_result.id} (confidence: {best_score:.3f})")
|
||||
else:
|
||||
# Queue for manual review
|
||||
self._queue_for_review(book, search_query, scored_results)
|
||||
self.queued_for_review += 1
|
||||
self.log.debug(f"Queued book {book.id} '{book.title}' for manual review (confidence: {best_score:.3f})")
|
||||
|
||||
def _apply_hardcover_id(self, book: db.Books, result):
|
||||
"""Apply Hardcover identifiers to a book"""
|
||||
try:
|
||||
# Add hardcover-id
|
||||
if 'hardcover-id' in result.identifiers:
|
||||
hardcover_id = str(result.identifiers['hardcover-id'])
|
||||
new_identifier = db.Identifiers(hardcover_id, 'hardcover-id', book.id)
|
||||
self.calibre_db.session.add(new_identifier)
|
||||
|
||||
# Add hardcover-slug
|
||||
if 'hardcover-slug' in result.identifiers:
|
||||
hardcover_slug = str(result.identifiers['hardcover-slug'])
|
||||
new_identifier = db.Identifiers(hardcover_slug, 'hardcover-slug', book.id)
|
||||
self.calibre_db.session.add(new_identifier)
|
||||
|
||||
# Add hardcover-edition (if available)
|
||||
if 'hardcover-edition' in result.identifiers:
|
||||
hardcover_edition = str(result.identifiers['hardcover-edition'])
|
||||
new_identifier = db.Identifiers(hardcover_edition, 'hardcover-edition', book.id)
|
||||
self.calibre_db.session.add(new_identifier)
|
||||
|
||||
self.calibre_db.session.commit()
|
||||
|
||||
except Exception as e:
|
||||
self.log.error(f"Error applying Hardcover ID to book {book.id}: {e}")
|
||||
self.calibre_db.session.rollback()
|
||||
raise
|
||||
|
||||
def _queue_for_review(self, book: db.Books, search_query: str, scored_results: List[dict]):
|
||||
"""Queue ambiguous match for manual review"""
|
||||
try:
|
||||
# Initialize user session for ub database
|
||||
ub.init_db_thread()
|
||||
|
||||
# Prepare results for JSON storage (top 5 candidates)
|
||||
results_json = []
|
||||
scores_json = []
|
||||
|
||||
for item in scored_results[:5]:
|
||||
result = item['result']
|
||||
results_json.append({
|
||||
'id': str(result.id),
|
||||
'title': result.title,
|
||||
'authors': result.authors,
|
||||
'url': result.url,
|
||||
'cover': result.cover,
|
||||
'description': result.description or "",
|
||||
'series': result.series or "",
|
||||
'series_index': str(result.series_index) if result.series_index else "",
|
||||
'publisher': result.publisher or "",
|
||||
'publishedDate': result.publishedDate or "",
|
||||
'identifiers': {k: str(v) for k, v in result.identifiers.items()}
|
||||
})
|
||||
scores_json.append([item['score'], item['reason']])
|
||||
|
||||
# Create queue entry
|
||||
queue_entry = ub.HardcoverMatchQueue(
|
||||
book_id=book.id,
|
||||
book_title=book.title,
|
||||
book_authors=", ".join([author.name for author in book.authors]) if book.authors else "",
|
||||
search_query=search_query,
|
||||
hardcover_results=json.dumps(results_json),
|
||||
confidence_scores=json.dumps(scores_json),
|
||||
created_at=datetime.utcnow().isoformat(),
|
||||
reviewed=0
|
||||
)
|
||||
|
||||
ub.session.add(queue_entry)
|
||||
ub.session.commit()
|
||||
|
||||
except Exception as e:
|
||||
self.log.error(f"Error queuing book {book.id} for review: {e}")
|
||||
ub.session.rollback()
|
||||
raise
|
||||
|
||||
def _save_stats(self):
|
||||
"""Save statistics to CWA database"""
|
||||
try:
|
||||
from scripts.cwa_db import CWA_DB
|
||||
cwa_db = CWA_DB()
|
||||
|
||||
avg_confidence = (self.total_confidence / self.auto_matched) if self.auto_matched > 0 else 0.0
|
||||
|
||||
query = """
|
||||
INSERT INTO hardcover_auto_fetch_stats
|
||||
(timestamp, books_processed, auto_matched, queued_for_review,
|
||||
skipped_no_results, errors, avg_confidence)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
|
||||
cwa_db.execute_write(
|
||||
query,
|
||||
(
|
||||
datetime.utcnow().isoformat(),
|
||||
self.books_processed,
|
||||
self.auto_matched,
|
||||
self.queued_for_review,
|
||||
self.skipped_no_results,
|
||||
self.errors,
|
||||
avg_confidence
|
||||
)
|
||||
)
|
||||
|
||||
self.log.debug("Saved Hardcover auto-fetch stats to database")
|
||||
|
||||
except Exception as e:
|
||||
self.log.warning(f"Error saving stats to database: {e}")
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "Auto-fetch Hardcover IDs"
|
||||
|
||||
@property
|
||||
def is_cancellable(self):
|
||||
return True
|
||||
|
||||
def _handleSuccess(self):
|
||||
self.stat = STAT_FINISH_SUCCESS
|
||||
self.progress = 1.0
|
||||
|
||||
def _handleError(self, error_message):
|
||||
self.stat = STAT_FAIL
|
||||
self.progress = 1.0
|
||||
self.error = error_message
|
||||
@@ -93,7 +93,9 @@ class TaskAutoSend(CalibreTask):
|
||||
self._handleError(f"Auto-send task failed: {str(e)}")
|
||||
finally:
|
||||
if 'calibre_db_instance' in locals():
|
||||
calibre_db_instance.session.close()
|
||||
session = getattr(calibre_db_instance, "session", None)
|
||||
if session:
|
||||
session.close()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Calibre-Web Automated – fork of Calibre-Web
|
||||
# Copyright (C) 2018-2025 Calibre-Web contributors
|
||||
# Copyright (C) 2024-2025 Calibre-Web Automated contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# See CONTRIBUTORS for full list of authors.
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from sqlalchemy import func
|
||||
from flask_babel import lazy_gettext as N_
|
||||
|
||||
from cps import calibre_db, db, logger
|
||||
from cps.duplicates import find_duplicate_books_python, find_duplicate_candidate_ids_sql
|
||||
from cps.services.worker import CalibreTask, STAT_CANCELLED, STAT_ENDED
|
||||
from cps.ub import init_db_thread
|
||||
|
||||
# Access CWA DB (scripts path)
|
||||
if '/app/calibre-web-automated/scripts/' not in sys.path:
|
||||
sys.path.insert(1, '/app/calibre-web-automated/scripts/')
|
||||
from cwa_db import CWA_DB
|
||||
|
||||
log = logger.create()
|
||||
|
||||
|
||||
class TaskDuplicateScan(CalibreTask):
|
||||
def __init__(self, full_scan=True, task_message=None, trigger_type='manual', user_id=None):
|
||||
super(TaskDuplicateScan, self).__init__(task_message or N_('Duplicate scan'))
|
||||
self.full_scan = full_scan
|
||||
self.trigger_type = trigger_type
|
||||
self.result_count = 0
|
||||
self.user_id = user_id
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return str(N_('Duplicate scan'))
|
||||
|
||||
@property
|
||||
def is_cancellable(self):
|
||||
return True
|
||||
|
||||
def run(self, worker_thread):
|
||||
try:
|
||||
init_db_thread()
|
||||
except Exception:
|
||||
# Non-fatal; continue
|
||||
pass
|
||||
|
||||
try:
|
||||
# Ensure calibre DB session is ready in this thread
|
||||
calibre_db.ensure_session()
|
||||
|
||||
if self.stat in (STAT_CANCELLED, STAT_ENDED):
|
||||
return
|
||||
|
||||
cwa_db = CWA_DB()
|
||||
cache_data = cwa_db.get_duplicate_cache() or {}
|
||||
|
||||
if not self.full_scan:
|
||||
last_scanned_book_id = int(cache_data.get('last_scanned_book_id') or 0)
|
||||
if last_scanned_book_id == 0:
|
||||
# No baseline yet; fall back to full scan
|
||||
self.full_scan = True
|
||||
|
||||
self.progress = 0.1
|
||||
self.message = N_('Scanning for duplicates')
|
||||
|
||||
# Import here to avoid circular dependency during module import
|
||||
from cps.duplicates import find_duplicate_books
|
||||
|
||||
if self.stat in (STAT_CANCELLED, STAT_ENDED):
|
||||
return
|
||||
|
||||
if self.full_scan:
|
||||
duplicate_groups = find_duplicate_books(include_dismissed=False, user_id=self.user_id)
|
||||
self.result_count = len(duplicate_groups)
|
||||
|
||||
# Store the duplicate groups for passing to auto-resolution
|
||||
self.found_duplicate_groups = duplicate_groups
|
||||
|
||||
if self.stat in (STAT_CANCELLED, STAT_ENDED):
|
||||
return
|
||||
|
||||
# Update cache with full results (including dismissed groups)
|
||||
all_groups = find_duplicate_books(include_dismissed=True, user_id=self.user_id)
|
||||
|
||||
max_book_id = 0
|
||||
try:
|
||||
max_id_result = calibre_db.session.query(func.max(db.Books.id)).scalar()
|
||||
max_book_id = max_id_result if max_id_result is not None else 0
|
||||
except Exception as ex:
|
||||
log.warning("[cwa-duplicates] Could not get max book ID in TaskDuplicateScan: %s", str(ex))
|
||||
|
||||
cwa_db.update_duplicate_cache(all_groups, len(all_groups), max_book_id)
|
||||
log.info("[cwa-duplicates] Duplicate cache updated (full scan): groups=%s max_book_id=%s",
|
||||
len(all_groups), max_book_id)
|
||||
else:
|
||||
# Incremental scan: only groups impacted by newly added books
|
||||
settings = cwa_db.cwa_settings
|
||||
use_title = settings.get('duplicate_detection_title', 1)
|
||||
use_author = settings.get('duplicate_detection_author', 1)
|
||||
use_language = settings.get('duplicate_detection_language', 1)
|
||||
use_series = settings.get('duplicate_detection_series', 0)
|
||||
use_publisher = settings.get('duplicate_detection_publisher', 0)
|
||||
use_format = settings.get('duplicate_detection_format', 0)
|
||||
|
||||
last_scanned_book_id = int(cache_data.get('last_scanned_book_id') or 0)
|
||||
candidate_ids = find_duplicate_candidate_ids_sql(use_title, use_author, user_id=self.user_id,
|
||||
min_book_id=last_scanned_book_id)
|
||||
|
||||
log.debug("[cwa-duplicates] Incremental scan: last_scanned_book_id=%s, candidate_ids=%s",
|
||||
last_scanned_book_id, len(candidate_ids) if candidate_ids else 0)
|
||||
print(f"[cwa-duplicates] Incremental scan: last_scanned_book_id={last_scanned_book_id}, "
|
||||
f"candidates={len(candidate_ids) if candidate_ids else 0}", flush=True)
|
||||
|
||||
max_book_id = 0
|
||||
try:
|
||||
max_id_result = calibre_db.session.query(func.max(db.Books.id)).scalar()
|
||||
max_book_id = max_id_result if max_id_result is not None else 0
|
||||
except Exception as ex:
|
||||
log.warning("[cwa-duplicates] Could not get max book ID in TaskDuplicateScan: %s", str(ex))
|
||||
|
||||
if not candidate_ids:
|
||||
# No impacted groups; just bump last_scanned_book_id
|
||||
try:
|
||||
cwa_db.cur.execute("""
|
||||
UPDATE cwa_duplicate_cache
|
||||
SET last_scanned_book_id = ?, scan_pending = 0, scan_timestamp = ?
|
||||
WHERE id = 1
|
||||
""", (max_book_id, datetime.now().isoformat()))
|
||||
cwa_db.con.commit()
|
||||
log.info("[cwa-duplicates] Incremental scan: no candidates; cache timestamp updated (last_scanned_book_id=%s)",
|
||||
max_book_id)
|
||||
except Exception:
|
||||
pass
|
||||
self.result_count = 0
|
||||
else:
|
||||
# Identify affected groups in cache
|
||||
cached_groups = cache_data.get('duplicate_groups', [])
|
||||
candidate_set = set(candidate_ids)
|
||||
affected_hashes = {
|
||||
group.get('group_hash')
|
||||
for group in cached_groups
|
||||
if group.get('book_ids') and candidate_set.intersection(set(group.get('book_ids')))
|
||||
}
|
||||
|
||||
# Recompute groups for candidate IDs (include dismissed for cache)
|
||||
recomputed_groups = find_duplicate_books_python(
|
||||
use_title, use_author, use_language, use_series, use_publisher, use_format,
|
||||
include_dismissed=True, user_id=self.user_id, candidate_ids=candidate_ids
|
||||
)
|
||||
|
||||
# Serialize recomputed groups
|
||||
serialized_groups = []
|
||||
for group in recomputed_groups:
|
||||
serialized_groups.append({
|
||||
'title': group.get('title', ''),
|
||||
'author': group.get('author', ''),
|
||||
'count': group.get('count', 0),
|
||||
'group_hash': group.get('group_hash', ''),
|
||||
'book_ids': [book.id for book in group.get('books', [])]
|
||||
})
|
||||
|
||||
# Merge cache: remove affected, then append recomputed
|
||||
kept_groups = [g for g in cached_groups if g.get('group_hash') not in affected_hashes]
|
||||
merged_groups = kept_groups + serialized_groups
|
||||
|
||||
try:
|
||||
import json
|
||||
cwa_db.cur.execute("""
|
||||
UPDATE cwa_duplicate_cache
|
||||
SET scan_timestamp = ?,
|
||||
duplicate_groups_json = ?,
|
||||
total_count = ?,
|
||||
scan_pending = 0,
|
||||
last_scanned_book_id = ?
|
||||
WHERE id = 1
|
||||
""", (datetime.now().isoformat(), json.dumps(merged_groups), len(merged_groups), max_book_id))
|
||||
cwa_db.con.commit()
|
||||
log.info("[cwa-duplicates] Duplicate cache updated (incremental): merged_groups=%s max_book_id=%s",
|
||||
len(merged_groups), max_book_id)
|
||||
except Exception as ex:
|
||||
log.warning("[cwa-duplicates] Failed to update incremental cache: %s", str(ex))
|
||||
|
||||
# Result count is unresolved duplicates for this run (exclude dismissed)
|
||||
unresolved_in_candidates = find_duplicate_books_python(
|
||||
use_title, use_author, use_language, use_series, use_publisher, use_format,
|
||||
include_dismissed=False, user_id=self.user_id, candidate_ids=candidate_ids
|
||||
)
|
||||
self.result_count = len(unresolved_in_candidates)
|
||||
|
||||
# Store the duplicate groups for passing to auto-resolution
|
||||
self.found_duplicate_groups = unresolved_in_candidates
|
||||
|
||||
log.debug("[cwa-duplicates] Incremental scan result: %s unresolved groups among candidates",
|
||||
self.result_count)
|
||||
print(f"[cwa-duplicates] Incremental scan result: {self.result_count} unresolved duplicate groups",
|
||||
flush=True)
|
||||
|
||||
self.progress = 1
|
||||
if self.full_scan:
|
||||
self.message = N_('Duplicate scan completed: %(count)s groups', count=self.result_count)
|
||||
else:
|
||||
self.message = N_('Duplicate scan completed: %(count)s new groups', count=self.result_count)
|
||||
self._handleSuccess()
|
||||
|
||||
# Check if auto-resolution is enabled
|
||||
log.debug("[cwa-duplicates] Scan complete. result_count=%s, trigger_type=%s",
|
||||
self.result_count, self.trigger_type)
|
||||
print(f"[cwa-duplicates] Scan complete: {self.result_count} groups found, trigger_type={self.trigger_type}",
|
||||
flush=True)
|
||||
|
||||
if self.result_count > 0: # Only if duplicates were found
|
||||
try:
|
||||
auto_resolve_enabled = cwa_db.cwa_settings.get('duplicate_auto_resolve_enabled', 0)
|
||||
auto_resolve_strategy = cwa_db.cwa_settings.get('duplicate_auto_resolve_strategy', 'newest')
|
||||
cooldown_minutes = int(cwa_db.cwa_settings.get('duplicate_auto_resolve_cooldown_minutes', 0))
|
||||
|
||||
log.debug("[cwa-duplicates] Auto-resolution settings: enabled=%s, strategy=%s, cooldown=%s min",
|
||||
auto_resolve_enabled, auto_resolve_strategy, cooldown_minutes)
|
||||
print(f"[cwa-duplicates] Auto-resolution settings: enabled={auto_resolve_enabled}, "
|
||||
f"strategy={auto_resolve_strategy}, cooldown={cooldown_minutes} min", flush=True)
|
||||
|
||||
if auto_resolve_enabled:
|
||||
# Check cooldown period
|
||||
if cooldown_minutes > 0:
|
||||
try:
|
||||
last_resolution = cwa_db.cur.execute("""
|
||||
SELECT MAX(timestamp) FROM cwa_duplicate_resolutions
|
||||
WHERE trigger_type='automatic'
|
||||
""").fetchone()[0]
|
||||
|
||||
if last_resolution:
|
||||
from datetime import datetime, timedelta
|
||||
last_time = datetime.fromisoformat(last_resolution)
|
||||
now = datetime.now()
|
||||
elapsed = (now - last_time).total_seconds() / 60
|
||||
|
||||
if elapsed < cooldown_minutes:
|
||||
remaining = cooldown_minutes - elapsed
|
||||
log.info("[cwa-duplicates] Auto-resolution skipped due to cooldown: %.1f minutes remaining",
|
||||
remaining)
|
||||
print(f"[cwa-duplicates] Auto-resolution on cooldown ({remaining:.1f} min remaining)",
|
||||
flush=True)
|
||||
return
|
||||
except Exception as e:
|
||||
log.warning("[cwa-duplicates] Cooldown check failed: %s", str(e))
|
||||
|
||||
log.info("[cwa-duplicates] Auto-resolution enabled, triggering resolution with strategy: %s",
|
||||
auto_resolve_strategy)
|
||||
print(f"[cwa-duplicates] Auto-resolution enabled, triggering with strategy: {auto_resolve_strategy}",
|
||||
flush=True)
|
||||
|
||||
from cps.duplicates import auto_resolve_duplicates
|
||||
|
||||
# Pass the pre-scanned duplicate groups to avoid re-scanning
|
||||
groups_to_pass = getattr(self, 'found_duplicate_groups', None)
|
||||
log.debug("[cwa-duplicates] Passing %s groups to auto_resolve (type: %s)",
|
||||
len(groups_to_pass) if groups_to_pass else 'None', type(groups_to_pass).__name__)
|
||||
print(f"[cwa-duplicates] Passing {len(groups_to_pass) if groups_to_pass else 'None'} pre-scanned groups to auto_resolve",
|
||||
flush=True)
|
||||
|
||||
result = auto_resolve_duplicates(
|
||||
strategy=auto_resolve_strategy,
|
||||
dry_run=False,
|
||||
user_id=None,
|
||||
trigger_type='automatic',
|
||||
duplicate_groups=groups_to_pass
|
||||
)
|
||||
|
||||
if result['success']:
|
||||
log.info("[cwa-duplicates] Auto-resolution completed: resolved=%s, kept=%s, deleted=%s",
|
||||
result['resolved_count'], result['kept_count'], result['deleted_count'])
|
||||
print(f"[cwa-duplicates] Auto-resolution completed: {result['resolved_count']} groups resolved, "
|
||||
f"{result['deleted_count']} books deleted, {result['kept_count']} books kept", flush=True)
|
||||
|
||||
self.message = N_('Duplicate scan completed: %(count)s groups auto-resolved',
|
||||
count=result['resolved_count'])
|
||||
else:
|
||||
log.warning("[cwa-duplicates] Auto-resolution completed with errors: %s",
|
||||
result.get('errors', []))
|
||||
print(f"[cwa-duplicates] Auto-resolution errors: {result.get('errors', [])}", flush=True)
|
||||
else:
|
||||
log.debug("[cwa-duplicates] Auto-resolution disabled in settings")
|
||||
print("[cwa-duplicates] Auto-resolution disabled in settings", flush=True)
|
||||
except Exception as ex:
|
||||
log.error("[cwa-duplicates] Exception during auto-resolution check: %s", str(ex))
|
||||
print(f"[cwa-duplicates] Exception during auto-resolution check: {str(ex)}", flush=True)
|
||||
else:
|
||||
log.debug("[cwa-duplicates] No duplicates found, skipping auto-resolution")
|
||||
print("[cwa-duplicates] No duplicates found, skipping auto-resolution", flush=True)
|
||||
|
||||
except Exception as ex:
|
||||
log.error("[cwa-duplicates] Duplicate scan task failed: %s", str(ex))
|
||||
self._handleError(str(ex))
|
||||
finally:
|
||||
try:
|
||||
if calibre_db.session is not None:
|
||||
calibre_db.session.close()
|
||||
except Exception:
|
||||
pass
|
||||
+36
-24
@@ -66,38 +66,50 @@ class TaskGenerateCoverThumbnails(CalibreTask):
|
||||
]
|
||||
|
||||
def run(self, worker_thread):
|
||||
if use_IM and self.stat != STAT_CANCELLED and self.stat != STAT_ENDED:
|
||||
self.message = 'Scanning Books'
|
||||
books_with_covers = self.get_books_with_covers(self.book_id)
|
||||
count = len(books_with_covers)
|
||||
try:
|
||||
if use_IM and self.stat != STAT_CANCELLED and self.stat != STAT_ENDED:
|
||||
self.message = 'Scanning Books'
|
||||
books_with_covers = self.get_books_with_covers(self.book_id)
|
||||
count = len(books_with_covers)
|
||||
|
||||
total_generated = 0
|
||||
for i, book in enumerate(books_with_covers):
|
||||
total_generated = 0
|
||||
for i, book in enumerate(books_with_covers):
|
||||
|
||||
# Generate new thumbnails for missing covers
|
||||
generated = self.create_book_cover_thumbnails(book)
|
||||
# Generate new thumbnails for missing covers
|
||||
generated = self.create_book_cover_thumbnails(book)
|
||||
|
||||
# Increment the progress
|
||||
self.progress = (1.0 / count) * i
|
||||
# Increment the progress
|
||||
self.progress = (1.0 / count) * i
|
||||
|
||||
if generated > 0:
|
||||
total_generated += generated
|
||||
self.message = N_('Generated %(count)s cover thumbnails', count=total_generated)
|
||||
if generated > 0:
|
||||
total_generated += generated
|
||||
self.message = N_('Generated %(count)s cover thumbnails', count=total_generated)
|
||||
|
||||
# Check if job has been cancelled or ended
|
||||
if self.stat == STAT_CANCELLED:
|
||||
self.log.info(f'GenerateCoverThumbnails task has been cancelled.')
|
||||
return
|
||||
# Check if job has been cancelled or ended
|
||||
if self.stat == STAT_CANCELLED:
|
||||
self.log.info(f'GenerateCoverThumbnails task has been cancelled.')
|
||||
return
|
||||
|
||||
if self.stat == STAT_ENDED:
|
||||
self.log.info(f'GenerateCoverThumbnails task has been ended.')
|
||||
return
|
||||
if self.stat == STAT_ENDED:
|
||||
self.log.info(f'GenerateCoverThumbnails task has been ended.')
|
||||
return
|
||||
|
||||
if total_generated == 0:
|
||||
self.self_cleanup = True
|
||||
if total_generated == 0:
|
||||
self.self_cleanup = True
|
||||
|
||||
self._handleSuccess()
|
||||
self.app_db_session.remove()
|
||||
self._handleSuccess()
|
||||
finally:
|
||||
# CRITICAL: Clear book from pending set on ALL exit paths (success, cancel, end, error)
|
||||
# This must run even if task is cancelled, ended, or errors out
|
||||
if self.book_id != -1:
|
||||
try:
|
||||
from .. import helper
|
||||
helper._pending_thumbnail_books.discard(self.book_id)
|
||||
except Exception:
|
||||
pass # Silently fail if helper module not available
|
||||
|
||||
# Always clean up database session
|
||||
self.app_db_session.remove()
|
||||
|
||||
@staticmethod
|
||||
def get_books_with_covers(book_id=-1):
|
||||
|
||||
@@ -87,43 +87,16 @@ def migrate_thumbnail_structure():
|
||||
log.info("Thumbnail migration: No old subdirectories found, skipping migration")
|
||||
return
|
||||
|
||||
log.info(f"Thumbnail migration: Found {len(subdirs_found)} old subdirectories, starting migration")
|
||||
log.info(f"Thumbnail migration: Found {len(subdirs_found)} old subdirectories with legacy thumbnails")
|
||||
log.info("Thumbnail migration: Using lazy migration strategy - legacy thumbnails will be replaced on-demand")
|
||||
log.info("Thumbnail migration: Old subdirectories will be cleaned up automatically as thumbnails regenerate")
|
||||
|
||||
# Clear all thumbnail database entries
|
||||
session = ub.get_new_session_instance()
|
||||
try:
|
||||
deleted_count = session.query(ub.Thumbnail).delete()
|
||||
session.commit()
|
||||
log.info(f"Thumbnail migration: Cleared {deleted_count} old database entries")
|
||||
except Exception as ex:
|
||||
log.error(f"Thumbnail migration: Failed to clear database entries: {ex}")
|
||||
session.rollback()
|
||||
finally:
|
||||
session.close()
|
||||
# Note: We don't delete thumbnails immediately anymore.
|
||||
# The TaskGenerateCoverThumbnails.create_book_cover_thumbnails() method already
|
||||
# detects legacy thumbnails (via legacy_naming check) and migrates them on-demand.
|
||||
# This prevents mass regeneration on first page load after update.
|
||||
|
||||
# Remove old files and subdirectories
|
||||
files_removed = 0
|
||||
dirs_removed = 0
|
||||
|
||||
for subdir in subdirs_found:
|
||||
subdir_path = os.path.join(thumbnails_dir, subdir)
|
||||
try:
|
||||
if os.path.exists(subdir_path):
|
||||
# Count files before removal
|
||||
for root, dirs, files in os.walk(subdir_path):
|
||||
files_removed += len(files)
|
||||
|
||||
# Remove the entire subdirectory
|
||||
shutil.rmtree(subdir_path)
|
||||
dirs_removed += 1
|
||||
log.debug(f"Thumbnail migration: Removed subdirectory {subdir}")
|
||||
except Exception as ex:
|
||||
log.warning(f"Thumbnail migration: Failed to remove {subdir_path}: {ex}")
|
||||
|
||||
log.info(f"Thumbnail migration: Removed {files_removed} old files and {dirs_removed} subdirectories")
|
||||
log.info("Thumbnail migration: Complete. Thumbnails will be regenerated automatically as needed.")
|
||||
|
||||
# Mark migration as completed
|
||||
# Mark migration as completed so this only runs once
|
||||
set_migration_completed()
|
||||
|
||||
except Exception as ex:
|
||||
|
||||
+2
-1
@@ -44,7 +44,8 @@ def render_task_status(tasklist):
|
||||
if user == current_user.name or current_user.role_admin():
|
||||
ret = {}
|
||||
if task.start_time:
|
||||
ret['starttime'] = format_datetime(task.start_time, format='short')
|
||||
# Use ISO-like date format for consistency across locales
|
||||
ret['starttime'] = format_datetime(task.start_time, format="yyyy-MM-dd HH:mm")
|
||||
ret['runtime'] = format_runtime(task.runtime)
|
||||
|
||||
# localize the task status
|
||||
|
||||
@@ -218,6 +218,10 @@
|
||||
<a class="btn btn-default github" id="cwa_github_link" href="https://github.com/crocodilestick/Calibre-Web-Automated/">{{_('CWA GitHub')}}</a>
|
||||
<a class="btn btn-default discord" id="cwa_discord_link" href="https://discord.gg/EjgSeek94R">{{_('CWA Discord Server')}}</a>
|
||||
</div>
|
||||
<div class="row form-group">
|
||||
<div class="btn btn-primary" id="hardcover_auto_fetch" data-toggle="modal" data-target="#StatusDialog" style="background-color: var(--color-primary); border-color: var(--color-primary); margin-left: 8px;">🔖 {{_('Run Hardcover Auto-Fetch')}}</div>
|
||||
<a href="{{url_for('admin.hardcover_review_matches')}}" class="btn btn-info" style="margin-left: 10px; background-color: #17a2b8; border-color: #17a2b8;">📋 {{_('Review Hardcover Matches')}}</a>
|
||||
</div>
|
||||
|
||||
<div class="row form-group">
|
||||
<h2>{{_('Administration 🚀')}}</h2>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<h2>{{title}}</h2>
|
||||
|
||||
{% if author is not none %}
|
||||
|
||||
@@ -169,6 +169,13 @@
|
||||
<input type="checkbox" id="config_embed_metadata" name="config_embed_metadata" {% if config.config_embed_metadata %}checked{% endif %}>
|
||||
<label for="config_embed_metadata">{{_('Embed Metadata to Ebook File on Download/Conversion/e-mail (needs Calibre/Kepubify binaries)')}}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="config_fulltext_search" name="config_fulltext_search" {% if config.config_fulltext_search %}checked{% endif %} data-toggle="tooltip" data-placement="right" title="{{_('Manage FTS indexing in CWA Settings for advanced options and status monitoring')}}">
|
||||
<label for="config_fulltext_search">{{_('Enable Full Text Search ')}} (<a href="https://manual.calibre-ebook.com/gui.html#searching-the-full-text-of-all-books" target="_blank" rel="noopener">{{_('Uses Calibre Full Text')}}</a>)</label>
|
||||
<span style="margin-left: 10px; color: #5bc0de; font-size: 0.9em;">
|
||||
ℹ️ <a href="{{ url_for('cwa_settings.set_cwa_settings') }}" style="color: #5bc0de; text-decoration: underline;">{{_('Advanced FTS management available in CWA Settings')}}</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="config_uploading" data-control="upload_settings" name="config_uploading" {% if config.config_uploading %}checked{% endif %}>
|
||||
<label for="config_uploading">{{_('Enable Uploads')}} {{_('(Please ensure that users also have upload permissions)')}}</label>
|
||||
@@ -489,6 +496,7 @@
|
||||
<div class="form-group">
|
||||
<label for="config_generic_oauth_admin_group">{{_('OAuth group for Admin')}}</label>
|
||||
<input type="text" class="form-control" id="config_generic_oauth_admin_group" name="config_generic_oauth_admin_group" value="{% if generic['oauth_admin_group'] != None %}{{ generic['oauth_admin_group'] }}{% endif %}" autocomplete="off">
|
||||
<div class="help-block">{{_('Name of the OAuth group that grants admin privileges. Leave empty to disable group-based admin management, or use the global setting in Security Settings for more control.')}}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_generic_oauth_login_button">{{_('Login Button Text')}}</label>
|
||||
@@ -552,6 +560,11 @@
|
||||
<label for="config_disable_standard_login">{{_('Disable Standard Login (Username/Password)')}}</label>
|
||||
<div class="help-block">{{_('Hides the standard login form. Users must log in via OAuth or LDAP. Ensure you have a working alternative login method before enabling.')}}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="config_enable_oauth_group_admin_management" name="config_enable_oauth_group_admin_management" {% if config.config_enable_oauth_group_admin_management %}checked{% endif %}>
|
||||
<label for="config_enable_oauth_group_admin_management">{{_('Enable OAuth Group-Based Admin Role Management')}}</label>
|
||||
<div class="help-block">{{_('When enabled, admin privileges are automatically granted or revoked based on OAuth group membership. Disable this to manually manage admin roles in the user management panel, preventing OAuth logins from overriding local role assignments.')}}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="config_ratelimiter" name="config_ratelimiter" {% if config.config_ratelimiter %}checked{% endif %}>
|
||||
<label for="config_ratelimiter">{{_('Limit failed login attempts')}}</label>
|
||||
@@ -639,6 +652,16 @@ $(document).ready(function() {
|
||||
|
||||
// Trigger change on load to set initial state
|
||||
$('#config_login_type').trigger('change');
|
||||
|
||||
// Warn about potential lockout when disabling standard login without OAuth (Issue #715)
|
||||
$('#config_disable_standard_login').on('change', function() {
|
||||
var isDisabled = $(this).is(':checked');
|
||||
var loginType = $('#config_login_type').val();
|
||||
|
||||
if (isDisabled && loginType === '0') {
|
||||
alert('Warning: You are about to disable standard login without enabling OAuth or LDAP authentication. This may lock you out of the system. Ensure you have configured and tested an alternative login method before saving.');
|
||||
}
|
||||
});
|
||||
|
||||
$('#test_oidc_connection').on('click', function() {
|
||||
var serverUrl = $('#config_generic_oauth_server_url_test').val() || $('#config_generic_oauth_server_url').val();
|
||||
|
||||
+180
-140
@@ -2,178 +2,218 @@
|
||||
{% block header %}
|
||||
<link href="{{ url_for('static', filename='css/libs/bootstrap-table.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='css/libs/bootstrap-editable.css') }}" rel="stylesheet">
|
||||
<style>
|
||||
/* Mobile responsive styles for config_view_edit template - triggers below 768px */
|
||||
@media (max-width: 768px) {
|
||||
.settings-container {
|
||||
padding-left: 1.5rem !important;
|
||||
padding-right: 1.5rem !important;
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
}
|
||||
|
||||
/* Stack buttons vertically on mobile */
|
||||
.btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<div class="discover">
|
||||
<h2>{{title}}</h2>
|
||||
<form role="form" method="POST" autocomplete="off" >
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="panel-group" class="col-md-10 col-lg-6">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a class="accordion-toggle" data-toggle="collapse" href="#collapsefour">
|
||||
<span class="glyphicon glyphicon-plus"></span>
|
||||
{{_('View Configuration')}}
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="collapsefour" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
<div class="form-group">
|
||||
<label for="config_calibre_web_title">{{_('Title')}}</label>
|
||||
<input type="text" class="form-control" name="config_calibre_web_title" id="config_calibre_web_title" value="{% if conf.config_calibre_web_title != None %}{{ conf.config_calibre_web_title }}{% endif %}" autocomplete="off" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_books_per_page">{{_('Books per Page')}}</label>
|
||||
<input type="number" min="1" max="200" class="form-control" name="config_books_per_page" id="config_books_per_page" value="{% if conf.config_books_per_page != None %}{{ conf.config_books_per_page }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_random_books">{{_('No. of Random Books to Display')}}</label>
|
||||
<input type="number" min="1" max="30" class="form-control" name="config_random_books" id="config_random_books" value="{% if conf.config_random_books != None %}{{ conf.config_random_books }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_authors_max">{{_('No. of Authors to Display Before Hiding (0=Disable Hiding)')}}</label>
|
||||
<input type="number" min="0" max="999" class="form-control" name="config_authors_max" id="config_authors_max" value="{% if conf.config_authors_max != None %}{{ conf.config_authors_max }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_theme">{{_('Default Theme (users can select their own preferred theme however new users will receive this theme by default)')}}</label>
|
||||
<select name="config_theme" id="config_theme" class="form-control">
|
||||
<option value="0" {% if conf.config_theme == 0 %}selected{% endif %}>{{ _("Standard Theme") }}</option>
|
||||
<option value="1" {% if conf.config_theme == 1 %}selected{% endif %}>{{ _("caliBlur! Dark Theme") }}</option>
|
||||
<option disabled>── {{ _('Per-user theme switching enabled; this global default only affects new users & anonymous') }} ──</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_columns_to_ignore">{{_('Regular Expression for Ignoring Columns')}}</label>
|
||||
<input type="text" class="form-control" name="config_columns_to_ignore" id="config_columns_to_ignore" value="{% if conf.config_columns_to_ignore != None %}{{ conf.config_columns_to_ignore }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_read_column">{{_('Link Read/Unread Status to Calibre Column')}}</label>
|
||||
<select name="config_read_column" id="config_read_column" class="form-control">
|
||||
<option value="0" {% if conf.config_read_column == 0 %}selected{% endif %}></option>
|
||||
{% for readColumn in readColumns %}
|
||||
<option value="{{ readColumn.id }}" {% if readColumn.id == conf.config_read_column %}selected{% endif %}>{{ readColumn.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_restricted_column">{{_('View Restrictions based on Calibre column')}}</label>
|
||||
<select name="config_restricted_column" id="config_restricted_column" class="form-control">
|
||||
<option value="0" {% if conf.config_restricted_column == 0 %}selected{% endif %}>{{ _('None') }}</option>
|
||||
{% for restrictColumn in restrictColumns %}
|
||||
<option value="{{ restrictColumn.id }}" {% if restrictColumn.id == conf.config_restricted_column %}selected{% endif %}>{{ restrictColumn.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_title_regex">{{_('Regular Expression for Title Sorting')}}</label>
|
||||
<input type="text" class="form-control" name="config_title_regex" id="config_title_regex" value="{% if conf.config_title_regex != None %}{{ conf.config_title_regex }}{% endif %}" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<form role="form" method="POST" autocomplete="off" style="margin-top: 3rem;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<!-- View Configuration Card -->
|
||||
<div class="settings-container">
|
||||
<h4 class="settings-section-header">{{_('View Configuration')}}</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="config_calibre_web_title">{{_('Title')}}</label>
|
||||
<input type="text" class="form-control" name="config_calibre_web_title" id="config_calibre_web_title" value="{% if conf.config_calibre_web_title != None %}{{ conf.config_calibre_web_title }}{% endif %}" autocomplete="off" required>
|
||||
<p class="settings-explanation">{{_('The title displayed in the browser tab and navbar.')}}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="config_books_per_page">{{_('Books per Page')}}</label>
|
||||
<input type="number" min="1" max="200" class="form-control" name="config_books_per_page" id="config_books_per_page" value="{% if conf.config_books_per_page != None %}{{ conf.config_books_per_page }}{% endif %}" autocomplete="off">
|
||||
<p class="settings-explanation">{{_('Number of books to display per page in list views (1-200).')}}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="config_random_books">{{_('No. of Random Books to Display')}}</label>
|
||||
<input type="number" min="1" max="30" class="form-control" name="config_random_books" id="config_random_books" value="{% if conf.config_random_books != None %}{{ conf.config_random_books }}{% endif %}" autocomplete="off">
|
||||
<p class="settings-explanation">{{_('Number of random books shown on the home page (1-30).')}}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="config_authors_max">{{_('No. of Authors to Display Before Hiding (0=Disable Hiding)')}}</label>
|
||||
<input type="number" min="0" max="999" class="form-control" name="config_authors_max" id="config_authors_max" value="{% if conf.config_authors_max != None %}{{ conf.config_authors_max }}{% endif %}" autocomplete="off">
|
||||
<p class="settings-explanation">{{_('Maximum number of authors to show in book details before truncating. Set to 0 to disable.')}}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="config_theme">{{_('Default Theme')}}</label>
|
||||
<select name="config_theme" id="config_theme" class="form-control">
|
||||
<option value="0" {% if conf.config_theme == 0 %}selected{% endif %}>{{ _("Standard Theme") }}</option>
|
||||
<option value="1" {% if conf.config_theme == 1 %}selected{% endif %}>{{ _("caliBlur! Dark Theme") }}</option>
|
||||
</select>
|
||||
<p class="settings-explanation">{{_('Default theme for new users and anonymous browsing. Users can select their own preferred theme in their profile.')}}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="config_columns_to_ignore">{{_('Regular Expression for Ignoring Columns')}}</label>
|
||||
<input type="text" class="form-control" name="config_columns_to_ignore" id="config_columns_to_ignore" value="{% if conf.config_columns_to_ignore != None %}{{ conf.config_columns_to_ignore }}{% endif %}" autocomplete="off">
|
||||
<p class="settings-explanation">{{_('Regex pattern to hide custom columns from being displayed in the UI.')}}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="config_read_column">{{_('Link Read/Unread Status to Calibre Column')}}</label>
|
||||
<select name="config_read_column" id="config_read_column" class="form-control">
|
||||
<option value="0" {% if conf.config_read_column == 0 %}selected{% endif %}>{{ _('None') }}</option>
|
||||
{% for readColumn in readColumns %}
|
||||
<option value="{{ readColumn.id }}" {% if readColumn.id == conf.config_read_column %}selected{% endif %}>{{ readColumn.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="settings-explanation">{{_('Sync read/unread status with a Calibre boolean custom column.')}}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="config_restricted_column">{{_('View Restrictions based on Calibre column')}}</label>
|
||||
<select name="config_restricted_column" id="config_restricted_column" class="form-control">
|
||||
<option value="0" {% if conf.config_restricted_column == 0 %}selected{% endif %}>{{ _('None') }}</option>
|
||||
{% for restrictColumn in restrictColumns %}
|
||||
<option value="{{ restrictColumn.id }}" {% if restrictColumn.id == conf.config_restricted_column %}selected{% endif %}>{{ restrictColumn.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="settings-explanation">{{_('Restrict book visibility based on values in a Calibre text custom column.')}}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="config_title_regex">{{_('Regular Expression for Title Sorting')}}</label>
|
||||
<input type="text" class="form-control" name="config_title_regex" id="config_title_regex" value="{% if conf.config_title_regex != None %}{{ conf.config_title_regex }}{% endif %}" autocomplete="off">
|
||||
<p class="settings-explanation">{{_('Regex to strip prefixes from titles for proper sorting (e.g., "The", "A", "An").')}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a class="accordion-toggle" data-toggle="collapse" href="#collapsesix">
|
||||
<span class="glyphicon glyphicon-plus"></span>
|
||||
{{_('Default Settings for New Users')}}
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="collapsesix" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="admin_role" id="admin_role" {% if conf.role_admin() %}checked{% endif %}>
|
||||
|
||||
<!-- Default Settings for New Users Card -->
|
||||
<div class="settings-container">
|
||||
<h4 class="settings-section-header">{{_('Default Settings for New Users')}}</h4>
|
||||
<p class="settings-explanation">{{_('These settings will be applied to all newly created user accounts.')}}</p>
|
||||
|
||||
<h5 style="color: #ffc200; margin-top: 25px; margin-bottom: 15px;">{{_('Permissions')}}</h5>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 12px; background: #202c34a3; padding: 2rem; border-radius: 4px;">
|
||||
<div>
|
||||
<input type="checkbox" name="admin_role" id="admin_role" {% if conf.role_admin() %}checked{% endif %} style="accent-color: var(--color-secondary);">
|
||||
<label for="admin_role">{{_('Admin User')}}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="download_role" id="download_role" {% if conf.role_download() %}checked{% endif %}>
|
||||
|
||||
<div>
|
||||
<input type="checkbox" name="download_role" id="download_role" {% if conf.role_download() %}checked{% endif %} style="accent-color: var(--color-secondary);">
|
||||
<label for="download_role">{{_('Allow Downloads')}}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="viewer_role" id="viewer_role" {% if conf.role_viewer() %}checked{% endif %}>
|
||||
|
||||
<div>
|
||||
<input type="checkbox" name="viewer_role" id="viewer_role" {% if conf.role_viewer() %}checked{% endif %} style="accent-color: var(--color-secondary);">
|
||||
<label for="viewer_role">{{_('Allow eBook Viewer')}}</label>
|
||||
</div>
|
||||
{% if conf.config_uploading %}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="upload_role" id="upload_role" {% if conf.role_upload() %}checked{% endif %}>
|
||||
|
||||
{% if conf.config_uploading %}
|
||||
<div>
|
||||
<input type="checkbox" name="upload_role" id="upload_role" {% if conf.role_upload() %}checked{% endif %} style="accent-color: var(--color-secondary);">
|
||||
<label for="upload_role">{{_('Allow Uploads')}}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="edit_role" data-control="edit_settings" id="edit_role" {% if conf.role_edit() %}checked{% endif %}>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<input type="checkbox" name="edit_role" data-control="edit_settings" id="edit_role" {% if conf.role_edit() %}checked{% endif %} style="accent-color: var(--color-secondary);">
|
||||
<label for="edit_role">{{_('Allow Edit')}}</label>
|
||||
</div>
|
||||
|
||||
<div data-related="edit_settings">
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="delete_role" id="delete_role" {% if conf.role_delete_books() %}checked{% endif %}>
|
||||
<label for="delete_role">{{_('Allow Delete Books')}}</label>
|
||||
</div>
|
||||
<input type="checkbox" name="delete_role" id="delete_role" {% if conf.role_delete_books() %}checked{% endif %} style="accent-color: var(--color-secondary);">
|
||||
<label for="delete_role">{{_('Allow Delete Books')}}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="passwd_role" id="passwd_role" {% if conf.role_passwd() %}checked{% endif %}>
|
||||
|
||||
<div>
|
||||
<input type="checkbox" name="passwd_role" id="passwd_role" {% if conf.role_passwd() %}checked{% endif %} style="accent-color: var(--color-secondary);">
|
||||
<label for="passwd_role">{{_('Allow Changing Password')}}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="edit_shelf_role" id="edit_shelf_role" {% if conf.role_edit_shelfs() %}checked{% endif %}>
|
||||
|
||||
<div>
|
||||
<input type="checkbox" name="edit_shelf_role" id="edit_shelf_role" {% if conf.role_edit_shelfs() %}checked{% endif %} style="accent-color: var(--color-secondary);">
|
||||
<label for="edit_shelf_role">{{_('Allow Editing Public Shelves')}}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
</div>
|
||||
|
||||
<h5 style="color: #ffc200; margin-top: 25px; margin-bottom: 15px;">{{_('Language & Display')}}</h5>
|
||||
<div class="form-group">
|
||||
<label for="config_default_locale">{{_('Default Language')}}</label>
|
||||
<select name="config_default_locale" id="config_default_locale" class="form-control">
|
||||
{% for translation in translations %}
|
||||
<option value="{{translation}}" {% if translation|string == conf.config_default_locale %}selected{% endif %}>{{ translation.display_name|capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_default_language">{{_('Default Visible Language of Books')}}</label>
|
||||
<select name="config_default_language" id="config_default_language" class="form-control">
|
||||
<option value="all" {% if conf.config_default_language == "all" %}selected{% endif %}>{{ _('Show All') }}</option>
|
||||
{% for language in languages %}
|
||||
<option value="{{ language.lang_code }}" {% if conf.config_default_language == language.lang_code %}selected{% endif %}>{{ language.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<select name="config_default_locale" id="config_default_locale" class="form-control">
|
||||
{% for translation in translations %}
|
||||
<option value="{{translation}}" {% if translation|string == conf.config_default_locale %}selected{% endif %}>{{ translation.display_name|capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="settings-explanation">{{_('Default interface language for new users.')}}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="config_default_language">{{_('Default Visible Language of Books')}}</label>
|
||||
<select name="config_default_language" id="config_default_language" class="form-control">
|
||||
<option value="all" {% if conf.config_default_language == "all" %}selected{% endif %}>{{ _('Show All') }}</option>
|
||||
{% for language in languages %}
|
||||
<option value="{{ language.lang_code }}" {% if conf.config_default_language == language.lang_code %}selected{% endif %}>{{ language.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="settings-explanation">{{_('Default book language filter for new users.')}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a class="accordion-toggle" data-toggle="collapse" href="#collapseseven">
|
||||
<span class="glyphicon glyphicon-plus"></span>
|
||||
{{_('Default Visibilities for New Users')}}
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="collapseseven" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
|
||||
<!-- Default Visibilities for New Users Card -->
|
||||
<div class="settings-container">
|
||||
<h4 class="settings-section-header">{{_('Default Visibilities for New Users')}}</h4>
|
||||
<p class="settings-explanation">{{_('Choose which sidebar sections are visible by default for new users.')}}</p>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 12px; margin-top: 20px; background: #202c34a3; padding: 2rem; border-radius: 4px;">
|
||||
{% for element in sidebar %}
|
||||
{% if element['config_show'] %}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="show_{{element['visibility']}}" id="show_{{element['visibility']}}" {% if conf.show_element_new_user(element['visibility']) %}checked{% endif %}>
|
||||
<label for="show_{{element['visibility']}}">{{element['show_text']}}</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="checkbox" name="show_{{element['visibility']}}" id="show_{{element['visibility']}}" {% if conf.show_element_new_user(element['visibility']) %}checked{% endif %} style="accent-color: var(--color-secondary);">
|
||||
<label for="show_{{element['visibility']}}">{{element['show_text']}}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="Show_detail_random" id="Show_detail_random" {% if conf.show_detail_random() %}checked{% endif %}>
|
||||
<label for="Show_detail_random">{{_('Show Random Books in Detail View')}}</label>
|
||||
<div>
|
||||
<input type="checkbox" name="Show_detail_random" id="Show_detail_random" {% if conf.show_detail_random() %}checked{% endif %} style="accent-color: var(--color-secondary);">
|
||||
<label for="Show_detail_random">{{_('Show Random Books in Detail View')}}</label>
|
||||
</div>
|
||||
{% if not simple %}
|
||||
<a href="#" id="get_tags" data-id="0" class="btn btn-default" data-toggle="modal" data-target="#restrictModal">{{_('Add Allowed/Denied Tags')}}</a>
|
||||
<a href="#" id="get_column_values" data-id="0" class="btn btn-default" data-toggle="modal" data-target="#restrictModal">{{_('Add Allowed/Denied custom column values')}}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if not simple %}
|
||||
<div style="margin-top: 25px; padding-top: 20px; border-top: 1px solid #5c6b74;">
|
||||
<a href="#" id="get_tags" data-id="0" class="btn btn-default" data-toggle="modal" data-target="#restrictModal">{{_('Add Allowed/Denied Tags')}}</a>
|
||||
<a href="#" id="get_column_values" data-id="0" class="btn btn-default" data-toggle="modal" data-target="#restrictModal" style="margin-left: 10px;">{{_('Add Allowed/Denied custom column values')}}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<button type="submit" name="submit" class="btn btn-default">{{_('Save')}}</button>
|
||||
<a href="{{ url_for('admin.admin') }}" class="btn btn-default">{{_('Cancel')}}</a>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div style="max-width: 900px;
|
||||
margin-left: 2rem;
|
||||
margin-right: 2rem;
|
||||
margin-bottom: 4rem;
|
||||
justify-self: center;
|
||||
width: -webkit-fill-available;
|
||||
display: flex;">
|
||||
<button type="submit" name="submit" class="btn btn-default" style="width: -webkit-fill-available;">{{_('Save')}}</button>
|
||||
<a href="{{ url_for('admin.admin') }}" class="btn btn-default" style="width: -webkit-fill-available;">{{_('Cancel')}}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
<!-- API Usage Stats Content -->
|
||||
|
||||
<style>
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
flex-wrap: wrap;
|
||||
background: #2a3441;
|
||||
border: 1px solid #3a4451;
|
||||
border-radius: 8px;
|
||||
padding: 15px 20px;
|
||||
}
|
||||
|
||||
.time-period-select {
|
||||
background: #1a2332;
|
||||
color: #ddd;
|
||||
border: 1px solid #3a4451;
|
||||
border-radius: 6px;
|
||||
padding: 8px 15px;
|
||||
font-size: 0.95em;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.time-period-select:focus {
|
||||
outline: none;
|
||||
border-color: #ff9800;
|
||||
}
|
||||
|
||||
#api-custom-date-range input[type="date"] {
|
||||
background: #1a2332;
|
||||
color: #ddd;
|
||||
border: 1px solid #3a4451;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
#api-custom-date-range input[type="date"]:focus {
|
||||
outline: none;
|
||||
border-color: #ff9800;
|
||||
}
|
||||
|
||||
#api-custom-date-range span {
|
||||
color: #999;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.demo-toggle {
|
||||
background: linear-gradient(135deg, #ff9800 0%, #ff6f00 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 15px 40px;
|
||||
border-radius: 6px;
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(255, 152, 0, 0.3);
|
||||
}
|
||||
|
||||
.demo-toggle:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(255, 152, 0, 0.5);
|
||||
background: linear-gradient(135deg, #ffa726 0%, #ff8a00 100%);
|
||||
}
|
||||
|
||||
.demo-toggle.active {
|
||||
background: linear-gradient(135deg, #4caf50 0%, #388e3c 100%);
|
||||
}
|
||||
|
||||
.demo-badge {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #4caf50 0%, #388e3c 100%);
|
||||
color: white;
|
||||
padding: 5px 15px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
margin-left: 10px;
|
||||
font-weight: 600;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* Loading Spinner */
|
||||
.chart-loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid #3a4451;
|
||||
border-top-color: #ff9800;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.chart-loading-overlay {
|
||||
position: relative;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: translate(-50%, -50%) rotate(0deg); }
|
||||
100% { transform: translate(-50%, -50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
.endpoint-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.endpoint-table thead {
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
border-bottom: 2px solid #ff9800;
|
||||
}
|
||||
|
||||
.endpoint-table th {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #ff9800;
|
||||
font-size: 0.9em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.endpoint-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #3a4451;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.endpoint-table tbody tr {
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.endpoint-table tbody tr:hover {
|
||||
background: rgba(255, 152, 0, 0.05);
|
||||
}
|
||||
|
||||
.category-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.category-kobo { background: #9c27b0; color: #fff; }
|
||||
.category-opds { background: #2196f3; color: #fff; }
|
||||
.category-email { background: #ff9800; color: #fff; }
|
||||
.category-downloads { background: #4caf50; color: #fff; }
|
||||
.category-reading { background: #00bcd4; color: #fff; }
|
||||
.category-search { background: #ff5722; color: #fff; }
|
||||
.category-authentication { background: #607d8b; color: #fff; }
|
||||
.category-other { background: #9e9e9e; color: #fff; }
|
||||
</style>
|
||||
|
||||
<div class="stats-hero">
|
||||
<h1>📱 API Usage Analytics</h1>
|
||||
<p>Track endpoint activity, integration usage, and API patterns</p>
|
||||
</div>
|
||||
|
||||
<!-- Time Period Filter -->
|
||||
<div class="filter-row">
|
||||
<select id="api-time-period" class="time-period-select" onchange="changeApiTimePeriod()">
|
||||
<option value="7" {{ 'selected' if request.args.get('days') == '7' else '' }}>Last 7 Days</option>
|
||||
<option value="30" {{ 'selected' if request.args.get('days') == '30' or not request.args.get('days') else '' }}>Last 30 Days</option>
|
||||
<option value="90" {{ 'selected' if request.args.get('days') == '90' else '' }}>Last 90 Days</option>
|
||||
<option value="180" {{ 'selected' if request.args.get('days') == '180' else '' }}>Last 6 Months</option>
|
||||
<option value="365" {{ 'selected' if request.args.get('days') == '365' else '' }}>Last Year</option>
|
||||
<option value="all" {{ 'selected' if request.args.get('days') == 'all' else '' }}>All Time</option>
|
||||
<option value="custom" {{ 'selected' if request.args.get('start_date') else '' }}>Custom Range</option>
|
||||
</select>
|
||||
|
||||
<div id="api-custom-date-range" style="display: {{ 'flex' if request.args.get('start_date') else 'none' }}; gap: 10px; align-items: center;">
|
||||
<input type="date" id="api-start-date" value="{{ request.args.get('start_date', '') }}" />
|
||||
<span>to</span>
|
||||
<input type="date" id="api-end-date" value="{{ request.args.get('end_date', '') }}" />
|
||||
<button onclick="applyApiDateRange()" class="btn btn-primary btn-sm">Apply</button>
|
||||
</div>
|
||||
|
||||
<button onclick="window.location.href='/cwa-stats-show?tab=api'" class="demo-toggle" style="margin-left: 10px;">
|
||||
🔄 Reset
|
||||
</button>
|
||||
|
||||
<button onclick="exportApiCSV()" class="demo-toggle" style="margin-left: 10px; background: linear-gradient(135deg, #4caf50 0%, #388e3c 100%); box-shadow: 0 4px 15px rgba(76, 175, 80, 0.3);">
|
||||
📥 Export CSV
|
||||
</button>
|
||||
|
||||
<button onclick="toggleApiDemoMode()" class="demo-toggle" id="api-demo-button" style="margin-left: auto;">
|
||||
🎨 Show Demo Data
|
||||
</button>
|
||||
<span class="demo-badge" id="api-demo-badge" style="display: none;">DEMO MODE</span>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- API Usage Cards -->
|
||||
<div class="metrics-container">
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">🔄</div>
|
||||
<div class="metric-label">Kobo Syncs</div>
|
||||
<div class="metric-value" id="kobo-sync-count">0</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">📡</div>
|
||||
<div class="metric-label">OPDS Accesses</div>
|
||||
<div class="metric-value" id="opds-access-count">0</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">📧</div>
|
||||
<div class="metric-label">Email Deliveries</div>
|
||||
<div class="metric-value" id="email-delivery-count">0</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">🌐</div>
|
||||
<div class="metric-label">Web UI Requests</div>
|
||||
<div class="metric-value" id="web-ui-count">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- First Row: Usage Breakdown & Timing Heatmap -->
|
||||
<div class="row cwa-stats-row">
|
||||
<div class="col-md-6">
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">📊 API Usage Breakdown</div>
|
||||
<div id="api-usage-chart" style="width: 100%; height: 350px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">🕐 API Activity Timing</div>
|
||||
<div id="api-timing-chart" style="width: 100%; height: 350px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Second Row: Endpoint Frequency Table -->
|
||||
<div class="row cwa-stats-row">
|
||||
<div class="col-md-12">
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">🎯 Endpoint Access Frequency</div>
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="endpoint-table" id="endpoint-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Endpoint</th>
|
||||
<th>Category</th>
|
||||
<th style="text-align: right;">Access Count</th>
|
||||
<th>Last Accessed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="endpoint-table-body">
|
||||
<tr>
|
||||
<td colspan="4" style="text-align: center; padding: 40px; color: #999;">
|
||||
No endpoint data available
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Data from backend (with defaults to prevent undefined)
|
||||
const realApiData = {
|
||||
usageBreakdown: {{ (api_usage_breakdown or []) | tojson }},
|
||||
endpointFrequency: {{ (endpoint_frequency or []) | tojson }},
|
||||
apiTiming: {{ (api_timing or []) | tojson }}
|
||||
};
|
||||
|
||||
console.log('API Real Data:', realApiData); // Debug logging
|
||||
|
||||
// State management
|
||||
let apiDemoMode = false;
|
||||
let currentApiData = realApiData;
|
||||
|
||||
// Initialize charts
|
||||
let apiUsageChart, apiTimingChart;
|
||||
|
||||
function initApiCharts() {
|
||||
apiUsageChart = echarts.init(document.getElementById('api-usage-chart'));
|
||||
apiTimingChart = echarts.init(document.getElementById('api-timing-chart'));
|
||||
|
||||
updateAllApiVisualizations();
|
||||
|
||||
// Responsive resize
|
||||
window.addEventListener('resize', () => {
|
||||
apiUsageChart.resize();
|
||||
apiTimingChart.resize();
|
||||
});
|
||||
}
|
||||
|
||||
function updateAllApiVisualizations() {
|
||||
updateApiMetrics();
|
||||
updateApiUsageChart();
|
||||
updateApiTimingChart();
|
||||
updateEndpointTable();
|
||||
}
|
||||
|
||||
function updateApiMetrics() {
|
||||
const breakdown = currentApiData.usageBreakdown || [];
|
||||
|
||||
let koboCount = 0, opdsCount = 0, emailCount = 0, webCount = 0;
|
||||
|
||||
breakdown.forEach(([category, count]) => {
|
||||
if (category === 'Kobo Sync') koboCount = count;
|
||||
else if (category === 'OPDS Feed') opdsCount = count;
|
||||
else if (category === 'Email Delivery') emailCount = count;
|
||||
else if (category === 'Web UI') webCount = count;
|
||||
});
|
||||
|
||||
document.getElementById('kobo-sync-count').textContent = koboCount;
|
||||
document.getElementById('opds-access-count').textContent = opdsCount;
|
||||
document.getElementById('email-delivery-count').textContent = emailCount;
|
||||
document.getElementById('web-ui-count').textContent = webCount;
|
||||
}
|
||||
|
||||
function updateApiUsageChart() {
|
||||
const data = currentApiData.usageBreakdown || [];
|
||||
|
||||
if (data.length === 0) {
|
||||
apiUsageChart.setOption({
|
||||
title: {
|
||||
text: 'No API usage data available',
|
||||
left: 'center',
|
||||
top: 'center',
|
||||
textStyle: { color: '#999', fontSize: 14 }
|
||||
}
|
||||
}, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const categories = data.map(d => d[0]);
|
||||
const values = data.map(d => d[1]);
|
||||
|
||||
const colors = {
|
||||
'Kobo Sync': '#9c27b0',
|
||||
'OPDS Feed': '#2196f3',
|
||||
'Email Delivery': '#ff9800',
|
||||
'Web UI': '#4caf50',
|
||||
'Other': '#9e9e9e'
|
||||
};
|
||||
|
||||
apiUsageChart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
textStyle: { color: '#fff' },
|
||||
formatter: '{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: '5%',
|
||||
textStyle: { color: '#fff' }
|
||||
},
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
center: ['50%', '45%'],
|
||||
data: data.map(([name, value]) => ({
|
||||
name: name,
|
||||
value: value,
|
||||
itemStyle: {
|
||||
color: colors[name] || '#9e9e9e'
|
||||
}
|
||||
})),
|
||||
label: {
|
||||
color: '#fff',
|
||||
formatter: '{b}\n{d}%'
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
||||
}
|
||||
}
|
||||
}]
|
||||
}, true);
|
||||
}
|
||||
|
||||
function updateApiTimingChart() {
|
||||
const data = currentApiData.apiTiming || [];
|
||||
|
||||
if (data.length === 0) {
|
||||
apiTimingChart.setOption({
|
||||
title: {
|
||||
text: 'No timing data available',
|
||||
left: 'center',
|
||||
top: 'center',
|
||||
textStyle: { color: '#999', fontSize: 14 }
|
||||
}
|
||||
}, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const hours = Array.from({length: 24}, (_, i) => `${i}:00`);
|
||||
|
||||
// Convert data to heatmap format
|
||||
const heatmapData = data.map(([day, hour, count]) => [hour, day, count]);
|
||||
|
||||
apiTimingChart.setOption({
|
||||
tooltip: {
|
||||
position: 'top',
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
textStyle: { color: '#fff' },
|
||||
formatter: (params) => {
|
||||
const hour = params.value[0];
|
||||
const day = days[params.value[1]];
|
||||
const count = params.value[2];
|
||||
return `${day} ${hour}:00<br/>API Calls: ${count}`;
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '10%',
|
||||
right: '5%',
|
||||
bottom: '15%',
|
||||
top: '5%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: hours,
|
||||
axisLabel: {
|
||||
color: '#fff',
|
||||
rotate: 45,
|
||||
interval: 2
|
||||
},
|
||||
axisLine: { lineStyle: { color: '#3a4451' } },
|
||||
splitLine: { show: false }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: days,
|
||||
axisLabel: { color: '#fff' },
|
||||
axisLine: { lineStyle: { color: '#3a4451' } },
|
||||
splitLine: { show: false }
|
||||
},
|
||||
visualMap: {
|
||||
min: 0,
|
||||
max: Math.max(...data.map(d => d[2]), 10),
|
||||
calculable: true,
|
||||
orient: 'horizontal',
|
||||
left: 'center',
|
||||
bottom: '0%',
|
||||
inRange: {
|
||||
color: ['#2c3e50', '#ff9800', '#ff5722']
|
||||
},
|
||||
textStyle: { color: '#fff' }
|
||||
},
|
||||
series: [{
|
||||
type: 'heatmap',
|
||||
data: heatmapData,
|
||||
label: {
|
||||
show: false
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
||||
}
|
||||
}
|
||||
}]
|
||||
}, true);
|
||||
}
|
||||
|
||||
function updateEndpointTable() {
|
||||
const data = currentApiData.endpointFrequency || [];
|
||||
|
||||
const tbody = document.getElementById('endpoint-table-body');
|
||||
|
||||
if (data.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="4" style="text-align: center; padding: 40px; color: #999;">
|
||||
No endpoint data available
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryMap = {
|
||||
'Kobo': 'category-kobo',
|
||||
'OPDS': 'category-opds',
|
||||
'Email': 'category-email',
|
||||
'Downloads': 'category-downloads',
|
||||
'Reading': 'category-reading',
|
||||
'Search': 'category-search',
|
||||
'Authentication': 'category-authentication',
|
||||
'Other': 'category-other'
|
||||
};
|
||||
|
||||
tbody.innerHTML = data.map(([endpoint, category, count, lastAccessed]) => {
|
||||
const badgeClass = categoryMap[category] || 'category-other';
|
||||
const formattedDate = new Date(lastAccessed).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td style="font-family: monospace; font-size: 0.9em;">${endpoint}</td>
|
||||
<td><span class="category-badge ${badgeClass}">${category}</span></td>
|
||||
<td style="text-align: right; font-weight: 600; color: #ff9800;">${count.toLocaleString()}</td>
|
||||
<td style="color: #aaa; font-size: 0.85em;">${formattedDate}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Demo data generator
|
||||
function generateApiDemoData() {
|
||||
return {
|
||||
usageBreakdown: [
|
||||
['Kobo Sync', 234 + Math.floor(Math.random() * 100)],
|
||||
['OPDS Feed', 567 + Math.floor(Math.random() * 200)],
|
||||
['Email Delivery', 123 + Math.floor(Math.random() * 50)],
|
||||
['Web UI', 1234 + Math.floor(Math.random() * 300)]
|
||||
],
|
||||
apiTiming: (() => {
|
||||
const timing = [];
|
||||
for (let day = 0; day < 7; day++) {
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
const count = Math.floor(Math.random() * 30);
|
||||
if (count > 0) timing.push([day, hour, count]);
|
||||
}
|
||||
}
|
||||
return timing;
|
||||
})(),
|
||||
endpointFrequency: [
|
||||
['/v1/library/sync', 'Kobo', 234, new Date().toISOString()],
|
||||
['/opds', 'OPDS', 189, new Date().toISOString()],
|
||||
['/opds/new', 'OPDS', 156, new Date().toISOString()],
|
||||
['/opds/search', 'OPDS', 134, new Date().toISOString()],
|
||||
['/download/123/epub', 'Downloads', 98, new Date().toISOString()],
|
||||
['/send/456', 'Email', 67, new Date().toISOString()],
|
||||
['/read/789', 'Reading', 56, new Date().toISOString()],
|
||||
['SEARCH', 'Search', 234, new Date().toISOString()],
|
||||
['LOGIN', 'Authentication', 89, new Date().toISOString()],
|
||||
['/opds/books', 'OPDS', 45, new Date().toISOString()]
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// Time period filter functions
|
||||
function changeApiTimePeriod() {
|
||||
const select = document.getElementById('api-time-period');
|
||||
const customRange = document.getElementById('api-custom-date-range');
|
||||
|
||||
if (select.value === 'custom') {
|
||||
customRange.style.display = 'flex';
|
||||
} else {
|
||||
customRange.style.display = 'none';
|
||||
window.location.href = `/cwa-stats-show?tab=api&days=${select.value}`;
|
||||
}
|
||||
}
|
||||
|
||||
function applyApiDateRange() {
|
||||
const startDate = document.getElementById('api-start-date').value;
|
||||
const endDate = document.getElementById('api-end-date').value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
alert('Please select both start and end dates');
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/cwa-stats-show?tab=api&start_date=${startDate}&end_date=${endDate}`;
|
||||
}
|
||||
|
||||
function toggleApiDemoMode() {
|
||||
apiDemoMode = !apiDemoMode;
|
||||
const button = document.getElementById('api-demo-button');
|
||||
const badge = document.getElementById('api-demo-badge');
|
||||
|
||||
if (apiDemoMode) {
|
||||
currentApiData = generateApiDemoData();
|
||||
button.textContent = '📊 Show Real Data';
|
||||
button.classList.add('active');
|
||||
badge.style.display = 'inline-block';
|
||||
} else {
|
||||
currentApiData = realApiData;
|
||||
button.textContent = '🎨 Show Demo Data';
|
||||
button.classList.remove('active');
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
|
||||
updateAllApiVisualizations();
|
||||
}
|
||||
|
||||
function exportApiCSV() {
|
||||
window.location.href = '/cwa-stats-export-csv/api' + window.location.search;
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initApiCharts();
|
||||
});
|
||||
</script>
|
||||
@@ -23,7 +23,7 @@
|
||||
<a class="btn btn-default" href="{{ url_for('convert_library.cancel_convert_library') }}" style="vertical-align: top; float: right; width: 100px; margin-left: 10px;">{{_('Cancel')}}</a>
|
||||
<a class="btn btn-default" href="{{ url_for('convert_library.start_conversion') }}" style="vertical-align: top; float: right; width: 100px;">{{_('Start')}}</a>
|
||||
{% if current_user.role_admin() %}
|
||||
<div class="btn-group" role="group" aria-label="schedule" style="float: right; margin-right: 10px;">
|
||||
<div class="btn-group" role="group" aria-label="schedule" style="float: right; margin-right: 10px; display: flex; gap: 1rem;">
|
||||
<button class="btn btn-default" onclick="scheduleConvertLibrary(5)">{{ _('Schedule 5m') }}</button>
|
||||
<button class="btn btn-default" onclick="scheduleConvertLibrary(15)">{{ _('Schedule 15m') }}</button>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div style="display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;">
|
||||
<p style="font-size: xx-small;max-width: 70%;">
|
||||
<p style="font-size: 11px; max-width: 70%;">
|
||||
Upon loading this page, if you have previously started a run of the CWA Send-to_kindle EPUB Fixer service either here in the Web UI or through the CLI, you will see the output of the most recent previous run below.
|
||||
Once you start a run, you are free to leave the page and return whenever you want to check on the run's progress. If you wish to cancel a run that is still in progress, simply press the Cancel button above
|
||||
and the run will terminate ASAP. This tool is based on the <a href="https://kindle-epub-fix.netlify.app/">Amazon Kindle EPUB Fix</a> tool by <a href="https://github.com/innocenat/kindle-epub-fix">innocenat</a>
|
||||
@@ -23,7 +23,7 @@
|
||||
<a class="btn btn-default" href="{{ url_for('epub_fixer.cancel_epub_fixer') }}" style="vertical-align: top; float: right; width: 100px; margin-left: 10px;">{{_('Cancel')}}</a>
|
||||
<a class="btn btn-default" href="{{ url_for('epub_fixer.start_epub_fixer') }}" style="vertical-align: top; float: right; width: 100px;">{{_('Start')}}</a>
|
||||
{% if current_user.role_admin() %}
|
||||
<div class="btn-group" role="group" aria-label="schedule" style="float: right; margin-right: 10px;">
|
||||
<div class="btn-group" role="group" aria-label="schedule" style="float: right; margin-top: 4px;">
|
||||
<button class="btn btn-default" onclick="scheduleEpubFixer(5)">{{ _('Schedule 5m') }}</button>
|
||||
<button class="btn btn-default" onclick="scheduleEpubFixer(15)">{{ _('Schedule 15m') }}</button>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -169,7 +169,7 @@
|
||||
{{_("When active, the encoding among other attributes of all EPUB files processed by CWA will be checked and fixed to ensure maximum
|
||||
compatibility with Amazon's Send-to-Kindle Service. This feature is particularly useful for users who frequently send EPUB files to their Kindle devices and have experienced issues with
|
||||
file rejections or formatting problems.")}}
|
||||
<div style="margin-top: 1rem; padding: 0.5rem; background-color: rgba(255, 193, 7, 0.1); border-left: 4px solid #ffc107; border-radius: 0.25rem;">
|
||||
<div class="cwa-settings-tip">
|
||||
<small>{{_('This tool was adapted from the <a href=\"https://kindle-epub-fix.netlify.app/\">kindle-epub-fix.netlify.app</a> tool made by <a href=\"https://github.com/innocenat\">innocenat</a>.') | safe }}</small>
|
||||
</div>
|
||||
|
||||
@@ -272,7 +272,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1rem; padding: 0.5rem; background-color: rgba(255, 193, 7, 0.1); border-left: 4px solid #ffc107; border-radius: 0.25rem;">
|
||||
<div class="cwa-settings-tip">
|
||||
<small style="color: var(--color-info-text);">
|
||||
💡 <strong>{{_('Tip')}}:</strong> {{_('These field selections apply to both Smart and Direct replacement modes. Smart mode will additionally apply quality criteria to selected fields, while Direct mode will replace all selected fields with provider data.')}}
|
||||
</small>
|
||||
@@ -339,7 +339,7 @@
|
||||
</div>
|
||||
<input type="hidden" name="metadata_provider_hierarchy" id="metadata_provider_hierarchy_hidden" value="">
|
||||
</div>
|
||||
<div style="margin-top: 1rem; padding: 0.5rem; background-color: rgba(255, 193, 7, 0.1); border-left: 4px solid #ffc107; border-radius: 0.25rem;">
|
||||
<div class="cwa-settings-tip">
|
||||
<small class="settings-explanation">💡 <strong>{{_('Tip')}}:</strong> {{_('Providers are tried in order from top to bottom. Drag to reorder.')}}</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,6 +362,195 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hardcover Auto-Fetch Settings -->
|
||||
<div class="settings-container">
|
||||
<h4 class="settings-section-header">🔖 {{_('Hardcover Auto-Fetch Settings')}}</h4>
|
||||
<p class="settings-description">
|
||||
{{_('Automatically fetch and assign Hardcover IDs to books in your library. Hardcover IDs enable reading progress sync and annotation sync with Hardcover.app when using compatible eReaders.')}}
|
||||
</p>
|
||||
|
||||
{% if not hardcover_token_available %}
|
||||
<div class="alert alert-warning" style="margin: 15px 0; padding: 12px; background-color: rgba(255, 193, 7, 0.1); border-left: 4px solid #ffc107; border-radius: 4px;">
|
||||
<strong>⚠️ {{_('Hardcover Token Required')}}</strong><br>
|
||||
<span style="font-size: 0.9em;">{{_('To enable Hardcover auto-fetch, you must set a valid HARDCOVER_TOKEN in your docker-compose environment variables or configure a global token in Basic Configuration. Without a valid token, this feature will remain disabled.')}}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<!-- Enable Auto-Fetch -->
|
||||
{% if cwa_settings['hardcover_auto_fetch_enabled'] and hardcover_token_available %}
|
||||
<input type="checkbox" id="hardcover_auto_fetch_enabled" name="hardcover_auto_fetch_enabled" value="True" checked style="accent-color: var(--color-secondary);" {% if not hardcover_token_available %}disabled{% endif %}>
|
||||
{% else %}
|
||||
<input type="checkbox" id="hardcover_auto_fetch_enabled" name="hardcover_auto_fetch_enabled" value="True" style="accent-color: var(--color-secondary);" {% if not hardcover_token_available %}disabled{% endif %}>
|
||||
{% endif %}
|
||||
<label for="hardcover_auto_fetch_enabled" style="padding-left: 10px; {% if not hardcover_token_available %}color: #888; cursor: not-allowed;{% endif %}">{{_('Enable Hardcover Auto-Fetch')}}</label>
|
||||
<p class="cwa-settings-tooltip">
|
||||
{{_('Automatically search Hardcover for books without Hardcover IDs. High-confidence matches are applied automatically; low-confidence matches are queued for manual review.')}}
|
||||
</p>
|
||||
|
||||
<!-- Schedule Interval -->
|
||||
<div class="form-group" style="margin-top: 20px;">
|
||||
<label for="hardcover_auto_fetch_schedule" class="settings-section-header" style="padding-right: 10px;">{{_('Run Schedule:')}}</label>
|
||||
<select name="hardcover_auto_fetch_schedule"
|
||||
id="hardcover_auto_fetch_schedule"
|
||||
style="padding: 5px; border: 1px solid transparent; border-radius: 4px; background-color: #151e2680; min-width: 200px;"
|
||||
onchange="toggleScheduleOptions()"
|
||||
{% if not hardcover_token_available %}disabled{% endif %}>
|
||||
<optgroup label="{{_('Frequent Intervals')}}">
|
||||
<option value="15min" {% if cwa_settings['hardcover_auto_fetch_schedule'] == '15min' %}selected{% endif %}>{{_('Every 15 Minutes')}}</option>
|
||||
<option value="30min" {% if cwa_settings['hardcover_auto_fetch_schedule'] == '30min' %}selected{% endif %}>{{_('Every 30 Minutes')}}</option>
|
||||
<option value="1hour" {% if cwa_settings['hardcover_auto_fetch_schedule'] == '1hour' %}selected{% endif %}>{{_('Every Hour')}}</option>
|
||||
<option value="2hours" {% if cwa_settings['hardcover_auto_fetch_schedule'] == '2hours' %}selected{% endif %}>{{_('Every 2 Hours')}}</option>
|
||||
<option value="4hours" {% if cwa_settings['hardcover_auto_fetch_schedule'] == '4hours' %}selected{% endif %}>{{_('Every 4 Hours')}}</option>
|
||||
<option value="6hours" {% if cwa_settings['hardcover_auto_fetch_schedule'] == '6hours' %}selected{% endif %}>{{_('Every 6 Hours')}}</option>
|
||||
<option value="12hours" {% if cwa_settings['hardcover_auto_fetch_schedule'] == '12hours' %}selected{% endif %}>{{_('Every 12 Hours')}}</option>
|
||||
</optgroup>
|
||||
<optgroup label="{{_('Daily/Weekly/Monthly')}}">
|
||||
<option value="daily" {% if cwa_settings['hardcover_auto_fetch_schedule'] == 'daily' %}selected{% endif %}>{{_('Daily at Specific Time')}}</option>
|
||||
<option value="weekly" {% if cwa_settings['hardcover_auto_fetch_schedule'] == 'weekly' %}selected{% endif %}>{{_('Weekly on Specific Day')}}</option>
|
||||
<option value="monthly" {% if cwa_settings['hardcover_auto_fetch_schedule'] == 'monthly' %}selected{% endif %}>{{_('Monthly on Specific Day')}}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<small style="color: #bbbbbb; margin-left: 10px;">{{_('How often to automatically run the Hardcover ID fetch task')}}</small>
|
||||
|
||||
<!-- Day of Week Selector (for weekly) -->
|
||||
<div id="schedule_day_selector" style="margin-top: 10px; display: none;">
|
||||
<label style="color: #bbbbbb; margin-right: 10px;">{{_('Day of Week:')}}</label>
|
||||
<select name="hardcover_auto_fetch_schedule_day"
|
||||
id="hardcover_auto_fetch_schedule_day"
|
||||
style="padding: 5px; border: 1px solid transparent; border-radius: 4px; background-color: #151e2680;"
|
||||
{% if not hardcover_token_available %}disabled{% endif %}>
|
||||
<option value="monday" {% if cwa_settings.get('hardcover_auto_fetch_schedule_day', 'sunday') == 'monday' %}selected{% endif %}>{{_('Monday')}}</option>
|
||||
<option value="tuesday" {% if cwa_settings.get('hardcover_auto_fetch_schedule_day', 'sunday') == 'tuesday' %}selected{% endif %}>{{_('Tuesday')}}</option>
|
||||
<option value="wednesday" {% if cwa_settings.get('hardcover_auto_fetch_schedule_day', 'sunday') == 'wednesday' %}selected{% endif %}>{{_('Wednesday')}}</option>
|
||||
<option value="thursday" {% if cwa_settings.get('hardcover_auto_fetch_schedule_day', 'sunday') == 'thursday' %}selected{% endif %}>{{_('Thursday')}}</option>
|
||||
<option value="friday" {% if cwa_settings.get('hardcover_auto_fetch_schedule_day', 'sunday') == 'friday' %}selected{% endif %}>{{_('Friday')}}</option>
|
||||
<option value="saturday" {% if cwa_settings.get('hardcover_auto_fetch_schedule_day', 'sunday') == 'saturday' %}selected{% endif %}>{{_('Saturday')}}</option>
|
||||
<option value="sunday" {% if cwa_settings.get('hardcover_auto_fetch_schedule_day', 'sunday') == 'sunday' %}selected{% endif %}>{{_('Sunday')}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Day of Month Selector (for monthly) -->
|
||||
<div id="schedule_monthday_selector" style="margin-top: 10px; display: none;">
|
||||
<label style="color: #bbbbbb; margin-right: 10px;">{{_('Day of Month:')}}</label>
|
||||
<input type="number"
|
||||
name="hardcover_auto_fetch_schedule_day"
|
||||
id="hardcover_auto_fetch_schedule_monthday"
|
||||
value="{{ cwa_settings.get('hardcover_auto_fetch_schedule_day', '1') if cwa_settings['hardcover_auto_fetch_schedule'] == 'monthly' else '1' }}"
|
||||
min="1"
|
||||
max="28"
|
||||
style="width: 80px; padding: 5px; border: 1px solid transparent; border-radius: 4px; background-color: #151e2680;"
|
||||
{% if not hardcover_token_available %}disabled{% endif %}>
|
||||
<small style="color: #888; margin-left: 5px;">{{_('(1-28)')}}</small>
|
||||
</div>
|
||||
|
||||
<!-- Hour Selector (for daily/weekly/monthly) -->
|
||||
<div id="schedule_hour_selector" style="margin-top: 10px; display: none;">
|
||||
<label style="color: #bbbbbb; margin-right: 10px;">{{_('Time of Day:')}}</label>
|
||||
<select name="hardcover_auto_fetch_schedule_hour"
|
||||
id="hardcover_auto_fetch_schedule_hour"
|
||||
style="padding: 5px; border: 1px solid transparent; border-radius: 4px; background-color: #151e2680;"
|
||||
{% if not hardcover_token_available %}disabled{% endif %}>
|
||||
{% for hour in range(24) %}
|
||||
<option value="{{hour}}" {% if cwa_settings.get('hardcover_auto_fetch_schedule_hour', 2) == hour %}selected{% endif %}>{{ "%02d:00"|format(hour) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small style="color: #888; margin-left: 5px;">{{_('(Server timezone: %(tz)s)', tz=config.config_timezone or 'UTC')}}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleScheduleOptions() {
|
||||
const scheduleType = document.getElementById('hardcover_auto_fetch_schedule').value;
|
||||
const daySelector = document.getElementById('schedule_day_selector');
|
||||
const monthdaySelector = document.getElementById('schedule_monthday_selector');
|
||||
const hourSelector = document.getElementById('schedule_hour_selector');
|
||||
|
||||
// Hide all by default
|
||||
daySelector.style.display = 'none';
|
||||
monthdaySelector.style.display = 'none';
|
||||
hourSelector.style.display = 'none';
|
||||
|
||||
// Show relevant selectors based on schedule type
|
||||
if (scheduleType === 'weekly') {
|
||||
daySelector.style.display = 'block';
|
||||
hourSelector.style.display = 'block';
|
||||
} else if (scheduleType === 'monthly') {
|
||||
monthdaySelector.style.display = 'block';
|
||||
hourSelector.style.display = 'block';
|
||||
} else if (scheduleType === 'daily') {
|
||||
hourSelector.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
toggleScheduleOptions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Minimum Confidence Threshold -->
|
||||
<div class="form-group" style="margin-top: 20px;">
|
||||
<label for="hardcover_auto_fetch_min_confidence" class="settings-section-header" style="padding-right: 10px;">{{_('Minimum Confidence Threshold:')}}</label>
|
||||
<input type="number"
|
||||
name="hardcover_auto_fetch_min_confidence"
|
||||
id="hardcover_auto_fetch_min_confidence"
|
||||
value="{{ cwa_settings['hardcover_auto_fetch_min_confidence'] }}"
|
||||
min="0.5"
|
||||
max="1.0"
|
||||
step="0.05"
|
||||
style="width: 100px; padding: 5px; border: 1px solid transparent; border-radius: 4px; background-color: #151e2680;"
|
||||
{% if not hardcover_token_available %}disabled{% endif %}>
|
||||
<small style="color: #bbbbbb; margin-left: 10px;">{{_('Range: 0.50-1.00 (default: 0.85)')}}</small>
|
||||
<p class="cwa-settings-tooltip">
|
||||
{{_('Matches with confidence scores above this threshold are automatically applied. Lower scores are queued for manual review. Higher values = fewer auto-matches but higher accuracy.')}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Batch Size -->
|
||||
<div class="form-group" style="margin-top: 20px;">
|
||||
<label for="hardcover_auto_fetch_batch_size" class="settings-section-header" style="padding-right: 10px;">{{_('Batch Size:')}}</label>
|
||||
<input type="number"
|
||||
name="hardcover_auto_fetch_batch_size"
|
||||
id="hardcover_auto_fetch_batch_size"
|
||||
value="{{ cwa_settings.get('hardcover_auto_fetch_batch_size', 50) }}"
|
||||
min="10"
|
||||
max="200"
|
||||
step="10"
|
||||
style="width: 100px; padding: 5px; border: 1px solid transparent; border-radius: 4px; background-color: #151e2680;"
|
||||
{% if not hardcover_token_available %}disabled{% endif %}>
|
||||
<small style="color: #bbbbbb; margin-left: 10px;">{{_('Range: 10-200 books per run (default: 50)')}}</small>
|
||||
<p class="cwa-settings-tooltip">
|
||||
{{_('Number of books to process in each scheduled run. Smaller batches reduce API load; larger batches process your library faster.')}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Rate Limit Delay -->
|
||||
<div class="form-group" style="margin-top: 20px;">
|
||||
<label for="hardcover_auto_fetch_rate_limit" class="settings-section-header" style="padding-right: 10px;">{{_('Rate Limit Delay (seconds):')}}</label>
|
||||
<input type="number"
|
||||
name="hardcover_auto_fetch_rate_limit"
|
||||
id="hardcover_auto_fetch_rate_limit"
|
||||
value="{{ cwa_settings['hardcover_auto_fetch_rate_limit'] }}"
|
||||
min="0"
|
||||
max="60"
|
||||
step="0.5"
|
||||
style="width: 100px; padding: 5px; border: 1px solid transparent; border-radius: 4px; background-color: #151e2680;"
|
||||
{% if not hardcover_token_available %}disabled{% endif %}>
|
||||
<small style="color: #bbbbbb; margin-left: 10px;">{{_('Range: 0-60 seconds (default: 5.0)')}}</small>
|
||||
<p class="cwa-settings-tooltip">
|
||||
{{_('Delay between API requests to Hardcover. Prevents rate limiting and protects your API key. Uses exponential backoff on errors.')}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="cwa-settings-tip" style="margin-top: 20px;">
|
||||
<small style="color: var(--color-info-text);">
|
||||
💡 <strong>{{_('Tip')}}:</strong> {{_('You can manually trigger the Hardcover auto-fetch task from the Admin panel at any time. Check the CWA Stats page to see progress and review queued matches that need manual approval.')}}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-container">
|
||||
<h4 class="settings-section-header">{{_('Web UI Settings')}}</h4>
|
||||
|
||||
@@ -385,6 +574,16 @@
|
||||
{{_('When disabled, you will no longer receive notifications in the Web UI when using a language other than English if the translations for your chosen language are not complete.')}}
|
||||
</p>
|
||||
|
||||
{% if config['config_kobo_sync_magic_shelves'] %}
|
||||
<input type="checkbox" id="config_kobo_sync_magic_shelves" name="config_kobo_sync_magic_shelves" value="True" checked style="accent-color: var(--color-secondary);" data-toggle="tooltip" data-placement="right" title="Requires Kobo Sync to be enabled in Basic Configuration">
|
||||
{% else %}
|
||||
<input type="checkbox" id="config_kobo_sync_magic_shelves" name="config_kobo_sync_magic_shelves" value="True" style="accent-color: var(--color-secondary);" data-toggle="tooltip" data-placement="right" title="Requires Kobo Sync to be enabled in Basic Configuration">
|
||||
{% endif %}
|
||||
<label for="config_kobo_sync_magic_shelves" style="padding-left: 10px;">{{_('Sync Magic Shelves to Kobo')}}</label><br>
|
||||
<p class="cwa-settings-tooltip">
|
||||
{{_('When active, your Magic Shelves will be synced to your Kobo device as collections. Note: You must also enable Kobo Sync in Basic Configuration.')}}
|
||||
</p>
|
||||
|
||||
{% if cwa_settings['enable_mobile_blur'] %}
|
||||
<input type="checkbox" id="enable_mobile_blur" name="enable_mobile_blur" value="True" checked style="accent-color: var(--color-secondary);" data-toggle="tooltip" data-placement="right" title="May impact performance on low-end mobile devices">
|
||||
{% else %}
|
||||
@@ -396,6 +595,108 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-container">
|
||||
<h4 class="settings-section-header">🔍 {{_('Full Text Search (FTS) Management')}}</h4>
|
||||
|
||||
<p class="settings-explanation" style="margin-bottom: 20px;">
|
||||
{{_('Full Text Search enables searching inside book content (not just titles/authors). Calibre indexes the text content of your books in the background.')}}
|
||||
</p>
|
||||
|
||||
<!-- Current Status Display -->
|
||||
<div id="fts-status-container" style="margin: 15px 0; padding: 15px; background-color: rgba(42, 52, 65, 0.6); border-left: 4px solid #5bc0de; border-radius: 4px;">
|
||||
<strong style="font-size: 1.1em;">{{_('Current Status')}}</strong>:
|
||||
<span id="fts-status-badge" style="margin-left: 10px; padding: 4px 10px; border-radius: 4px; font-size: 0.95em; font-weight: 500;">
|
||||
{{_('Loading...')}}
|
||||
</span>
|
||||
<br>
|
||||
<span id="fts-progress" style="margin-top: 8px; display: inline-block; font-size: 0.95em; color: #ddd;">
|
||||
{{_('Checking...')}}
|
||||
</span>
|
||||
<br>
|
||||
<span id="fts-indexing-status" style="margin-top: 8px; display: inline-block; font-size: 0.9em; color: #bbb;">
|
||||
<!-- Indexing status will be shown here -->
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div style="margin: 20px 0; display: flex; gap: 10px; flex-wrap: wrap;">
|
||||
<button id="btn-fts-enable" type="button" class="btn btn-primary" style="min-width: 120px;">
|
||||
{{_('Enable FTS')}}
|
||||
</button>
|
||||
<button id="btn-fts-disable" type="button" class="btn btn-default" style="min-width: 120px;">
|
||||
{{_('Disable FTS')}}
|
||||
</button>
|
||||
<button id="btn-fts-reindex" type="button" class="btn btn-default" style="min-width: 120px;">
|
||||
{{_('Reindex All')}}
|
||||
</button>
|
||||
<button id="btn-fts-refresh" type="button" class="btn btn-info" style="min-width: 120px;">
|
||||
{{_('Refresh Status')}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Network Share Warning - Static Info Box -->
|
||||
<div style="margin: 15px 0; padding: 12px; background-color: rgba(255, 193, 7, 0.05); border-left: 4px solid #ffc107; border-radius: 4px;">
|
||||
<strong style="color: #ffc107;">⚠️ {{_('Network Shares')}}</strong><br>
|
||||
<span style="font-size: 0.9em; color: #ddd;">
|
||||
{{_('If your library is on a network share (NFS/SMB), indexing may be slower due to network I/O latency.')}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Disable Behavior Warning -->
|
||||
<div style="margin: 15px 0; padding: 12px; background-color: rgba(244, 67, 54, 0.05); border-left: 4px solid #f44336; border-radius: 4px;">
|
||||
<strong style="color: #f44336;">⚠️ {{_('Important')}}</strong>: {{_('Disabling FTS requires complete re-indexing')}}<br>
|
||||
<span style="font-size: 0.9em; color: #ddd;">
|
||||
{{_('When you disable FTS, Calibre invalidates the index. Re-enabling will restart indexing from 0, even if books were previously indexed. This is Calibre\'s design to ensure index consistency.')}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Resource Information Collapsible -->
|
||||
<details style="margin: 20px 0; padding: 12px; background-color: rgba(91, 192, 222, 0.05); border-left: 4px solid #5bc0de; border-radius: 4px; cursor: pointer;">
|
||||
<summary style="font-weight: bold; font-size: 1em; color: #5bc0de; outline: none; cursor: pointer;">
|
||||
ℹ️ {{_('Resource Usage & Performance Information')}}
|
||||
</summary>
|
||||
<div style="margin-top: 15px; padding-top: 10px; border-top: 1px solid rgba(91, 192, 222, 0.2); font-size: 0.9em; line-height: 1.6;">
|
||||
<p><strong>{{_('What the indexing process does')}}</strong>:</p>
|
||||
<ul style="margin-left: 20px; margin-bottom: 15px;">
|
||||
<li>{{_('Reads each book file (EPUB, MOBI, PDF, etc.)')}}</li>
|
||||
<li>{{_('Extracts text content using Calibre\'s format parsers')}}</li>
|
||||
<li>{{_('Tokenizes text with the custom tokenizer')}}</li>
|
||||
<li>{{_('Builds FTS5 index structures')}}</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>{{_('Performance characteristics')}}</strong>:</p>
|
||||
<ul style="margin-left: 20px; margin-bottom: 15px;">
|
||||
<li><strong>{{_('CPU')}}</strong>: {{_('Moderate - text extraction and tokenization')}}</li>
|
||||
<li><strong>{{_('Disk I/O')}}</strong>: {{_('Heavy - reads every book file sequentially')}}</li>
|
||||
<li><strong>{{_('Memory')}}</strong>: {{_('Low - processes one book at a time')}}</li>
|
||||
<li><strong>{{_('Network I/O')}}</strong>: {{_('Can be significant for large files on network shares')}}</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>{{_('For large libraries (10,000+ books)')}}</strong>:</p>
|
||||
<ul style="margin-left: 20px; margin-bottom: 15px;">
|
||||
<li>{{_('Initial indexing could take hours to days depending on')}}:</li>
|
||||
<ul style="margin-left: 20px;">
|
||||
<li>{{_('Book file sizes (PDFs are slower than EPUBs)')}}</li>
|
||||
<li>{{_('Disk/network speed')}}</li>
|
||||
<li>{{_('CPU speed')}}</li>
|
||||
</ul>
|
||||
</ul>
|
||||
|
||||
<p style="margin-top: 15px; padding: 10px; background-color: rgba(76, 175, 80, 0.1); border-left: 3px solid #4CAF50; border-radius: 3px;">
|
||||
<strong>✅ {{_('Good news')}}</strong>: {{_('It\'s a background process that doesn\'t block CWA. Your library remains fully usable during indexing.')}}
|
||||
</p>
|
||||
|
||||
<p style="margin-top: 10px; padding: 10px; background-color: rgba(91, 192, 222, 0.1); border-left: 3px solid #5bc0de; border-radius: 3px;">
|
||||
<strong>ℹ️ {{_('Note')}}</strong>: {{_('FTS searches only become active once 90%% of your library is indexed. Until then, standard search is used.')}}
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="cwa-settings-tip" style="margin-top: 20px;">
|
||||
<small>💡 <strong>{{_('Tip')}}</strong>: {{_('You can also manage FTS from the Basic Configuration page, but this interface provides more detailed status and control.')}}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-container">
|
||||
<h4 class="settings-section-header">{{_('Automatic Backup Settings')}}</h4>
|
||||
|
||||
@@ -458,7 +759,7 @@
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div style="margin-top: 1rem; padding: 0.5rem; background-color: rgba(255, 193, 7, 0.1); border-left: 4px solid #ffc107; border-radius: 0.25rem;">
|
||||
<div class="cwa-settings-tip">
|
||||
<small class="settings-explanation">
|
||||
{{_("NOTE: CWA's Metadata Enforcement service can currently only support file in either EPUB and AZW3 format. Files in other formats will simply be ignored by the service")}}
|
||||
</small>
|
||||
@@ -584,6 +885,99 @@
|
||||
</tbody></table>
|
||||
</div>
|
||||
|
||||
<div class="settings-container">
|
||||
<h4 class="settings-section-header">{{_('CWA Duplicate Detection System')}}</h4>
|
||||
|
||||
<p class="cwa-settings-explanation settings-explanation">
|
||||
{{_('Enable or disable the duplicate detection system. When disabled, duplicate scanning will not run and no notifications will be shown.')}}
|
||||
</p>
|
||||
|
||||
<div class="checkbox-wrapper" style="margin-bottom: 1.5rem;">
|
||||
<input type="checkbox" id="duplicate_detection_enabled" name="duplicate_detection_enabled" value="1" {% if cwa_settings['duplicate_detection_enabled'] %} checked {% endif %}>
|
||||
<label for="duplicate_detection_enabled">{{_('Enable Duplicate Detection')}}</label>
|
||||
<small class="settings-explanation">{{_('When enabled, CWA will scan for duplicate books after each import')}}</small>
|
||||
</div>
|
||||
|
||||
<!-- Detection Method (Hybrid default) -->
|
||||
{% set scan_method = cwa_settings.get('duplicate_scan_method')|default('hybrid', true) %}
|
||||
<div class="form-group" style="max-width: 520px;">
|
||||
<label for="duplicate_scan_method" class="settings-section-header" style="margin-top: 8px;">{{_('Duplicate Detection Method')}}</label>
|
||||
<select class="cwa-settings-select" name="duplicate_scan_method" id="duplicate_scan_method" style="width: fit-content;">
|
||||
<option value="hybrid" {% if scan_method == 'hybrid' %}selected{% endif %}>
|
||||
{{_('Hybrid (SQL prefilter + Python validation)')}}
|
||||
</option>
|
||||
<option value="python" {% if scan_method == 'python' %}selected{% endif %}>
|
||||
{{_('Python only (slowest, most robust)')}}
|
||||
</option>
|
||||
<option value="sql" {% if scan_method == 'sql' %}selected{% endif %}>
|
||||
{{_('Legacy SQL only (experimental)')}}
|
||||
</option>
|
||||
</select>
|
||||
<p class="cwa-settings-explanation settings-explanation" style="margin-top: 10px;">
|
||||
{{_('Hybrid mode uses a fast SQL prefilter to narrow candidates, then applies the robust Python logic for final results.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Keep SQL prefilter enabled by default for hybrid/auto modes -->
|
||||
<input type="hidden" id="duplicate_detection_use_sql" name="duplicate_detection_use_sql" value="1">
|
||||
</div>
|
||||
|
||||
<div class="settings-container">
|
||||
<h4 class="settings-section-header">{{_('Automatic Duplicate Scanning')}}</h4>
|
||||
|
||||
<p class="cwa-settings-explanation settings-explanation">
|
||||
{{_('Configure background duplicate scans. These run automatically without blocking the UI.') }}
|
||||
</p>
|
||||
|
||||
<div class="checkbox-wrapper" style="margin-bottom: 1rem;">
|
||||
<input type="checkbox" id="duplicate_scan_enabled" name="duplicate_scan_enabled" value="1" {% if cwa_settings['duplicate_scan_enabled'] %} checked {% endif %}>
|
||||
<label for="duplicate_scan_enabled">{{_('Enable automatic duplicate scans')}}</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="max-width: 520px;">
|
||||
<label for="duplicate_scan_frequency" class="settings-section-header">{{_('After import scans')}}</label>
|
||||
<select class="cwa-settings-select" name="duplicate_scan_frequency" id="duplicate_scan_frequency" style="width: fit-content;">
|
||||
<option value="after_import" {% if cwa_settings.get('duplicate_scan_frequency', 'after_import') == 'after_import' %}selected{% endif %}>
|
||||
{{_('Run after each import (debounced)')}}
|
||||
</option>
|
||||
<option value="manual" {% if cwa_settings.get('duplicate_scan_frequency') == 'manual' %}selected{% endif %}>
|
||||
{{_('Manual only')}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="max-width: 520px; margin-top: 0.75rem;">
|
||||
<label for="duplicate_scan_debounce_seconds" class="settings-section-header">{{_('After import debounce (seconds)')}}</label>
|
||||
<input type="number" min="5" max="600" step="1" class="cwa-settings-input"
|
||||
name="duplicate_scan_debounce_seconds" id="duplicate_scan_debounce_seconds"
|
||||
value="{{ cwa_settings.get('duplicate_scan_debounce_seconds', 30) }}"
|
||||
style="width: 120px; background: #151e2680; border: none; padding: 1rem; border-radius: 4px; margin-left: 1rem;">
|
||||
<p class="cwa-settings-explanation settings-explanation" style="margin-top: 8px;">
|
||||
{{_('Wait this many seconds after the last import before starting a background scan.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="max-width: 520px; margin-top: 1rem;">
|
||||
<label for="duplicate_scan_cron" class="settings-section-header">{{_('Scheduled scans (cron expression)')}}</label>
|
||||
<input type="text" class="cwa-settings-input" name="duplicate_scan_cron" id="duplicate_scan_cron" placeholder="0 3 * * *"
|
||||
value="{{ cwa_settings.get('duplicate_scan_cron', '') }}" style="width: 220px; background: #151e2680; border: none; padding: 1rem; border-radius: 4px; margin-left: 1rem;">
|
||||
<p class="cwa-settings-explanation settings-explanation" style="margin-top: 8px;">
|
||||
{{_('Leave blank to disable scheduled scans. Example:')}} <code>0 3 * * *</code>
|
||||
</p>
|
||||
<p class="cwa-settings-explanation settings-explanation" style="margin-top: 6px;">
|
||||
{{_('If enabled, after-import scans and cron scans can both run. Use “Manual only” to disable after-import scans while keeping cron schedules.')}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if next_duplicate_scan_run %}
|
||||
<div class="cwa-settings-tip" style="margin-top: 12px;">
|
||||
<small class="settings-explanation">
|
||||
<strong>{{_('Next scheduled scan:')}}</strong> {{ next_duplicate_scan_run }}
|
||||
</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="settings-container">
|
||||
<h4 class="settings-section-header">{{_('CWA Duplicate Detection Criteria')}}</h4>
|
||||
|
||||
@@ -629,13 +1023,101 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1rem; padding: 0.5rem; background-color: rgba(255, 193, 7, 0.1); border-left: 4px solid #ffc107; border-radius: 0.25rem;">
|
||||
<div class="cwa-settings-tip">
|
||||
<small class="settings-explanation">
|
||||
<strong>{{_('Note:')}}</strong> {{_('At least one criterion must be selected. Title and Author are recommended as core criteria.')}}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-container">
|
||||
<h4 class="settings-section-header">{{_('Duplicate Format Priority Ranking')}}</h4>
|
||||
|
||||
<p class="cwa-settings-explanation settings-explanation">
|
||||
{{_('Configure which file formats are preferred when using the "Highest Quality Format" auto-resolution strategy. Drag and drop to reorder formats by priority (higher = better).')}}
|
||||
</p>
|
||||
|
||||
<input type="hidden" id="duplicate_format_priority" name="duplicate_format_priority" value='{{ cwa_settings.get("duplicate_format_priority", "{\"EPUB\":100,\"KEPUB\":95,\"AZW3\":90,\"MOBI\":80,\"AZW\":75,\"PDF\":60,\"TXT\":40,\"CBZ\":35,\"CBR\":35,\"FB2\":30,\"DJVU\":25,\"HTML\":20,\"RTF\":15,\"DOC\":10,\"DOCX\":10}") }}'>
|
||||
|
||||
<div id="format_priority_container" style="margin-top: 1rem;">
|
||||
<ul id="format_priority_list" style="list-style: none; padding: 0; margin: 0;">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="cwa-settings-tip">
|
||||
<small class="settings-explanation">
|
||||
<strong>{{_('Tip:')}}</strong> {{_('When resolving duplicates with "Highest Quality Format" strategy, books with higher-ranked formats will be kept. Lower priority formats will be deleted.')}}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div class="settings-container">
|
||||
<h4 class="settings-section-header">{{_('Duplicate Notifications & Auto-Resolution')}}</h4>
|
||||
|
||||
<p class="cwa-settings-explanation settings-explanation">
|
||||
{{_('Configure how you are notified about duplicate books and optionally enable automatic resolution.')}}
|
||||
</p>
|
||||
|
||||
<div class="checkbox-wrapper">
|
||||
<input type="checkbox" id="duplicate_notifications_enabled" name="duplicate_notifications_enabled" value="1" {% if cwa_settings['duplicate_notifications_enabled'] %} checked {% endif %}>
|
||||
<label for="duplicate_notifications_enabled">{{_('Enable Duplicate Notifications')}}</label>
|
||||
</div>
|
||||
<p class="cwa-settings-tip" style="font-size: small;">{{_('Show popup notifications when unresolved duplicates are detected. Admins and users with edit rights will see a badge on the Duplicates sidebar button and a notification popup when they login.')}}</small>
|
||||
|
||||
<div class="checkbox-wrapper">
|
||||
<input type="checkbox" id="duplicate_auto_resolve_enabled" name="duplicate_auto_resolve_enabled" value="1" {% if cwa_settings['duplicate_auto_resolve_enabled'] %} checked {% endif %}>
|
||||
<label for="duplicate_auto_resolve_enabled">{{_('Enable Automatic Duplicate Resolution')}} ⚠️</label>
|
||||
</div>
|
||||
<p class="cwa-settings-tip" style="background: #e74c3c57; border-color: #e74c3c; font-size: small;">
|
||||
<strong>{{_('Warning:')}}</strong> {{_('This will automatically delete books based on the strategy below. Original files will be backed up to')}} <code>/config/processed_books/</code>
|
||||
</p>
|
||||
|
||||
<div class="form-group" style="padding: 2rem; background: #151e2680;">
|
||||
<h4 for="duplicate_auto_resolve_strategy" class="settings-section-header">{{_('Auto-Resolution Strategy')}}</h4>
|
||||
<select class="form-control" id="duplicate_auto_resolve_strategy" name="duplicate_auto_resolve_strategy">
|
||||
<option value="newest" {% if cwa_settings.get('duplicate_auto_resolve_strategy', 'newest') == 'newest' %} selected {% endif %}>
|
||||
{{_('Keep Newest')}} - {{_('Delete older copies, keep the most recently added book')}}
|
||||
</option>
|
||||
<option value="oldest" {% if cwa_settings.get('duplicate_auto_resolve_strategy') == 'oldest' %} selected {% endif %}>
|
||||
{{_('Keep Oldest')}} - {{_('Delete newer copies, keep the earliest added')}}
|
||||
</option>
|
||||
<option value="merge" {% if cwa_settings.get('duplicate_auto_resolve_strategy') == 'merge' %} selected {% endif %}>
|
||||
{{_('Merge Formats')}} - {{_('Merge formats into the newest book, delete the rest')}}
|
||||
</option>
|
||||
<option value="highest_quality_format" {% if cwa_settings.get('duplicate_auto_resolve_strategy') == 'highest_quality_format' %} selected {% endif %}>
|
||||
{{_('Keep Highest Quality Format')}} - {{_('Prefer EPUB > KEPUB > AZW3 > MOBI > PDF')}}
|
||||
</option>
|
||||
<option value="most_metadata" {% if cwa_settings.get('duplicate_auto_resolve_strategy') == 'most_metadata' %} selected {% endif %}>
|
||||
{{_('Keep Most Metadata')}} - {{_('Keep book with most complete information')}}
|
||||
</option>
|
||||
<option value="largest_file_size" {% if cwa_settings.get('duplicate_auto_resolve_strategy') == 'largest_file_size' %} selected {% endif %}>
|
||||
{{_('Keep Largest File Size')}} - {{_('Keep book with largest total file size')}}
|
||||
</option>
|
||||
</select>
|
||||
<p class="cwa-settings-explanation settings-explanation" style="margin-top: 2rem !important;">{{_('Choose which book to keep when duplicates are automatically resolved.')}}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="padding: 2rem; background: #151e2680;">
|
||||
<h4 class="settings-section-header">{{_('Rate Limiting')}}</h4>
|
||||
<label for="duplicate_auto_resolve_cooldown_minutes">{{_('Cooldown Period (minutes)')}}</label>
|
||||
<input type="number" class="form-control" id="duplicate_auto_resolve_cooldown_minutes"
|
||||
name="duplicate_auto_resolve_cooldown_minutes" min="0" max="1440"
|
||||
value="{{ cwa_settings.get('duplicate_auto_resolve_cooldown_minutes', 0) if cwa_settings.get('duplicate_auto_resolve_cooldown_minutes') not in [False, None, ''] else 0 }}">
|
||||
<p class="cwa-settings-explanation settings-explanation" style="margin-top: 1rem;">
|
||||
{{_('Minimum time between automatic resolutions (0 to disable). Prevents rapid-fire deletions during batch imports.')}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="cwa-settings-tip">
|
||||
<small class="settings-explanation">
|
||||
<strong>{{_('Note:')}}</strong> {{_('Auto-resolution runs after duplicate scans detect new duplicates. Dismissed duplicate groups are never auto-resolved.')}}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<br>
|
||||
@@ -865,6 +1347,373 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Initialize the hidden input value
|
||||
updateHiddenInput();
|
||||
|
||||
// Format Priority Ranking System
|
||||
function initializeFormatPriorityList() {
|
||||
const hiddenInput = document.getElementById('duplicate_format_priority');
|
||||
const container = document.getElementById('format_priority_list');
|
||||
|
||||
if (!hiddenInput || !container) return;
|
||||
|
||||
try {
|
||||
// Decode HTML entities if present
|
||||
const rawValue = hiddenInput.value || '{}';
|
||||
const decodedValue = rawValue.replace(/"/g, '"').replace(/"/g, '"');
|
||||
const formatPriority = JSON.parse(decodedValue);
|
||||
|
||||
// Convert to array and sort by priority (highest first)
|
||||
const formatArray = Object.entries(formatPriority).map(([format, priority]) => ({
|
||||
format: format,
|
||||
priority: priority
|
||||
}));
|
||||
formatArray.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
// Check if we actually have formats to display
|
||||
if (formatArray.length === 0) {
|
||||
console.error('No format priority data found');
|
||||
container.innerHTML = '<li style="color: #e74c3c; padding: 12px;">No format data available. Please save settings to initialize.</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate list
|
||||
container.innerHTML = '';
|
||||
formatArray.forEach((item, index) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'format-priority-item';
|
||||
li.dataset.format = item.format;
|
||||
li.dataset.priority = item.priority;
|
||||
li.draggable = true;
|
||||
li.innerHTML = `
|
||||
<div style="display: flex; align-items: center; padding: 6px; margin-bottom: 8px; background: #2a3441; border: 1px solid #444; border-radius: 6px; cursor: move; transition: all 0.2s; padding-left: 2rem;">
|
||||
<span style="color: #ffc200; margin-right: 15px; font-size: 18px;">☰</span>
|
||||
<span style="flex: 1; color: white; font-weight: 500;">${item.format}</span>
|
||||
<span style="color: #4CAF50; font-size: 13px; padding: 4px 8px; background: rgba(76, 160, 80, 0.1); border-radius: 4px;">Priority: ${item.priority}</span>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(li);
|
||||
});
|
||||
|
||||
setupFormatPriorityDragDrop();
|
||||
} catch (e) {
|
||||
console.error('Error initializing format priority list:', e);
|
||||
console.error('Hidden input value:', hiddenInput ? hiddenInput.value : 'not found');
|
||||
container.innerHTML = '<li style="color: #e74c3c; padding: 12px;">Error loading format priority: ' + e.message + '</li>';
|
||||
}
|
||||
}
|
||||
|
||||
function setupFormatPriorityDragDrop() {
|
||||
let draggedFormatElement = null;
|
||||
let isDraggingFormat = false;
|
||||
|
||||
const items = document.querySelectorAll('.format-priority-item');
|
||||
|
||||
items.forEach(item => {
|
||||
item.addEventListener('mousedown', function(e) {
|
||||
isDraggingFormat = true;
|
||||
draggedFormatElement = this;
|
||||
this.style.opacity = '0.5';
|
||||
document.body.style.userSelect = 'none';
|
||||
e.preventDefault();
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (!isDraggingFormat || !draggedFormatElement) return;
|
||||
|
||||
const container = document.getElementById('format_priority_list');
|
||||
const afterElement = getFormatAfterElement(container, e.clientY);
|
||||
|
||||
if (afterElement == null) {
|
||||
container.appendChild(draggedFormatElement);
|
||||
} else {
|
||||
container.insertBefore(draggedFormatElement, afterElement);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', function(e) {
|
||||
if (!isDraggingFormat) return;
|
||||
|
||||
isDraggingFormat = false;
|
||||
if (draggedFormatElement) {
|
||||
draggedFormatElement.style.opacity = '1';
|
||||
draggedFormatElement = null;
|
||||
}
|
||||
document.body.style.userSelect = '';
|
||||
updateFormatPriorityHiddenInput();
|
||||
});
|
||||
}
|
||||
|
||||
function getFormatAfterElement(container, y) {
|
||||
const draggableElements = [...container.querySelectorAll('.format-priority-item')].filter(el => el.style.opacity !== '0.5');
|
||||
|
||||
return draggableElements.reduce((closest, child) => {
|
||||
const box = child.getBoundingClientRect();
|
||||
const offset = y - box.top - box.height / 2;
|
||||
|
||||
if (offset < 0 && offset > closest.offset) {
|
||||
return { offset: offset, element: child };
|
||||
} else {
|
||||
return closest;
|
||||
}
|
||||
}, { offset: Number.NEGATIVE_INFINITY }).element;
|
||||
}
|
||||
|
||||
function updateFormatPriorityHiddenInput() {
|
||||
const container = document.getElementById('format_priority_list');
|
||||
const hiddenInput = document.getElementById('duplicate_format_priority');
|
||||
const items = container.querySelectorAll('.format-priority-item');
|
||||
|
||||
// Recalculate priorities based on order (top = highest)
|
||||
const formatPriority = {};
|
||||
const totalItems = items.length;
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const format = item.dataset.format;
|
||||
// Higher priority for items at the top
|
||||
const priority = 100 - (index * Math.floor(100 / totalItems));
|
||||
formatPriority[format] = priority;
|
||||
});
|
||||
|
||||
hiddenInput.value = JSON.stringify(formatPriority);
|
||||
|
||||
// Update priority badges
|
||||
items.forEach((item, index) => {
|
||||
const format = item.dataset.format;
|
||||
const priorityBadge = item.querySelector('span:last-child');
|
||||
if (priorityBadge) {
|
||||
priorityBadge.textContent = `Priority: ${formatPriority[format]}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize format priority list on page load
|
||||
initializeFormatPriorityList();
|
||||
|
||||
// ========================================
|
||||
// Full Text Search (FTS) Management
|
||||
// ========================================
|
||||
|
||||
let ftsPollingInterval = null;
|
||||
let ftsActionInProgress = false;
|
||||
|
||||
function updateFTSStatusUI(status) {
|
||||
const statusBadge = document.getElementById('fts-status-badge');
|
||||
const progressSpan = document.getElementById('fts-progress');
|
||||
const indexingStatusSpan = document.getElementById('fts-indexing-status');
|
||||
const enableBtn = document.getElementById('btn-fts-enable');
|
||||
const disableBtn = document.getElementById('btn-fts-disable');
|
||||
const reindexBtn = document.getElementById('btn-fts-reindex');
|
||||
|
||||
if (status.error) {
|
||||
statusBadge.style.backgroundColor = 'rgba(231, 76, 60, 0.2)';
|
||||
statusBadge.style.color = '#e74c3c';
|
||||
statusBadge.textContent = '❌ Error';
|
||||
progressSpan.textContent = status.error;
|
||||
indexingStatusSpan.textContent = '';
|
||||
disableBtn.disabled = true;
|
||||
reindexBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!status.enabled) {
|
||||
statusBadge.style.backgroundColor = 'rgba(149, 165, 166, 0.2)';
|
||||
statusBadge.style.color = '#95a5a6';
|
||||
statusBadge.textContent = '⚪ Disabled';
|
||||
progressSpan.textContent = 'FTS indexing is not enabled';
|
||||
indexingStatusSpan.textContent = '';
|
||||
enableBtn.disabled = false;
|
||||
disableBtn.disabled = true;
|
||||
reindexBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// FTS is enabled
|
||||
const percentage = status.percentage.toFixed(1);
|
||||
const progressText = `${status.indexed} of ${status.total} books indexed (${percentage}%)`;
|
||||
|
||||
if (status.available) {
|
||||
// 90%+ indexed - search is active
|
||||
statusBadge.style.backgroundColor = 'rgba(76, 175, 80, 0.2)';
|
||||
statusBadge.style.color = '#4CAF50';
|
||||
statusBadge.textContent = '✅ Active';
|
||||
progressSpan.textContent = progressText;
|
||||
} else {
|
||||
// Less than 90% - not available yet
|
||||
statusBadge.style.backgroundColor = 'rgba(255, 193, 7, 0.2)';
|
||||
statusBadge.style.color = '#ffc107';
|
||||
statusBadge.textContent = '❌ Not Available (< 90%)';
|
||||
progressSpan.textContent = progressText + ' - Search will activate at 90%';
|
||||
}
|
||||
|
||||
// Show indexing status
|
||||
if (status.active) {
|
||||
indexingStatusSpan.innerHTML = '<span style="color: #ffc107;">⚙️ Currently Indexing: <strong>Yes</strong></span>';
|
||||
} else if (status.indexed < status.total) {
|
||||
indexingStatusSpan.innerHTML = '<span style="color: #95a5a6;">✓ Currently Indexing: <strong>No</strong> (paused or idle)</span>';
|
||||
} else {
|
||||
indexingStatusSpan.innerHTML = '<span style="color: #4CAF50;">✓ Indexing Complete</span>';
|
||||
}
|
||||
|
||||
// Button states
|
||||
enableBtn.disabled = true; // Already enabled
|
||||
disableBtn.disabled = false;
|
||||
reindexBtn.disabled = false;
|
||||
}
|
||||
|
||||
function fetchFTSStatus() {
|
||||
if (ftsActionInProgress) {
|
||||
return; // Don't poll during actions
|
||||
}
|
||||
|
||||
fetch('{{ url_for("cwa_settings.get_fts_status") }}', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
updateFTSStatusUI(data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching FTS status:', error);
|
||||
const statusBadge = document.getElementById('fts-status-badge');
|
||||
const progressSpan = document.getElementById('fts-progress');
|
||||
statusBadge.style.backgroundColor = 'rgba(231, 76, 60, 0.2)';
|
||||
statusBadge.style.color = '#e74c3c';
|
||||
statusBadge.textContent = '❌ Error';
|
||||
progressSpan.textContent = 'Failed to fetch status: ' + error.message;
|
||||
});
|
||||
}
|
||||
|
||||
function performFTSAction(action, buttonId) {
|
||||
if (ftsActionInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
ftsActionInProgress = true;
|
||||
const button = document.getElementById(buttonId);
|
||||
const originalText = button.textContent;
|
||||
|
||||
// Disable all action buttons
|
||||
document.getElementById('btn-fts-enable').disabled = true;
|
||||
document.getElementById('btn-fts-disable').disabled = true;
|
||||
document.getElementById('btn-fts-reindex').disabled = true;
|
||||
document.getElementById('btn-fts-refresh').disabled = true;
|
||||
|
||||
// Show spinner
|
||||
button.innerHTML = '<span class="glyphicon glyphicon-refresh spinning"></span> ' +
|
||||
(action === 'enable' ? 'Enabling...' :
|
||||
action === 'disable' ? 'Disabling...' :
|
||||
action === 'reindex' ? 'Reindexing...' : 'Working...');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('action', action);
|
||||
|
||||
fetch('{{ url_for("cwa_settings.fts_action") }}', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => {
|
||||
console.log('FTS action response status:', response.status);
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP error ' + response.status);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('FTS action response:', data);
|
||||
if (data.success) {
|
||||
// Show success message
|
||||
const flashContainer = document.getElementById('flash_success');
|
||||
if (flashContainer) {
|
||||
flashContainer.innerHTML = '<div class="alert alert-success">' + data.message + '</div>';
|
||||
flashContainer.style.display = 'block';
|
||||
setTimeout(() => { flashContainer.style.display = 'none'; }, 5000);
|
||||
}
|
||||
|
||||
// Refresh status after a brief delay to allow backend to update
|
||||
setTimeout(() => {
|
||||
fetchFTSStatus();
|
||||
}, 1500);
|
||||
} else {
|
||||
// Show error message
|
||||
const flashContainer = document.getElementById('flash_danger');
|
||||
if (flashContainer) {
|
||||
flashContainer.innerHTML = '<div class="alert alert-danger">' + (data.message || 'Unknown error') + '</div>';
|
||||
flashContainer.style.display = 'block';
|
||||
}
|
||||
// Re-enable buttons on error
|
||||
document.getElementById('btn-fts-enable').disabled = false;
|
||||
document.getElementById('btn-fts-disable').disabled = false;
|
||||
document.getElementById('btn-fts-reindex').disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error performing FTS action:', error);
|
||||
const flashContainer = document.getElementById('flash_danger');
|
||||
if (flashContainer) {
|
||||
flashContainer.innerHTML = '<div class="alert alert-danger">Error: ' + error.message + '</div>';
|
||||
flashContainer.style.display = 'block';
|
||||
}
|
||||
// Re-enable buttons on error
|
||||
document.getElementById('btn-fts-enable').disabled = false;
|
||||
document.getElementById('btn-fts-disable').disabled = false;
|
||||
document.getElementById('btn-fts-reindex').disabled = false;
|
||||
})
|
||||
.finally(() => {
|
||||
button.textContent = originalText;
|
||||
ftsActionInProgress = false;
|
||||
document.getElementById('btn-fts-refresh').disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function startFTSPolling() {
|
||||
// Initial fetch
|
||||
fetchFTSStatus();
|
||||
|
||||
// Poll every 10 seconds
|
||||
if (ftsPollingInterval) {
|
||||
clearInterval(ftsPollingInterval);
|
||||
}
|
||||
ftsPollingInterval = setInterval(fetchFTSStatus, 10000);
|
||||
}
|
||||
|
||||
// Attach button handlers
|
||||
document.getElementById('btn-fts-enable').addEventListener('click', function() {
|
||||
performFTSAction('enable', 'btn-fts-enable');
|
||||
});
|
||||
|
||||
document.getElementById('btn-fts-disable').addEventListener('click', function() {
|
||||
if (confirm('Are you sure you want to disable FTS indexing? This will stop any ongoing indexing process.')) {
|
||||
performFTSAction('disable', 'btn-fts-disable');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btn-fts-reindex').addEventListener('click', function() {
|
||||
if (confirm('Are you sure you want to reindex all books? This will restart the indexing process from scratch.')) {
|
||||
performFTSAction('reindex', 'btn-fts-reindex');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btn-fts-refresh').addEventListener('click', function() {
|
||||
fetchFTSStatus();
|
||||
});
|
||||
|
||||
// Start polling when page loads
|
||||
startFTSPolling();
|
||||
|
||||
// Stop polling when page is hidden (save resources)
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (document.hidden) {
|
||||
if (ftsPollingInterval) {
|
||||
clearInterval(ftsPollingInterval);
|
||||
ftsPollingInterval = null;
|
||||
}
|
||||
} else {
|
||||
startFTSPolling();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -35,6 +35,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hardcover Auto-Fetch Stats -->
|
||||
{% if hardcover_stats %}
|
||||
<hr style="width: 85%;text-align: center;margin-bottom: 36px;border-width: medium;border-color: #96a2a9;border-radius: 8px;">
|
||||
|
||||
<div>
|
||||
<h3>🔖 Hardcover Auto-Fetch Statistics</h3>
|
||||
<div class="cwa_stats_container">
|
||||
<div class="cwa_stats_section">
|
||||
<div class="cwa_stats_header">Total Processed</div>
|
||||
<div class="cwa_stats_value">{{hardcover_stats.total_processed}}</div>
|
||||
</div>
|
||||
<div class="cwa_stats_section">
|
||||
<div class="cwa_stats_header">Auto-Matched</div>
|
||||
<div class="cwa_stats_value" style="color: #5cb85c;">{{hardcover_stats.total_auto_matched}}</div>
|
||||
</div>
|
||||
<div class="cwa_stats_section">
|
||||
<div class="cwa_stats_header">Pending Review</div>
|
||||
<div class="cwa_stats_value" style="color: #f0ad4e;">{{hardcover_stats.pending_review}}</div>
|
||||
</div>
|
||||
<div class="cwa_stats_section">
|
||||
<div class="cwa_stats_header">Manually Reviewed</div>
|
||||
<div class="cwa_stats_value" style="color: #5bc0de;">{{hardcover_stats.manually_reviewed}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if hardcover_stats.pending_review > 0 %}
|
||||
<div style="margin-top: 15px; text-align: center;">
|
||||
<a href="{{url_for('admin.hardcover_review_matches')}}" class="btn btn-warning" style="font-size: 1.1em; padding: 10px 20px;">
|
||||
📋 {{_('Review %(count)s Pending Match(es)', count=hardcover_stats.pending_review)}}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<hr style="width: 85%;text-align: center;margin-bottom: 36px;border-width: medium;border-color: #96a2a9;border-radius: 8px;">
|
||||
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<!-- System Logs Content (formerly cwa_stats.html) -->
|
||||
|
||||
<div class="stats-hero">
|
||||
<h1>⚙️ Server Activity Dashboard</h1>
|
||||
<p>Track server activity, file conversions, automatic fixes ect.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>Calibre-Web Automated - Server Stats</h3>
|
||||
<div class="cwa_stats_container">
|
||||
<div class="cwa_stats_section">
|
||||
<div class="cwa_stats_header">Total Books</div>
|
||||
<div class="cwa_stats_value">{{cwa_stats["total_books"]}}</div>
|
||||
</div>
|
||||
<div class="cwa_stats_section">
|
||||
<div class="cwa_stats_header">Books Enforced</div>
|
||||
<div class="cwa_stats_value">{{cwa_stats["cwa_enforcement"]}}</div>
|
||||
</div>
|
||||
<div class="cwa_stats_section">
|
||||
<div class="cwa_stats_header">Books Converted</div>
|
||||
<div class="cwa_stats_value">{{cwa_stats["cwa_conversions"]}}</div>
|
||||
</div>
|
||||
<div class="cwa_stats_section">
|
||||
<div class="cwa_stats_header">Books Fixed</div>
|
||||
<div class="cwa_stats_value">{{cwa_stats["epub_fixes"]}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hardcover Auto-Fetch Stats -->
|
||||
{% if hardcover_stats %}
|
||||
<hr style="width: 85%;text-align: center;margin-bottom: 36px;border-width: medium;border-color: #96a2a9;border-radius: 8px;">
|
||||
|
||||
<div>
|
||||
<h3>🔖 Hardcover Auto-Fetch Statistics</h3>
|
||||
<div class="cwa_stats_container">
|
||||
<div class="cwa_stats_section">
|
||||
<div class="cwa_stats_header">Total Processed</div>
|
||||
<div class="cwa_stats_value">{{hardcover_stats.total_processed}}</div>
|
||||
</div>
|
||||
<div class="cwa_stats_section">
|
||||
<div class="cwa_stats_header">Auto-Matched</div>
|
||||
<div class="cwa_stats_value" style="color: #5cb85c;">{{hardcover_stats.total_auto_matched}}</div>
|
||||
</div>
|
||||
<div class="cwa_stats_section">
|
||||
<div class="cwa_stats_header">Pending Review</div>
|
||||
<div class="cwa_stats_value" style="color: #f0ad4e;">{{hardcover_stats.pending_review}}</div>
|
||||
</div>
|
||||
<div class="cwa_stats_section">
|
||||
<div class="cwa_stats_header">Manually Reviewed</div>
|
||||
<div class="cwa_stats_value" style="color: #5bc0de;">{{hardcover_stats.manually_reviewed}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if hardcover_stats.pending_review > 0 %}
|
||||
<div style="margin-top: 15px; text-align: center;">
|
||||
<a href="{{url_for('admin.hardcover_review_matches')}}" class="btn btn-warning" style="font-size: 1.1em; padding: 10px 20px;">
|
||||
📋 {{_('Review %(count)s Pending Match(es)', count=hardcover_stats.pending_review)}}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<hr style="width: 85%;text-align: center;margin-bottom: 36px;border-width: medium;border-color: #96a2a9;border-radius: 8px;">
|
||||
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h3 style="margin: 0;">Calibre-Web Automated Conversion History</h3>
|
||||
<a class="btn btn-default stats_see_more_btn" href="{{ url_for('cwa_stats.show_full_conversions') }}">{{_('Click to See More')}}</a>
|
||||
</div>
|
||||
<br>
|
||||
<table class="table table-striped">
|
||||
<tr>
|
||||
{% for header in headers_conversion %}
|
||||
<th>{{ header }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% for row in data_conversions|reverse %}
|
||||
<tr>
|
||||
{% for cell in row %}
|
||||
<td>{{ cell }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h3 style="margin: 0;">Calibre-Web Automated Import History</h3>
|
||||
<a class="btn btn-default stats_see_more_btn" href="{{ url_for('cwa_stats.show_full_imports') }}">{{_('Click to See More')}}</a>
|
||||
</div>
|
||||
<br>
|
||||
<table class="table table-striped">
|
||||
<tr>
|
||||
{% for header in headers_import %}
|
||||
<th>{{ header }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% for row in data_imports|reverse %}
|
||||
<tr>
|
||||
{% for cell in row %}
|
||||
<td>{{ cell }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h3 style="margin: 0;">Calibre-Web Automated EPUB Fixer History</h3>
|
||||
<a class="btn btn-default stats_see_more_btn" href="{{ url_for('cwa_stats.show_full_epub_fixer') }}">{{_('Click to See More')}}</a>
|
||||
</div>
|
||||
<br>
|
||||
<table class="table table-striped">
|
||||
<tr>
|
||||
{% for header in headers_epub_fixer %}
|
||||
<th>{{ header }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% for row in data_epub_fixer|reverse %}
|
||||
<tr>
|
||||
{% for cell in row %}
|
||||
<td>{{ cell }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h4 style="margin: 0;"><i>EPUB Fixer History with Paths & Fixes</i></h4>
|
||||
<a class="btn btn-default stats_see_more_btn" href="{{ url_for('cwa_stats.show_full_epub_fixer_with_paths_fixes') }}">{{_('Click to See More')}}</a>
|
||||
</div>
|
||||
<br>
|
||||
<table class="table table-striped">
|
||||
<tr>
|
||||
{% for header in headers_epub_fixer_with_fixes %}
|
||||
<th>{{ header }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% for row in data_epub_fixer_with_fixes|reverse %}
|
||||
<tr>
|
||||
{% for cell in row %}
|
||||
<td>
|
||||
<div style="max-height: 20rem; overflow-y: auto;">{{ cell | replace("\n", "<br>") | safe }}</div>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h3 style="margin: 0;">Calibre-Web Automated Enforcement History</h3>
|
||||
<a class="btn btn-default stats_see_more_btn" href="{{url_for('cwa_stats.show_full_enforcement')}}">{{_('Click to See More')}}</a>
|
||||
</div>
|
||||
<br>
|
||||
<table class="table table-striped">
|
||||
<tr>
|
||||
{% for header in headers_enforcement %}
|
||||
<th>{{ header }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% for row in data_enforcement|reverse %}
|
||||
<tr>
|
||||
{% for cell in row %}
|
||||
<td>{{ cell }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h4 style="margin: 0;"><i>Enforcement History with Paths</i></h4>
|
||||
<a class="btn btn-default stats_see_more_btn" href="{{ url_for('cwa_stats.show_full_enforcement_path') }}">{{_('Click to See More')}}</a>
|
||||
</div>
|
||||
<br>
|
||||
<table class="table table-striped">
|
||||
<tr>
|
||||
{% for header in headers_enforcement_with_paths %}
|
||||
<th>{{ header }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% for row in data_enforcement_with_paths|reverse %}
|
||||
<tr>
|
||||
{% for cell in row %}
|
||||
<td>{{ cell }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,153 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
|
||||
<script src="{{ url_for('static', filename='js/libs/echarts.min.js') }}"></script>
|
||||
|
||||
<style>
|
||||
.stats-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 2px solid #3a4451;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.stats-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 15px 30px;
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
border-bottom: 3px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
bottom: -2px;
|
||||
}
|
||||
|
||||
.stats-tab:hover {
|
||||
color: #ff9800;
|
||||
background: rgba(255, 152, 0, 0.05);
|
||||
}
|
||||
|
||||
.stats-tab.active {
|
||||
color: #ff9800;
|
||||
border-bottom-color: #ff9800;
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="discover" style="margin-top: 1rem !important;">
|
||||
<h2>{{title}}</h2>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="stats-tabs">
|
||||
<button class="stats-tab {{ 'active' if active_tab == 'activity' else '' }}"
|
||||
onclick="switchTab('activity')">
|
||||
📊 User Activity
|
||||
</button>
|
||||
<button class="stats-tab {{ 'active' if active_tab == 'library' else '' }}"
|
||||
onclick="switchTab('library')">
|
||||
📚 Library Stats
|
||||
</button>
|
||||
<button class="stats-tab {{ 'active' if active_tab == 'api' else '' }}"
|
||||
onclick="switchTab('api')">
|
||||
📱 API Usage
|
||||
</button>
|
||||
<button class="stats-tab {{ 'active' if active_tab == 'system' else '' }}"
|
||||
onclick="switchTab('system')">
|
||||
⚙️ System Stats
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="max-width: 1200px; margin: auto;">
|
||||
<!-- User Activity Tab -->
|
||||
<div id="activity-tab" class="tab-content {{ 'active' if active_tab == 'activity' else '' }}">
|
||||
{% include 'cwa_user_activity.html' %}
|
||||
</div>
|
||||
|
||||
<!-- Library Stats Tab -->
|
||||
<div id="library-tab" class="tab-content {{ 'active' if active_tab == 'library' else '' }}">
|
||||
{% include 'cwa_library_stats.html' %}
|
||||
</div>
|
||||
|
||||
<!-- API Usage Tab -->
|
||||
<div id="api-tab" class="tab-content {{ 'active' if active_tab == 'api' else '' }}">
|
||||
{% include 'cwa_api_stats.html' %}
|
||||
</div>
|
||||
|
||||
<!-- System Logs Tab -->
|
||||
<div id="system-tab" class="tab-content {{ 'active' if active_tab == 'system' else '' }}">
|
||||
{% include 'cwa_stats_system.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchTab(tabName) {
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.stats-tab').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
event.target.classList.add('active');
|
||||
|
||||
// Update tab content
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
document.getElementById(tabName + '-tab').classList.add('active');
|
||||
|
||||
// Update URL without page reload
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('tab', tabName);
|
||||
window.history.pushState({}, '', url);
|
||||
|
||||
// Resize charts after tab switch to fix layout issues
|
||||
setTimeout(() => {
|
||||
if (tabName === 'activity') {
|
||||
// Resize User Activity charts
|
||||
if (typeof timelineChart !== 'undefined' && timelineChart) timelineChart.resize();
|
||||
if (typeof eventsChart !== 'undefined' && eventsChart) eventsChart.resize();
|
||||
if (typeof formatsChart !== 'undefined' && formatsChart) formatsChart.resize();
|
||||
if (typeof heatmapChart !== 'undefined' && heatmapChart) heatmapChart.resize();
|
||||
if (typeof velocityChart !== 'undefined' && velocityChart) velocityChart.resize();
|
||||
if (typeof formatPreferencesChart !== 'undefined' && formatPreferencesChart) formatPreferencesChart.resize();
|
||||
if (typeof discoveryChart !== 'undefined' && discoveryChart) discoveryChart.resize();
|
||||
if (typeof deviceChart !== 'undefined' && deviceChart) deviceChart.resize();
|
||||
if (typeof sessionDurationChart !== 'undefined' && sessionDurationChart) sessionDurationChart.resize();
|
||||
if (typeof searchSuccessChart !== 'undefined' && searchSuccessChart) searchSuccessChart.resize();
|
||||
} else if (tabName === 'library') {
|
||||
// Resize Library Stats charts
|
||||
if (typeof growthChart !== 'undefined' && growthChart) growthChart.resize();
|
||||
if (typeof libraryFormatsChart !== 'undefined' && libraryFormatsChart) libraryFormatsChart.resize();
|
||||
if (typeof seriesChart !== 'undefined' && seriesChart) seriesChart.resize();
|
||||
if (typeof publicationYearChart !== 'undefined' && publicationYearChart) publicationYearChart.resize();
|
||||
if (typeof ratingStatsChart !== 'undefined' && ratingStatsChart) ratingStatsChart.resize();
|
||||
if (typeof topEnforcedChart !== 'undefined' && topEnforcedChart) topEnforcedChart.resize();
|
||||
if (typeof importSankeyChart !== 'undefined' && importSankeyChart) importSankeyChart.resize();
|
||||
} else if (tabName === 'api') {
|
||||
// Resize API Usage charts
|
||||
if (typeof apiUsageChart !== 'undefined' && apiUsageChart) apiUsageChart.resize();
|
||||
if (typeof apiTimingChart !== 'undefined' && apiTimingChart) apiTimingChart.resize();
|
||||
}
|
||||
}, 50); // Small delay to ensure DOM has updated
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -85,7 +85,7 @@
|
||||
id="btnGroupDrop1{{ format.format|lower }}" class="btn btn-primary"
|
||||
role="button">
|
||||
<span class="glyphicon glyphicon-download"></span>{{ format.format }}
|
||||
({{ format.uncompressed_size|filesizeformat }})
|
||||
({{ format.uncompressed_size|filesizeformat_binary }})
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
@@ -98,7 +98,7 @@
|
||||
{% for format in entry.data %}
|
||||
<li>
|
||||
<a href="{{ url_for('web.download_link', book_id=entry.id, book_format=format.format|lower, anyname=entry.id|string+'.'+format.format|lower|replace('kepub', 'kepub.epub')) }}">{{ format.format }}
|
||||
({{ format.uncompressed_size|filesizeformat }})</a></li>
|
||||
({{ format.uncompressed_size|filesizeformat_binary }})</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
+259
-39
@@ -4,10 +4,10 @@
|
||||
<style>
|
||||
.duplicate-group {
|
||||
margin-bottom: 35px;
|
||||
border: 1px solid #e0e6ed;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 25px;
|
||||
background: linear-gradient(145deg, #ffffff 0%, #f8fafc 100%);
|
||||
background: #1c28328a;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.07), 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
@@ -19,33 +19,33 @@
|
||||
margin-bottom: 25px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 3px solid #e74c3c;
|
||||
background: linear-gradient(90deg, rgba(231, 76, 60, 0.05) 0%, rgba(231, 76, 60, 0.02) 100%);
|
||||
background: linear-gradient(90deg, rgba(231, 76, 60, 0.2) 0%, rgba(231, 76, 60, 0.04) 100%);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
padding: 3rem;
|
||||
margin: -10px -10px 25px -10px;
|
||||
}
|
||||
.duplicate-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #2c3e50;
|
||||
color: white;
|
||||
text-shadow: 0 1px 3px rgba(44, 62, 80, 0.1);
|
||||
}
|
||||
.duplicate-author {
|
||||
font-size: 16px;
|
||||
color: #34495e;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.duplicate-count {
|
||||
color: #e74c3c;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
background: linear-gradient(135deg, rgba(231, 76, 60, 0.1) 0%, rgba(231, 76, 60, 0.05) 100%);
|
||||
background: linear-gradient(135deg, rgb(231 76 60 / 51%) 0%, rgb(231 76 60 / 42%) 100%);
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
border: 1px solid rgba(231, 76, 60, 0.2);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.books-grid {
|
||||
display: flex;
|
||||
@@ -57,9 +57,9 @@
|
||||
display: flex;
|
||||
max-width: 450px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e1e8ed;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(145deg, #ffffff 0%, #fafbfc 100%);
|
||||
background: #34455185;
|
||||
box-shadow: 0 3px 6px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.06);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
@@ -86,7 +86,7 @@
|
||||
}
|
||||
.book-item.selected {
|
||||
border-color: #e74c3c;
|
||||
background: linear-gradient(145deg, #fff5f5 0%, #fef2f2 100%);
|
||||
background: #64859d78;
|
||||
box-shadow: 0 4px 8px rgba(231, 76, 60, 0.15), 0 2px 4px rgba(231, 76, 60, 0.1);
|
||||
}
|
||||
.book-item.selected::before {
|
||||
@@ -125,28 +125,71 @@
|
||||
accent-color: #e74c3c;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
.book-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.3;
|
||||
color: #2c3e50;
|
||||
font-size: 15px;
|
||||
text-shadow: 0 1px 2px rgba(44, 62, 80, 0.1);
|
||||
.book-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.3;
|
||||
color: white;
|
||||
font-size: 15px;
|
||||
text-shadow: 0 1px 2px rgba(44, 62, 80, 0.1);
|
||||
}
|
||||
.book-title-link {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
.book-title-link:hover {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
}
|
||||
.book-cover a {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
}
|
||||
.book-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.book-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(52, 152, 219, 0.2);
|
||||
border: 1px solid rgba(52, 152, 219, 0.4);
|
||||
border-radius: 4px;
|
||||
color: #3498db;
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.book-action-btn:hover {
|
||||
background: rgba(52, 152, 219, 0.3);
|
||||
border-color: #3498db;
|
||||
color: #5dade2;
|
||||
text-decoration: none;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.book-action-btn .glyphicon {
|
||||
font-size: 11px;
|
||||
}
|
||||
.book-meta {
|
||||
color: #34495e;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
}
|
||||
.book-meta div {
|
||||
margin-bottom: 6px;
|
||||
color: #7d7d7d !important;
|
||||
}
|
||||
.book-meta strong {
|
||||
color: #2c3e50;
|
||||
font-weight: 600;
|
||||
color: white !important;
|
||||
}
|
||||
.book-meta strong {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.no-duplicates {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
@@ -216,9 +259,9 @@
|
||||
.bulk-actions {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
background: #1c2832;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #dee2e6;
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
.btn {
|
||||
@@ -259,11 +302,50 @@
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
color: #fff;
|
||||
background-color: #3498db;
|
||||
border-color: #3498db;
|
||||
}
|
||||
.btn-primary:hover:not(.disabled) {
|
||||
background-color: #2980b9;
|
||||
border-color: #2471a3;
|
||||
}
|
||||
.btn-primary.disabled {
|
||||
background-color: #6c757d;
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.selection-info {
|
||||
margin-left: 15px;
|
||||
font-weight: 600;
|
||||
color: #28a745;
|
||||
color: white;
|
||||
text-shadow: 0 1px 2px rgba(40, 167, 69, 0.1);
|
||||
background: #cc7b1969;
|
||||
padding: 1rem;
|
||||
max-height: 37px;
|
||||
border-radius: 4px;
|
||||
text-transform: capitalize;
|
||||
line-height: normal;
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
div.bulk-actions > div.form-inline {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
.glyphicon.glyphicon-refresh {
|
||||
font-size: smaller !important;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.container-fluid img {
|
||||
display: block;
|
||||
width: max(10vw,120px) !important;
|
||||
height: auto;
|
||||
max-width: 26rem;
|
||||
margin: auto;
|
||||
box-shadow: 0 0 12px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.books-grid {
|
||||
@@ -284,23 +366,102 @@
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<p style="color: whitesmoke; margin-bottom: 2rem;" class="text-muted">{{_('Books with matching titles, authors, and languages. Books in different languages are not considered duplicates.')}}</p>
|
||||
|
||||
{% if duplicate_groups %}
|
||||
<div class="bulk-actions">
|
||||
<div class="form-inline">
|
||||
<!-- Action bar - always show for manual scan trigger -->
|
||||
<div class="bulk-actions" style="margin-bottom: 20px;">
|
||||
<div class="form-inline">
|
||||
<button type="button" class="btn btn-sm btn-default" id="trigger_scan">
|
||||
<span class="glyphicon glyphicon-refresh"></span> {{_('Scan for Duplicates Now')}}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-danger" id="cancel_scan" style="display: none;">
|
||||
<span class="glyphicon glyphicon-ban-circle"></span> {{_('Cancel Scan')}}
|
||||
</button>
|
||||
{% if duplicate_groups %}
|
||||
<button type="button" class="btn btn-sm btn-default" id="select_all">{{_('Select Duplicates')}}</button>
|
||||
<button type="button" class="btn btn-sm btn-default" id="select_none">{{_('Select None')}}</button>
|
||||
<button type="button" class="btn btn-sm btn-danger disabled" id="delete_selected" aria-disabled="true">{{_('Delete Selected')}}</button>
|
||||
<span id="selection_count" class="selection-info"></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="scan_progress_container" style="margin-top: 12px; display: none;">
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<div style="flex: 1; background: #22303a; border-radius: 6px; overflow: hidden; height: 10px;">
|
||||
<div id="scan_progress_bar" style="height: 10px; width: 0%; background: linear-gradient(90deg, #2ecc71, #3498db);"></div>
|
||||
</div>
|
||||
<span id="scan_progress_label" style="color: #ddd; font-size: 12px;">0%</span>
|
||||
</div>
|
||||
<div id="scan_progress_message" style="margin-top: 6px; color: #bbb; font-size: 12px;"></div>
|
||||
</div>
|
||||
|
||||
{% if next_scan_run %}
|
||||
<div style="margin-top: 10px; color: #bbb; font-size: 12px;">
|
||||
{{_('Next scheduled scan:')}} <span id="next_scan_run">{{ next_scan_run }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if duplicate_groups %}
|
||||
|
||||
<!-- Auto-Resolution Section -->
|
||||
<div class="settings-container" style="margin-bottom: 30px; padding: 25px; background: #1c28328a; border-radius: 10px; margin-inline: 0rem; max-width: none;">
|
||||
<h4 style="color: white; margin-bottom: 15px; display: flex; align-items: center;">
|
||||
<span class="glyphicon glyphicon-cog" style="margin-right: 10px;"></span>
|
||||
{{_('Auto-Resolve Duplicates')}}
|
||||
</h4>
|
||||
|
||||
<p style="color: #bbb; margin-bottom: 20px;">
|
||||
{{_('Automatically resolve all duplicate groups by keeping one book and deleting others based on a strategy.')}}
|
||||
</p>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 15px; flex-wrap: wrap;">
|
||||
<label for="resolution_strategy" style="color: white; font-weight: 600;">{{_('Strategy:')}}</label>
|
||||
<select id="resolution_strategy" class="form-control" style="background: #1c2832; color: white; border: 1px solid #444;">
|
||||
<option value="newest">{{_('Keep Newest - Delete older copies, keep the most recently added')}}</option>
|
||||
<option value="oldest">{{_('Keep Oldest - Delete newer copies, keep the earliest added')}}</option>
|
||||
<option value="merge">{{_('Merge Formats - Merge formats into the newest book, delete the rest')}}</option>
|
||||
<option value="highest_quality_format">{{_('Keep Highest Quality Format - Prefer EPUB > KEPUB > AZW3 > MOBI > PDF')}}</option>
|
||||
<option value="most_metadata">{{_('Keep Most Metadata - Keep book with most complete information')}}</option>
|
||||
<option value="largest_file_size">{{_('Keep Largest File Size - Keep book with largest total file size')}}</option>
|
||||
</select>
|
||||
|
||||
<button type="button" class="btn btn-default" id="preview_resolution">
|
||||
<span class="glyphicon glyphicon-eye-open"></span> {{_('Preview')}}
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-danger disabled" id="execute_resolution" aria-disabled="true">
|
||||
<span class="glyphicon glyphicon-flash"></span> {{_('Execute Resolution')}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cwa-settings-tip" style="margin-top: 15px; background: rgba(231, 76, 60, 0.15); border-left: 3px solid #e74c3c; padding: 12px; border-radius: 4px;">
|
||||
<strong style="color: #e74c3c;">⚠️ {{_('Warning:')}}</strong>
|
||||
<span style="color: #ddd;">{{_('This will permanently delete books. Original files will be backed up to')}} <code style="background: #2a3441; padding: 2px 6px; border-radius: 3px; color: #f39c12; line-break: anywhere;">/config/processed_books/duplicate_resolutions/</code></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="duplicates-container">
|
||||
{% for group in duplicate_groups %}
|
||||
<div class="duplicate-group">
|
||||
<div class="duplicate-header">
|
||||
<div class="duplicate-title">{{ group.title }}</div>
|
||||
<div class="duplicate-author">by {{ group.author }}</div>
|
||||
<div class="duplicate-count">{{ group.count }} duplicate{{ 's' if group.count > 1 else '' }} found</div>
|
||||
<div class="duplicate-group" data-group-hash="{{ group.group_hash }}">
|
||||
<div class="duplicate-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div style="flex: 1;">
|
||||
<div class="duplicate-title">{{ group.title }}</div>
|
||||
<div class="duplicate-author">by {{ group.author }}</div>
|
||||
<div class="duplicate-count">{{ group.count }} duplicate{{ 's' if group.count > 1 else '' }} found</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px; align-items: center;">
|
||||
<button type="button"
|
||||
class="merge-selected-btn disabled"
|
||||
aria-disabled="true"
|
||||
style="background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.3); color: white; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.2s ease; white-space: nowrap; margin-left: 10px;">
|
||||
<span class="glyphicon glyphicon-link"></span> {{_('Merge Selected')}}</button>
|
||||
<button class="dismiss-duplicate-btn"
|
||||
data-group-hash="{{ group.group_hash }}"
|
||||
data-dismissed="false"
|
||||
title="{{_('Dismiss this duplicate group')}}"
|
||||
style="background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.3); color: white; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.2s ease; white-space: nowrap; margin-left: 10px;">
|
||||
<span class="glyphicon glyphicon-eye-close"></span> {{_('Dismiss')}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="books-grid">
|
||||
@@ -309,11 +470,15 @@
|
||||
<input type="checkbox" class="book-checkbox" value="{{ book.id }}" data-title="{{ book.title }}" data-authors="{{ book.author_names }}">
|
||||
|
||||
<div class="book-cover">
|
||||
<img src="{{ book.cover_url }}" alt="Cover for {{ book.title }}" onerror="this.src='/static/generic_cover.jpg'">
|
||||
<a href="{{ url_for('web.show_book', book_id=book.id) }}" title="{{_('View book details')}}">
|
||||
<img src="{{ book.cover_url }}" alt="Cover for {{ book.title }}" onerror="this.src='/static/generic_cover.svg'">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="book-details">
|
||||
<div class="book-title">{{ book.title }}</div>
|
||||
<div class="book-title">
|
||||
<a href="{{ url_for('web.show_book', book_id=book.id) }}" class="book-title-link">{{ book.title }}</a>
|
||||
</div>
|
||||
<div class="book-meta">
|
||||
<div><strong>Authors:</strong> {{ book.author_names }}</div>
|
||||
<div><strong>Added:</strong> {{ book.timestamp.strftime('%Y-%m-%d %H:%M') }}</div>
|
||||
@@ -331,6 +496,14 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="book-actions">
|
||||
<a href="{{ url_for('edit-book.show_edit_book', book_id=book.id) }}" class="book-action-btn" title="{{_('Edit book metadata')}}">
|
||||
<span class="glyphicon glyphicon-edit"></span> {{_('Edit')}}
|
||||
</a>
|
||||
<a href="{{ url_for('web.show_book', book_id=book.id) }}" class="book-action-btn" title="{{_('Archive/delete book')}}">
|
||||
<span class="glyphicon glyphicon-trash"></span> {{_('Archive')}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -370,6 +543,53 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Merge Confirmation Modal -->
|
||||
<div class="modal fade" id="merge_selected_modal" role="dialog" aria-labelledby="mergeSelectedLabel">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary text-center">
|
||||
<span>{{_('Merge Books')}}</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p></p>
|
||||
<div class="text-left">{{_('The following book will be kept:')}}</div>
|
||||
<p></p>
|
||||
<div class="text-left" id="display-merge-target-book" style="font-weight: bold; color: #28a745;"></div>
|
||||
<p></p>
|
||||
<div class="text-left">{{_('The following books will be merged into it (and then deleted):')}}</div>
|
||||
<p></p>
|
||||
<div class="text-left" id="display-merge-source-books"></div>
|
||||
<p></p>
|
||||
<div class="text-left"><strong>{{_('Note: File formats from source books will be added to the target book.')}}</strong></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<input id="merge_selected_confirm" type="button" class="btn btn-primary" value="{{_('Merge')}}" name="merge_selected_confirm" data-dismiss="modal">
|
||||
<button id="merge_selected_abort" type="button" class="btn btn-default" data-dismiss="modal">{{_('Cancel')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resolution Preview Modal -->
|
||||
<div class="modal fade" id="resolution_preview_modal" role="dialog">
|
||||
<div class="modal-dialog modal-lg" style="width: 50%; max-width: 1200px;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header" style="background: #e59029; color: white;">
|
||||
<button type="button" class="close" data-dismiss="modal" style="color: white;">×</button>
|
||||
<h4 class="modal-title">
|
||||
<span class="glyphicon glyphicon-eye-open"></span> {{_('Resolution Preview')}}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="modal-body" id="resolution_preview_body" style="max-height: 600px; overflow-y: auto; padding: 3rem;">
|
||||
<!-- Preview content will be injected here -->
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{_('Close')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Modal -->
|
||||
<div class="modal fade" id="success_modal" role="dialog" aria-labelledby="successModalLabel">
|
||||
<div class="modal-dialog">
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<div class="form-group input-group">
|
||||
<input type="number" min="1" max="600" step="1" class="form-control" name="mail_size" id="mail_size" value="{% if content.mail_size != None %}{{ (content.mail_size / 1024 / 1024)|int }}{% endif %}" required>
|
||||
<span class="input-group-btn">
|
||||
<button type="button" id="attachement_size" class="btn btn-default" disabled>MB</button>
|
||||
<button type="button" id="attachement_size" class="btn btn-default" disabled>MiB</button>
|
||||
</span>
|
||||
</div>
|
||||
<button type="submit" name="submit" value="submit" class="btn btn-default">{{_('Save')}}</button>
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block header %}
|
||||
<style>
|
||||
.status-hero {
|
||||
background: linear-gradient(135deg, #2a3441 0%, #1f2c35 100%);
|
||||
padding: 2.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
border: 1px solid #3a4451;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.status-hero-icon {
|
||||
font-size: 3em;
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.status-hero-title {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.status-hero-message {
|
||||
font-size: 1.15em;
|
||||
color: #bbb;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.status-hero.success {
|
||||
background: linear-gradient(135deg, #2d5c3f 0%, #1f4129 100%);
|
||||
border-color: #3d7d54;
|
||||
}
|
||||
|
||||
.status-hero.warning {
|
||||
background: linear-gradient(135deg, #7d5c2a 0%, #5c421f 100%);
|
||||
border-color: #9d7d3d;
|
||||
}
|
||||
|
||||
.status-hero-actions {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<div class="discover hardcover-review-page">
|
||||
<h2>{{_('Hardcover Match Review 🔖')}}</h2>
|
||||
|
||||
<div class="row" style="margin-top: 20px;">
|
||||
<div class="col-sm-12">
|
||||
{% if matches|length == 0 %}
|
||||
<div class="status-hero success" style="text-align: center;">
|
||||
<span class="status-hero-icon">✅</span>
|
||||
<h3 class="status-hero-title">{{_('All Caught Up!')}}</h3>
|
||||
<p class="status-hero-message">{{_('There are no pending Hardcover matches requiring review. New matches will appear here when the auto-fetch task runs.')}}</p>
|
||||
<div class="status-hero-actions">
|
||||
<a href="{{url_for('admin.admin')}}" class="btn btn-primary btn-lg">{{_('Back to Admin Panel')}}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<div class="status-hero warning" style="text-align: center;">
|
||||
<span class="status-hero-icon">⚠️</span>
|
||||
<h3 class="status-hero-title">{{_('Review Required')}}</h3>
|
||||
<p class="status-hero-message">{{_('%(count)s book(s) have ambiguous Hardcover matches. Please review each match and select the correct result, or skip/reject if no good match exists.', count=matches|length)}}</p>
|
||||
</div>
|
||||
|
||||
<p class="lead" style="margin: 30px 0 20px 0; color: #bbb; text-align: center;">
|
||||
{{_('Review and approve ambiguous Hardcover ID matches for your books. High-confidence matches are applied automatically; these require manual verification.')}}
|
||||
</p>
|
||||
|
||||
<h3 style="margin-top: 30px; margin-bottom: 20px;">🔖 {{_('Pending Matches')}}</h3>
|
||||
|
||||
{% for match in matches %}
|
||||
<div class="settings-container" style="margin-bottom: 30px;" id="match-card-{{match.id}}">
|
||||
<div style="background-color: rgba(42, 52, 65, 0.6); padding: 20px; border-radius: 8px; border: 1px solid #3a4451;">
|
||||
<div style="border-bottom: 1px solid #3a4451; padding-bottom: 15px; margin-bottom: 20px;">
|
||||
<h4 style="margin: 0; color: #fff; font-size: 1.3em;">
|
||||
📚 {{match.book_title}}
|
||||
<small style="color: #aaa; font-size: 0.65em; margin-left: 10px;">ID: {{match.book_id}}</small>
|
||||
</h4>
|
||||
<p style="margin: 8px 0 5px 0; color: #bbb; font-size: 0.95em;">✍️ {{match.book_authors}}</p>
|
||||
<small style="color: #888; font-size: 0.85em;">{{_('Search Query')}}: <em>{{match.search_query}}</em></small>
|
||||
</div>
|
||||
|
||||
<div style="padding: 10px 0;">
|
||||
<h5 style="margin-top: 0; margin-bottom: 20px; color: #f8be03;">{{_('Candidate Matches')}} <span style="color: #aaa; font-size: 0.85em;">({{_('Top %(count)s', count=match.results|length)}})</span></h5>
|
||||
|
||||
<div class="row">
|
||||
{% for result in match.results[:3] %}
|
||||
<div class="col-md-4" style="margin-bottom: 20px;">
|
||||
<div class="match-candidate" style="border: 1px solid #3a4451; border-radius: 6px; padding: 15px; background-color: rgba(26, 32, 39, 0.5); height: 100%; position: relative; transition: all 0.2s ease;" onmouseover="this.style.borderColor='#f8be03'; this.style.backgroundColor='rgba(26, 32, 39, 0.8)'" onmouseout="this.style.borderColor='#3a4451'; this.style.backgroundColor='rgba(26, 32, 39, 0.5)';">
|
||||
<!-- Confidence Score Badge -->
|
||||
<div style="position: absolute; top: 10px; right: 10px;">
|
||||
{% set score = match.scores[loop.index0][0] %}
|
||||
{% if score >= 0.85 %}
|
||||
<span class="label label-success" style="font-size: 1.1em; padding: 5px 10px;">{{_('Confidence')}}: {{"{:.0%}".format(score)}}</span>
|
||||
{% elif score >= 0.7 %}
|
||||
<span class="label label-warning" style="font-size: 1.1em; padding: 5px 10px;">{{_('Confidence')}}: {{"{:.0%}".format(score)}}</span>
|
||||
{% else %}
|
||||
<span class="label label-danger" style="font-size: 1.1em; padding: 5px 10px;">{{_('Confidence')}}: {{"{:.0%}".format(score)}}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Cover Image -->
|
||||
{% if result.cover %}
|
||||
<div style="text-align: center; margin-bottom: 15px; margin-top: 30px;">
|
||||
<img src="{{result.cover}}" alt="Cover" style="max-width: 100%; max-height: 200px; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.3);">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Title & Authors -->
|
||||
<h5 style="margin: 10px 0; color: #fff;">{{result.title}}</h5>
|
||||
<p style="color: #bbb; font-size: 0.9em; margin: 5px 0;">
|
||||
<strong>{{_('Authors')}}:</strong> {{result.authors|join(', ')}}
|
||||
</p>
|
||||
|
||||
<!-- Series Info -->
|
||||
{% if result.series %}
|
||||
<p style="color: #bbb; font-size: 0.9em; margin: 5px 0;">
|
||||
<strong>{{_('Series')}}:</strong> {{result.series}}
|
||||
{% if result.series_index %}#{{result.series_index}}{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Publisher & Date -->
|
||||
{% if result.publisher or result.publishedDate %}
|
||||
<p style="color: #bbb; font-size: 0.9em; margin: 5px 0;">
|
||||
{% if result.publisher %}<strong>{{_('Publisher')}}:</strong> {{result.publisher}}{% endif %}
|
||||
{% if result.publishedDate %} ({{result.publishedDate[:4]}}){% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Match Reason -->
|
||||
<div style="margin-top: 10px; padding: 8px; background-color: rgba(0,0,0,0.3); border-radius: 4px; font-size: 0.85em; color: #aaa;">
|
||||
<strong>{{_('Match Reason')}}:</strong> {{match.scores[loop.index0][1]}}
|
||||
</div>
|
||||
|
||||
<!-- Hardcover Link -->
|
||||
<p style="margin-top: 10px;">
|
||||
<a href="{{result.url}}" target="_blank" style="color: #f8be03;">{{_('View on Hardcover')}} →</a>
|
||||
</p>
|
||||
|
||||
<!-- Action Button -->
|
||||
<button class="btn btn-success btn-block accept-match-btn"
|
||||
data-queue-id="{{match.id}}"
|
||||
data-result-id="{{result.id}}"
|
||||
style="margin-top: 15px; font-weight: bold;">
|
||||
✓ {{_('Select This Match')}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div style="margin-top: 25px; border-top: 1px solid #3a4451; padding-top: 20px; text-align: center;">
|
||||
<button class="btn btn-danger reject-match-btn"
|
||||
data-queue-id="{{match.id}}"
|
||||
style="margin-right: 10px; min-width: 150px;">
|
||||
✗ {{_('Reject All')}}
|
||||
</button>
|
||||
<button class="btn btn-default skip-match-btn"
|
||||
data-queue-id="{{match.id}}"
|
||||
style="min-width: 150px;">
|
||||
→ {{_('Skip for Now')}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-container {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.match-candidate img {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.match-candidate:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.btn {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Handle accept match button
|
||||
$('.accept-match-btn').click(function() {
|
||||
var queueId = $(this).data('queue-id');
|
||||
var resultId = $(this).data('result-id');
|
||||
var btn = $(this);
|
||||
|
||||
btn.prop('disabled', true).text('{{_("Processing...")}}');
|
||||
|
||||
$.ajax({
|
||||
url: '{{url_for("admin.hardcover_review_action")}}',
|
||||
method: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({
|
||||
queue_id: queueId,
|
||||
action: 'accept',
|
||||
selected_result_id: resultId
|
||||
}),
|
||||
success: function(response) {
|
||||
var data = JSON.parse(response);
|
||||
if (data.success) {
|
||||
$('#match-card-' + queueId).fadeOut(500, function() {
|
||||
$(this).remove();
|
||||
// Check if no more matches
|
||||
if ($('.card').length === 0) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
alert('{{_("Error")}}: ' + data.error);
|
||||
btn.prop('disabled', false).text('{{_("✓ Select This Match")}}');
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
var errorMsg = '{{_("Failed to apply match")}}';
|
||||
if (xhr.responseJSON && xhr.responseJSON.error) {
|
||||
errorMsg = xhr.responseJSON.error;
|
||||
}
|
||||
alert('{{_("Error")}}: ' + errorMsg);
|
||||
btn.prop('disabled', false).text('{{_("✓ Select This Match")}}');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle reject button
|
||||
$('.reject-match-btn').click(function() {
|
||||
var queueId = $(this).data('queue-id');
|
||||
var btn = $(this);
|
||||
|
||||
if (!confirm('{{_("Are you sure you want to reject all matches for this book? This cannot be undone.")}}')) {
|
||||
return;
|
||||
}
|
||||
|
||||
btn.prop('disabled', true).text('{{_("Processing...")}}');
|
||||
|
||||
$.ajax({
|
||||
url: '{{url_for("admin.hardcover_review_action")}}',
|
||||
method: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({
|
||||
queue_id: queueId,
|
||||
action: 'reject'
|
||||
}),
|
||||
success: function(response) {
|
||||
var data = JSON.parse(response);
|
||||
if (data.success) {
|
||||
$('#match-card-' + queueId).fadeOut(500, function() {
|
||||
$(this).remove();
|
||||
if ($('.card').length === 0) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
alert('{{_("Error")}}: ' + data.error);
|
||||
btn.prop('disabled', false).text('{{_("✗ Reject All Matches")}}');
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
var errorMsg = '{{_("Failed to reject match")}}';
|
||||
if (xhr.responseJSON && xhr.responseJSON.error) {
|
||||
errorMsg = xhr.responseJSON.error;
|
||||
}
|
||||
alert('{{_("Error")}}: ' + errorMsg);
|
||||
btn.prop('disabled', false).text('{{_("✗ Reject All Matches")}}');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle skip button
|
||||
$('.skip-match-btn').click(function() {
|
||||
var queueId = $(this).data('queue-id');
|
||||
var btn = $(this);
|
||||
|
||||
btn.prop('disabled', true).text('{{_("Processing...")}}');
|
||||
|
||||
$.ajax({
|
||||
url: '{{url_for("admin.hardcover_review_action")}}',
|
||||
method: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({
|
||||
queue_id: queueId,
|
||||
action: 'skip'
|
||||
}),
|
||||
success: function(response) {
|
||||
var data = JSON.parse(response);
|
||||
if (data.success) {
|
||||
$('#match-card-' + queueId).fadeOut(500, function() {
|
||||
$(this).remove();
|
||||
if ($('.card').length === 0) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
alert('{{_("Error")}}: ' + data.error);
|
||||
btn.prop('disabled', false).text('{{_("→ Skip for Now")}}');
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
var errorMsg = '{{_("Failed to skip match")}}';
|
||||
if (xhr.responseJSON && xhr.responseJSON.error) {
|
||||
errorMsg = xhr.responseJSON.error;
|
||||
}
|
||||
alert('{{_("Error")}}: ' + errorMsg);
|
||||
btn.prop('disabled', false).text('{{_("→ Skip for Now")}}');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
+136
-1
@@ -1,6 +1,48 @@
|
||||
{% import 'image.html' as image %}
|
||||
{% extends "layout.html" %}
|
||||
{% block header %}
|
||||
<style>
|
||||
.shelf-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #202c35;
|
||||
padding: 2rem;
|
||||
flex-direction: row;
|
||||
margin-inline: 3rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.magic-shelf-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
/* Override caliBlur_override.css fixed positioning */
|
||||
.shelf-actions .filterheader {
|
||||
position: static !important;
|
||||
margin: 0 !important;
|
||||
display: block !important;
|
||||
width: auto !important;
|
||||
float: none !important;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.shelf-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.magic-shelf-buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
.shelf-actions .filterheader {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% if current_user.show_detail_random() and page != "discover" %}
|
||||
<div class="discover random-books">
|
||||
<h2 class="random-books">{{_('Discover (Random Books)')}}</h2>
|
||||
@@ -68,7 +110,59 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="discover load-more">
|
||||
<h2 class="{{title}}">{{title}}</h2>
|
||||
<h2 class="{{title}}">
|
||||
{{title | safe}}
|
||||
</h2>
|
||||
{% if page == 'magicshelf' %}
|
||||
<div class="alert alert-warning visible-xs" style="margin-top: 10px;">
|
||||
<span class="glyphicon glyphicon-warning-sign"></span>
|
||||
<strong>{{_('Mobile Notice')}}</strong>: {{_('Magic shelf editing works best on desktop or tablet devices.')}}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page != 'newest' %}<div class="shelf-actions">{% endif %}
|
||||
{% if page == 'magicshelf' %}
|
||||
<div class="magic-shelf-buttons">
|
||||
<a href="{{url_for('web.render_magic_shelf', shelf_id=id, sort_param=order, page=1, refresh=1)}}"
|
||||
class="btn btn-default"
|
||||
style="display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;"
|
||||
data-toggle="tooltip"
|
||||
title="{{_('Refresh shelf to update with latest changes')}}">
|
||||
<span style="padding: 5px;" class="glyphicon glyphicon-refresh"></span> {{_('Refresh')}}
|
||||
</a>
|
||||
{% if shelf.user_id == current_user.id or current_user.role_admin() %}
|
||||
<a href="{{url_for('web.edit_magic_shelf', shelf_id=id)}}"
|
||||
class="btn btn-default"
|
||||
style="display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;"
|
||||
data-toggle="tooltip"
|
||||
title="{{_('Edit shelf rules')}}">
|
||||
<span style="padding: 5px;" class="glyphicon glyphicon-edit"></span> {{_('Edit')}}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if shelf.user_id != current_user.id %}
|
||||
<button id="toggle-hide-shelf"
|
||||
class="btn btn-{% if is_hidden_shelf %}success{% else %}warning{% endif %}"
|
||||
data-shelf-id="{{ id }}"
|
||||
data-is-hidden="{{ 'true' if is_hidden_shelf else 'false' }}"
|
||||
style="display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;"
|
||||
data-toggle="tooltip"
|
||||
title="{{_('Hide this shelf from your sidebar') if not is_hidden_shelf else _('Show this shelf in your sidebar')}}">
|
||||
<span style="padding: 5px;" class="glyphicon glyphicon-{% if is_hidden_shelf %}eye-open{% else %}eye-close{% endif %}"></span>
|
||||
<span class="hide-text">{% if is_hidden_shelf %}{{_('Show Shelf')}}{% else %}{{_('Hide Shelf')}}{% endif %}</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if page != 'discover' %}
|
||||
<div class="filterheader hidden-xs">
|
||||
{% if page == 'hot' %}
|
||||
@@ -90,6 +184,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if page != 'newest' %}</div>{% endif %}
|
||||
<div class="row display-flex">
|
||||
{% if entries[0] %}
|
||||
{% for entry in entries %}
|
||||
@@ -162,3 +257,43 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
{{ super() }}
|
||||
{% if page == 'magicshelf' %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Handle hide/unhide shelf button
|
||||
$('#toggle-hide-shelf').on('click', function() {
|
||||
var $btn = $(this);
|
||||
var shelfId = $btn.data('shelf-id');
|
||||
var isHidden = $btn.data('is-hidden') === 'true';
|
||||
var action = isHidden ? 'unhide' : 'hide';
|
||||
|
||||
$btn.prop('disabled', true);
|
||||
|
||||
$.ajax({
|
||||
url: '/magicshelf/' + shelfId + '/' + action,
|
||||
method: 'POST',
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
// Reload page to update sidebar and button state
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + (response.message || 'Unknown error'));
|
||||
$btn.prop('disabled', false);
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
var message = 'An unexpected error occurred.';
|
||||
if (xhr.responseJSON && xhr.responseJSON.message) {
|
||||
message = xhr.responseJSON.message;
|
||||
}
|
||||
alert('Error: ' + message);
|
||||
$btn.prop('disabled', false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -35,9 +35,9 @@
|
||||
"uuid": {{ entry.uuid | tojson }},
|
||||
"timestamp": {{ entry.timestamp | string | tojson }},
|
||||
"thumbnail": {{ url_for('opds.feed_get_cover', book_id=entry.id) | tojson }},
|
||||
"main_format": {
|
||||
"main_format": {% if entry.data.__len__() > 0 %}{
|
||||
{{ entry.data[0].format|lower | tojson }}: {{ url_for('opds.opds_download_link', book_id=entry.id, book_format=entry.data[0].format|lower) | tojson }}
|
||||
},
|
||||
}{% else %}{}{% endif %},
|
||||
"rating": {% if entry.ratings.__len__() > 0 %}{{ (entry.ratings[0].rating|string + ".0") | tojson }}{% else %}0.0{% endif %},
|
||||
"authors": [
|
||||
{% for author in entry.authors %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ current_user.locale }}">
|
||||
<head>
|
||||
<title>{{instance}} | {{title}}</title>
|
||||
<title>{{instance}} | {{title|safe}}</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
@@ -24,6 +24,9 @@
|
||||
<link href="{{ url_for('static', filename='css/caliBlur_override.css') }}" rel="stylesheet" media="screen">
|
||||
{% endif %}
|
||||
<link href="{{ url_for('static', filename='css/cwa.css') }}" rel="stylesheet" media="screen">
|
||||
{% if current_user.is_authenticated and (current_user.role_admin() or current_user.role_edit()) %}
|
||||
<link href="{{ url_for('static', filename='css/duplicates-notifications.css') }}" rel="stylesheet" media="screen">
|
||||
{% endif %}
|
||||
<script>
|
||||
let checkMessagesInterval = null; // Store interval ID
|
||||
|
||||
@@ -278,20 +281,36 @@
|
||||
<li class="nav-head hidden-xs">{{_('Browse')}}</li>
|
||||
{% for element in sidebar %}
|
||||
{% if current_user.check_visibility(element['visibility']) and element['public'] %}
|
||||
<li id="nav_{{element['id']}}" {% if page == element['page'] %}class="active"{% endif %}><a href="{{url_for(element['link'], data=element['page'], sort_param='stored')}}"><span class="glyphicon {{element['glyph']}}"></span> {{_(element['text'])}}</a></li>
|
||||
<li id="nav_{{element['id']}}" {% if page == element['page'] %}class="active"{% endif %} style="{% if element['id'] == 'duplicates' %}position: relative;{% endif %}">
|
||||
<a href="{{url_for(element['link'], data=element['page'], sort_param='stored')}}">
|
||||
<span class="glyphicon {{element['glyph']}}"></span> {{_(element['text'])}}
|
||||
{% if element['id'] == 'duplicates' %}
|
||||
<span id="duplicate-count-badge" class="duplicate-badge" style="display: none;">0</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if current_user.is_authenticated or g.allow_anonymous %}
|
||||
<li class="nav-head hidden-xs public-shelves">{{_('Shelves')}}</li>
|
||||
{% for shelf in g.shelves_access %}
|
||||
<li><a href="{{url_for('shelf.show_shelf', shelf_id=shelf.id)}}"><span class="glyphicon glyphicon-list shelf"></span> {{shelf.name|shortentitle(40)}}{% if shelf.is_public == 1 %} {{_('(Public)')}}{% endif %} <span style="font-size: 80%; color: #888;">({{shelf.books.all()|length}})</span></a></li>
|
||||
{% set book_count = shelf.books.count() %}
|
||||
<li><a href="{{url_for('shelf.show_shelf', shelf_id=shelf.id)}}" title="{{shelf.name}} ({{book_count}})"><span class="glyphicon glyphicon-list shelf"></span> {{shelf.name|shortentitle(40)}}{% if shelf.is_public == 1 %} {{_('(Public)')}}{% endif %} <span style="font-size: 80%; color: #888;">({{book_count}})</span></a></li>
|
||||
{% endfor %}
|
||||
{% if not current_user.is_anonymous %}
|
||||
<li id="nav_createshelf" class="create-shelf"><a href="{{url_for('shelf.create_shelf')}}">{{_('Create a Shelf')}}</a></li>
|
||||
<li id="nav_createshelf" class="create-shelf"><a href="{{url_for('shelf.create_shelf')}}">{{_('Create Shelf')}}</a></li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-head hidden-xs" style="margin-top: 1rem;">{{_('Magic Shelves ✨')}}</li>
|
||||
{% for shelf in g.magic_shelves_access %}
|
||||
<li><a href="{{url_for('web.render_magic_shelf', shelf_id=shelf.id)}}" title="{{shelf.name}} ({{shelf.book_count}})"><span style="font-size: 14px;">{{shelf.icon}}</span> {{shelf.name|shortentitle(40)}}{% if shelf.is_public == 1 %} {{_('(Public)')}}{% endif %} <span style="font-size: 80%; color: #888;">({{shelf.book_count}})</span></a></li>
|
||||
{% endfor %}
|
||||
<li id="nav_createmagicshelf" class="create-shelf"><a href="{{url_for('web.create_magic_shelf')}}" style="display: flex; align-items: center;">{{_('Create Magic Shelf')}}</a></li>
|
||||
{% endif %}
|
||||
{% if not current_user.is_anonymous %}
|
||||
<li id="nav_about" {% if page == 'stat' %}class="active"{% endif %}><a href="{{url_for('about.stats')}}"><span class="glyphicon glyphicon-info-sign"></span> {{_('About')}}</a></li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -341,8 +360,11 @@
|
||||
{% block modal %}{% endblock %}
|
||||
<!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
|
||||
<script src="{{ url_for('static', filename='js/libs/jquery.min.js') }}"></script>
|
||||
<!-- Include all compiled plugins (below), or include individual files as needed -->
|
||||
<script src="{{ url_for('static', filename='js/libs/bootstrap.min.js') }}"></script>
|
||||
|
||||
<!-- Patch: Define global bootstrap object for plugins expecting Bootstrap 5+ -->
|
||||
<script>window.bootstrap = window.bootstrap || {};</script>
|
||||
<!-- Include all compiled plugins (below), or include individual files as needed -->
|
||||
<script src="{{ url_for('static', filename='js/libs/underscore-umd-min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/libs/intention.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/libs/context.min.js') }}"></script>
|
||||
@@ -390,5 +412,34 @@
|
||||
window.addEventListener('DOMContentLoaded', updateFilterHeaderFixed);
|
||||
window.addEventListener('resize', updateFilterHeaderFixed);
|
||||
</script>
|
||||
{% if current_user.is_authenticated and (current_user.role_admin() or current_user.role_edit()) %}
|
||||
<!-- Duplicate Notification Modal -->
|
||||
<div id="duplicate-notification-backdrop" class="duplicate-notification-backdrop"></div>
|
||||
<div id="duplicate-notification-modal" class="duplicate-notification-modal" tabindex="-1" role="dialog" aria-labelledby="duplicate-notification-title">
|
||||
<div class="duplicate-notification-header">
|
||||
<button id="duplicate-notification-close" class="duplicate-notification-close" aria-label="{{_('Close')}}">×</button>
|
||||
<h2 id="duplicate-notification-title">{{_('Duplicate Books Detected')}}</h2>
|
||||
<p>{{_('You have unresolved duplicate books in your library')}}</p>
|
||||
</div>
|
||||
<div class="duplicate-notification-body">
|
||||
<div class="duplicate-count-section">
|
||||
<div class="duplicate-count-badge" id="duplicate-notification-count">0</div>
|
||||
<h3>{{_('Duplicate Groups Found')}}</h3>
|
||||
</div>
|
||||
<ul id="duplicate-notification-preview" class="duplicate-preview-list">
|
||||
<!-- Preview items will be inserted here by JavaScript -->
|
||||
</ul>
|
||||
</div>
|
||||
<div class="duplicate-notification-footer">
|
||||
<button id="duplicate-notification-remind" class="duplicate-notification-btn duplicate-notification-btn-secondary">
|
||||
{{_('Remind Me Later')}}
|
||||
</button>
|
||||
<a href="{{ url_for('duplicates.show_duplicates') }}" class="duplicate-notification-btn duplicate-notification-btn-primary">
|
||||
{{_('View & Resolve Duplicates')}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='js/duplicate-notifier.js') }}"></script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,889 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block header %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/query-builder.default.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/query_builder.css') }}">
|
||||
<style>
|
||||
/* Icon Picker Styles - Dark Theme Compatible */
|
||||
.icon-picker-container {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.icon-picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(50px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
border: 1px solid #3e444c;
|
||||
border-radius: 4px;
|
||||
background: #292d32;
|
||||
}
|
||||
.icon-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 45px;
|
||||
border: 2px solid #3e444c;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: #21252b;
|
||||
font-size: 28px;
|
||||
}
|
||||
.icon-option:hover {
|
||||
border-color: #428bca;
|
||||
background: #2c3e50;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.icon-option.selected {
|
||||
border-color: #cc7b19;
|
||||
background: #3d3020;
|
||||
}
|
||||
|
||||
.icon-search {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Preview Results Styles */
|
||||
.preview-results {
|
||||
margin-top: 15px;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
display: none;
|
||||
}
|
||||
.preview-results.success {
|
||||
background: #1e3a28;
|
||||
border: 1px solid #2d5a3d;
|
||||
color: #90ee90;
|
||||
}
|
||||
.preview-results.error {
|
||||
background: #3a1e1e;
|
||||
border: 1px solid #5a2d2d;
|
||||
color: #ff9090;
|
||||
}
|
||||
|
||||
/* Date Format Help Tooltips */
|
||||
.date-format-help {
|
||||
margin-top: 5px;
|
||||
padding: 8px 12px;
|
||||
background: #21252b;
|
||||
border: 1px solid #3e444c;
|
||||
border-radius: 4px;
|
||||
color: #90b8e0;
|
||||
font-size: 12px;
|
||||
display: none;
|
||||
}
|
||||
.date-format-help.warning {
|
||||
background: #3a2e1e;
|
||||
border-color: #5a4d2d;
|
||||
color: #ffcc66;
|
||||
}
|
||||
.preview-book-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.preview-book-list li {
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid #3e444c;
|
||||
color: #e8e8e8;
|
||||
}
|
||||
.preview-book-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 767px) {
|
||||
.icon-picker-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(45px, 1fr));
|
||||
gap: 5px;
|
||||
}
|
||||
.icon-option {
|
||||
height: 40px;
|
||||
}
|
||||
.icon-option i {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
/* Help Panel Styles - Dark Theme */
|
||||
.help-panel .panel-body {
|
||||
background: #292d32;
|
||||
color: #e8e8e8;
|
||||
}
|
||||
.help-panel .panel-heading {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: #21252b;
|
||||
border-bottom: 1px solid #3e444c;
|
||||
}
|
||||
.help-panel .panel-heading:hover {
|
||||
background: #2c3137;
|
||||
}
|
||||
.help-panel .panel-title {
|
||||
color: #e8e8e8;
|
||||
}
|
||||
.help-content {
|
||||
margin-top: 15px;
|
||||
color: #e8e8e8;
|
||||
}
|
||||
.help-content p, .help-content ul, .help-content li {
|
||||
color: #e8e8e8;
|
||||
}
|
||||
.help-example {
|
||||
background: #21252b;
|
||||
padding: 10px;
|
||||
border-left: 3px solid #cc7b19;
|
||||
margin: 10px 0;
|
||||
color: #e8e8e8;
|
||||
}
|
||||
.help-example code {
|
||||
background: #1a1d21;
|
||||
color: #cc7b19;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
/* Override form control backgrounds for dark theme */
|
||||
#magic-shelf-form .form-control {
|
||||
background-color: #292d32 !important;
|
||||
border: 1px solid #3e444c !important;
|
||||
color: #e8e8e8 !important;
|
||||
}
|
||||
#magic-shelf-form .form-control:focus {
|
||||
background-color: #21252b;
|
||||
border-color: #428bca;
|
||||
color: #e8e8e8;
|
||||
}
|
||||
#magic-shelf-form label {
|
||||
color: #e8e8e8;
|
||||
}
|
||||
#magic-shelf-form .text-muted {
|
||||
color: #8b949e !important;
|
||||
}
|
||||
/* Query Builder dark theme overrides */
|
||||
.query-builder {
|
||||
background: #292d32 !important;
|
||||
}
|
||||
.query-builder .rules-group-container {
|
||||
background: #21252b !important;
|
||||
border-color: #3e444c !important;
|
||||
}
|
||||
.query-builder .rule-container {
|
||||
background: #292d32 !important;
|
||||
border-color: #3e444c !important;
|
||||
}
|
||||
.query-builder select,
|
||||
.query-builder input[type="text"],
|
||||
.query-builder input[type="number"],
|
||||
.query-builder input[type="date"] {
|
||||
background: #21252b !important;
|
||||
border-color: #3e444c !important;
|
||||
color: #e8e8e8 !important;
|
||||
}
|
||||
/* Alert styles for dark theme */
|
||||
.alert-info {
|
||||
background-color: #1e3a5f !important;
|
||||
border-color: #2d5a8f !important;
|
||||
color: #9ec9ff !important;
|
||||
}
|
||||
.alert-info .btn {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.panel.panel-default {
|
||||
background: #22272b9e;
|
||||
border-color: #292d32;
|
||||
}
|
||||
|
||||
div.help-panel > .panel-body {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.help-content {
|
||||
padding: 4rem;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.system-template-notice {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: #cc7b197a;
|
||||
padding-left: 2rem;
|
||||
border-left: 4px solid #cc7b19;
|
||||
}
|
||||
|
||||
.query-builder .rules-group-container {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.space {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
gap: 1rem;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.rule-value-container {
|
||||
display: flex !important;
|
||||
gap: 2rem;
|
||||
flex-direction: row;
|
||||
padding: 1rem;
|
||||
padding-inline: 2rem !important;
|
||||
background: #202429;
|
||||
border-radius: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rule-value-container > label {
|
||||
display: flex !important;
|
||||
gap: 0.75rem;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
width: fit-content;
|
||||
background: #292d32;
|
||||
padding: 0.5rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
/* Date format hint styling - matches preview results box */
|
||||
.date-format-hint {
|
||||
display: none;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: #1a1d21;
|
||||
border: 1px solid #3a3d41;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid #5bc0de;
|
||||
}
|
||||
|
||||
.date-format-hint .hint-icon {
|
||||
color: #5bc0de;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.date-format-hint .hint-format {
|
||||
color: #cc7b19;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.date-format-hint.show {
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #3e444c;
|
||||
}
|
||||
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="discover">
|
||||
<h2>{{ title }}</h2>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<!-- Help Panel -->
|
||||
<div class="panel panel-default help-panel">
|
||||
<div class="panel-heading" onclick="$('#help-content').slideToggle();">
|
||||
<h4 class="panel-title">
|
||||
<span class="glyphicon space glyphicon-question-sign"></span> What are Magic Shelves?
|
||||
<small class="pull-right"><span class="glyphicon space glyphicon-chevron-down"></span></small>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div id="help-content" class="help-content" style="display: none;">
|
||||
<p><strong>Magic Shelves</strong> are dynamic shelves that automatically populate based on rules you define. No manual organization needed!</p>
|
||||
|
||||
<div class="help-example">
|
||||
<strong>Example:</strong> Create a "Recently Added" shelf that shows all books added in the last 30 days:
|
||||
<ul>
|
||||
<li>Field: <code>Date Added</code></li>
|
||||
<li>Operator: <code>greater than</code></li>
|
||||
<li>Value: <code>30 days ago</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p><strong>Tips:</strong></p>
|
||||
<ul>
|
||||
<li>Use the <strong>+ Rule</strong> button to add more conditions</li>
|
||||
<li>Use the <strong>+ Group</strong> button to create nested AND/OR logic</li>
|
||||
<li>Click <strong>Preview Rules</strong> to see what books match before saving</li>
|
||||
<li>Best experienced on desktop/tablet (mobile view is limited)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div><!-- /.panel-body -->
|
||||
</div><!-- /.help-panel -->
|
||||
|
||||
<!-- Main Form -->
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-body">
|
||||
|
||||
<form id="magic-shelf-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<!-- Shelf Name -->
|
||||
<div class="form-group">
|
||||
{% if shelf and shelf.is_system %}
|
||||
<div class="system-template-notice">
|
||||
<span class="glyphicon space glyphicon-info-sign"></span>
|
||||
<strong>{{_('System Template')}}</strong>: {{_('This is a pre-configured template shelf. You can edit it freely.')}}
|
||||
</div>
|
||||
{% endif %}
|
||||
<label for="shelf-name">
|
||||
Shelf Name <span class="text-danger">*</span>
|
||||
<span class="glyphicon space glyphicon-question-sign" data-toggle="tooltip" title="Give your shelf a descriptive name (max 100 characters)"></span>
|
||||
</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="shelf-name"
|
||||
value="{{ shelf.name if shelf else '' }}"
|
||||
maxlength="100"
|
||||
placeholder="e.g., Recently Added, Highly Rated, Unread Sci-Fi"
|
||||
required>
|
||||
<small class="form-text text-muted">
|
||||
<span id="char-count">0</span>/100 characters
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Icon Picker -->
|
||||
<div class="form-group">
|
||||
<label for="shelf-icon">
|
||||
Shelf Icon
|
||||
<span class="glyphicon space glyphicon-question-sign" data-toggle="tooltip" title="Select from the grid below or type/paste your own emoji"></span>
|
||||
</label>
|
||||
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="shelf-icon"
|
||||
value="{{ shelf.icon if shelf else '🪄' }}"
|
||||
placeholder="🪄"
|
||||
maxlength="10"
|
||||
style="font-size: 24px; width: 100px;">
|
||||
<small class="form-text text-muted">
|
||||
Type your own emoji or select from the quick-pick options below
|
||||
</small>
|
||||
|
||||
<div class="icon-picker-container">
|
||||
<label class="control-label">Quick Pick Icons:</label>
|
||||
<input type="text"
|
||||
class="form-control icon-search"
|
||||
id="icon-search"
|
||||
placeholder="Search quick-pick icons...">
|
||||
<div class="icon-picker-grid" id="icon-picker-grid">
|
||||
{% for icon in allowed_icons %}
|
||||
<div class="icon-option" data-icon="{{ icon }}" title="{{ icon }}">
|
||||
{{ icon }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kobo Sync Checkbox -->
|
||||
<div class="form-group" style="background: #21252b; padding: 2rem; padding-inline: 4rem; border-radius: 4px;">
|
||||
<label>
|
||||
<input style="margin-right: 2rem !important;" type="checkbox" id="shelf-kobo-sync" {{ 'checked' if shelf and shelf.kobo_sync else '' }}>
|
||||
Enable Kobo sync (shelf appears on Kobo devices)
|
||||
</label>
|
||||
<small class="form-text text-muted">Only works if Kobo integration is enabled</small>
|
||||
</div>
|
||||
|
||||
<!-- Public Shelf Checkbox (only for custom shelves with permission) -->
|
||||
{% if current_user.role_edit_shelfs() and not (shelf and shelf.is_system) %}
|
||||
<div class="form-group" style="background: #21252b; padding: 2rem; padding-inline: 4rem; border-radius: 4px;">
|
||||
<label>
|
||||
<input style="margin-right: 2rem !important;" type="checkbox" id="shelf-is-public" {{ 'checked' if shelf and shelf.is_public == 1 else '' }}>
|
||||
{{_('Share with Everyone (Public)')}}
|
||||
</label>
|
||||
<small class="form-text text-muted">
|
||||
{{_('Other users can view this shelf. Users with "Edit Public Shelves" permission can edit/delete it.')}}
|
||||
</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Rules Builder -->
|
||||
<h4>
|
||||
Rules <span class="text-danger">*</span>
|
||||
<span class="glyphicon space glyphicon-question-sign" data-toggle="tooltip" title="Define conditions that books must match to appear in this shelf"></span>
|
||||
</h4>
|
||||
<div id="builder"></div>
|
||||
|
||||
<!-- Date Format Hint (shown when date fields are used) -->
|
||||
<div id="date-format-hint" class="date-format-hint">
|
||||
<span class="glyphicon glyphicon-info-sign hint-icon"></span>
|
||||
<strong>Date Format:</strong> Use <span class="hint-format">YYYY-MM-DD</span> format for date fields (e.g., 2024-01-15)
|
||||
</div>
|
||||
|
||||
<!-- Preview Results -->
|
||||
<div id="preview-results" class="preview-results">
|
||||
<h5><span class="glyphicon space glyphicon-eye-open"></span> Preview Results</h5>
|
||||
<div id="preview-content"></div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-info" id="preview-btn">
|
||||
<span class="glyphicon space glyphicon-eye-open"></span> Preview Rules
|
||||
</button>
|
||||
<button type="submit" class="btn btn-default" id="save-shelf-btn">
|
||||
<span class="glyphicon space glyphicon-floppy-disk"></span> Save Shelf
|
||||
</button>
|
||||
<a href="{{ url_for('web.index') }}" class="btn btn-default">
|
||||
<span class="glyphicon space glyphicon-remove"></span> Cancel
|
||||
</a>
|
||||
{% if shelf %}
|
||||
{% if shelf.is_system %}
|
||||
<button type="button"
|
||||
class="btn btn-danger pull-right"
|
||||
id="delete-shelf-btn"
|
||||
disabled
|
||||
data-toggle="tooltip"
|
||||
title="{{_('System shelves cannot be deleted. You can hide them in your user profile settings.')}}">
|
||||
<span class="glyphicon space glyphicon-trash"></span> Delete Shelf
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-danger pull-right" id="delete-shelf-btn">
|
||||
<span class="glyphicon space glyphicon-trash"></span> Delete Shelf
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.col-md-12 -->
|
||||
</div><!-- /.row -->
|
||||
</div><!-- /.discover -->
|
||||
{% endblock %}
|
||||
|
||||
{% set rules_json = shelf.rules|tojson if shelf and shelf.rules else 'null' %}
|
||||
{% set languages_json = languages|tojson if languages else '{}' %}
|
||||
{% block js %}
|
||||
{{ super() }}
|
||||
<script src="{{ url_for('static', filename='js/moment.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/query-builder.standalone.min.js') }}"></script>
|
||||
<script>
|
||||
$(function() {
|
||||
// Initialize tooltips
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
|
||||
// Character counter for shelf name
|
||||
$('#shelf-name').on('input', function() {
|
||||
$('#char-count').text($(this).val().length);
|
||||
}).trigger('input');
|
||||
|
||||
// Icon Picker Logic
|
||||
var $iconInput = $('#shelf-icon');
|
||||
var $iconOptions = $('.icon-option');
|
||||
var $iconSearch = $('#icon-search');
|
||||
|
||||
// Set initial selected icon in grid
|
||||
var currentIcon = $iconInput.val();
|
||||
$('.icon-option[data-icon="' + currentIcon + '"]').addClass('selected');
|
||||
|
||||
// When user clicks a quick-pick icon, populate the input field
|
||||
$iconOptions.on('click', function() {
|
||||
var icon = $(this).data('icon');
|
||||
$iconOptions.removeClass('selected');
|
||||
$(this).addClass('selected');
|
||||
$iconInput.val(icon);
|
||||
});
|
||||
|
||||
// When user types/pastes in the input, update grid selection if it matches
|
||||
$iconInput.on('input change paste', function() {
|
||||
var icon = $(this).val();
|
||||
$iconOptions.removeClass('selected');
|
||||
$('.icon-option[data-icon="' + icon + '"]').addClass('selected');
|
||||
});
|
||||
|
||||
// Icon search filter
|
||||
$iconSearch.on('input', function() {
|
||||
var searchTerm = $(this).val().toLowerCase();
|
||||
$iconOptions.each(function() {
|
||||
var iconName = $(this).data('icon').toLowerCase();
|
||||
if (iconName.indexOf(searchTerm) !== -1) {
|
||||
$(this).show();
|
||||
} else {
|
||||
$(this).hide();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Query Builder Configuration
|
||||
var languages = {{ languages_json|safe }};
|
||||
var fields = [
|
||||
{
|
||||
id: 'title',
|
||||
label: 'Title',
|
||||
type: 'string',
|
||||
description: 'The book title'
|
||||
},
|
||||
{
|
||||
id: 'author',
|
||||
label: 'Author',
|
||||
type: 'string',
|
||||
description: 'Author name'
|
||||
},
|
||||
{
|
||||
id: 'tag',
|
||||
label: 'Tag',
|
||||
type: 'string',
|
||||
description: 'Book tags/genres'
|
||||
},
|
||||
{
|
||||
id: 'series',
|
||||
label: 'Series',
|
||||
type: 'string',
|
||||
description: 'Series name'
|
||||
},
|
||||
{
|
||||
id: 'publisher',
|
||||
label: 'Publisher',
|
||||
type: 'string',
|
||||
description: 'Publisher name'
|
||||
},
|
||||
{
|
||||
id: 'language',
|
||||
label: 'Language',
|
||||
type: 'string',
|
||||
input: 'select',
|
||||
values: languages,
|
||||
description: 'Book language'
|
||||
},
|
||||
{
|
||||
id: 'rating',
|
||||
label: 'Rating',
|
||||
type: 'integer',
|
||||
input: 'select',
|
||||
values: {1:1, 2:2, 3:3, 4:4, 5:5, 6:6, 7:7, 8:8, 9:9, 10:10},
|
||||
description: 'Book rating (1-10)'
|
||||
},
|
||||
{
|
||||
id: 'pubdate',
|
||||
label: 'Published Date',
|
||||
type: 'date',
|
||||
validation: {format: 'YYYY-MM-DD'},
|
||||
description: 'Original publication date'
|
||||
},
|
||||
{
|
||||
id: 'timestamp',
|
||||
label: 'Date Added',
|
||||
type: 'date',
|
||||
validation: {format: 'YYYY-MM-DD'},
|
||||
description: 'When book was added to library'
|
||||
},
|
||||
{
|
||||
id: 'has_cover',
|
||||
label: 'Has Cover',
|
||||
type: 'integer',
|
||||
input: 'radio',
|
||||
values: {1: 'Yes', 0: 'No'},
|
||||
description: 'Whether book has cover image'
|
||||
},
|
||||
{
|
||||
id: 'read_status',
|
||||
label: 'Read Status',
|
||||
type: 'integer', // Changed from 'boolean' to 'integer'
|
||||
input: 'radio',
|
||||
values: {0: 'Unread', 1: 'Read'}, // Swapped order for clarity
|
||||
description: 'Book read status'
|
||||
},
|
||||
{
|
||||
id: 'series_index',
|
||||
label: 'Series Index',
|
||||
type: 'double',
|
||||
description: 'Position in series'
|
||||
},
|
||||
{
|
||||
id: 'comments',
|
||||
label: 'Description',
|
||||
type: 'string',
|
||||
description: 'Book description/comments'
|
||||
},
|
||||
{
|
||||
id: 'hardcover_id',
|
||||
label: 'Has Hardcover ID',
|
||||
type: 'integer',
|
||||
input: 'radio',
|
||||
values: {1: 'Yes', 0: 'No'},
|
||||
description: 'Whether book has Hardcover identifier'
|
||||
}
|
||||
];
|
||||
|
||||
var operators = [
|
||||
{type: 'equal', optgroup: 'basic'},
|
||||
{type: 'not_equal', optgroup: 'basic'},
|
||||
{type: 'in', optgroup: 'basic'},
|
||||
{type: 'not_in', optgroup: 'basic'},
|
||||
{type: 'less', optgroup: 'number'},
|
||||
{type: 'less_or_equal', optgroup: 'number'},
|
||||
{type: 'greater', optgroup: 'number'},
|
||||
{type: 'greater_or_equal', optgroup: 'number'},
|
||||
{type: 'between', optgroup: 'number'},
|
||||
{type: 'not_between', optgroup: 'number'},
|
||||
{type: 'begins_with', optgroup: 'string'},
|
||||
{type: 'not_begins_with', optgroup: 'string'},
|
||||
{type: 'contains', optgroup: 'string'},
|
||||
{type: 'not_contains', optgroup: 'string'},
|
||||
{type: 'ends_with', optgroup: 'string'},
|
||||
{type: 'not_ends_with', optgroup: 'string'},
|
||||
{type: 'is_empty'},
|
||||
{type: 'is_not_empty'},
|
||||
{type: 'is_null'},
|
||||
{type: 'is_not_null'}
|
||||
];
|
||||
|
||||
var rules = {{ rules_json|safe }};
|
||||
|
||||
// Date validation helper
|
||||
function isValidDate(dateString) {
|
||||
if (!dateString) return true; // Empty is valid (will be handled by is_empty operator)
|
||||
var regex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!regex.test(dateString)) return false;
|
||||
var date = new Date(dateString);
|
||||
var timestamp = date.getTime();
|
||||
if (typeof timestamp !== 'number' || Number.isNaN(timestamp)) return false;
|
||||
return date.toISOString().startsWith(dateString);
|
||||
}
|
||||
|
||||
// Show/hide date format hint based on rules
|
||||
function checkForDateFields() {
|
||||
var hasDateField = false;
|
||||
|
||||
// Check all filter dropdowns for date fields
|
||||
$('#builder .rule-filter-container select').each(function() {
|
||||
var filterId = $(this).val();
|
||||
if (filterId === 'pubdate' || filterId === 'timestamp') {
|
||||
hasDateField = true;
|
||||
return false; // break out of each loop
|
||||
}
|
||||
});
|
||||
|
||||
if (hasDateField) {
|
||||
$('#date-format-hint').addClass('show');
|
||||
} else {
|
||||
$('#date-format-hint').removeClass('show');
|
||||
}
|
||||
}
|
||||
|
||||
$('#builder').queryBuilder({
|
||||
plugins: ['bt-tooltip-errors'],
|
||||
filters: fields,
|
||||
operators: operators,
|
||||
rules: rules
|
||||
});
|
||||
|
||||
// Check for date fields on initialization and after changes
|
||||
$('#builder').on('afterCreateRuleInput.queryBuilder afterUpdateRuleFilter.queryBuilder afterUpdateRuleValue.queryBuilder afterDeleteRule.queryBuilder afterUpdateRuleOperator.queryBuilder', function(e) {
|
||||
setTimeout(function() {
|
||||
checkForDateFields();
|
||||
}, 100);
|
||||
|
||||
// Validate date inputs
|
||||
$(this).find('input[type="text"]').each(function() {
|
||||
var $input = $(this);
|
||||
var $rule = $input.closest('.rule-container');
|
||||
var filterId = $rule.find('.rule-filter-container select').val();
|
||||
|
||||
if (filterId === 'pubdate' || filterId === 'timestamp') {
|
||||
$input.off('blur.dateValidation').on('blur.dateValidation', function() {
|
||||
var value = $(this).val();
|
||||
if (value && !isValidDate(value)) {
|
||||
$(this).css('border-color', '#d9534f');
|
||||
if (!$(this).next('.date-error').length) {
|
||||
$(this).after('<div class="date-error" style="color: #d9534f; font-size: 12px; margin-top: 4px;">Invalid format. Use YYYY-MM-DD (e.g., 2024-01-15)</div>');
|
||||
}
|
||||
} else {
|
||||
$(this).css('border-color', '');
|
||||
$(this).next('.date-error').remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Also listen for direct changes on filter dropdowns
|
||||
$('#builder').on('change', '.rule-filter-container select', function() {
|
||||
setTimeout(function() {
|
||||
checkForDateFields();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Initial check
|
||||
setTimeout(function() {
|
||||
checkForDateFields();
|
||||
}, 100);
|
||||
|
||||
// Preview Rules Functionality
|
||||
$('#preview-btn').on('click', function() {
|
||||
var $btn = $(this);
|
||||
var $results = $('#preview-results');
|
||||
var $content = $('#preview-content');
|
||||
|
||||
var rules = $('#builder').queryBuilder('getRules');
|
||||
if (!rules) {
|
||||
$results.removeClass('success').addClass('error').show();
|
||||
$content.html('<p><strong>Error:</strong> Please define at least one rule.</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
$btn.prop('disabled', true).html('<span class="glyphicon space glyphicon-refresh glyphicon-refresh-animate"></span> Loading...');
|
||||
|
||||
$.ajax({
|
||||
url: "{{ url_for('web.preview_magic_shelf') }}",
|
||||
method: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({rules: rules}),
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
$results.removeClass('error').addClass('success').show();
|
||||
var html = '<p><strong><span class="glyphicon space glyphicon-ok-circle"></span> ' + response.count + ' book(s) match these rules</strong></p>';
|
||||
if (response.sample_books && response.sample_books.length > 0) {
|
||||
html += '<p><small>Sample results:</small></p><ul class="preview-book-list">';
|
||||
response.sample_books.forEach(function(book) {
|
||||
html += '<li>' + book + '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
$content.html(html);
|
||||
} else {
|
||||
$results.removeClass('success').addClass('error').show();
|
||||
$content.html('<p><strong>Error:</strong> ' + response.message + '</p>');
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
$results.removeClass('success').addClass('error').show();
|
||||
var message = 'An unexpected error occurred.';
|
||||
if (xhr.responseJSON && xhr.responseJSON.message) {
|
||||
message = xhr.responseJSON.message;
|
||||
}
|
||||
$content.html('<p><strong>Error:</strong> ' + message + '</p>');
|
||||
},
|
||||
complete: function() {
|
||||
$btn.prop('disabled', false).html('<span class="glyphicon space glyphicon-eye-open"></span> Preview Rules');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Form Submission
|
||||
$('#magic-shelf-form').on('submit', function(e) {
|
||||
console.log('Form submit triggered');
|
||||
e.preventDefault();
|
||||
var rules = $('#builder').queryBuilder('getRules');
|
||||
console.log('Rules:', rules);
|
||||
|
||||
|
||||
if (!rules) {
|
||||
alert('Please define at least one rule.');
|
||||
return;
|
||||
}
|
||||
|
||||
var shelfName = $('#shelf-name').val().trim();
|
||||
if (!shelfName) {
|
||||
alert('Please enter a shelf name.');
|
||||
return;
|
||||
}
|
||||
|
||||
var shelfData = {
|
||||
name: shelfName,
|
||||
icon: $('#shelf-icon').val(),
|
||||
rules: rules,
|
||||
kobo_sync: $('#shelf-kobo-sync').is(':checked'),
|
||||
is_public: $('#shelf-is-public').is(':checked')
|
||||
};
|
||||
|
||||
console.log('Submitting shelf data:', shelfData);
|
||||
|
||||
var url = "{{ url_for('web.create_magic_shelf') }}";
|
||||
var method = "POST";
|
||||
|
||||
{% if shelf %}
|
||||
url = "{{ url_for('web.edit_magic_shelf', shelf_id=shelf.id) }}";
|
||||
{% endif %}
|
||||
|
||||
$.ajax({
|
||||
url: url,
|
||||
method: method,
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(shelfData),
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
window.location.href = "{{ url_for('web.index') }}";
|
||||
} else {
|
||||
alert('Error saving shelf: ' + (response.message || 'Unknown error'));
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
console.error('AJAX error:', xhr);
|
||||
var message = 'An unexpected error occurred.';
|
||||
if (xhr.responseJSON && xhr.responseJSON.message) {
|
||||
message = xhr.responseJSON.message;
|
||||
}
|
||||
alert('Error: ' + message);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Delete Shelf
|
||||
{% if shelf %}
|
||||
$('#delete-shelf-btn').on('click', function() {
|
||||
if (confirm('Are you sure you want to delete this shelf? This action cannot be undone.')) {
|
||||
$.ajax({
|
||||
url: "{{ url_for('web.delete_magic_shelf', shelf_id=shelf.id) }}",
|
||||
method: 'POST',
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
window.location.href = "{{ url_for('web.index') }}";
|
||||
} else {
|
||||
alert('Error deleting shelf: ' + (response.message || 'Unknown error'));
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
var message = 'An unexpected error occurred.';
|
||||
if (xhr.responseJSON && xhr.responseJSON.message) {
|
||||
message = xhr.responseJSON.message;
|
||||
}
|
||||
alert('Error: ' + message);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Duplicate Shelf
|
||||
$('#duplicate-shelf-btn').on('click', function() {
|
||||
var $btn = $(this);
|
||||
$btn.prop('disabled', true).html('<span class="glyphicon space glyphicon-refresh glyphicon-refresh-animate"></span> Duplicating...');
|
||||
|
||||
$.ajax({
|
||||
url: "{{ url_for('web.duplicate_magic_shelf', shelf_id=shelf.id) }}",
|
||||
method: 'POST',
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
// Redirect to edit the new duplicated shelf
|
||||
window.location.href = "{{ url_for('web.edit_magic_shelf', shelf_id=0) }}".replace('/0', '/' + response.shelf_id);
|
||||
} else {
|
||||
alert('Error duplicating shelf: ' + (response.message || 'Unknown error'));
|
||||
$btn.prop('disabled', false).html('<span class="glyphicon space glyphicon-duplicate"></span> Duplicate');
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
var message = 'An unexpected error occurred.';
|
||||
if (xhr.responseJSON && xhr.responseJSON.message) {
|
||||
message = xhr.responseJSON.message;
|
||||
}
|
||||
alert('Error: ' + message);
|
||||
$btn.prop('disabled', false).html('<span class="glyphicon space glyphicon-duplicate"></span> Duplicate');
|
||||
}
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
{% if entry['visible'] %}
|
||||
<img title="{{entry['Books']['title']}}" class="cover-height" src="{{ url_for('web.get_cover', book_id=entry['Books']['id']) }}">
|
||||
{% else %}
|
||||
<img title="{{entry['Books']['title']}}" class="cover-height" src="{{ url_for('static', filename='generic_cover.jpg') }}">
|
||||
<img title="{{entry['Books']['title']}}" class="cover-height" src="{{ url_for('static', filename='generic_cover.svg') }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-lg-10 col-sm-8 col-xs-12">
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<div class="btn-group" role="group">
|
||||
{% for format in entry.Books.data %}
|
||||
<a href="{{ url_for('web.download_link', book_id=entry.Books.id, book_format=format.format|lower, anyname=entry.Books.id|string+'.'+format.format|lower|replace('kepub', 'kepub.epub')) }}" id="btnGroupDrop{{entry.Books.id}}{{format.format|lower}}" class="btn btn-primary" role="button">
|
||||
<span class="glyphicon glyphicon-download"></span>{{format.format}} ({{ format.uncompressed_size|filesizeformat }})
|
||||
<span class="glyphicon glyphicon-download"></span>{{format.format}} ({{ format.uncompressed_size|filesizeformat_binary }})
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
<link href="{{ url_for('static', filename='css/libs/bootstrap-table.min.css') }}" rel="stylesheet">
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<div class="discover">
|
||||
<div class="discover" style="padding-inline: 2rem !important;">
|
||||
<h2>{{_('Tasks')}}</h2>
|
||||
<h3>{{ _('Background Tasks Overview') }}</h3>
|
||||
<table class="table table-no-bordered" id="tasktable" data-url="{{ url_for('tasks.get_email_status_json') }}" data-sort-name="starttime" data-sort-order="asc" data-locale="{{ current_user.locale }}" data-classes="table table-no-bordered table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -117,10 +118,19 @@
|
||||
'z-index': '9999',
|
||||
'min-width': '300px',
|
||||
'max-width': '500px',
|
||||
'box-shadow': '0 4px 8px rgba(0,0,0,0.2)'
|
||||
'max-height': '200px',
|
||||
'overflow': 'auto',
|
||||
'padding': '15px',
|
||||
'margin-bottom': '20px',
|
||||
'border': '1px solid transparent',
|
||||
'border-radius': '4px',
|
||||
'box-shadow': '0 4px 8px rgba(0,0,0,0.2)',
|
||||
'background-color': type === 'success' ? '#dff0d8' : type === 'danger' ? '#f2dede' : type === 'warning' ? '#fcf8e3' : '#d9edf7',
|
||||
'border-color': type === 'success' ? '#d6e9c6' : type === 'danger' ? '#ebccd1' : type === 'warning' ? '#faebcc' : '#bce8f1',
|
||||
'color': type === 'success' ? '#3c763d' : type === 'danger' ? '#a94442' : type === 'warning' ? '#8a6d3b' : '#31708f'
|
||||
})
|
||||
.html(`
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close" style="position: relative; top: -2px; right: -21px; color: inherit; opacity: 0.5;">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
${message}
|
||||
|
||||
+448
-139
@@ -1,179 +1,453 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block header %}
|
||||
<style>
|
||||
/* Hide the original :before pseudo-elements for user_edit template */
|
||||
body.me > div.container-fluid > div.row-fluid > div.col-sm-10:before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.me > div.container-fluid > div > div.col-sm-10 > div.discover[name="profile-page"]:before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Main container positioning */
|
||||
body.me > div.container-fluid > div > div.col-sm-10 > div.discover[name="profile-page"] {
|
||||
margin: 4rem !important;
|
||||
width: 80% !important;
|
||||
padding: 0px !important;
|
||||
border: none;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
/* Profile header flexbox container */
|
||||
.profile-header-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3rem;
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
background: #1f2c3554;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.profile-picture {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
flex-shrink: 0;
|
||||
background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYwIiBoZWlnaHQ9IjU2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxnIGZpbGw9IiNGRkYiIGZpbGwtcnVsZT0ibm9uemVybyI+CiAgICAgICAgPHBhdGggZD0iTTE0NC4yOTEgNDkyLjMyNUMxNjYuNjI0IDQ3Ni4yNjUgMTkzLjE5NCA0NjYuMTU2IDIyNCA0NjJjMTIuNDQ0IDkuMzMzIDMxLjExMSAxNCA1NiAxNHM0My41NTYtNC42NjcgNTYtMTRjMzAuODA2IDQuMTU2IDU3LjM3NiAxNC4yNjQgNzkuNzA5IDMwLjMyNUMzNzYuNTI3IDUxNy40MzUgMzI5Ljk1MSA1MzIgMjgwIDUzMmMtNDkuOTUxIDAtOTYuNTI3LTE0LjU2NS0xMzUuNzA5LTM5LjY3NXoiIGZpbGwtb3BhY2l0eT0iLjYiLz4KICAgICAgICA8cGF0aCBkPSJNMjI0IDQ2MmwxMi44OC00MC4yNTFDMTk2LjQyOSAzOTcuNDYyIDE2OCAzNDAuMDM1IDE2OCAyNzMuMDU5YzAtMzUuMTkzIDcuODQ5LTY3Ljc1IDIxLjE2OC05NC4yNDggMTYuMTczIDQuOTc3IDM1LjMxNSA3Ljg1NiA1NS44MzIgNy44NTYgNTEuMTA0IDAgOTMuNjgtMTcuODYgMTAzLjA3Mi00MS41MTZDMzc0Ljc3OSAxNzQuNTg4IDM5MiAyMjAuOTMxIDM5MiAyNzMuMDU5YzAgNjYuOTc2LTI4LjQyOSAxMjQuNDAzLTY4Ljg4IDE0OC42OUwzMzYgNDYyYy0xMi40NDQgOS4zMzMtMzEuMTExIDE0LTU2IDE0LTI0LjY5NCAwLTQzLjI2My00LjU5NC01NS43MDctMTMuNzgyTDIyNCA0NjJ6IiBmaWxsLW9wYWNpdHk9Ii43NSIvPgogICAgICAgIDxwYXRoIGQ9Ik0xODAuMDY0IDM0NS44NDlDMTU1LjI4MiAzMTguMDY3IDE0MCAyNzkuOTk3IDE0MCAyMzhjMC04NS4wNTIgNjIuNjgtMTU0IDE0MC0xNTRzMTQwIDY4Ljk0OCAxNDAgMTU0YzAgNDEuOTk3LTE1LjI4MiA4MC4wNjctNDAuMDY0IDEwNy44NDkgNy43MTYtMjEuODYyIDEyLjA2NC00Ni41OTYgMTIuMDY0LTcyLjc5IDAtNTIuMTI4LTE3LjIyMS05OC40NzEtNDMuOTI4LTEyNy45MDgtOS4zOTIgMjMuNjU2LTUxLjk2OCA0MS41MTYtMTAzLjA3MiA0MS41MTYtMjAuNTE3IDAtMzkuNjU5LTIuODc5LTU1LjgzMi03Ljg1NkMxNzUuODQ5IDIwNS4zMDkgMTY4IDIzNy44NjYgMTY4IDI3My4wNTljMCAyNi4xOTQgNC4zNDggNTAuOTI4IDEyLjA2NCA3Mi43OXoiIGZpbGwtb3BhY2l0eT0iLjQ1Ii8+CiAgICAgICAgPHBhdGggZD0iTTI4MCA1MzJjMTM5LjE3NiAwIDI1Mi0xMTIuODI0IDI1Mi0yNTJTNDE5LjE3NiAyOCAyODAgMjggMjggMTQwLjgyNCAyOCAyODBzMTEyLjgyNCAyNTIgMjUyIDI1MnptMCAyOEMxMjUuMzYgNTYwIDAgNDM0LjY0IDAgMjgwUzEyNS4zNiAwIDI4MCAwczI4MCAxMjUuMzYgMjgwIDI4MC0xMjUuMzYgMjgwLTI4MCAyODB6IiBmaWxsLW9wYWNpdHk9Ii43NSIvPgogICAgPC9nPgo8L3N2Zz4K);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.profile-picture .fallback-icon {
|
||||
display: none;
|
||||
font-size: 4rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.profile-picture.no-custom-picture {
|
||||
background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYwIiBoZWlnaHQ9IjU2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxnIGZpbGw9IiNGRkYiIGZpbGwtcnVsZT0ibm9uemVybyI+CiAgICAgICAgPHBhdGggZD0iTTE0NC4yOTEgNDkyLjMyNUMxNjYuNjI0IDQ3Ni4yNjUgMTkzLjE5NCA0NjYuMTU2IDIyNCA0NjJjMTIuNDQ0IDkuMzMzIDMxLjExMSAxNCA1NiAxNHM0My41NTYtNC42NjcgNTYtMTRjMzAuODA2IDQuMTU2IDU3LjM3NiAxNC4yNjQgNzkuNzA5IDMwLjMyNUMzNzYuNTI3IDUxNy40MzUgMzI5Ljk1MSA1MzIgMjgwIDUzMmMtNDkuOTUxIDAtOTYuNTI3LTE0LjU2NS0xMzUuNzA5LTM5LjY3NXoiIGZpbGwtb3BhY2l0eT0iLjYiLz4KICAgICAgICA8cGF0aCBkPSJNMjI0IDQ2MmwxMi44OC00MC4yNTFDMTk2LjQyOSAzOTcuNDYyIDE2OCAzNDAuMDM1IDE2OCAyNzMuMDU5YzAtMzUuMTkzIDcuODQ5LTY3Ljc1IDIxLjE2OC05NC4yNDggMTYuMTczIDQuOTc3IDM1LjMxNSA3Ljg1NiA1NS44MzIgNy44NTYgNTEuMTA0IDAgOTMuNjgtMTcuODYgMTAzLjA3Mi00MS41MTZDMzc0Ljc3OSAxNzQuNTg4IDM5MiAyMjAuOTMxIDM5MiAyNzMuMDU5YzAgNjYuOTc2LTI4LjQyOSAxMjQuNDAzLTY4Ljg4IDE0OC42OUwzMzYgNDYyYy0xMi40NDQgOS4zMzMtMzEuMTExIDE0LTU2IDE0LTI0LjY5NCAwLTQzLjI2My00LjU5NC01NS43MDctMTMuNzgyTDIyNCA0NjJ6IiBmaWxsLW9wYWNpdHk9Ii43NSIvPgogICAgICAgIDxwYXRoIGQ9Ik0xODAuMDY0IDM0NS44NDlDMTU1LjI4MiAzMTguMDY3IDE0MCAyNzkuOTk3IDE0MCAyMzhjMC04NS4wNTIgNjIuNjgtMTU0IDE0MC0xNTRzMTQwIDY4Ljk0OCAxNDAgMTU0YzAgNDEuOTk3LTE1LjI4MiA4MC4wNjctNDAuMDY0IDEwNy44NDkgNy43MTYtMjEuODYyIDEyLjA2NC00Ni41OTYgMTIuMDY0LTcyLjc5IDAtNTIuMTI4LTE3LjIyMS05OC40NzEtNDMuOTI4LTEyNy45MDgtOS4zOTIgMjMuNjU2LTUxLjk2OCA0MS41MTYtMTAzLjA3MiA0MS41MTYtMjAuNTE3IDAtMzkuNjU5LTIuODc5LTU1LjgzMi03Ljg1NkMxNzUuODQ5IDIwNS4zMDkgMTY4IDIzNy44NjYgMTY4IDI3My4wNTljMCAyNi4xOTQgNC4zNDggNTAuOTI4IDEyLjA2NCA3Mi43OXoiIGZpbGwtb3BhY2l0eT0iLjQ1Ii8+CiAgICAgICAgPHBhdGggZD0iTTI4MCA1MzJjMTM5LjE3NiAwIDI1Mi0xMTIuODI0IDI1Mi0yNTJTNDE5LjE3NiAyOCAyODAgMjggMjggMTQwLjgyNCAyOCAyODBzMTEyLjgyNCAyNTIgMjUyIDI1MnptMCAyOEMxMjUuMzYgNTYwIDAgNDM0LjY0IDAgMjgwUzEyNS4zNiAwIDI4MCAwczI4MCAxMjUuMzYgMjgwIDI4MC0xMjUuMzYgMjgwLTI4MCAyODB6IiBmaWxsLW9wYWNpdHk9Ii43NSIvPgogICAgPC9nPgo8L3N2Zz4K);
|
||||
}
|
||||
|
||||
.profile-title-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.profile-title-section h2 {
|
||||
margin: 0;
|
||||
color: #ffc200;
|
||||
}
|
||||
|
||||
.profile-subtitle {
|
||||
font-size: 1.2rem;
|
||||
color: #bbbbbb;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Mobile responsive styles for user_edit template - triggers below 768px */
|
||||
@media (max-width: 768px) {
|
||||
body.me > div.container-fluid > div > div.col-sm-10 > div.discover[name="profile-page"] {
|
||||
margin-inline: 3rem !important;
|
||||
width: 90% !important;
|
||||
justify-self: center !important;
|
||||
padding-top: 0px !important;
|
||||
}
|
||||
|
||||
.profile-header-container {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.profile-picture {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.profile-picture .fallback-icon {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.profile-title-section {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.profile-title-section h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.profile-subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<div class="discover">
|
||||
<h1>{{title}}</h1>
|
||||
<form role="form" method="POST" autocomplete="off">
|
||||
<div name="profile-page" class="discover" style="padding-top: 0px !important;">
|
||||
<h2>{{title}}</h2>
|
||||
<!-- Profile Header with Picture and Title -->
|
||||
<div class="profile-header-container">
|
||||
<div class="profile-picture" id="user-profile-picture">
|
||||
<span class="fallback-icon">👤</span>
|
||||
</div>
|
||||
<div class="profile-title-section">
|
||||
<h2>{{title}}</h2>
|
||||
<p class="profile-subtitle">{% if profile %}{{_('Your Profile')}}{% else %}{{_('User Settings')}}{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<form role="form" method="POST" autocomplete="off" style="margin-top: 3rem;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="col-md-10 col-lg-8">
|
||||
{% if new_user or ( current_user and content.name != "Guest" and current_user.role_admin() ) %}
|
||||
<div class="form-group required">
|
||||
<label for="name">{{_('Username')}}</label>
|
||||
<input type="text" class="form-control" name="name" id="name" value="{{ content.name if content.name != None }}" autocomplete="off">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<label for="email">{{_('Email')}}</label>
|
||||
<input type="email" class="form-control" name="email" id="email" value="{{ content.email if content.email != None }}" autocomplete="off">
|
||||
</div>
|
||||
{% if ( current_user and current_user.role_passwd() or current_user.role_admin() ) and not content.role_anonymous() %}
|
||||
{% if current_user and current_user.role_admin() and not new_user and not profile and ( mail_configured and content.email if content.email != None ) %}
|
||||
<a class="btn btn-default postAction" id="resend_password" role="button" data-action="{{url_for('admin.reset_user_password', user_id = content.id) }}">{{_('Reset user Password')}}</a>
|
||||
|
||||
<!-- User Account Information Card -->
|
||||
<div class="settings-container" style="max-width: none; margin-inline: 0rem;">
|
||||
<h4 class="settings-section-header">{{_('Account Information')}}</h4>
|
||||
|
||||
{% if new_user or ( current_user and content.name != "Guest" and current_user.role_admin() ) %}
|
||||
<div class="form-group required">
|
||||
<label for="name">{{_('Username')}}</label>
|
||||
<input type="text" class="form-control" name="name" id="name" value="{{ content.name if content.name != None }}" autocomplete="off">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="cwa-settings-tip">{{_('If you need to change your email address or password, you can do so here.')}}</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">{{_('Email')}}</label>
|
||||
<input type="email" class="form-control" name="email" id="email" value="{{ content.email if content.email != None }}" autocomplete="off">
|
||||
</div>
|
||||
|
||||
{% if ( current_user and current_user.role_passwd() or current_user.role_admin() ) and not content.role_anonymous() %}
|
||||
{% if current_user and current_user.role_admin() and not new_user and not profile and ( mail_configured and content.email if content.email != None ) %}
|
||||
<div style="margin-bottom: 15px;">
|
||||
<a class="btn btn-default postAction" id="resend_password" role="button" data-action="{{url_for('admin.reset_user_password', user_id = content.id) }}">{{_('Reset user Password')}}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<label for="password">{{_('Password')}}</label>
|
||||
<input type="password" class="form-control" name="password" id="password" data-lang="{{ current_user.locale }}" data-verify="{{ config.config_password_policy }}" {% if config.config_password_policy %} data-min={{ config.config_password_min_length }} data-word={{ config.config_password_character }} data-special={{ config.config_password_special }} data-upper={{ config.config_password_upper }} data-lower={{ config.config_password_lower }} data-number={{ config.config_password_number }}{% endif %} value="" autocomplete="off">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<label for="kindle_mail">{{_('Send to eReader Email Address. Use comma to separate emails for multiple eReaders')}}</label>
|
||||
<input type="email" class="form-control" name="kindle_mail" id="kindle_mail" value="{{ content.kindle_mail if content.kindle_mail != None }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="kindle_mail_subject">{{_('Send to eReader Subject')}}</label>
|
||||
<input type="text" class="form-control" name="kindle_mail_subject" id="kindle_mail_subject" value="{{ content.kindle_mail_subject if content.kindle_mail_subject != None }}">
|
||||
|
||||
<!-- eReader Configuration Card -->
|
||||
<div class="settings-container" style="max-width: none; margin-inline: 0rem;">
|
||||
<h4 class="settings-section-header">{{_('eReader Configuration')}}</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="kindle_mail">{{_('Send to eReader Email Address')}}</label>
|
||||
<input type="email" class="form-control" name="kindle_mail" id="kindle_mail" value="{{ content.kindle_mail if content.kindle_mail != None }}" placeholder="{{_('Use comma to separate multiple addresses')}}">
|
||||
<p class="cwa-settings-tip">{{_('Email address(es) for your eReader devices. Separate multiple addresses with commas.')}}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="kindle_mail_subject">{{_('Send to eReader Subject')}}</label>
|
||||
<input type="text" class="form-control" name="kindle_mail_subject" id="kindle_mail_subject" value="{{ content.kindle_mail_subject if content.kindle_mail_subject != None }}">
|
||||
</div>
|
||||
|
||||
{% if not content.role_anonymous() %}
|
||||
<div style="margin-top: 20px;">
|
||||
<input type="checkbox" id="auto_send_enabled" name="auto_send_enabled" {% if content.auto_send_enabled %}checked{% endif %} style="accent-color: var(--color-secondary);">
|
||||
<label for="auto_send_enabled">{{_('Automatically send new books to my eReader(s)')}}</label>
|
||||
<p class="cwa-settings-tip">{{_('When enabled, newly ingested books will be automatically sent to all configured eReader email addresses above.')}}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Language & Theme Preferences Card -->
|
||||
{% if not content.role_anonymous() %}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="auto_send_enabled" name="auto_send_enabled" {% if content.auto_send_enabled %}checked{% endif %}>
|
||||
<label for="auto_send_enabled">{{_('Automatically send new books to my eReader(s)')}}</label>
|
||||
<p class="help-block">{{_('When enabled, newly ingested books will be automatically sent to all configured eReader email addresses above.')}}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not content.role_anonymous() %}
|
||||
<div class="form-group">
|
||||
<label for="locale">{{_('Language')}}</label>
|
||||
<div class="settings-container" style="max-width: none; margin-inline: 0rem;">
|
||||
<h4 class="settings-section-header">{{_('Language & Theme Preferences')}}</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="locale">{{_('Interface Language')}}</label>
|
||||
<select name="locale" id="locale" class="form-control">
|
||||
{% for translation in translations %}
|
||||
<option value="{{translation}}" {% if translation|string == content.locale %}selected{% endif %}>{{ translation.display_name|capitalize }}</option>
|
||||
{% endfor %}
|
||||
{% for translation in translations %}
|
||||
<option value="{{translation}}" {% if translation|string == content.locale %}selected{% endif %}>{{ translation.display_name|capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="default_language">{{_('Language of Books')}}</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="default_language">{{_('Language of Books')}}</label>
|
||||
<select name="default_language" id="default_language" class="form-control">
|
||||
<option value="all" {% if content.default_language == "all" %}selected{% endif %}>{{ _('Show All') }}</option>
|
||||
{% for language in languages %}
|
||||
<option value="{{ language.lang_code }}" {% if content.default_language == language.lang_code %}selected{% endif %}>{{ language.name }}</option>
|
||||
{% endfor %}
|
||||
<option value="all" {% if content.default_language == "all" %}selected{% endif %}>{{ _('Show All') }}</option>
|
||||
{% for language in languages %}
|
||||
<option value="{{ language.lang_code }}" {% if content.default_language == language.lang_code %}selected{% endif %}>{{ language.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="cwa-settings-tip">{{_('Filter the library to only show books in a specific language.')}}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="theme">{{ _('Theme') }}</label>
|
||||
<select name="theme" id="theme" class="form-control">
|
||||
<option value="0" {% if content.theme == 0 %}selected{% endif %}>Standard</option>
|
||||
<option value="1" {% if content.theme == 1 %}selected{% endif %}>caliBlur (Dark)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{% if not content.role_anonymous() %}
|
||||
<div class="form-group">
|
||||
<label for="theme">{{ _('Theme') }}</label>
|
||||
<select name="theme" id="theme" class="form-control">
|
||||
<option value="0" {% if content.theme == 0 %}selected{% endif %}>Standard</option>
|
||||
<option value="1" {% if content.theme == 1 %}selected{% endif %}>caliBlur (Dark)</option>
|
||||
</select>
|
||||
{% else %}
|
||||
<div class="settings-container" style="max-width: none; margin-inline: 0rem;">
|
||||
<h4 class="settings-section-header">{{_('Book Language Filter')}}</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="default_language">{{_('Language of Books')}}</label>
|
||||
<select name="default_language" id="default_language" class="form-control">
|
||||
<option value="all" {% if content.default_language == "all" %}selected{% endif %}>{{ _('Show All') }}</option>
|
||||
{% for language in languages %}
|
||||
<option value="{{ language.lang_code }}" {% if content.default_language == language.lang_code %}selected{% endif %}>{{ language.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="settings-explanation">{{_('Filter the library to only show books in a specific language.')}}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if registered_oauth.keys()| length > 0 and not new_user and profile %}
|
||||
{% for id, name in registered_oauth.items() %}
|
||||
<div class="form-group">
|
||||
<label>{{ name }} {{_('OAuth Settings')}}</label>
|
||||
{% if id not in oauth_status %}
|
||||
<a href="{{ url_for('oauth.'+ name +'_login') }}" id="config_{{ id }}_oauth" class="btn btn-primary">{{_('Link')}}</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('oauth.'+ name +'_login_unlink') }}" id="config_{{ id }}_oauth" class="btn btn-primary">{{_('Unlink')}}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if hardcover_support and not new_user %}
|
||||
<div class="form-group">
|
||||
<label for="hardcover_token">{{_('Hardcover API token')}}</label>
|
||||
<input type="text" class="form-control" name="hardcover_token" id="hardcover_token" value="{{ content.hardcover_token if content.hardcover_token != None }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if kobo_support and not new_user %}
|
||||
<label>{{ _('Kobo Sync Token')}}</label>
|
||||
<div class="form-group col">
|
||||
<a class="btn btn-default" id="config_create_kobo_token" data-toggle="modal" data-target="#modal_kobo_token" data-remote="false" href="{{ url_for('kobo_auth.generate_auth_token', user_id=content.id) }}">{{_('Create/View')}}</a>
|
||||
<div class="btn btn-danger" id="config_delete_kobo_token" data-value="{{ content.id }}" data-remote="false" {% if not content.remote_auth_token.first() %} style="display: none;" {% endif %}>{{_('Delete')}}</div>
|
||||
</div>
|
||||
<div class="form-group col">
|
||||
<div class="btn btn-default" id="kobo_full_sync" data-value="{% if current_user.role_admin() %}{{ content.id }}{% else %}0{% endif %}" {% if not content.remote_auth_token.first() %} style="display: none;" {% endif %}>{{_('Force full kobo sync')}}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-sm-6">
|
||||
{% for element in sidebar %}
|
||||
{% if element['config_show'] %}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="show_{{element['visibility']}}" id="show_{{element['visibility']}}" {% if content.check_visibility(element['visibility']) %}checked{% endif %}>
|
||||
<label for="show_{{element['visibility']}}">{{element['show_text']}}</label>
|
||||
|
||||
<!-- OAuth & API Integrations Card -->
|
||||
{% if (registered_oauth.keys()| length > 0 and not new_user and profile) or (hardcover_support and not new_user) or (kobo_support and not new_user) %}
|
||||
<div class="settings-container" style="max-width: none; margin-inline: 0rem;">
|
||||
<h4 class="settings-section-header">{{_('OAuth & API Integrations')}}</h4>
|
||||
|
||||
{% if registered_oauth.keys()| length > 0 and not new_user and profile %}
|
||||
{% for id, name in registered_oauth.items() %}
|
||||
<div class="form-group" style="margin-bottom: 20px; display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
<label style="display: block; margin-bottom: 8px;">{{ name|capitalize }} {{_('OAuth Settings')}}</label>
|
||||
{% if id not in oauth_status %}
|
||||
<a href="{{ url_for('oauth.'+ name +'_login') }}" id="config_{{ id }}_oauth" class="btn btn-primary">{{_('Link')}} {{ name|capitalize }} {{_('Account')}}</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('oauth.'+ name +'_login_unlink') }}" id="config_{{ id }}_oauth" class="btn btn-primary">{{_('Unlink')}} {{ name|capitalize }} {{_('Account')}}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if hardcover_support and not new_user %}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="Show_detail_random" id="Show_detail_random" {% if content.show_detail_random() %}checked{% endif %}>
|
||||
<label for="hardcover_token">{{_('Hardcover API Token')}}</label>
|
||||
<input type="text" class="form-control" name="hardcover_token" id="hardcover_token" value="{{ content.hardcover_token if content.hardcover_token != None }}">
|
||||
<p class="cwa-settings-tip">{{_('API token for Hardcover metadata provider integration.')}}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if kobo_support and not new_user %}
|
||||
<div style="margin-top: 25px;">
|
||||
<label style="display: block; margin-bottom: 12px; font-weight: bold;">{{ _('Kobo Sync Token')}}</label>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<a class="btn btn-default" id="config_create_kobo_token" data-toggle="modal" data-target="#modal_kobo_token" data-remote="false" href="{{ url_for('kobo_auth.generate_auth_token', user_id=content.id) }}">{{_('Create/View')}}</a>
|
||||
<div class="btn btn-danger" id="config_delete_kobo_token" data-value="{{ content.id }}" data-remote="false" {% if not content.remote_auth_token.first() %} style="display: none;" {% endif %}>{{_('Delete')}}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="btn btn-default" id="kobo_full_sync" data-value="{% if current_user.role_admin() %}{{ content.id }}{% else %}0{% endif %}" {% if not content.remote_auth_token.first() %} style="display: none;" {% endif %}>{{_('Force full kobo sync')}}</div>
|
||||
</div>
|
||||
{% if kobo_support and not content.role_anonymous() and not simple %}
|
||||
<div style="margin-top: 20px;
|
||||
background: #202c34a3;
|
||||
padding: 2rem;">
|
||||
<input type="checkbox" name="kobo_only_shelves_sync" id="kobo_only_shelves_sync" {% if content.kobo_only_shelves_sync %}checked{% endif %} style="accent-color: var(--color-secondary);">
|
||||
<label for="kobo_only_shelves_sync">{{_('Sync only books in selected shelves with Kobo')}}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Sidebar Display Settings Card -->
|
||||
<div class="settings-container" style="max-width: none; margin-inline: 0rem;">
|
||||
<h4 class="settings-section-header">{{_('Sidebar Display Settings')}}</h4>
|
||||
<p class="settings-explanation">{{_('Choose which sections to display in your sidebar navigation.')}}</p>
|
||||
|
||||
<div style="display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
background: #202c34a3;
|
||||
padding: 2rem;">
|
||||
{% for element in sidebar %}
|
||||
{% if element['config_show'] %}
|
||||
<div>
|
||||
<input type="checkbox" name="show_{{element['visibility']}}" id="show_{{element['visibility']}}" {% if content.check_visibility(element['visibility']) %}checked{% endif %} style="accent-color: var(--color-secondary);">
|
||||
<label for="show_{{element['visibility']}}">{{element['show_text']}}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div>
|
||||
<input type="checkbox" name="Show_detail_random" id="Show_detail_random" {% if content.show_detail_random() %}checked{% endif %} style="accent-color: var(--color-secondary);">
|
||||
<label for="Show_detail_random">{{_('Show Random Books in Detail View')}}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if ( current_user and current_user.role_admin() and not new_user ) and not simple %}
|
||||
<a href="#" id="get_user_tags" class="btn btn-default" data-id="{{content.id}}" data-toggle="modal" data-target="#restrictModal">{{_('Add Allowed/Denied Tags')}}</a>
|
||||
<a href="#" id="get_user_column_values" data-id="{{content.id}}" class="btn btn-default" data-toggle="modal" data-target="#restrictModal">{{_('Add allowed/Denied Custom Column Values')}}</a>
|
||||
<div style="margin-top: 25px; padding-top: 20px; border-top: 1px solid #5c6b74;">
|
||||
<a href="#" id="get_user_tags" class="btn btn-default" data-id="{{content.id}}" data-toggle="modal" data-target="#restrictModal">{{_('Add Allowed/Denied Tags')}}</a>
|
||||
<a href="#" id="get_user_column_values" data-id="{{content.id}}" class="btn btn-default" data-toggle="modal" data-target="#restrictModal" style="margin-left: 10px;">{{_('Add allowed/Denied Custom Column Values')}}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
|
||||
<!-- User Permissions Card (Admin Only) -->
|
||||
{% if current_user and current_user.role_admin() and not profile %}
|
||||
{% if not content.role_anonymous() %}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="admin_role" id="admin_role" {% if content.role_admin() %}checked{% endif %}>
|
||||
<label for="admin_role">{{_('Admin User')}}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="download_role" id="download_role" {% if content.role_download() %}checked{% endif %}>
|
||||
<label for="download_role">{{_('Allow Downloads')}}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="viewer_role" id="viewer_role" {% if content.role_viewer() %}checked{% endif %}>
|
||||
<label for="viewer_role">{{_('Allow eBook Viewer')}}</label>
|
||||
</div>
|
||||
{% if config.config_uploading %}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="upload_role" id="upload_role" {% if content.role_upload() %}checked{% endif %}>
|
||||
<label for="upload_role">{{_('Allow Uploads')}}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="edit_role" data-control="edit_settings" id="edit_role" {% if content.role_edit() %}checked{% endif %}>
|
||||
<label for="edit_role">{{_('Allow Edit')}}</label>
|
||||
</div>
|
||||
<div data-related="edit_settings">
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="delete_role" id="delete_role" {% if content.role_delete_books() %}checked{% endif %}>
|
||||
<label for="delete_role">{{_('Allow Delete Books')}}</label>
|
||||
<div class="settings-container" style="max-width: none; margin-inline: 0rem;">
|
||||
<h4 class="settings-section-header">{{_('User Permissions')}}</h4>
|
||||
<p class="settings-explanation">{{_('Configure what actions this user is allowed to perform.')}}</p>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 12px; margin-top: 20px;">
|
||||
{% if not content.role_anonymous() %}
|
||||
<div>
|
||||
<input type="checkbox" name="admin_role" id="admin_role" {% if content.role_admin() %}checked{% endif %} style="accent-color: var(--color-secondary);">
|
||||
<label for="admin_role">{{_('Admin User')}}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<input type="checkbox" name="download_role" id="download_role" {% if content.role_download() %}checked{% endif %} style="accent-color: var(--color-secondary);">
|
||||
<label for="download_role">{{_('Allow Downloads')}}</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input type="checkbox" name="viewer_role" id="viewer_role" {% if content.role_viewer() %}checked{% endif %} style="accent-color: var(--color-secondary);">
|
||||
<label for="viewer_role">{{_('Allow eBook Viewer')}}</label>
|
||||
</div>
|
||||
|
||||
{% if config.config_uploading %}
|
||||
<div>
|
||||
<input type="checkbox" name="upload_role" id="upload_role" {% if content.role_upload() %}checked{% endif %} style="accent-color: var(--color-secondary);">
|
||||
<label for="upload_role">{{_('Allow Uploads')}}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<input type="checkbox" name="edit_role" data-control="edit_settings" id="edit_role" {% if content.role_edit() %}checked{% endif %} style="accent-color: var(--color-secondary);">
|
||||
<label for="edit_role">{{_('Allow Edit')}}</label>
|
||||
</div>
|
||||
|
||||
<div data-related="edit_settings">
|
||||
<input type="checkbox" name="delete_role" id="delete_role" {% if content.role_delete_books() %}checked{% endif %} style="accent-color: var(--color-secondary);">
|
||||
<label for="delete_role">{{_('Allow Delete Books')}}</label>
|
||||
</div>
|
||||
|
||||
{% if not content.role_anonymous() %}
|
||||
<div>
|
||||
<input type="checkbox" name="passwd_role" id="passwd_role" {% if content.role_passwd() %}checked{% endif %} style="accent-color: var(--color-secondary);">
|
||||
<label for="passwd_role">{{_('Allow Changing Password')}}</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input type="checkbox" name="edit_shelf_role" id="edit_shelf_role" {% if content.role_edit_shelfs() %}checked{% endif %} style="accent-color: var(--color-secondary);">
|
||||
<label for="edit_shelf_role">{{_('Allow Editing Public Shelves')}}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if not content.role_anonymous() %}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="passwd_role" id="passwd_role" {% if content.role_passwd() %}checked{% endif %}>
|
||||
<label for="passwd_role">{{_('Allow Changing Password')}}</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="edit_shelf_role" id="edit_shelf_role" {% if content.role_edit_shelfs() %}checked{% endif %}>
|
||||
<label for="edit_shelf_role">{{_('Allow Editing Public Shelves')}}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if kobo_support and not content.role_anonymous() and not simple%}
|
||||
<div class="form-group">
|
||||
<input type="checkbox" name="kobo_only_shelves_sync" id="kobo_only_shelves_sync" {% if content.kobo_only_shelves_sync %}checked{% endif %}>
|
||||
<label for="kobo_only_shelves_sync">{{_('Sync only books in selected shelves with Kobo')}}</label>
|
||||
|
||||
<!-- Magic Shelves Visibility Card -->
|
||||
{% if not content.role_anonymous() and not new_user %}
|
||||
<div class="settings-container" style="max-width: none; margin-inline: 0rem;">
|
||||
<h4 class="settings-section-header">{{_('Magic Shelves Visibility')}}</h4>
|
||||
<p class="settings-explanation">{{_('Uncheck shelves you want to hide from your sidebar. System shelves cannot be deleted, only hidden. Public shelves from other users can also be hidden.')}}</p>
|
||||
|
||||
{% if system_shelf_templates|length > 0 %}
|
||||
<div style="margin-top: 20px; margin-bottom: 20px;">
|
||||
<strong style="color: #ffc200;">{{_('Default System Shelves:')}}</strong>
|
||||
</div>
|
||||
<div style="display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 8px;
|
||||
margin-left: 10px;
|
||||
background: #202c34a3;
|
||||
padding: 2rem;">
|
||||
{% for key, template in system_shelf_templates.items() %}
|
||||
<div>
|
||||
<input type="checkbox"
|
||||
name="show_magic_shelf_{{ key }}"
|
||||
id="show_magic_shelf_{{ key }}"
|
||||
{% if key not in hidden_shelf_templates %}checked{% endif %}
|
||||
style="accent-color: var(--color-secondary);">
|
||||
<label for="show_magic_shelf_{{ key }}">{{ template['icon'] }} {{ template['name'] }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% set all_public_shelves = visible_public_shelves + hidden_custom_shelves %}
|
||||
{% if all_public_shelves|length > 0 %}
|
||||
<div style="margin-top: 30px; margin-bottom: 20px;">
|
||||
<strong style="color: #ffc200;">{{_('Public Shelves:')}}</strong>
|
||||
</div>
|
||||
<div style="display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 8px;
|
||||
margin-left: 10px;
|
||||
background: #202c34a3;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;">
|
||||
{% for shelf in all_public_shelves %}
|
||||
<div>
|
||||
<input type="checkbox"
|
||||
name="show_custom_shelf_{{ shelf.id }}"
|
||||
id="show_custom_shelf_{{ shelf.id }}"
|
||||
{% if shelf.id not in hidden_custom_shelf_ids %}checked{% endif %}
|
||||
style="accent-color: var(--color-secondary);">
|
||||
<label for="show_custom_shelf_{{ shelf.id }}">{{ shelf.icon }} {{ shelf.name }} <small style="color: #888;">(by {{ shelf.user.name }})</small></label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if system_shelf_templates|length > 0 or all_public_shelves|length > 0 %}
|
||||
<div class="settings-disclaimer" style="margin-top: 25px !important; padding-top: 20px; border-top: 1px solid #5c6b74;">
|
||||
{% if system_shelf_templates|length > 0 %}
|
||||
{{_('%(visible)s of %(total)s default shelves visible', visible=(system_shelf_templates|length - hidden_shelf_templates|length), total=system_shelf_templates|length)}}
|
||||
{% endif %}
|
||||
{% if all_public_shelves|length > 0 %}
|
||||
{% set visible_count = all_public_shelves|length - hidden_custom_shelf_ids|length %}
|
||||
{% if system_shelf_templates|length > 0 %} • {% endif %}{{_('%(visible)s of %(total)s public shelves visible', visible=visible_count, total=all_public_shelves|length)}}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<div id="user_submit" class="btn btn-default">{{_('Save')}}</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div style="margin-bottom: 4rem; display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
<div id="user_submit" class="btn btn-default" style="width: -webkit-fill-available;">{{_('Save')}}</div>
|
||||
{% if not profile %}
|
||||
<div class="btn btn-default" data-back="{{ url_for('admin.admin') }}" id="back">{{_('Cancel')}}</div>
|
||||
<div class="btn btn-default" data-back="{{ url_for('admin.admin') }}" id="back" style="width: -webkit-fill-available;">{{_('Cancel')}}</div>
|
||||
{% endif %}
|
||||
{% if current_user and current_user.role_admin() and not profile and not new_user and not content.role_anonymous() %}
|
||||
<div class="btn btn-danger" id="btndeluser" data-value="{{ content.id }}" data-remote="false" >{{_('Delete User')}}</div>
|
||||
<div class="btn btn-danger" id="btndeluser" data-value="{{ content.id }}" data-remote="false" style="width: -webkit-fill-available;">{{_('Delete User')}}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -198,6 +472,41 @@
|
||||
{{ delete_confirm_modal() }}
|
||||
{% endblock %}
|
||||
{% block js %}
|
||||
<script>
|
||||
// Load custom profile picture if available
|
||||
(function() {
|
||||
const username = "{{ content.name }}";
|
||||
const profilePictureElement = document.getElementById('user-profile-picture');
|
||||
|
||||
if (username && profilePictureElement) {
|
||||
// Fetch user profiles from the JSON endpoint
|
||||
fetch('{{ url_for("profile_pictures.user_profiles_json") }}')
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load profile pictures');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(userProfiles => {
|
||||
console.log('User profiles loaded:', userProfiles);
|
||||
console.log('Looking for username:', username);
|
||||
if (userProfiles[username]) {
|
||||
// User has a custom profile picture
|
||||
console.log('Setting custom profile picture:', userProfiles[username]);
|
||||
profilePictureElement.style.backgroundImage = `url(${userProfiles[username]})`;
|
||||
} else {
|
||||
// No custom profile picture - keep default SVG
|
||||
console.log('No custom profile picture found, using default');
|
||||
profilePictureElement.classList.add('no-custom-picture');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Could not load user profile picture:', error);
|
||||
profilePictureElement.classList.add('no-custom-picture');
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table-editable.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-editable.min.js') }}"></script>
|
||||
|
||||
@@ -60,7 +60,7 @@ msgstr "Basiskonfiguration"
|
||||
|
||||
#: cps/admin.py:326
|
||||
msgid "UI Configuration"
|
||||
msgstr "Benutzeroberflächenkonfiguration"
|
||||
msgstr "Ansichtsteinstellungen"
|
||||
|
||||
#: cps/admin.py:350 cps/admin.py:1030 cps/db.py:820 cps/search.py:140
|
||||
#: cps/web.py:797
|
||||
@@ -102,11 +102,11 @@ msgstr "Name 'Guest' kann nicht geändert werden"
|
||||
|
||||
#: cps/admin.py:527
|
||||
msgid "Guest can't have this role"
|
||||
msgstr "Benutzer 'Guest' kann diese Rolle nicht haben"
|
||||
msgstr "Benutzer 'Guest' darf diese Rolle nicht besitzen"
|
||||
|
||||
#: cps/admin.py:539 cps/admin.py:2164
|
||||
msgid "No admin user remaining, can't remove admin role"
|
||||
msgstr "Kein Admin-Benutzer verblieben, Admin-Rolle kann nicht entfernt werden"
|
||||
msgstr "Kein verbleibender Admin-Benutzer, Admin-Rolle kann nicht entfernt werden"
|
||||
|
||||
#: cps/admin.py:543 cps/admin.py:557
|
||||
msgid "Value has to be true or false"
|
||||
@@ -118,7 +118,7 @@ msgstr "Ungültige Rolle"
|
||||
|
||||
#: cps/admin.py:549
|
||||
msgid "Guest can't have this view"
|
||||
msgstr "Benutzer 'Guest' kann diese Ansicht nicht haben"
|
||||
msgstr "Benutzer 'Guest' darf diese Ansicht nicht besitzen"
|
||||
|
||||
#: cps/admin.py:559
|
||||
msgid "Invalid view"
|
||||
@@ -328,7 +328,7 @@ msgstr "Neuen Benutzer hinzufügen"
|
||||
|
||||
#: cps/admin.py:1411 cps/templates/admin.html:103
|
||||
msgid "Edit Email Server Settings"
|
||||
msgstr "SMTP-Einstellungen ändern"
|
||||
msgstr "Einstellungen für den E-Mail-Server"
|
||||
|
||||
#: cps/admin.py:1430
|
||||
msgid "Success! Gmail Account Verified."
|
||||
@@ -402,7 +402,7 @@ msgstr "Erfolg! Passwort für Benutzer %(user)s wurde zurückgesetzt"
|
||||
|
||||
#: cps/admin.py:1579
|
||||
msgid "Oops! Please configure the SMTP mail settings."
|
||||
msgstr "Ups! Bitte die SMTP-Einstellungen konfigurieren."
|
||||
msgstr "Ups! Bitte die SMTP-Einstellungen des E-Mail-Server konfigurieren."
|
||||
|
||||
#: cps/admin.py:1590
|
||||
msgid "Logfile viewer"
|
||||
@@ -502,7 +502,7 @@ msgstr "DB Pfad ist nicht gültig, bitte einen gültigen Pfad angeben"
|
||||
|
||||
#: cps/admin.py:1888
|
||||
msgid "DB is not Writeable"
|
||||
msgstr "Datenbank ist nicht schreibbar"
|
||||
msgstr "Datenbank ist nicht beschreibbar"
|
||||
|
||||
#: cps/admin.py:1901
|
||||
msgid "Keyfile Location is not Valid, Please Enter Correct Path"
|
||||
@@ -558,7 +558,7 @@ msgstr "Benutzer 'Guest' kann nicht gelöscht werden"
|
||||
|
||||
#: cps/admin.py:2141
|
||||
msgid "No admin user remaining, can't delete user"
|
||||
msgstr "Kein Admin-Benutzer verblieben, Benutzer kann nicht gelöscht werden"
|
||||
msgstr "Kein verbleibender Admin-Benutzer, Benutzer kann nicht gelöscht werden"
|
||||
|
||||
#: cps/admin.py:2204 cps/web.py:1608
|
||||
msgid "Email can't be empty and has to be a valid Email"
|
||||
@@ -769,7 +769,7 @@ msgstr "Manuell?"
|
||||
|
||||
#: cps/cwa_functions.py:325
|
||||
msgid "No. Fixes"
|
||||
msgstr "Anzahl Korrekturen"
|
||||
msgstr "Anzahl der Korrekturen"
|
||||
|
||||
#: cps/cwa_functions.py:325 cps/cwa_functions.py:330 cps/cwa_functions.py:332
|
||||
msgid "Original Backed Up?"
|
||||
@@ -849,7 +849,7 @@ msgstr "Ein Fehler ist aufgetreten"
|
||||
|
||||
#: cps/cwa_functions.py:604 cps/cwa_functions.py:611
|
||||
msgid "Calibre-Web Automated - Convert Library"
|
||||
msgstr "Calibre-Web Automated - Bibliothek Konvertieren"
|
||||
msgstr "Calibre-Web Automated - Bibliothek konvertieren"
|
||||
|
||||
#: cps/cwa_functions.py:724 cps/cwa_functions.py:730
|
||||
msgid "Calibre-Web Automated - Send-to-Kindle EPUB Fixer Service"
|
||||
@@ -1162,7 +1162,7 @@ msgstr "Ungültiges E-Mail Adressformat"
|
||||
|
||||
#: cps/helper.py:720
|
||||
msgid "Password doesn't comply with password validation rules"
|
||||
msgstr "Passwort entspricht nicht mit den Passwortregeln"
|
||||
msgstr "Passwort entspricht nicht den Passwortvorgaben"
|
||||
|
||||
#: cps/helper.py:866
|
||||
msgid ""
|
||||
@@ -1228,7 +1228,7 @@ msgstr "Bitte keine Datei sondern einen Ordner angeben"
|
||||
|
||||
#: cps/helper.py:1066
|
||||
msgid "Calibre binaries not viable"
|
||||
msgstr "Calibre Programm ist nicht nutzbar"
|
||||
msgstr "Die Calibre Programmdaten sind nicht verwendbar."
|
||||
|
||||
#: cps/helper.py:1075
|
||||
#, python-format
|
||||
@@ -1384,7 +1384,7 @@ msgstr "Bücher"
|
||||
|
||||
#: cps/render_template.py:42
|
||||
msgid "Show recent books"
|
||||
msgstr "Zeige kürzlich hinzugefügte Bücher"
|
||||
msgstr "Zeige zuletzt hinzugefügte Bücher"
|
||||
|
||||
#: cps/render_template.py:43 cps/templates/index.xml:27
|
||||
msgid "Hot Books"
|
||||
@@ -1623,7 +1623,7 @@ msgstr ""
|
||||
|
||||
#: cps/shelf.py:207 cps/templates/layout.html:270
|
||||
msgid "Create a Shelf"
|
||||
msgstr "Bücherregal erzeugen"
|
||||
msgstr "Neues Bücherregal"
|
||||
|
||||
#: cps/shelf.py:215
|
||||
msgid "Sorry you are not allowed to edit this shelf"
|
||||
@@ -1696,7 +1696,7 @@ msgstr "Aufgaben"
|
||||
|
||||
#: cps/tasks_status.py:53
|
||||
msgid "Waiting"
|
||||
msgstr "Wartend"
|
||||
msgstr "Warte..."
|
||||
|
||||
#: cps/tasks_status.py:55
|
||||
msgid "Failed"
|
||||
@@ -1824,7 +1824,7 @@ msgstr "Liste der Dateiformate"
|
||||
|
||||
#: cps/web.py:1349 cps/web.py:1370
|
||||
msgid "Please configure the SMTP mail settings first..."
|
||||
msgstr "Bitte zuerst die SMTP-Einstellung konfigurieren..."
|
||||
msgstr "Bitte zunächst die SMTP-Einstellungen des E-Mail-Server konfigurieren..."
|
||||
|
||||
#: cps/web.py:1356
|
||||
#, python-format
|
||||
@@ -1869,7 +1869,7 @@ msgstr "Verbindugnsfehler zu Limiter Backend, bitte Administrator kontaktieren"
|
||||
msgid ""
|
||||
"Oops! Email server is not configured, please contact your administrator."
|
||||
msgstr ""
|
||||
"Ups! Der E-Mail Server ist nicht konfiguriert, bitte den Administrator "
|
||||
"Ups! Der E-Mail-Server ist nicht konfiguriert, bitte den Administrator "
|
||||
"kontaktieren."
|
||||
|
||||
#: cps/web.py:1452
|
||||
@@ -2102,7 +2102,7 @@ msgstr "LDAP Benutzer importieren"
|
||||
|
||||
#: cps/templates/admin.html:65
|
||||
msgid "Email Server Settings 📬"
|
||||
msgstr "Einstellungen des SMTP-Servers 📬"
|
||||
msgstr "Einstellungen des E-Mail-Server 📬"
|
||||
|
||||
#: cps/templates/admin.html:70 cps/templates/email_edit.html:31
|
||||
msgid "SMTP Hostname"
|
||||
@@ -2183,7 +2183,7 @@ msgstr "Reverse Proxy Header Name"
|
||||
|
||||
#: cps/templates/admin.html:162
|
||||
msgid "Edit Calibre Database Configuration"
|
||||
msgstr "Calibre Datenbank Konfiguration editieren"
|
||||
msgstr "Konfiguration der Calibre Datenbank editieren"
|
||||
|
||||
#: cps/templates/admin.html:163
|
||||
msgid "Edit Basic Configuration"
|
||||
@@ -2191,7 +2191,7 @@ msgstr "Basiskonfiguration"
|
||||
|
||||
#: cps/templates/admin.html:164
|
||||
msgid "Edit UI Configuration"
|
||||
msgstr "Benutzeroberflächenkonfiguration"
|
||||
msgstr "Ansichtsteinstellungen"
|
||||
|
||||
#: cps/templates/admin.html:170
|
||||
msgid "Scheduled Tasks ⌛"
|
||||
@@ -2344,7 +2344,7 @@ msgstr "Calibre-Web wirklich anhalten?"
|
||||
|
||||
#: cps/templates/admin.html:320
|
||||
msgid "Updating, please do not reload this page"
|
||||
msgstr "Aktualisierungsvorgang, bitte nicht die Seite neu laden"
|
||||
msgstr "Aktualisierungsvorgang, bitte diese Seite nicht neu laden"
|
||||
|
||||
#: cps/templates/author.html:15
|
||||
msgid "via"
|
||||
@@ -3402,7 +3402,7 @@ msgstr "Editieren öffentlicher Bücherregale erlauben"
|
||||
|
||||
#: cps/templates/config_view_edit.html:124
|
||||
msgid "Default Language"
|
||||
msgstr "Default Sprache"
|
||||
msgstr "Standardsprache"
|
||||
|
||||
#: cps/templates/config_view_edit.html:132
|
||||
msgid "Default Visible Language of Books"
|
||||
@@ -4093,7 +4093,7 @@ msgstr "Hier mitmachen!"
|
||||
|
||||
#: cps/templates/layout.html:248
|
||||
msgid "Please do not refresh the page"
|
||||
msgstr "Bitte die Seite nicht neu laden"
|
||||
msgstr "Bitte diese Seite nicht neu laden"
|
||||
|
||||
#: cps/templates/layout.html:258
|
||||
msgid "Browse"
|
||||
|
||||
@@ -336,13 +336,13 @@ msgstr "Siker! A Gmail-fiók hitelesítve."
|
||||
#: cps/tasks/convert.py:145 cps/web.py:1669
|
||||
#, python-format
|
||||
msgid "Oops! Database Error: %(error)s."
|
||||
msgstr "Hoppá! Adatbázis hiba: %(error)"
|
||||
msgstr "Hoppá! Adatbázis hiba: %(error)s"
|
||||
|
||||
#: cps/admin.py:1460
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Test e-mail queued for sending to %(email)s, please check Tasks for result"
|
||||
msgstr "A %(email) címre küldendő teszt e-mail sorba állítva. Kérjük, "
|
||||
msgstr "A %(email)s címre küldendő teszt e-mail sorba állítva. Kérjük, "
|
||||
"ellenőrizze a Feladatok menüpontban az eredményt"
|
||||
|
||||
#: cps/admin.py:1463
|
||||
|
||||
@@ -598,19 +598,19 @@ msgstr "Verbindingsfout"
|
||||
#: cps/admin.py:2283 cps/admin.py:2326
|
||||
#, python-format
|
||||
msgid "Connection failed: Server returned status code %(code)s"
|
||||
msgstr ""
|
||||
msgstr "Verbinding mislukt: Server gaf statuscode %(code)s terug"
|
||||
|
||||
#: cps/admin.py:2285 cps/admin.py:2328
|
||||
msgid "Connection failed: Could not connect to server."
|
||||
msgstr ""
|
||||
msgstr "Verbinding mislukt: Kon geen verbinding maken met de server."
|
||||
|
||||
#: cps/admin.py:2287 cps/admin.py:2330
|
||||
msgid "Connection failed: Request timed out."
|
||||
msgstr ""
|
||||
msgstr "Verbinding mislukt: De aanvraag duurde te lang."
|
||||
|
||||
#: cps/admin.py:2289 cps/admin.py:2332
|
||||
msgid "Connection failed: Invalid JSON in response."
|
||||
msgstr ""
|
||||
msgstr "Verbinding mislukt: Ongeldige JSON in de respons."
|
||||
|
||||
#: cps/admin.py:2292 cps/admin.py:2335
|
||||
#, fuzzy
|
||||
@@ -620,7 +620,7 @@ msgstr "Onbekende fout opgetreden. Probeer het later nog eens."
|
||||
#: cps/admin.py:2318
|
||||
#, python-format
|
||||
msgid "Metadata is missing required fields: %(fields)s"
|
||||
msgstr ""
|
||||
msgstr "Metadata mist vereiste velden: %(fields)s"
|
||||
|
||||
#: cps/admin.py:2323
|
||||
#, python-format
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2824,7 +2824,7 @@ msgid ""
|
||||
"any, it will automatically create and mount a new one. For more details,\n"
|
||||
" <a href=\"https://github.com/crocodilestick/Calibre-Web-Automated/"
|
||||
"wiki/Configuration#database-configuration\">see here</a>!"
|
||||
msgstr "CWA está configurado de forma que seu Banco de Dados Calibre (arquivo
|
||||
msgstr "CWA está configurado de forma que seu Banco de Dados Calibre (arquivo "
|
||||
"<code>metadata.db</code> deve sempre estar em algum lugar de <code>/calibre-library</code>.\n"
|
||||
" CWA procura automaticamente em qualquer diretório que você ligou ao <code>/"
|
||||
"calibre-library</code> em seu arquivo docker-compose no Calibre existente\n"
|
||||
|
||||
@@ -29,7 +29,7 @@ except ImportError as e:
|
||||
OAuthConsumerMixin = BaseException
|
||||
oauth_support = False
|
||||
from sqlalchemy import create_engine, exc, exists, event, text
|
||||
from sqlalchemy import Column, ForeignKey, Index
|
||||
from sqlalchemy import Column, ForeignKey, Index, UniqueConstraint
|
||||
from sqlalchemy import String, Integer, SmallInteger, Boolean, DateTime, Float, JSON
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
from sqlalchemy.sql.expression import func
|
||||
@@ -235,6 +235,7 @@ class User(UserBase, Base):
|
||||
kindle_mail = Column(String(120), default="")
|
||||
kindle_mail_subject = Column(String(256), default="", doc="Subject line for eReader email sending, empty=default")
|
||||
shelf = relationship('Shelf', backref='user', lazy='dynamic', order_by='Shelf.name')
|
||||
magic_shelf = relationship('MagicShelf', backref='user', lazy='dynamic', order_by='MagicShelf.name')
|
||||
downloads = relationship('Downloads', backref='user', lazy='dynamic')
|
||||
locale = Column(String(2), default="en")
|
||||
sidebar_view = Column(Integer, default=1)
|
||||
@@ -385,6 +386,87 @@ class Shelf(Base):
|
||||
return '<Shelf %d:%r>' % (self.id, self.name)
|
||||
|
||||
|
||||
# Baseclass representing Magic Shelfs in calibre-web in app.db
|
||||
class MagicShelf(Base):
|
||||
__tablename__ = 'magic_shelf'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
uuid = Column(String, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String)
|
||||
is_public = Column(Integer, default=0)
|
||||
is_system = Column(Boolean, default=False) # System-created template shelves
|
||||
user_id = Column(Integer, ForeignKey('user.id'))
|
||||
icon = Column(String, default="glyphicon-star")
|
||||
rules = Column(JSON, default={})
|
||||
kobo_sync = Column(Boolean, default=False) # Sync to Kobo devices
|
||||
created = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
last_modified = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'name', 'is_system', name='unique_user_system_shelf_name'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return '<MagicShelf %d:%r>' % (self.id, self.name)
|
||||
|
||||
|
||||
class MagicShelfCache(Base):
|
||||
__tablename__ = 'magic_shelf_cache'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
shelf_id = Column(Integer, ForeignKey('magic_shelf.id'), index=True)
|
||||
user_id = Column(Integer, ForeignKey('user.id'), index=True)
|
||||
sort_param = Column(String, default='stored')
|
||||
book_ids = Column(JSON) # Stores [1, 45, 2, ...]
|
||||
total_count = Column(Integer)
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# Composite index for fast lookups
|
||||
__table_args__ = (
|
||||
Index('ix_magic_shelf_cache_lookup', 'shelf_id', 'user_id', 'sort_param'),
|
||||
)
|
||||
|
||||
|
||||
class HiddenMagicShelfTemplate(Base):
|
||||
__tablename__ = 'hidden_magic_shelf_templates'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey('user.id'), nullable=False)
|
||||
template_key = Column(String, nullable=True) # For system templates: 'recently_added', 'highly_rated', etc.
|
||||
shelf_id = Column(Integer, ForeignKey('magic_shelf.id'), nullable=True) # For custom public shelves
|
||||
hidden_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
__table_args__ = (
|
||||
# Either template_key OR shelf_id must be set, but not both
|
||||
# User can only hide the same template/shelf once
|
||||
UniqueConstraint('user_id', 'template_key', name='unique_user_template_hidden'),
|
||||
UniqueConstraint('user_id', 'shelf_id', name='unique_user_shelf_hidden'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
if self.template_key:
|
||||
return '<HiddenMagicShelfTemplate %d: user=%d template=%s>' % (self.id, self.user_id, self.template_key)
|
||||
else:
|
||||
return '<HiddenMagicShelfTemplate %d: user=%d shelf_id=%d>' % (self.id, self.user_id, self.shelf_id)
|
||||
|
||||
|
||||
class DismissedDuplicateGroup(Base):
|
||||
__tablename__ = 'dismissed_duplicate_groups'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey('user.id'), nullable=False)
|
||||
group_hash = Column(String(32), nullable=False) # MD5 hash of title+author combo
|
||||
dismissed_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
__table_args__ = (
|
||||
# User can only dismiss the same duplicate group once
|
||||
UniqueConstraint('user_id', 'group_hash', name='unique_user_duplicate_dismissed'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return '<DismissedDuplicateGroup %d: user=%d hash=%s>' % (self.id, self.user_id, self.group_hash)
|
||||
|
||||
|
||||
# Baseclass representing Relationship between books and Shelfs in Calibre-Web in app.db (N:M)
|
||||
class BookShelf(Base):
|
||||
__tablename__ = 'book_shelf_link'
|
||||
@@ -535,6 +617,28 @@ class HardcoverBookBlacklist(Base):
|
||||
return f'<HardcoverBookBlacklist book_id={self.book_id} annotations={self.blacklist_annotations} progress={self.blacklist_reading_progress}>'
|
||||
|
||||
|
||||
class HardcoverMatchQueue(Base):
|
||||
"""Queue for ambiguous Hardcover metadata matches requiring manual review."""
|
||||
__tablename__ = 'hardcover_match_queue'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
book_id = Column(Integer, nullable=False)
|
||||
book_title = Column(String, nullable=False)
|
||||
book_authors = Column(String, nullable=False)
|
||||
search_query = Column(String, nullable=False)
|
||||
hardcover_results = Column(String, nullable=False) # JSON array of MetaRecord candidates
|
||||
confidence_scores = Column(String, nullable=False) # JSON array of [score, reason] tuples
|
||||
created_at = Column(String, nullable=False)
|
||||
reviewed = Column(Integer, default=0, nullable=False) # 0=pending, 1=reviewed
|
||||
selected_result_id = Column(String, default=None) # Hardcover ID if manually selected
|
||||
review_action = Column(String, default=None) # 'accept', 'reject', 'skip'
|
||||
reviewed_at = Column(String, default=None)
|
||||
reviewed_by = Column(String, default=None)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<HardcoverMatchQueue book_id={self.book_id} title="{self.book_title}" reviewed={bool(self.reviewed)}>'
|
||||
|
||||
|
||||
# Updates the last_modified timestamp in the KoboReadingState table if any of its children tables are modified.
|
||||
@event.listens_for(Session, 'before_flush')
|
||||
def receive_before_flush(session, flush_context, instances):
|
||||
@@ -646,9 +750,17 @@ class Thumbnail(Base):
|
||||
# Add missing tables during migration of database
|
||||
def add_missing_tables(engine, _session):
|
||||
if not engine.dialect.has_table(engine.connect(), "archived_book"):
|
||||
ArchivedBook.__table__.create(bind=engine)
|
||||
ArchivedBook.__table__.create(bind=engine, checkfirst=True)
|
||||
if not engine.dialect.has_table(engine.connect(), "thumbnail"):
|
||||
Thumbnail.__table__.create(bind=engine)
|
||||
Thumbnail.__table__.create(bind=engine, checkfirst=True)
|
||||
if not engine.dialect.has_table(engine.connect(), "kosync_progress"):
|
||||
KOSyncProgress.__table__.create(bind=engine, checkfirst=True)
|
||||
if not engine.dialect.has_table(engine.connect(), "magic_shelf"):
|
||||
MagicShelf.__table__.create(bind=engine, checkfirst=True)
|
||||
if not engine.dialect.has_table(engine.connect(), "magic_shelf_cache"):
|
||||
MagicShelfCache.__table__.create(bind=engine, checkfirst=True)
|
||||
if not engine.dialect.has_table(engine.connect(), "hidden_magic_shelf_templates"):
|
||||
HiddenMagicShelfTemplate.__table__.create(bind=engine, checkfirst=True)
|
||||
|
||||
|
||||
# migrate all settings missing in registration table
|
||||
@@ -775,7 +887,7 @@ def migrate_oauth_provider_table(engine, _session):
|
||||
def migrate_config_table(engine, _session):
|
||||
"""Migrate configuration table to add new authentication columns"""
|
||||
if not engine or not _session:
|
||||
logger.get_logger("cps.ub").error("Cannot migrate config table: missing engine or session")
|
||||
log.error("Cannot migrate config table: missing engine or session")
|
||||
return
|
||||
|
||||
# Add OAuth redirect host configuration
|
||||
@@ -790,7 +902,7 @@ def migrate_config_table(engine, _session):
|
||||
conn.execute(text("ALTER TABLE settings ADD column 'config_oauth_redirect_host' String DEFAULT ''"))
|
||||
trans.commit()
|
||||
except Exception as e:
|
||||
logger.get_logger("cps.ub").error("Failed to add config_oauth_redirect_host column: %s", e)
|
||||
log.error("Failed to add config_oauth_redirect_host column: %s", e)
|
||||
# Don't raise - let CWA continue without this feature
|
||||
pass
|
||||
|
||||
@@ -806,7 +918,7 @@ def migrate_config_table(engine, _session):
|
||||
conn.execute(text("ALTER TABLE settings ADD column 'config_reverse_proxy_auto_create_users' Boolean DEFAULT 0"))
|
||||
trans.commit()
|
||||
except Exception as e:
|
||||
logger.get_logger("cps.ub").error("Failed to add config_reverse_proxy_auto_create_users column: %s", e)
|
||||
log.error("Failed to add config_reverse_proxy_auto_create_users column: %s", e)
|
||||
# Don't raise - let CWA continue without this feature
|
||||
pass
|
||||
|
||||
@@ -822,11 +934,34 @@ def migrate_config_table(engine, _session):
|
||||
conn.execute(text("ALTER TABLE settings ADD column 'config_ldap_auto_create_users' Boolean DEFAULT 1"))
|
||||
trans.commit()
|
||||
except Exception as e:
|
||||
logger.get_logger("cps.ub").error("Failed to add config_ldap_auto_create_users column: %s", e)
|
||||
log.error("Failed to add config_ldap_auto_create_users column: %s", e)
|
||||
# Don't raise - let CWA continue without this feature
|
||||
pass
|
||||
|
||||
|
||||
def migrate_magic_shelf_table(engine, _session):
|
||||
"""Migrate magic_shelf table to add new columns."""
|
||||
# Check and add is_system column
|
||||
try:
|
||||
_session.query(exists().where(MagicShelf.is_system)).scalar()
|
||||
_session.commit()
|
||||
except exc.OperationalError:
|
||||
with engine.connect() as conn:
|
||||
trans = conn.begin()
|
||||
conn.execute(text("ALTER TABLE magic_shelf ADD column 'is_system' Boolean DEFAULT 0"))
|
||||
trans.commit()
|
||||
|
||||
# Check and add kobo_sync column
|
||||
try:
|
||||
_session.query(exists().where(MagicShelf.kobo_sync)).scalar()
|
||||
_session.commit()
|
||||
except exc.OperationalError:
|
||||
with engine.connect() as conn:
|
||||
trans = conn.begin()
|
||||
conn.execute(text("ALTER TABLE magic_shelf ADD column 'kobo_sync' Boolean DEFAULT 0"))
|
||||
trans.commit()
|
||||
|
||||
|
||||
# Migrate database to current version, has to be updated after every database change. Currently migration from
|
||||
# maybe 4/5 versions back to current should work.
|
||||
# Migration is done by checking if relevant columns are existing, and then adding rows with SQL commands
|
||||
@@ -838,10 +973,76 @@ def migrate_Database(_session):
|
||||
migrate_user_table(engine, _session)
|
||||
migrate_oauth_provider_table(engine, _session)
|
||||
migrate_config_table(engine, _session)
|
||||
migrate_magic_shelf_table(engine, _session)
|
||||
|
||||
# Ensure progress syncing tables in app.db (user-related tables)
|
||||
from .progress_syncing.models import ensure_app_db_tables
|
||||
ensure_app_db_tables(engine.raw_connection())
|
||||
|
||||
# Migrate system magic shelves for existing users
|
||||
try:
|
||||
from . import magic_shelf
|
||||
|
||||
# Get all valid current template names
|
||||
current_template_names = {template['name'] for template in magic_shelf.SYSTEM_SHELF_TEMPLATES.values()}
|
||||
|
||||
log.info("Migrating system magic shelves...")
|
||||
users = _session.query(User).filter(User.role != constants.ROLE_ANONYMOUS).all()
|
||||
total_deleted = 0
|
||||
total_created = 0
|
||||
|
||||
for user in users:
|
||||
# Get all system shelves for this user
|
||||
user_system_shelves = _session.query(MagicShelf).filter(
|
||||
MagicShelf.user_id == user.id,
|
||||
MagicShelf.is_system == True
|
||||
).all()
|
||||
|
||||
# Delete system shelves that don't match current templates
|
||||
for shelf in user_system_shelves:
|
||||
if shelf.name not in current_template_names:
|
||||
# This is an old/deprecated system shelf - delete it
|
||||
_session.query(MagicShelfCache).filter_by(shelf_id=shelf.id).delete()
|
||||
_session.query(HiddenMagicShelfTemplate).filter_by(shelf_id=shelf.id).delete()
|
||||
_session.delete(shelf)
|
||||
total_deleted += 1
|
||||
log.debug(f"Deleted deprecated system shelf '{shelf.name}' (ID: {shelf.id}) for user {user.id}")
|
||||
|
||||
# Get user's template-based hide preferences (not shelf-specific)
|
||||
hidden_templates = _session.query(HiddenMagicShelfTemplate.template_key).filter(
|
||||
HiddenMagicShelfTemplate.user_id == user.id,
|
||||
HiddenMagicShelfTemplate.template_key.isnot(None)
|
||||
).all()
|
||||
hidden_keys = {ht.template_key for ht in hidden_templates}
|
||||
|
||||
# Create missing current templates
|
||||
templates_to_create = []
|
||||
for template_key, template_data in magic_shelf.SYSTEM_SHELF_TEMPLATES.items():
|
||||
# Skip if user has hidden this template type
|
||||
if template_key in hidden_keys:
|
||||
continue
|
||||
|
||||
# Check if user already has this current template
|
||||
has_template = _session.query(MagicShelf).filter(
|
||||
MagicShelf.user_id == user.id,
|
||||
MagicShelf.name == template_data['name'],
|
||||
MagicShelf.is_system == True
|
||||
).first()
|
||||
|
||||
if not has_template:
|
||||
templates_to_create.append(template_key)
|
||||
|
||||
# Create missing templates
|
||||
if templates_to_create:
|
||||
created = magic_shelf.create_system_magic_shelves(user.id, templates_to_create)
|
||||
total_created += created
|
||||
|
||||
if total_deleted > 0 or total_created > 0:
|
||||
_session.commit()
|
||||
log.info(f"System shelf migration complete: {total_deleted} old shelves removed, {total_created} new shelves created")
|
||||
except Exception as e:
|
||||
log.error(f"Error during system shelf migration: {e}")
|
||||
_session.rollback()
|
||||
|
||||
|
||||
def clean_database(_session):
|
||||
@@ -888,6 +1089,8 @@ def create_anonymous_user(_session):
|
||||
_session.add(user)
|
||||
try:
|
||||
_session.commit()
|
||||
# Note: Anonymous users don't get system shelves
|
||||
# They will be created if/when the user registers
|
||||
except Exception:
|
||||
_session.rollback()
|
||||
|
||||
@@ -905,9 +1108,29 @@ def create_admin_user(_session):
|
||||
_session.add(user)
|
||||
try:
|
||||
_session.commit()
|
||||
# Create system magic shelves for admin user
|
||||
try:
|
||||
from . import magic_shelf
|
||||
magic_shelf.create_system_magic_shelves(user.id)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to create system magic shelves for admin: {e}")
|
||||
except Exception:
|
||||
_session.rollback()
|
||||
|
||||
|
||||
def create_system_magic_shelves_for_user(user_id):
|
||||
"""
|
||||
Create system magic shelves for a user if they don't already exist.
|
||||
Should be called after user creation.
|
||||
"""
|
||||
try:
|
||||
from . import magic_shelf
|
||||
return magic_shelf.create_system_magic_shelves(user_id)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to create system magic shelves for user {user_id}: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def init_db_thread():
|
||||
global app_DB_path
|
||||
engine = create_engine('sqlite:///{0}'.format(app_DB_path), echo=False,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Calibre-Web Automated – fork of Calibre-Web
|
||||
# Copyright (C) 2018-2025 Calibre-Web contributors
|
||||
# Copyright (C) 2024-2025 Calibre-Web Automated contributors
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# See CONTRIBUTORS for full list of authors.
|
||||
|
||||
"""
|
||||
Text similarity utilities for metadata matching
|
||||
"""
|
||||
from typing import List, Set
|
||||
import re
|
||||
|
||||
|
||||
def levenshtein_distance(s1: str, s2: str) -> int:
|
||||
"""
|
||||
Calculate Levenshtein distance between two strings.
|
||||
Returns the minimum number of single-character edits required to change one word into the other.
|
||||
"""
|
||||
if len(s1) < len(s2):
|
||||
return levenshtein_distance(s2, s1)
|
||||
|
||||
if len(s2) == 0:
|
||||
return len(s1)
|
||||
|
||||
previous_row = range(len(s2) + 1)
|
||||
for i, c1 in enumerate(s1):
|
||||
current_row = [i + 1]
|
||||
for j, c2 in enumerate(s2):
|
||||
# Cost of insertions, deletions, or substitutions
|
||||
insertions = previous_row[j + 1] + 1
|
||||
deletions = current_row[j] + 1
|
||||
substitutions = previous_row[j] + (c1 != c2)
|
||||
current_row.append(min(insertions, deletions, substitutions))
|
||||
previous_row = current_row
|
||||
|
||||
return previous_row[-1]
|
||||
|
||||
|
||||
def normalized_levenshtein_similarity(s1: str, s2: str) -> float:
|
||||
"""
|
||||
Calculate normalized Levenshtein similarity (0.0 to 1.0).
|
||||
1.0 means exact match, 0.0 means completely different.
|
||||
"""
|
||||
s1_norm = normalize_string(s1)
|
||||
s2_norm = normalize_string(s2)
|
||||
|
||||
if not s1_norm or not s2_norm:
|
||||
return 0.0
|
||||
|
||||
max_len = max(len(s1_norm), len(s2_norm))
|
||||
if max_len == 0:
|
||||
return 1.0
|
||||
|
||||
distance = levenshtein_distance(s1_norm, s2_norm)
|
||||
return 1.0 - (distance / max_len)
|
||||
|
||||
|
||||
def normalize_string(s: str) -> str:
|
||||
"""
|
||||
Normalize a string for comparison:
|
||||
- Convert to lowercase
|
||||
- Remove special characters and extra whitespace
|
||||
- Remove common articles and conjunctions
|
||||
"""
|
||||
if not s:
|
||||
return ""
|
||||
|
||||
# Convert to lowercase
|
||||
s = s.lower()
|
||||
|
||||
# Remove common articles and conjunctions
|
||||
articles = ['the', 'a', 'an', 'and', '&']
|
||||
words = s.split()
|
||||
words = [w for w in words if w not in articles]
|
||||
s = ' '.join(words)
|
||||
|
||||
# Remove special characters except spaces and alphanumeric
|
||||
s = re.sub(r'[^\w\s]', '', s)
|
||||
|
||||
# Collapse multiple spaces
|
||||
s = re.sub(r'\s+', ' ', s)
|
||||
|
||||
return s.strip()
|
||||
|
||||
|
||||
def tokenize(s: str) -> Set[str]:
|
||||
"""
|
||||
Tokenize a string into a set of normalized words.
|
||||
"""
|
||||
normalized = normalize_string(s)
|
||||
return set(normalized.split())
|
||||
|
||||
|
||||
def jaccard_similarity(s1: str, s2: str) -> float:
|
||||
"""
|
||||
Calculate Jaccard similarity coefficient between two strings (0.0 to 1.0).
|
||||
Based on word-level token overlap.
|
||||
"""
|
||||
tokens1 = tokenize(s1)
|
||||
tokens2 = tokenize(s2)
|
||||
|
||||
if not tokens1 and not tokens2:
|
||||
return 1.0
|
||||
if not tokens1 or not tokens2:
|
||||
return 0.0
|
||||
|
||||
intersection = tokens1.intersection(tokens2)
|
||||
union = tokens1.union(tokens2)
|
||||
|
||||
return len(intersection) / len(union)
|
||||
|
||||
|
||||
def author_list_similarity(authors1: List[str], authors2: List[str]) -> tuple[float, bool]:
|
||||
"""
|
||||
Calculate similarity between two author lists.
|
||||
|
||||
Returns:
|
||||
tuple: (similarity_score, is_and_match)
|
||||
- similarity_score: 0.0 to 1.0
|
||||
- is_and_match: True if all authors from the smaller list match (AND logic)
|
||||
"""
|
||||
if not authors1 or not authors2:
|
||||
return 0.0, False
|
||||
|
||||
# Normalize author names
|
||||
norm_authors1 = [normalize_string(a) for a in authors1]
|
||||
norm_authors2 = [normalize_string(a) for a in authors2]
|
||||
|
||||
# Calculate per-author similarities
|
||||
max_scores = []
|
||||
for auth1 in norm_authors1:
|
||||
# Find best match for this author in the other list
|
||||
best_score = max([
|
||||
normalized_levenshtein_similarity(auth1, auth2)
|
||||
for auth2 in norm_authors2
|
||||
])
|
||||
max_scores.append(best_score)
|
||||
|
||||
# Check if all authors from smaller list have good matches (>0.8)
|
||||
threshold = 0.8
|
||||
is_and_match = all(score >= threshold for score in max_scores)
|
||||
|
||||
# Overall similarity is average of best matches
|
||||
avg_similarity = sum(max_scores) / len(max_scores) if max_scores else 0.0
|
||||
|
||||
return avg_similarity, is_and_match
|
||||
|
||||
|
||||
def calculate_year_similarity(year1: str, year2: str) -> float:
|
||||
"""
|
||||
Calculate similarity between publication years.
|
||||
Returns 1.0 for exact match, 0.5 for ±1 year, 0.0 otherwise.
|
||||
"""
|
||||
if not year1 or not year2:
|
||||
return 0.0
|
||||
|
||||
try:
|
||||
# Extract 4-digit year from date string
|
||||
y1_match = re.search(r'\b(\d{4})\b', str(year1))
|
||||
y2_match = re.search(r'\b(\d{4})\b', str(year2))
|
||||
|
||||
if not y1_match or not y2_match:
|
||||
return 0.0
|
||||
|
||||
y1 = int(y1_match.group(1))
|
||||
y2 = int(y2_match.group(1))
|
||||
|
||||
diff = abs(y1 - y2)
|
||||
if diff == 0:
|
||||
return 1.0
|
||||
elif diff == 1:
|
||||
return 0.5
|
||||
else:
|
||||
return 0.0
|
||||
except (ValueError, AttributeError):
|
||||
return 0.0
|
||||
+742
-12
@@ -40,6 +40,7 @@ from .redirect import get_redirect_location
|
||||
from .cw_babel import get_available_locale
|
||||
from .usermanagement import login_required_if_no_ano
|
||||
from .kobo_sync_status import remove_synced_book
|
||||
from . import magic_shelf
|
||||
from .render_template import render_title_template
|
||||
from .kobo_sync_status import change_archived_books
|
||||
from . import limiter
|
||||
@@ -65,13 +66,32 @@ feature_support = {
|
||||
}
|
||||
|
||||
try:
|
||||
from .oauth_bb import oauth_check, register_user_with_oauth, logout_oauth_user, get_oauth_status
|
||||
from . import oauth_bb
|
||||
# Import functions directly since they don't change
|
||||
register_user_with_oauth = oauth_bb.register_user_with_oauth
|
||||
logout_oauth_user = oauth_bb.logout_oauth_user
|
||||
get_oauth_status = oauth_bb.get_oauth_status
|
||||
|
||||
feature_support['oauth'] = True
|
||||
except ImportError:
|
||||
feature_support['oauth'] = False
|
||||
oauth_check = {}
|
||||
register_user_with_oauth = logout_oauth_user = get_oauth_status = None
|
||||
# Create a mock oauth_bb module for when OAuth is not available
|
||||
class MockOAuth:
|
||||
oauth_check = {}
|
||||
oauthblueprints = []
|
||||
@staticmethod
|
||||
def register_user_with_oauth(*args, **kwargs):
|
||||
return None
|
||||
@staticmethod
|
||||
def logout_oauth_user(*args, **kwargs):
|
||||
return None
|
||||
@staticmethod
|
||||
def get_oauth_status(*args, **kwargs):
|
||||
return None
|
||||
oauth_bb = MockOAuth()
|
||||
register_user_with_oauth = oauth_bb.register_user_with_oauth
|
||||
logout_oauth_user = oauth_bb.logout_oauth_user
|
||||
get_oauth_status = oauth_bb.get_oauth_status
|
||||
|
||||
from functools import wraps
|
||||
|
||||
@@ -443,6 +463,8 @@ def render_books_list(data, sort_param, book_id, page):
|
||||
term = json.loads(flask_session.get('query', '{}'))
|
||||
offset = int(int(config.config_books_per_page) * (page - 1))
|
||||
return render_adv_search_results(term, offset, order, config.config_books_per_page)
|
||||
elif data == "magicshelf":
|
||||
return render_magic_shelf(book_id, sort_param, page)
|
||||
else:
|
||||
website = data or "newest"
|
||||
entries, random, pagination = calibre_db.fill_indexpage(page, 0, db.Books, True, order[0],
|
||||
@@ -738,6 +760,7 @@ def render_category_books(page, book_id, order):
|
||||
else:
|
||||
tagsname = calibre_db.session.query(db.Tags).filter(db.Tags.id == book_id).first()
|
||||
if tagsname:
|
||||
# Issue #906: Pass viewing_tag_id to allow this tag even if not in allowed tags
|
||||
entries, random, pagination = calibre_db.fill_indexpage(page, 0,
|
||||
db.Books,
|
||||
db.Books.tags.any(db.Tags.id == book_id),
|
||||
@@ -746,7 +769,8 @@ def render_category_books(page, book_id, order):
|
||||
True, config.config_read_column,
|
||||
db.books_series_link,
|
||||
db.Books.id == db.books_series_link.c.book,
|
||||
db.Series)
|
||||
db.Series,
|
||||
viewing_tag_id=book_id)
|
||||
tagsname = tagsname.name
|
||||
else:
|
||||
abort(404)
|
||||
@@ -850,6 +874,97 @@ def render_archived_books(page, sort_param):
|
||||
return render_title_template('index.html', random=random, entries=entries, pagination=pagination,
|
||||
title=name, page=page_name, order=sort_param[1])
|
||||
|
||||
|
||||
@web.route("/magicshelf/<int:shelf_id>", defaults={"sort_param": "stored", 'page': 1})
|
||||
@web.route("/magicshelf/<int:shelf_id>/<sort_param>", defaults={'page': 1})
|
||||
@web.route("/magicshelf/<int:shelf_id>/<sort_param>/<int:page>")
|
||||
@login_required_if_no_ano
|
||||
def render_magic_shelf(shelf_id, sort_param, page):
|
||||
"""Render a magic shelf with proper pagination and sorting."""
|
||||
shelf = ub.session.query(ub.MagicShelf).get(shelf_id)
|
||||
if not shelf:
|
||||
log.warning(f"Magic shelf {shelf_id} not found")
|
||||
abort(404)
|
||||
|
||||
# Check access - users can view their own shelves OR public shelves
|
||||
if shelf.user_id != current_user.id and shelf.is_public != 1:
|
||||
log.warning(f"User {current_user.id} attempted to access private magic shelf {shelf_id} owned by {shelf.user_id}")
|
||||
abort(403)
|
||||
|
||||
# Get sort order using the same function as other book lists
|
||||
order = get_sort_function(sort_param, "magicshelf")
|
||||
|
||||
# Get pagination settings\
|
||||
per_page = config.config_books_per_page or 20
|
||||
|
||||
# Build sort order - order[0] is a list, we need to unpack it
|
||||
sort_order = order[0] if order and len(order) > 0 else []
|
||||
|
||||
# Check for cache bypass
|
||||
bypass_cache = request.args.get('refresh') == '1'
|
||||
|
||||
# Get books with pagination
|
||||
try:
|
||||
books, total_count = magic_shelf.get_books_for_magic_shelf(
|
||||
shelf_id,
|
||||
page=page,
|
||||
page_size=per_page,
|
||||
sort_order=sort_order,
|
||||
sort_param=sort_param,
|
||||
bypass_cache=bypass_cache
|
||||
)
|
||||
log.debug(f"Magic shelf {shelf_id} returned {len(books)} books out of {total_count} total")
|
||||
|
||||
# Log activity
|
||||
try:
|
||||
from scripts.cwa_db import CWA_DB
|
||||
cwa_db = CWA_DB()
|
||||
cwa_db.log_activity(
|
||||
user_id=current_user.id,
|
||||
user_name=current_user.name,
|
||||
event_type='MAGIC_SHELF_VIEW',
|
||||
item_id=shelf_id,
|
||||
item_title=shelf.name,
|
||||
extra_data=json.dumps({'shelf_name': shelf.name, 'shelf_type': 'magic'})
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to log magic shelf activity: {e}")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error retrieving books for magic shelf {shelf_id}: {e}")
|
||||
flash(_("Error loading magic shelf"), category="error")
|
||||
return redirect(url_for('web.index'))
|
||||
|
||||
# Create proper pagination object
|
||||
from .pagination import Pagination
|
||||
pagination = Pagination(page, per_page, total_count)
|
||||
|
||||
# Wrap books in entry objects with .Books attribute for template compatibility
|
||||
class Entry:
|
||||
def __init__(self, book):
|
||||
self.Books = book
|
||||
|
||||
entries = [Entry(book) for book in books]
|
||||
|
||||
# Check if this shelf is hidden by current user (for public shelves)
|
||||
is_hidden = False
|
||||
if shelf.user_id != current_user.id:
|
||||
is_hidden = ub.session.query(ub.HiddenMagicShelfTemplate).filter(
|
||||
ub.HiddenMagicShelfTemplate.user_id == current_user.id,
|
||||
ub.HiddenMagicShelfTemplate.shelf_id == shelf_id
|
||||
).first() is not None
|
||||
|
||||
return render_title_template('index.html',
|
||||
entries=entries,
|
||||
pagination=pagination,
|
||||
title=_("Magic Shelf   —   %(icon)s %(name)s", icon=shelf.icon, name=shelf.name),
|
||||
page="magicshelf",
|
||||
shelf=shelf,
|
||||
is_hidden_shelf=is_hidden,
|
||||
id=shelf_id,
|
||||
order=order[1])
|
||||
|
||||
|
||||
# ################################### Health Check ##################################################################
|
||||
|
||||
@web.route("/health")
|
||||
@@ -906,6 +1021,402 @@ def books_list(data, sort_param, book_id, page):
|
||||
return render_books_list(data, sort_param, book_id, page)
|
||||
|
||||
|
||||
@web.route("/magicshelf/preview", methods=["POST"])
|
||||
@user_login_required
|
||||
def preview_magic_shelf():
|
||||
"""Preview what books match the given rules without saving the shelf."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
rules = data.get('rules')
|
||||
|
||||
if not rules or not rules.get('rules'):
|
||||
return jsonify({"success": False, "message": _("No rules provided")}), 400
|
||||
|
||||
# Temporarily create a query to count matching books
|
||||
try:
|
||||
query_filter = magic_shelf.build_query_from_rules(rules, user_id=current_user.id)
|
||||
if query_filter is None:
|
||||
return jsonify({"success": False, "message": _("Invalid rules format")}), 400
|
||||
|
||||
cdb = db.CalibreDB(init=True)
|
||||
query = cdb.session.query(db.Books)
|
||||
query = query.filter(query_filter)
|
||||
query = query.filter(cdb.common_filters())
|
||||
|
||||
# Get total count
|
||||
total_count = query.count()
|
||||
|
||||
# Get sample books (first 5)
|
||||
sample_books = query.limit(5).all()
|
||||
sample_titles = [book.title for book in sample_books]
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"count": total_count,
|
||||
"sample_books": sample_titles
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error previewing magic shelf rules: {str(e)}", exc_info=True)
|
||||
return jsonify({"success": False, "message": _("Error processing rules")}), 500
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error in preview_magic_shelf: {e}")
|
||||
return jsonify({"success": False, "message": _("Invalid request")}), 400
|
||||
|
||||
|
||||
@web.route("/magicshelf", methods=["GET", "POST"])
|
||||
@user_login_required
|
||||
def create_magic_shelf():
|
||||
# Curated emoji list for magic shelf icons - organized by category
|
||||
ALLOWED_ICONS = [
|
||||
# Books & Reading
|
||||
'📚', '📖', '📕', '📗', '📘', '📙', '📔', '📓', '📒', '📰',
|
||||
# Stars & Favorites
|
||||
'⭐', '🌟', '✨', '💫', '🌠', '⚡', '🔥', '💥', '🎯', '🏆',
|
||||
# Hearts
|
||||
'❤️', '💙', '💚', '💛', '🧡', '💜', '🖤', '🤍', '💖', '💝',
|
||||
# Entertainment
|
||||
'🎭', '🎬', '🎪', '🎨', '🎮', '🎲', '🎰', '🎳', '🎱', '🎸',
|
||||
# Travel & Space
|
||||
'🚀', '🛸', '🌌', '🌍', '🌎', '🌏', '🗺️', '🧭', '⛰️', '🏔️',
|
||||
# Fantasy & Magic
|
||||
'🔮', '🎃', '👻', '🦄', '🐉', '🐲', '🧙', '🧚', '🧛', '🧜',
|
||||
# Awards & Achievement
|
||||
'🥇', '🥈', '🥉', '🏅', '🎖️', '👑', '💎', '💍', '🔱', '🎗️',
|
||||
# Time & Organization
|
||||
'⏰', '⏱️', '⌛', '⏳', '🕰️', '🔔', '📅', '📆', '📌', '📍',
|
||||
# Learning & Science
|
||||
'🎓', '🏫', '📝', '✏️', '📐', '📏', '🔬', '🔭', '🖌️', '🖍️',
|
||||
# Nature & Weather
|
||||
'🌈', '☀️', '🌙', '🌸', '🌺', '🌻', '🌹', '🌷', '🍀', '🌱'
|
||||
]
|
||||
|
||||
if request.method == "POST":
|
||||
data = request.get_json()
|
||||
name = strip_whitespaces(data.get('name', ''))
|
||||
rules = data.get('rules')
|
||||
icon = data.get('icon', '🪄')
|
||||
kobo_sync = data.get('kobo_sync', False)
|
||||
is_public = data.get('is_public', False)
|
||||
|
||||
# Only allow public if user has permission
|
||||
if is_public and not current_user.role_edit_shelfs():
|
||||
is_public = False
|
||||
|
||||
# Validate inputs
|
||||
if not name or not rules:
|
||||
return jsonify({"success": False, "message": _("Name and rules are required")}), 400
|
||||
|
||||
if len(name) > 100:
|
||||
return jsonify({"success": False, "message": _("Shelf name too long (max 100 characters)")}), 400
|
||||
|
||||
# Use default icon if none provided, otherwise accept any emoji/symbol
|
||||
if not icon:
|
||||
icon = '🪄'
|
||||
|
||||
try:
|
||||
new_shelf = ub.MagicShelf(
|
||||
name=name,
|
||||
user_id=current_user.id,
|
||||
rules=rules,
|
||||
icon=icon,
|
||||
kobo_sync=kobo_sync,
|
||||
is_public=1 if is_public else 0
|
||||
)
|
||||
ub.session.add(new_shelf)
|
||||
ub.session_commit()
|
||||
log.info(f"User {current_user.id} created magic shelf '{name}' (ID: {new_shelf.id})")
|
||||
return jsonify({"success": True, "shelf_id": new_shelf.id})
|
||||
except Exception as e:
|
||||
log.error(f"Error creating magic shelf: {e}")
|
||||
ub.session.rollback()
|
||||
return jsonify({"success": False, "message": _("Error creating shelf")}), 500
|
||||
|
||||
# For GET request, render the creation form
|
||||
# Fetch available languages for the dropdown
|
||||
languages = calibre_db.session.query(db.Languages).all()
|
||||
language_map = {}
|
||||
for lang in languages:
|
||||
try:
|
||||
lang_name = isoLanguages.get_language_name(get_locale(), lang.lang_code)
|
||||
language_map[lang.lang_code] = lang_name
|
||||
except:
|
||||
language_map[lang.lang_code] = lang.lang_code
|
||||
|
||||
return render_title_template('magic_shelf_edit.html',
|
||||
title=_("Create Magic Shelf"),
|
||||
page="magic_shelf_create",
|
||||
allowed_icons=ALLOWED_ICONS,
|
||||
languages=language_map)
|
||||
|
||||
|
||||
@web.route("/magicshelf/<int:shelf_id>/edit", methods=["GET", "POST"])
|
||||
@user_login_required
|
||||
def edit_magic_shelf(shelf_id):
|
||||
# Curated emoji list for magic shelf icons - organized by category
|
||||
ALLOWED_ICONS = [
|
||||
# Books & Reading
|
||||
'📚', '📖', '📕', '📗', '📘', '📙', '📔', '📓', '📒', '📰',
|
||||
# Stars & Favorites
|
||||
'⭐', '🌟', '✨', '💫', '🌠', '⚡', '🔥', '💥', '🎯', '🏆',
|
||||
# Hearts
|
||||
'❤️', '💙', '💚', '💛', '🧡', '💜', '🖤', '🤍', '💖', '💝',
|
||||
# Entertainment
|
||||
'🎭', '🎬', '🎪', '🎨', '🎮', '🎲', '🎰', '🎳', '🎱', '🎸',
|
||||
# Travel & Space
|
||||
'🚀', '🛸', '🌌', '🌍', '🌎', '🌏', '🗺️', '🧭', '⛰️', '🏔️',
|
||||
# Fantasy & Magic
|
||||
'🔮', '🎃', '👻', '🦄', '🐉', '🐲', '🧙', '🧚', '🧛', '🧜',
|
||||
# Awards & Achievement
|
||||
'🥇', '🥈', '🥉', '🏅', '🎖️', '👑', '💎', '💍', '🔱', '🎗️',
|
||||
# Time & Organization
|
||||
'⏰', '⏱️', '⌛', '⏳', '🕰️', '🔔', '📅', '📆', '📌', '📍',
|
||||
# Learning & Science
|
||||
'🎓', '🏫', '📝', '✏️', '📐', '📏', '🔬', '🔭', '🖌️', '🖍️',
|
||||
# Nature & Weather
|
||||
'🌈', '☀️', '🌙', '🌸', '🌺', '🌻', '🌹', '🌷', '🍀', '🌱'
|
||||
]
|
||||
|
||||
shelf = ub.session.query(ub.MagicShelf).get(shelf_id)
|
||||
if not shelf:
|
||||
log.warning(f"Magic shelf {shelf_id} not found")
|
||||
abort(404)
|
||||
|
||||
# Check if user can edit this shelf (owner or admin only)
|
||||
if shelf.user_id != current_user.id and not current_user.role_admin():
|
||||
log.warning(f"User {current_user.id} attempted to edit magic shelf {shelf_id} without permission")
|
||||
abort(403)
|
||||
|
||||
if request.method == "POST":
|
||||
data = request.get_json()
|
||||
name = strip_whitespaces(data.get('name', shelf.name))
|
||||
rules = data.get('rules', shelf.rules)
|
||||
icon = data.get('icon', shelf.icon)
|
||||
kobo_sync = data.get('kobo_sync', shelf.kobo_sync)
|
||||
is_public = data.get('is_public', shelf.is_public == 1)
|
||||
|
||||
# Only allow changing public status if user has permission
|
||||
if is_public != (shelf.is_public == 1):
|
||||
if not current_user.role_edit_shelfs():
|
||||
return jsonify({"success": False, "message": _("Permission denied to change public status")}), 403
|
||||
|
||||
# Validate inputs
|
||||
if not name:
|
||||
return jsonify({"success": False, "message": _("Shelf name is required")}), 400
|
||||
|
||||
if len(name) > 100:
|
||||
return jsonify({"success": False, "message": _("Shelf name too long (max 100 characters)")}), 400
|
||||
|
||||
# Use default icon if none provided, otherwise accept any emoji/symbol
|
||||
if not icon:
|
||||
icon = '🪄'
|
||||
|
||||
try:
|
||||
shelf.name = name
|
||||
shelf.rules = rules
|
||||
shelf.icon = icon
|
||||
shelf.kobo_sync = kobo_sync
|
||||
shelf.is_public = 1 if is_public else 0
|
||||
flag_modified(shelf, "rules")
|
||||
|
||||
# Invalidate Complex Query Cache
|
||||
ub.session.query(ub.MagicShelfCache).filter_by(shelf_id=shelf.id).delete()
|
||||
|
||||
ub.session_commit()
|
||||
|
||||
# Invalidate cache
|
||||
if 'magic_shelf_counts' in flask_session:
|
||||
counts = flask_session['magic_shelf_counts']
|
||||
if str(shelf_id) in counts:
|
||||
del counts[str(shelf_id)]
|
||||
flask_session.modified = True
|
||||
|
||||
log.info(f"User {current_user.id} updated magic shelf {shelf_id} ('{name}') with icon '{icon}'")
|
||||
return jsonify({"success": True})
|
||||
except Exception as e:
|
||||
log.error(f"Error updating magic shelf {shelf_id}: {e}")
|
||||
ub.session.rollback()
|
||||
return jsonify({"success": False, "message": _("Error updating shelf")}), 500
|
||||
|
||||
# For GET request, render the edit form
|
||||
# Fetch available languages for the dropdown
|
||||
languages = calibre_db.session.query(db.Languages).all()
|
||||
language_map = {}
|
||||
for lang in languages:
|
||||
try:
|
||||
lang_name = isoLanguages.get_language_name(get_locale(), lang.lang_code)
|
||||
language_map[lang.lang_code] = lang_name
|
||||
except:
|
||||
language_map[lang.lang_code] = lang.lang_code
|
||||
|
||||
return render_title_template('magic_shelf_edit.html',
|
||||
shelf=shelf,
|
||||
title=_("Edit Magic Shelf"),
|
||||
page="magic_shelf_edit",
|
||||
allowed_icons=ALLOWED_ICONS,
|
||||
languages=language_map)
|
||||
|
||||
|
||||
@web.route("/magicshelf/<int:shelf_id>/duplicate", methods=["POST"])
|
||||
@user_login_required
|
||||
def duplicate_magic_shelf(shelf_id):
|
||||
"""Duplicate an existing magic shelf (especially useful for system templates)."""
|
||||
shelf = ub.session.query(ub.MagicShelf).get(shelf_id)
|
||||
if not shelf:
|
||||
log.warning(f"Magic shelf {shelf_id} not found for duplication")
|
||||
return jsonify({"success": False, "message": _("Shelf not found")}), 404
|
||||
|
||||
# Users can duplicate their own shelves or any public shelf
|
||||
if shelf.user_id != current_user.id and not shelf.is_public:
|
||||
log.warning(f"User {current_user.id} attempted to duplicate private shelf {shelf_id} owned by {shelf.user_id}")
|
||||
return jsonify({"success": False, "message": _("Permission denied")}), 403
|
||||
|
||||
try:
|
||||
# Create duplicate with " (Copy)" suffix
|
||||
new_name = f"{shelf.name} (Copy)"
|
||||
|
||||
# If name already exists, add number
|
||||
counter = 1
|
||||
while ub.session.query(ub.MagicShelf).filter(
|
||||
ub.MagicShelf.user_id == current_user.id,
|
||||
ub.MagicShelf.name == new_name
|
||||
).first():
|
||||
counter += 1
|
||||
new_name = f"{shelf.name} (Copy {counter})"
|
||||
|
||||
duplicate_shelf = ub.MagicShelf(
|
||||
user_id=current_user.id,
|
||||
name=new_name,
|
||||
icon=shelf.icon,
|
||||
rules=shelf.rules.copy() if shelf.rules else {},
|
||||
is_system=False, # Duplicates are never system shelves
|
||||
is_public=0 # Duplicates start as private
|
||||
)
|
||||
|
||||
ub.session.add(duplicate_shelf)
|
||||
ub.session_commit()
|
||||
|
||||
log.info(f"User {current_user.id} duplicated magic shelf {shelf_id} as '{new_name}' (ID: {duplicate_shelf.id})")
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"shelf_id": duplicate_shelf.id,
|
||||
"message": _("Shelf duplicated successfully")
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error duplicating magic shelf {shelf_id}: {e}")
|
||||
ub.session.rollback()
|
||||
return jsonify({"success": False, "message": _("Error duplicating shelf")}), 500
|
||||
|
||||
|
||||
@web.route("/magicshelf/<int:shelf_id>/delete", methods=["POST"])
|
||||
@user_login_required
|
||||
def delete_magic_shelf(shelf_id):
|
||||
shelf = ub.session.query(ub.MagicShelf).get(shelf_id)
|
||||
if not shelf:
|
||||
log.warning(f"Magic shelf {shelf_id} not found for deletion")
|
||||
abort(404)
|
||||
|
||||
# Check if user can delete this shelf
|
||||
can_delete = False
|
||||
if shelf.user_id == current_user.id:
|
||||
can_delete = True
|
||||
elif shelf.is_public == 1 and current_user.role_edit_shelfs():
|
||||
can_delete = True
|
||||
|
||||
if not can_delete:
|
||||
log.warning(f"User {current_user.id} attempted to delete magic shelf {shelf_id} without permission")
|
||||
abort(403)
|
||||
|
||||
# Prevent deletion of system shelves
|
||||
if shelf.is_system:
|
||||
log.warning(f"User {current_user.id} attempted to delete system shelf {shelf_id}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": _("System shelves cannot be deleted. You can hide them in your user profile settings.")
|
||||
}), 400
|
||||
|
||||
try:
|
||||
shelf_name = shelf.name
|
||||
# Delete cache entries first
|
||||
ub.session.query(ub.MagicShelfCache).filter_by(shelf_id=shelf_id).delete()
|
||||
# Delete any hide records for this shelf
|
||||
ub.session.query(ub.HiddenMagicShelfTemplate).filter_by(shelf_id=shelf_id).delete()
|
||||
# Delete the shelf
|
||||
ub.session.delete(shelf)
|
||||
ub.session_commit()
|
||||
log.info(f"User {current_user.id} deleted magic shelf {shelf_id} ('{shelf_name}')")
|
||||
return jsonify({"success": True})
|
||||
except Exception as e:
|
||||
log.error(f"Error deleting magic shelf {shelf_id}: {e}")
|
||||
ub.session.rollback()
|
||||
return jsonify({"success": False, "message": _("Error deleting shelf")}), 500
|
||||
|
||||
|
||||
@web.route("/magicshelf/<int:shelf_id>/hide", methods=["POST"])
|
||||
@user_login_required
|
||||
def hide_magic_shelf(shelf_id):
|
||||
"""Hide a public magic shelf (user doesn't want to see it in their sidebar)."""
|
||||
shelf = ub.session.query(ub.MagicShelf).get(shelf_id)
|
||||
if not shelf:
|
||||
log.warning(f"Magic shelf {shelf_id} not found")
|
||||
abort(404)
|
||||
|
||||
# Can only hide shelves you don't own (public or system)
|
||||
if shelf.user_id == current_user.id:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": _("You cannot hide your own shelves. Delete them instead if you don't want them.")
|
||||
}), 400
|
||||
|
||||
# Check if already hidden
|
||||
existing = ub.session.query(ub.HiddenMagicShelfTemplate).filter(
|
||||
ub.HiddenMagicShelfTemplate.user_id == current_user.id,
|
||||
ub.HiddenMagicShelfTemplate.shelf_id == shelf_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return jsonify({"success": True, "message": _("Shelf already hidden")})
|
||||
|
||||
try:
|
||||
hidden = ub.HiddenMagicShelfTemplate(
|
||||
user_id=current_user.id,
|
||||
shelf_id=shelf_id
|
||||
)
|
||||
ub.session.add(hidden)
|
||||
ub.session_commit()
|
||||
log.info(f"User {current_user.id} hid magic shelf {shelf_id} ('{shelf.name}')")
|
||||
return jsonify({"success": True})
|
||||
except Exception as e:
|
||||
log.error(f"Error hiding magic shelf {shelf_id}: {e}")
|
||||
ub.session.rollback()
|
||||
return jsonify({"success": False, "message": _("Error hiding shelf")}), 500
|
||||
|
||||
|
||||
@web.route("/magicshelf/<int:shelf_id>/unhide", methods=["POST"])
|
||||
@user_login_required
|
||||
def unhide_magic_shelf(shelf_id):
|
||||
"""Unhide a previously hidden magic shelf."""
|
||||
try:
|
||||
hidden = ub.session.query(ub.HiddenMagicShelfTemplate).filter(
|
||||
ub.HiddenMagicShelfTemplate.user_id == current_user.id,
|
||||
ub.HiddenMagicShelfTemplate.shelf_id == shelf_id
|
||||
).first()
|
||||
|
||||
if not hidden:
|
||||
return jsonify({"success": True, "message": _("Shelf was not hidden")})
|
||||
|
||||
ub.session.delete(hidden)
|
||||
ub.session_commit()
|
||||
log.info(f"User {current_user.id} unhid magic shelf {shelf_id}")
|
||||
return jsonify({"success": True})
|
||||
except Exception as e:
|
||||
log.error(f"Error unhiding magic shelf {shelf_id}: {e}")
|
||||
ub.session.rollback()
|
||||
return jsonify({"success": False, "message": _("Error unhiding shelf")}), 500
|
||||
|
||||
|
||||
@web.route("/table")
|
||||
@user_login_required
|
||||
def books_table():
|
||||
@@ -1368,6 +1879,21 @@ def send_to_ereader(book_id, book_format, convert):
|
||||
current_user.name, current_user.kindle_mail_subject)
|
||||
if result is None:
|
||||
ub.update_download(book_id, int(current_user.id))
|
||||
# Track email/send activity
|
||||
try:
|
||||
from scripts.cwa_db import CWA_DB
|
||||
book = calibre_db.get_book(book_id)
|
||||
cwa_db = CWA_DB()
|
||||
cwa_db.log_activity(
|
||||
user_id=int(current_user.id),
|
||||
user_name=current_user.name,
|
||||
event_type='EMAIL',
|
||||
item_id=book_id,
|
||||
item_title=book.title if book else 'Unknown',
|
||||
extra_data=book_format.upper()
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to log email activity: {e}")
|
||||
response = [{'type': "success", 'message': _("Success! Book queued for sending to %(eReadermail)s",
|
||||
eReadermail=current_user.kindle_mail)}]
|
||||
else:
|
||||
@@ -1395,6 +1921,21 @@ def send_to_selected_ereaders(book_id):
|
||||
|
||||
if result is None:
|
||||
ub.update_download(book_id, int(current_user.id))
|
||||
# Track email/send activity
|
||||
try:
|
||||
from scripts.cwa_db import CWA_DB
|
||||
book = calibre_db.get_book(book_id)
|
||||
cwa_db = CWA_DB()
|
||||
cwa_db.log_activity(
|
||||
user_id=int(current_user.id),
|
||||
user_name=current_user.name,
|
||||
event_type='EMAIL',
|
||||
item_id=book_id,
|
||||
item_title=book.title if book else 'Unknown',
|
||||
extra_data=book_format.upper()
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to log email activity: {e}")
|
||||
response = [{'type': "success", 'message': _("Success! Book queued for sending to the selected address(es)!")}]
|
||||
else:
|
||||
response = [{'type': "danger", 'message': _("Oops! There was an error sending book: %(res)s", res=result)}]
|
||||
@@ -1484,6 +2025,19 @@ def register():
|
||||
|
||||
def handle_login_user(user, remember, message, category):
|
||||
login_user(user, remember=remember)
|
||||
|
||||
# Track login activity
|
||||
try:
|
||||
from scripts.cwa_db import CWA_DB
|
||||
cwa_db = CWA_DB()
|
||||
cwa_db.log_activity(
|
||||
user_id=int(user.id),
|
||||
user_name=user.name,
|
||||
event_type='LOGIN'
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to log login activity: {e}")
|
||||
|
||||
flash(message, category=category)
|
||||
[limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
|
||||
|
||||
@@ -1507,12 +2061,14 @@ def render_login(username="", password=""):
|
||||
if url_for("web.logout") == next_url:
|
||||
next_url = url_for("web.index")
|
||||
|
||||
# Get OAuth check status
|
||||
oauth_check = oauth_bb.oauth_check if feature_support['oauth'] else {}
|
||||
|
||||
# Get generic OAuth login button text for display
|
||||
generic_login_button = None
|
||||
if feature_support['oauth']:
|
||||
try:
|
||||
# oauth_bb is already imported at module level, access oauthblueprints from it
|
||||
from . import oauth_bb
|
||||
# oauthblueprints[2] is the generic OIDC provider (index 0=github, 1=google, 2=generic)
|
||||
if hasattr(oauth_bb, 'oauthblueprints') and len(oauth_bb.oauthblueprints) > 2:
|
||||
generic_login_button = oauth_bb.oauthblueprints[2].get('login_button') or 'OpenID Connect'
|
||||
@@ -1645,6 +2201,23 @@ def login_post():
|
||||
# Use request.remote_addr (already corrected by ProxyFix) instead of raw header
|
||||
ip_address = request.remote_addr
|
||||
log.warning('LDAP Login failed for user "%s" IP-address: %s', username, ip_address)
|
||||
|
||||
# Track failed login attempt
|
||||
try:
|
||||
from scripts.cwa_db import CWA_DB
|
||||
import json
|
||||
cwa_db = CWA_DB()
|
||||
cwa_db.log_activity(
|
||||
user_id=None,
|
||||
user_name='Anonymous',
|
||||
event_type='LOGIN_FAILED',
|
||||
item_id=None,
|
||||
item_title=None,
|
||||
extra_data=json.dumps({'username_attempted': username, 'ip': ip_address, 'method': 'LDAP'})
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to log failed login attempt: {e}")
|
||||
|
||||
flash(_(u"Wrong Username or Password"), category="error")
|
||||
flash(_(u"Wrong Username or Password"), category="error")
|
||||
else:
|
||||
@@ -1672,6 +2245,23 @@ def login_post():
|
||||
"success")
|
||||
else:
|
||||
log.warning('Login failed for user "{}" IP-address: {}'.format(username, ip_address))
|
||||
|
||||
# Track failed login attempt
|
||||
try:
|
||||
from scripts.cwa_db import CWA_DB
|
||||
import json
|
||||
cwa_db = CWA_DB()
|
||||
cwa_db.log_activity(
|
||||
user_id=None,
|
||||
user_name='Anonymous',
|
||||
event_type='LOGIN_FAILED',
|
||||
item_id=None,
|
||||
item_title=None,
|
||||
extra_data=json.dumps({'username_attempted': username, 'ip': ip_address, 'method': 'standard'})
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to log failed login attempt: {e}")
|
||||
|
||||
flash(_(u"Wrong Username or Password"), category="error")
|
||||
return render_login(username, form.get("password", ""))
|
||||
|
||||
@@ -1734,6 +2324,65 @@ def change_profile(kobo_support, hardcover_support, local_oauth_check, oauth_sta
|
||||
# Auto-send and metadata fetch settings
|
||||
current_user.auto_send_enabled = to_save.get("auto_send_enabled") == "on"
|
||||
current_user.auto_metadata_fetch = to_save.get("auto_metadata_fetch") == "on"
|
||||
|
||||
# Handle hidden magic shelf templates and custom shelves
|
||||
from . import magic_shelf
|
||||
if not current_user.is_anonymous:
|
||||
# Get all system template keys
|
||||
all_template_keys = set(magic_shelf.SYSTEM_SHELF_TEMPLATES.keys())
|
||||
# Get currently hidden items for this user
|
||||
current_hidden = ub.session.query(ub.HiddenMagicShelfTemplate).filter(
|
||||
ub.HiddenMagicShelfTemplate.user_id == current_user.id
|
||||
).all()
|
||||
current_hidden_template_keys = {h.template_key for h in current_hidden if h.template_key}
|
||||
current_hidden_shelf_ids = {h.shelf_id for h in current_hidden if h.shelf_id}
|
||||
|
||||
# Handle system templates
|
||||
visible_template_keys = {key for key in all_template_keys if to_save.get(f"show_magic_shelf_{key}") == "on"}
|
||||
should_be_hidden_templates = all_template_keys - visible_template_keys
|
||||
|
||||
# Add newly hidden templates
|
||||
for key in should_be_hidden_templates:
|
||||
if key not in current_hidden_template_keys:
|
||||
new_hidden = ub.HiddenMagicShelfTemplate(
|
||||
user_id=current_user.id,
|
||||
template_key=key
|
||||
)
|
||||
ub.session.add(new_hidden)
|
||||
log.info(f"User {current_user.id} hid system shelf template '{key}'")
|
||||
|
||||
# Remove templates that should no longer be hidden
|
||||
for hidden in current_hidden:
|
||||
if hidden.template_key and hidden.template_key in visible_template_keys:
|
||||
ub.session.delete(hidden)
|
||||
log.info(f"User {current_user.id} unhid system shelf template '{hidden.template_key}'")
|
||||
|
||||
# Handle custom public shelves - get all available ones
|
||||
all_public_shelves = ub.session.query(ub.MagicShelf).filter(
|
||||
ub.MagicShelf.is_public == 1,
|
||||
ub.MagicShelf.user_id != current_user.id,
|
||||
ub.MagicShelf.is_system == False
|
||||
).all()
|
||||
|
||||
# Check which ones should be visible (checked)
|
||||
visible_shelf_ids = {s.id for s in all_public_shelves if to_save.get(f"show_custom_shelf_{s.id}") == "on"}
|
||||
|
||||
# Hide shelves that are unchecked but not currently hidden
|
||||
for shelf in all_public_shelves:
|
||||
if shelf.id not in visible_shelf_ids and shelf.id not in current_hidden_shelf_ids:
|
||||
new_hidden = ub.HiddenMagicShelfTemplate(
|
||||
user_id=current_user.id,
|
||||
shelf_id=shelf.id
|
||||
)
|
||||
ub.session.add(new_hidden)
|
||||
log.info(f"User {current_user.id} hid custom shelf {shelf.id}")
|
||||
|
||||
# Unhide shelves that are checked but currently hidden
|
||||
for hidden in current_hidden:
|
||||
if hidden.shelf_id and hidden.shelf_id in visible_shelf_ids:
|
||||
ub.session.delete(hidden)
|
||||
log.info(f"User {current_user.id} unhid custom shelf {hidden.shelf_id}")
|
||||
|
||||
# Theme change
|
||||
if 'theme' in to_save:
|
||||
try:
|
||||
@@ -1751,7 +2400,7 @@ def change_profile(kobo_support, hardcover_support, local_oauth_check, oauth_sta
|
||||
translations=translations,
|
||||
profile=1,
|
||||
languages=languages,
|
||||
title=_("%(name)s's Profile", name=current_user.name),
|
||||
title=_(f"{current_user.name.capitalize()}'s Profile", name=current_user.name),
|
||||
page="me",
|
||||
kobo_support=kobo_support,
|
||||
hardcover_support=hardcover_support,
|
||||
@@ -1760,8 +2409,12 @@ def change_profile(kobo_support, hardcover_support, local_oauth_check, oauth_sta
|
||||
|
||||
val = 0
|
||||
for key, __ in to_save.items():
|
||||
if key.startswith('show'):
|
||||
val += int(key[5:])
|
||||
if key.startswith('show') and not key.startswith('show_magic_shelf_') and not key.startswith('show_custom_shelf_'):
|
||||
try:
|
||||
val += int(key[5:])
|
||||
except (ValueError, IndexError) as e:
|
||||
log.warning(f"Skipping invalid sidebar checkbox key: {key}")
|
||||
continue
|
||||
current_user.sidebar_view = val
|
||||
if to_save.get("Show_detail_random"):
|
||||
current_user.sidebar_view += constants.DETAIL_RANDOM
|
||||
@@ -1770,6 +2423,8 @@ def change_profile(kobo_support, hardcover_support, local_oauth_check, oauth_sta
|
||||
ub.session.commit()
|
||||
flash(_("Success! Profile Updated"), category="success")
|
||||
log.debug("Profile updated")
|
||||
# Redirect to refresh sidebar with updated shelf visibility
|
||||
return redirect(url_for('web.profile'))
|
||||
except IntegrityError:
|
||||
ub.session.rollback()
|
||||
flash(_("Oops! An account already exists for this Email."), category="error")
|
||||
@@ -1789,13 +2444,37 @@ def profile():
|
||||
hardcover_support = feature_support['hardcover']
|
||||
if feature_support['oauth'] and config.config_login_type == 2:
|
||||
oauth_status = get_oauth_status()
|
||||
local_oauth_check = oauth_check
|
||||
local_oauth_check = oauth_bb.oauth_check
|
||||
else:
|
||||
oauth_status = None
|
||||
local_oauth_check = {}
|
||||
|
||||
|
||||
if request.method == "POST":
|
||||
change_profile(kobo_support, hardcover_support, local_oauth_check, oauth_status, translations, languages)
|
||||
return change_profile(kobo_support, hardcover_support, local_oauth_check, oauth_status, translations, languages)
|
||||
|
||||
# Query magic shelf data after POST to get updated values
|
||||
from . import magic_shelf
|
||||
system_shelf_templates = magic_shelf.SYSTEM_SHELF_TEMPLATES
|
||||
hidden_items = ub.session.query(
|
||||
ub.HiddenMagicShelfTemplate.template_key,
|
||||
ub.HiddenMagicShelfTemplate.shelf_id
|
||||
).filter(
|
||||
ub.HiddenMagicShelfTemplate.user_id == current_user.id
|
||||
).all()
|
||||
hidden_shelf_templates = {item.template_key for item in hidden_items if item.template_key}
|
||||
hidden_custom_shelf_ids = {item.shelf_id for item in hidden_items if item.shelf_id}
|
||||
|
||||
# Get ALL public custom shelves that user doesn't own (both hidden and visible)
|
||||
all_public_shelves = ub.session.query(ub.MagicShelf).filter(
|
||||
ub.MagicShelf.is_public == 1,
|
||||
ub.MagicShelf.user_id != current_user.id,
|
||||
ub.MagicShelf.is_system == False
|
||||
).all()
|
||||
|
||||
# Separate into hidden and visible
|
||||
hidden_custom_shelves = [s for s in all_public_shelves if s.id in hidden_custom_shelf_ids]
|
||||
visible_public_shelves = [s for s in all_public_shelves if s.id not in hidden_custom_shelf_ids]
|
||||
|
||||
return render_title_template("user_edit.html",
|
||||
translations=translations,
|
||||
profile=1,
|
||||
@@ -1804,7 +2483,12 @@ def profile():
|
||||
config=config,
|
||||
kobo_support=kobo_support,
|
||||
hardcover_support=hardcover_support,
|
||||
title=_("%(name)s's Profile", name=current_user.name),
|
||||
system_shelf_templates=system_shelf_templates,
|
||||
hidden_shelf_templates=hidden_shelf_templates,
|
||||
hidden_custom_shelf_ids=hidden_custom_shelf_ids,
|
||||
hidden_custom_shelves=hidden_custom_shelves,
|
||||
visible_public_shelves=visible_public_shelves,
|
||||
title=_(f"{current_user.name.capitalize()}'s Profile", name=current_user.name),
|
||||
page="me",
|
||||
registered_oauth=local_oauth_check,
|
||||
oauth_status=oauth_status)
|
||||
@@ -1833,6 +2517,39 @@ def read_book(book_id, book_format):
|
||||
bookmark = ub.session.query(ub.Bookmark).filter(and_(ub.Bookmark.user_id == int(current_user.id),
|
||||
ub.Bookmark.book_id == book_id,
|
||||
ub.Bookmark.format == book_format.upper())).first()
|
||||
# Track read activity
|
||||
if current_user.is_authenticated:
|
||||
try:
|
||||
from scripts.cwa_db import CWA_DB
|
||||
import json
|
||||
|
||||
# Detect source of book discovery
|
||||
source = request.args.get('from', 'direct')
|
||||
referer = request.headers.get('Referer', '')
|
||||
if not source or source == 'direct':
|
||||
if '/search' in referer:
|
||||
source = 'search'
|
||||
elif '/series' in referer:
|
||||
source = 'series'
|
||||
elif '/author' in referer:
|
||||
source = 'author'
|
||||
elif '/category' in referer:
|
||||
source = 'category'
|
||||
elif '/shelf' in referer:
|
||||
source = 'shelf'
|
||||
|
||||
cwa_db = CWA_DB()
|
||||
cwa_db.log_activity(
|
||||
user_id=int(current_user.id),
|
||||
user_name=current_user.name,
|
||||
event_type='READ',
|
||||
item_id=book_id,
|
||||
item_title=book.title,
|
||||
extra_data=json.dumps({'format': book_format.upper(), 'source': source})
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to log read activity: {e}")
|
||||
|
||||
if book_format.lower() == "epub":
|
||||
log.debug("Start epub reader for %d", book_id)
|
||||
return render_title_template('read.html', bookid=book_id, title=book.title, bookmark=bookmark)
|
||||
@@ -1897,6 +2614,19 @@ def show_book(book_id):
|
||||
book_in_shelves.append(sh.shelf)
|
||||
|
||||
entry.tags = sort(entry.tags, key=lambda tag: tag.name)
|
||||
|
||||
# Filter tags based on user's allowed/denied tags (Issue #906)
|
||||
if current_user.is_authenticated:
|
||||
allowed_tags = current_user.list_allowed_tags()
|
||||
denied_tags = current_user.list_denied_tags()
|
||||
|
||||
# If allowed tags are configured (not empty), filter to only show allowed tags
|
||||
if allowed_tags and allowed_tags != ['']:
|
||||
entry.tags = [tag for tag in entry.tags if tag.name in allowed_tags]
|
||||
|
||||
# Remove denied tags
|
||||
if denied_tags and denied_tags != ['']:
|
||||
entry.tags = [tag for tag in entry.tags if tag.name not in denied_tags]
|
||||
|
||||
entry.ordered_authors = calibre_db.order_authors([entry])
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ install -d -o abc -g abc /config/processed_books/converted
|
||||
install -d -o abc -g abc /config/processed_books/imported
|
||||
install -d -o abc -g abc /config/processed_books/failed
|
||||
install -d -o abc -g abc /config/processed_books/fixed_originals
|
||||
install -d -o abc -g abc /config/processed_books/duplicate_resolutions
|
||||
install -d -o abc -g abc /config/log_archive
|
||||
install -d -o abc -g abc /config/.cwa_conversion_tmp
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# shellcheck shell=bash
|
||||
|
||||
export CALIBRE_DBPATH=/config
|
||||
export LD_LIBRARY_PATH=/app/calibre/lib
|
||||
|
||||
exec \
|
||||
s6-notifyoncheck -d -n 300 -w 1000 -c "nc -z localhost ${CWA_PORT_OVERRIDE:-8083}" \
|
||||
|
||||
+135
-27
@@ -119,7 +119,7 @@ class Book:
|
||||
|
||||
def get_split_library(self) -> dict[str, str] | None:
|
||||
"""Checks whether or not the user has split library enabled. Returns None if they don't and the path of the Split Library location if True."""
|
||||
con = sqlite3.connect("/config/app.db", timeout=30)
|
||||
con = sqlite3.connect("/config/app.db", timeout=60)
|
||||
cur = con.cursor()
|
||||
split_library = cur.execute('SELECT config_calibre_split FROM settings;').fetchone()[0]
|
||||
|
||||
@@ -157,9 +157,44 @@ class Book:
|
||||
|
||||
def get_new_metadata_path(self) -> str:
|
||||
"""Uses the export function of the calibredb utility to export any new metadata for the given book to metadata_temp, and returns the path to the new metadata.opf"""
|
||||
subprocess.run(["calibredb", "export", "--with-library", self.calibre_library, "--to-dir", metadata_temp_dir, self.book_id], env=self.calibre_env, check=True)
|
||||
temp_files = [os.path.join(dirpath,f) for (dirpath, dirnames, filenames) in os.walk(metadata_temp_dir) for f in filenames]
|
||||
return [f for f in temp_files if f.endswith('.opf')][0]
|
||||
# Add retry logic with exponential backoff to handle database locks
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Add small delay before first attempt to allow other operations to complete
|
||||
if attempt > 0:
|
||||
delay = 2 ** attempt # Exponential backoff: 2s, 4s
|
||||
print(f"[cover-metadata-enforcer] Retrying calibredb export (attempt {attempt + 1}/{max_retries}) after {delay}s delay...", flush=True)
|
||||
time.sleep(delay)
|
||||
else:
|
||||
# Small initial delay to ensure database writes are flushed
|
||||
time.sleep(0.5)
|
||||
|
||||
result = subprocess.run(
|
||||
["calibredb", "export", "--with-library", self.calibre_library, "--to-dir", metadata_temp_dir, self.book_id],
|
||||
env=self.calibre_env, check=False, capture_output=True, text=True, timeout=60
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
temp_files = [os.path.join(dirpath,f) for (dirpath, dirnames, filenames) in os.walk(metadata_temp_dir) for f in filenames]
|
||||
opf_files = [f for f in temp_files if f.endswith('.opf')]
|
||||
if opf_files:
|
||||
return opf_files[0]
|
||||
else:
|
||||
raise FileNotFoundError("No .opf file found after calibredb export")
|
||||
else:
|
||||
if attempt < max_retries - 1 and "database is locked" in result.stderr.lower():
|
||||
continue # Retry on database lock
|
||||
else:
|
||||
raise subprocess.CalledProcessError(result.returncode, result.args, result.stdout, result.stderr)
|
||||
except subprocess.TimeoutExpired:
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
else:
|
||||
raise
|
||||
|
||||
# If all retries failed
|
||||
raise RuntimeError(f"Failed to export metadata for book {self.book_id} after {max_retries} attempts")
|
||||
|
||||
|
||||
def export_as_dict(self) -> dict[str,str | None]:
|
||||
@@ -203,7 +238,7 @@ class Enforcer:
|
||||
|
||||
# Read Calibre-Web setting: config_unicode_filename (True -> transliterate non-English in filenames)
|
||||
try:
|
||||
with sqlite3.connect("/config/app.db", timeout=30) as con:
|
||||
with sqlite3.connect("/config/app.db", timeout=60) as con:
|
||||
cur = con.cursor()
|
||||
self.unicode_filename = bool(cur.execute('SELECT config_unicode_filename FROM settings;').fetchone()[0])
|
||||
except Exception:
|
||||
@@ -222,7 +257,7 @@ class Enforcer:
|
||||
|
||||
def get_split_library(self) -> dict[str, str] | None:
|
||||
"""Checks whether or not the user has split library enabled. Returns None if they don't and the path of the Split Library location if True."""
|
||||
con = sqlite3.connect("/config/app.db", timeout=30)
|
||||
con = sqlite3.connect("/config/app.db", timeout=60)
|
||||
cur = con.cursor()
|
||||
split_library = cur.execute('SELECT config_calibre_split FROM settings;').fetchone()[0]
|
||||
|
||||
@@ -268,7 +303,7 @@ class Enforcer:
|
||||
"metadata.db"
|
||||
)
|
||||
|
||||
con = sqlite3.connect(metadb_path, timeout=30)
|
||||
con = sqlite3.connect(metadb_path, timeout=60)
|
||||
|
||||
try:
|
||||
success = store_checksum(
|
||||
@@ -292,30 +327,69 @@ class Enforcer:
|
||||
|
||||
|
||||
def read_log(self, auto=True, log_path: str = "None") -> dict:
|
||||
"""Reads pertinent information from the given log file, adds the book_id from the log name and returns the info as a dict"""
|
||||
"""Reads pertinent information from the given log file, adds the book_id from the log name and returns the info as a dict.
|
||||
Returns None if the file doesn't exist after retries (handles race conditions)."""
|
||||
if auto:
|
||||
file_path = f'{change_logs_dir}/{self.args.log}'
|
||||
book_id = (self.args.log.split('-')[1]).split('.')[0]
|
||||
timestamp_raw = self.args.log.split('-')[0]
|
||||
timestamp = datetime.strptime(timestamp_raw, '%Y%m%d%H%M%S')
|
||||
|
||||
log_info = {}
|
||||
with open(f'{change_logs_dir}/{self.args.log}', 'r', encoding='utf-8') as f:
|
||||
log_info = json.load(f)
|
||||
log_info['book_id'] = book_id
|
||||
log_info['timestamp'] = timestamp.strftime('%Y-%m-%d %H:%M:%S')
|
||||
else:
|
||||
file_path = log_path
|
||||
log_name = os.path.basename(log_path)
|
||||
book_id = (log_name.split('-')[1]).split('.')[0]
|
||||
timestamp_raw = log_name.split('-')[0]
|
||||
|
||||
try:
|
||||
timestamp = datetime.strptime(timestamp_raw, '%Y%m%d%H%M%S')
|
||||
except ValueError as e:
|
||||
print(f"[cover-metadata-enforcer] ERROR: Invalid timestamp format in log filename: {e}", flush=True)
|
||||
return None
|
||||
|
||||
log_info = {}
|
||||
with open(log_path, 'r', encoding='utf-8') as f:
|
||||
log_info = json.load(f)
|
||||
log_info['book_id'] = book_id
|
||||
log_info['timestamp'] = timestamp.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
return log_info
|
||||
# Retry logic to handle race conditions where file is detected but not yet fully written
|
||||
max_retries = 3
|
||||
retry_delay = 0.5 # seconds
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Check if file exists first
|
||||
if not os.path.exists(file_path):
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(retry_delay)
|
||||
continue
|
||||
else:
|
||||
print(f"[cover-metadata-enforcer] WARNING: Log file '{os.path.basename(file_path)}' not found after {max_retries} attempts. "
|
||||
f"This may be due to a race condition or the file was already processed and deleted.", flush=True)
|
||||
return None
|
||||
|
||||
# Try to read the file
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
log_info = json.load(f)
|
||||
|
||||
log_info['book_id'] = book_id
|
||||
log_info['timestamp'] = timestamp.strftime('%Y-%m-%d %H:%M:%S')
|
||||
return log_info
|
||||
|
||||
except FileNotFoundError:
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(retry_delay)
|
||||
continue
|
||||
else:
|
||||
print(f"[cover-metadata-enforcer] WARNING: Log file '{os.path.basename(file_path)}' not found after {max_retries} attempts. "
|
||||
f"This may be due to a race condition or the file was already processed and deleted.", flush=True)
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
if attempt < max_retries - 1:
|
||||
# File might still be being written
|
||||
time.sleep(retry_delay)
|
||||
continue
|
||||
else:
|
||||
print(f"[cover-metadata-enforcer] ERROR: Failed to parse log file '{os.path.basename(file_path)}': {e}", flush=True)
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[cover-metadata-enforcer] ERROR: Unexpected error reading log file '{os.path.basename(file_path)}': {e}", flush=True)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_book_dir_from_log(self, log_info: dict) -> str:
|
||||
@@ -333,9 +407,12 @@ class Enforcer:
|
||||
(self.split_library or {}).get("db_path", self.calibre_library),
|
||||
"metadata.db",
|
||||
)
|
||||
with sqlite3.connect(metadb_path, timeout=30) as con:
|
||||
con = sqlite3.connect(metadb_path, timeout=60)
|
||||
try:
|
||||
cur = con.cursor()
|
||||
row = cur.execute('SELECT path FROM books WHERE id = ?', (book_id,)).fetchone()
|
||||
finally:
|
||||
con.close()
|
||||
if row and row[0]:
|
||||
resolved = os.path.join(self.calibre_library, row[0])
|
||||
resolved = resolved if resolved.endswith(os.sep) else resolved + os.sep
|
||||
@@ -468,10 +545,32 @@ class Enforcer:
|
||||
for file in supported_files:
|
||||
book = Book(book_dir, file)
|
||||
self.replace_old_metadata(book.old_metadata_path, book.new_metadata_path)
|
||||
if Path(book.cover_path).exists():
|
||||
os.system(f'ebook-polish -c "{book.cover_path}" -o "{book.new_metadata_path}" -U "{file}" "{file}"')
|
||||
else:
|
||||
os.system(f'ebook-polish -o "{book.new_metadata_path}" -U "{file}" "{file}"')
|
||||
|
||||
# Use subprocess instead of os.system for better error handling
|
||||
# Add small delay to ensure any file locks are released
|
||||
time.sleep(0.5)
|
||||
|
||||
try:
|
||||
if Path(book.cover_path).exists():
|
||||
result = subprocess.run(
|
||||
['ebook-polish', '-c', book.cover_path, '-o', book.new_metadata_path, '-U', file, file],
|
||||
capture_output=True, text=True, timeout=120, check=False
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
['ebook-polish', '-o', book.new_metadata_path, '-U', file, file],
|
||||
capture_output=True, text=True, timeout=120, check=False
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"[cover-metadata-enforcer] Warning: ebook-polish returned {result.returncode} for {file}", flush=True)
|
||||
if result.stderr:
|
||||
print(f"[cover-metadata-enforcer] Error output: {result.stderr.strip()}", flush=True)
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"[cover-metadata-enforcer] Error: ebook-polish timed out for {file}", flush=True)
|
||||
except Exception as e:
|
||||
print(f"[cover-metadata-enforcer] Error running ebook-polish for {file}: {e}", flush=True)
|
||||
|
||||
self.empty_metadata_temp()
|
||||
print(f"[cover-metadata-enforcer]: DONE: '{book.title_author}.{book.file_format}': Cover & Metadata updated", flush=True)
|
||||
|
||||
@@ -554,6 +653,9 @@ class Enforcer:
|
||||
for log in log_files:
|
||||
if log.endswith('.json'):
|
||||
log_info = self.read_log(auto=False, log_path=log)
|
||||
# Skip if log_info is None (file was deleted or invalid)
|
||||
if log_info is None:
|
||||
continue
|
||||
book_dir = self.get_book_dir_from_log(log_info)
|
||||
book_objects = self.enforce_cover(book_dir)
|
||||
if book_objects:
|
||||
@@ -626,6 +728,12 @@ def main():
|
||||
elif args.log is not None and args.dir is None and args.all is False and args.list is False and args.history is False:
|
||||
### log passed: (args.log), no dir
|
||||
log_info = enforcer.read_log()
|
||||
|
||||
# Handle case where log file doesn't exist (race condition)
|
||||
if log_info is None:
|
||||
print(f"[cover-metadata-enforcer] Skipping processing due to missing or invalid log file. This is normal if the file was already processed.")
|
||||
sys.exit(0)
|
||||
|
||||
book_dir = enforcer.get_book_dir_from_log(log_info)
|
||||
if enforcer.enforcer_on:
|
||||
book_objects = enforcer.enforce_cover(book_dir)
|
||||
|
||||
+1888
-25
File diff suppressed because it is too large
Load Diff
+103
-1
@@ -69,7 +69,30 @@ CREATE TABLE IF NOT EXISTS cwa_settings(
|
||||
duplicate_detection_language SMALLINT DEFAULT 1 NOT NULL,
|
||||
duplicate_detection_series SMALLINT DEFAULT 0 NOT NULL,
|
||||
duplicate_detection_publisher SMALLINT DEFAULT 0 NOT NULL,
|
||||
duplicate_detection_format SMALLINT DEFAULT 0 NOT NULL
|
||||
duplicate_detection_format SMALLINT DEFAULT 0 NOT NULL,
|
||||
hardcover_auto_fetch_enabled SMALLINT DEFAULT 0 NOT NULL,
|
||||
hardcover_auto_fetch_schedule TEXT DEFAULT 'weekly' NOT NULL,
|
||||
hardcover_auto_fetch_schedule_day TEXT DEFAULT 'sunday' NOT NULL,
|
||||
hardcover_auto_fetch_schedule_hour INTEGER DEFAULT 2 NOT NULL,
|
||||
hardcover_auto_fetch_min_confidence REAL DEFAULT 0.85 NOT NULL,
|
||||
hardcover_auto_fetch_batch_size INTEGER DEFAULT 50 NOT NULL,
|
||||
hardcover_auto_fetch_rate_limit REAL DEFAULT 5.0 NOT NULL,
|
||||
-- Duplicate notification and auto-resolution settings
|
||||
duplicate_detection_enabled SMALLINT DEFAULT 1 NOT NULL,
|
||||
duplicate_notifications_enabled SMALLINT DEFAULT 1 NOT NULL,
|
||||
duplicate_auto_resolve_enabled SMALLINT DEFAULT 0 NOT NULL,
|
||||
duplicate_auto_resolve_strategy TEXT DEFAULT 'newest' NOT NULL,
|
||||
duplicate_auto_resolve_cooldown_minutes INTEGER DEFAULT 0 NOT NULL, -- 0 = disabled, >0 = minutes between auto-resolutions
|
||||
duplicate_format_priority TEXT DEFAULT '{"EPUB":100,"KEPUB":95,"AZW3":90,"MOBI":80,"AZW":75,"PDF":60,"TXT":40,"CBZ":35,"CBR":35,"FB2":30,"DJVU":25,"HTML":20,"RTF":15,"DOC":10,"DOCX":10}' NOT NULL,
|
||||
-- Duplicate scanning performance settings
|
||||
duplicate_detection_use_sql SMALLINT DEFAULT 1 NOT NULL, -- Enable SQL prefilter for hybrid by default
|
||||
duplicate_scan_method TEXT DEFAULT 'hybrid' NOT NULL, -- Use hybrid prefilter by default
|
||||
duplicate_scan_enabled SMALLINT DEFAULT 1 NOT NULL,
|
||||
duplicate_scan_frequency TEXT DEFAULT 'after_import' NOT NULL,
|
||||
duplicate_scan_cron TEXT DEFAULT '' NOT NULL,
|
||||
duplicate_scan_hour INTEGER DEFAULT 3 NOT NULL,
|
||||
duplicate_scan_chunk_size INTEGER DEFAULT 5000 NOT NULL,
|
||||
duplicate_scan_debounce_seconds INTEGER DEFAULT 30 NOT NULL
|
||||
);
|
||||
|
||||
-- Persisted scheduled jobs (initial focus: auto-send). Rows remain until dispatched or manually cleared.
|
||||
@@ -86,3 +109,82 @@ CREATE TABLE IF NOT EXISTS cwa_scheduled_jobs(
|
||||
state TEXT NOT NULL DEFAULT 'scheduled', -- 'scheduled' | 'dispatched' | 'cancelled'
|
||||
last_error TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cwa_user_activity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
user_name TEXT,
|
||||
event_type TEXT,
|
||||
item_id INTEGER,
|
||||
item_title TEXT,
|
||||
extra_data TEXT,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_user ON cwa_user_activity(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_event ON cwa_user_activity(event_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_time ON cwa_user_activity(timestamp);
|
||||
|
||||
-- Hardcover auto-fetch match queue for manual review of ambiguous matches
|
||||
CREATE TABLE IF NOT EXISTS hardcover_match_queue(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
book_id INTEGER NOT NULL,
|
||||
book_title TEXT NOT NULL,
|
||||
book_authors TEXT NOT NULL,
|
||||
search_query TEXT NOT NULL,
|
||||
hardcover_results TEXT NOT NULL, -- JSON array of MetaRecord candidates
|
||||
confidence_scores TEXT NOT NULL, -- JSON array of [score, reason] tuples
|
||||
created_at TEXT NOT NULL,
|
||||
reviewed INTEGER DEFAULT 0 NOT NULL, -- 0=pending, 1=reviewed
|
||||
selected_result_id TEXT DEFAULT NULL, -- Hardcover ID if manually selected
|
||||
review_action TEXT DEFAULT NULL, -- 'accept', 'reject', 'skip'
|
||||
reviewed_at TEXT DEFAULT NULL,
|
||||
reviewed_by TEXT DEFAULT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_hardcover_queue_book ON hardcover_match_queue(book_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_hardcover_queue_reviewed ON hardcover_match_queue(reviewed);
|
||||
|
||||
-- Stats for hardcover auto-fetch operations
|
||||
CREATE TABLE IF NOT EXISTS hardcover_auto_fetch_stats(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
books_processed INTEGER DEFAULT 0 NOT NULL,
|
||||
auto_matched INTEGER DEFAULT 0 NOT NULL,
|
||||
queued_for_review INTEGER DEFAULT 0 NOT NULL,
|
||||
skipped_no_results INTEGER DEFAULT 0 NOT NULL,
|
||||
errors INTEGER DEFAULT 0 NOT NULL,
|
||||
avg_confidence REAL DEFAULT 0.0 NOT NULL
|
||||
);
|
||||
|
||||
-- Duplicate detection cache table
|
||||
CREATE TABLE IF NOT EXISTS cwa_duplicate_cache (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1), -- Singleton table, only one row
|
||||
scan_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
duplicate_groups_json TEXT, -- JSON serialized duplicate groups
|
||||
total_count INTEGER DEFAULT 0,
|
||||
scan_pending INTEGER DEFAULT 1, -- 1=needs scan, 0=cache valid
|
||||
last_scanned_book_id INTEGER DEFAULT 0, -- Track last scanned book for incremental updates
|
||||
scan_duration_seconds REAL DEFAULT 0, -- Performance tracking
|
||||
scan_method_used TEXT DEFAULT 'python' -- Track which method was used: 'sql', 'python', 'hybrid'
|
||||
);
|
||||
|
||||
-- Insert default row for cache table
|
||||
INSERT OR IGNORE INTO cwa_duplicate_cache (id, scan_pending) VALUES (1, 1);
|
||||
|
||||
-- Auto-resolution audit log
|
||||
CREATE TABLE IF NOT EXISTS cwa_duplicate_resolutions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
group_hash TEXT NOT NULL,
|
||||
group_title TEXT,
|
||||
group_author TEXT,
|
||||
kept_book_id INTEGER NOT NULL,
|
||||
deleted_book_ids TEXT NOT NULL, -- JSON array of deleted IDs
|
||||
strategy TEXT NOT NULL, -- 'newest', 'highest_quality_format', 'most_metadata', 'largest_file_size'
|
||||
trigger_type TEXT NOT NULL, -- 'manual', 'scheduled', 'automatic'
|
||||
backed_up INTEGER DEFAULT 1, -- 1=yes, 0=no
|
||||
user_id INTEGER, -- NULL for automatic, admin user ID for manual
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_duplicate_resolutions_timestamp ON cwa_duplicate_resolutions(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_duplicate_resolutions_group_hash ON cwa_duplicate_resolutions(group_hash);
|
||||
|
||||
@@ -15,10 +15,11 @@ on every boot (via cwa-checksum-backfill service) to backfill any missing checks
|
||||
for newly added books.
|
||||
|
||||
Usage:
|
||||
python generate_book_checksums.py [--library-path /path/to/calibre/library] [--force]
|
||||
python generate_book_checksums.py [--library-path /path/to/calibre/library] [--books-path /path/to/books] [--force]
|
||||
|
||||
Options:
|
||||
--library-path Path to Calibre library directory (defaults to /calibre-library)
|
||||
--books-path Path to books directory (defaults to config_calibre_split_dir setting with --library-path fallback)
|
||||
--force Regenerate checksums even if they already exist
|
||||
--batch-size Number of books to process before committing (default: 100)
|
||||
"""
|
||||
@@ -32,11 +33,12 @@ from pathlib import Path
|
||||
# Import the centralized partial MD5 calculation function
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
from cps.progress_syncing.checksums import calculate_koreader_partial_md5, store_checksum, CHECKSUM_VERSION
|
||||
def generate_checksums(library_path: str, force: bool = False, batch_size: int = 100):
|
||||
def generate_checksums(library_path: str, books_path: str = None, force: bool = False, batch_size: int = 100):
|
||||
"""Generate checksums for all books in the library
|
||||
|
||||
Args:
|
||||
library_path: Path to Calibre library directory
|
||||
library_path: Path to Calibre library directory (contains metadata.db)
|
||||
books_path: Path to books directory (if different from library_path in split mode)
|
||||
force: If True, regenerate checksums even if they exist
|
||||
batch_size: Number of books to process before committing
|
||||
"""
|
||||
@@ -46,7 +48,14 @@ def generate_checksums(library_path: str, force: bool = False, batch_size: int =
|
||||
print(f"ERROR: Calibre database not found at {metadata_db}")
|
||||
sys.exit(1)
|
||||
|
||||
# Use books_path if provided and valid, otherwise fall back to library_path
|
||||
base_path = books_path if (books_path and os.path.exists(books_path)) else library_path
|
||||
|
||||
print(f"Connecting to Calibre library at: {library_path}")
|
||||
if base_path != library_path:
|
||||
print(f"Books path (split library mode): {base_path}")
|
||||
else:
|
||||
print(f"Books path: {base_path}")
|
||||
print(f"Force regenerate: {force}")
|
||||
print(f"Batch size: {batch_size}")
|
||||
print(f"Checksum version: {CHECKSUM_VERSION}")
|
||||
@@ -98,7 +107,7 @@ def generate_checksums(library_path: str, force: bool = False, batch_size: int =
|
||||
processed += 1
|
||||
|
||||
# Construct full file path
|
||||
file_path = os.path.join(library_path, book_path, f"{format_name}.{format_ext.lower()}")
|
||||
file_path = os.path.join(base_path, book_path, f"{format_name}.{format_ext.lower()}")
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
print(f"[{processed}/{total}] SKIP: File not found - {title} ({format_ext})")
|
||||
@@ -152,6 +161,42 @@ def generate_checksums(library_path: str, force: bool = False, batch_size: int =
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_books_path():
|
||||
"""
|
||||
Get the split library books path from app.db if split mode is enabled.
|
||||
|
||||
Returns:
|
||||
The books path from config_calibre_split_dir if it exists and is valid,
|
||||
otherwise None to indicate the library path should be used.
|
||||
"""
|
||||
try:
|
||||
conn = sqlite3.connect("/config/app.db", timeout=30)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Check if split mode is enabled and get split path
|
||||
result = cur.execute('SELECT config_calibre_split, config_calibre_split_dir FROM settings LIMIT 1;').fetchone()
|
||||
|
||||
if not result:
|
||||
return None
|
||||
|
||||
split_enabled, split_path = result
|
||||
|
||||
# Only return split path if split mode is enabled, path is not NULL, and path exists
|
||||
if split_enabled and split_path and os.path.exists(split_path):
|
||||
return split_path
|
||||
|
||||
return None
|
||||
|
||||
except sqlite3.Error as e:
|
||||
# Log warning but don't crash - fall back to library path
|
||||
print(f"WARNING: Could not read split library setting from app.db: {e}")
|
||||
print(f"WARNING: Falling back to --library-path for books location")
|
||||
return None
|
||||
finally:
|
||||
if 'conn' in locals():
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Generate KOReader sync checksums for books in Calibre library',
|
||||
@@ -164,6 +209,12 @@ def main():
|
||||
help='Path to Calibre library directory (default: /calibre-library)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--books-path',
|
||||
default=get_books_path(),
|
||||
help='Path to books directory (default: config_calibre_split_dir setting or --library-path)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--force',
|
||||
action='store_true',
|
||||
@@ -185,7 +236,7 @@ def main():
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
generate_checksums(args.library_path, args.force, args.batch_size)
|
||||
generate_checksums(args.library_path, args.books_path, args.force, args.batch_size)
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nInterrupted by user. Exiting...")
|
||||
sys.exit(130)
|
||||
|
||||
+227
-18
@@ -14,6 +14,7 @@ import time
|
||||
import shutil
|
||||
import sqlite3
|
||||
import fcntl
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from cwa_db import CWA_DB
|
||||
@@ -31,6 +32,10 @@ TaskAutoSend = None
|
||||
WorkerThread = None
|
||||
_ub = None
|
||||
|
||||
# Debounced duplicate scan timer
|
||||
_duplicate_scan_timer = None
|
||||
_duplicate_scan_lock = threading.Lock()
|
||||
|
||||
class ProcessLock:
|
||||
"""Robust process lock using both file locking and PID tracking"""
|
||||
|
||||
@@ -201,6 +206,8 @@ try:
|
||||
from cps.tasks.auto_send import TaskAutoSend
|
||||
from cps.services.worker import WorkerThread
|
||||
from cps import ub as _ub
|
||||
from cps.calibre_init import init_calibre_db_from_app_db
|
||||
init_calibre_db_from_app_db()
|
||||
_CPS_AVAILABLE = True
|
||||
print("[ingest-processor] Auto-send and metadata functionality available", flush=True)
|
||||
except ImportError as e:
|
||||
@@ -262,17 +269,36 @@ def get_internal_api_url(path):
|
||||
port = '8083'
|
||||
|
||||
protocol = "http"
|
||||
certfile = None
|
||||
keyfile = None
|
||||
if _cps_config:
|
||||
certfile = _cps_config.get_config_certfile()
|
||||
keyfile = _cps_config.get_config_keyfile()
|
||||
if certfile and keyfile and os.path.isfile(certfile) and os.path.isfile(keyfile):
|
||||
protocol = "https"
|
||||
certfile = getattr(_cps_config, "config_certfile", None)
|
||||
keyfile = getattr(_cps_config, "config_keyfile", None)
|
||||
if not certfile and not keyfile:
|
||||
try:
|
||||
with sqlite3.connect("/config/app.db", timeout=30) as con:
|
||||
cur = con.cursor()
|
||||
row = cur.execute(
|
||||
"SELECT config_certfile, config_keyfile FROM settings LIMIT 1"
|
||||
).fetchone()
|
||||
if row:
|
||||
certfile, keyfile = row[0], row[1]
|
||||
except Exception as e:
|
||||
print(f"[ingest-processor] WARN: Could not read TLS settings from app.db: {e}", flush=True)
|
||||
|
||||
if certfile and keyfile and os.path.isfile(certfile) and os.path.isfile(keyfile):
|
||||
protocol = "https"
|
||||
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
|
||||
return f"{protocol}://127.0.0.1:{port}{path}"
|
||||
|
||||
|
||||
def get_internal_api_headers():
|
||||
"""Provide headers that satisfy localhost-only internal endpoint checks."""
|
||||
return {"X-Forwarded-For": "127.0.0.1"}
|
||||
|
||||
class NewBookProcessor:
|
||||
def __init__(self, filepath: str):
|
||||
# Settings / DB
|
||||
@@ -344,16 +370,34 @@ class NewBookProcessor:
|
||||
# Track the last added Calibre book id(s) from calibredb output
|
||||
self.last_added_book_id: int | None = None
|
||||
self.last_added_book_ids: list[int] = []
|
||||
self._title_sort_regex = self._get_title_sort_regex()
|
||||
|
||||
@staticmethod
|
||||
def _get_title_sort_regex() -> str:
|
||||
default_regex = (
|
||||
r'^(A|The|An|Der|Die|Das|Den|Ein|Eine|Einen|Dem|Des|Einem|Eines|Le|La|Les|L\'|Un|Une)\s+'
|
||||
)
|
||||
try:
|
||||
with sqlite3.connect("/config/app.db", timeout=30) as con:
|
||||
cur = con.cursor()
|
||||
row = cur.execute(
|
||||
"SELECT config_title_regex FROM settings LIMIT 1"
|
||||
).fetchone()
|
||||
if row and row[0]:
|
||||
return row[0]
|
||||
except Exception as e:
|
||||
print(f"[ingest-processor] WARN: Could not read config_title_regex from app.db: {e}", flush=True)
|
||||
return default_regex
|
||||
|
||||
@staticmethod
|
||||
def _parse_added_book_ids(output: str) -> list[int]:
|
||||
"""Parse calibredb stdout for the 'Added book ids: X[, Y, ...]' line and return IDs.
|
||||
"""Parse calibredb stdout for the 'Added/Merged/Updated book ids: X[, Y, ...]' line and return IDs.
|
||||
|
||||
Handles variations like 'Added book id: 4' or 'Added book ids: 4, 5'.
|
||||
Handles variations like 'Added book id: 4' or 'Merged book ids: 4, 5'.
|
||||
"""
|
||||
try:
|
||||
import re
|
||||
m = re.search(r"Added book id[s]?:\s*([0-9,\s]+)", output, flags=re.IGNORECASE)
|
||||
m = re.search(r"(?:Added|Merged|Updated) book id[s]?:\s*([0-9,\s]+)", output, flags=re.IGNORECASE)
|
||||
if not m:
|
||||
return []
|
||||
nums = m.group(1)
|
||||
@@ -361,6 +405,47 @@ class NewBookProcessor:
|
||||
return ids
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _fallback_last_added_book_id(self) -> None:
|
||||
"""Fallback to the most recently modified book when calibredb output lacks IDs."""
|
||||
if self.last_added_book_id is not None:
|
||||
return
|
||||
try:
|
||||
with sqlite3.connect(self.metadata_db, timeout=30) as con:
|
||||
cur = con.cursor()
|
||||
row = cur.execute(
|
||||
"SELECT id FROM books ORDER BY last_modified DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if row:
|
||||
self.last_added_book_id = int(row[0])
|
||||
self.last_added_book_ids = [self.last_added_book_id]
|
||||
print(
|
||||
"[ingest-processor] WARN: Could not parse calibredb output; using most recently modified book ID.",
|
||||
flush=True,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[ingest-processor] WARN: Failed to infer book ID after import: {e}", flush=True)
|
||||
|
||||
def _register_title_sort_function(self, connection: sqlite3.Connection) -> bool:
|
||||
"""Register title_sort SQL function on a raw SQLite connection."""
|
||||
try:
|
||||
import re
|
||||
title_pat = re.compile(self._title_sort_regex, re.IGNORECASE)
|
||||
|
||||
def _title_sort(title):
|
||||
if title is None:
|
||||
title = ""
|
||||
match = title_pat.search(title)
|
||||
if match:
|
||||
prep = match.group(1)
|
||||
title = title[len(prep):] + ', ' + prep
|
||||
return " ".join(str(title).split())
|
||||
|
||||
connection.create_function("title_sort", 1, _title_sort)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[ingest-processor] WARN: Could not register title_sort function: {e}", flush=True)
|
||||
return False
|
||||
def get_split_library(self) -> dict[str, str] | None:
|
||||
"""Checks whether or not the user has split library enabled. Returns None if they don't and the path of the Split Library location if True."""
|
||||
with sqlite3.connect("/config/app.db", timeout=30) as con:
|
||||
@@ -604,6 +689,8 @@ class NewBookProcessor:
|
||||
if added_ids:
|
||||
self.last_added_book_ids = added_ids
|
||||
self.last_added_book_id = added_ids[-1]
|
||||
else:
|
||||
self._fallback_last_added_book_id()
|
||||
else: # audiobook path
|
||||
meta = audiobook.get_audio_file_info(str(staged_path), format, os.path.basename(str(staged_path)), False)
|
||||
|
||||
@@ -649,6 +736,8 @@ class NewBookProcessor:
|
||||
if added_ids:
|
||||
self.last_added_book_ids = added_ids
|
||||
self.last_added_book_id = added_ids[-1]
|
||||
else:
|
||||
self._fallback_last_added_book_id()
|
||||
print(f"[ingest-processor] Added {staged_path.stem} to Calibre database", flush=True)
|
||||
|
||||
if self.cwa_settings['auto_backup_imports']:
|
||||
@@ -676,6 +765,12 @@ class NewBookProcessor:
|
||||
# This solves the issue where multiple books don't appear until container restart
|
||||
self.refresh_cwa_session()
|
||||
|
||||
# Invalidate duplicate cache since a new book was added
|
||||
self.invalidate_duplicate_cache()
|
||||
|
||||
# Debounced duplicate scan (after import)
|
||||
self.schedule_debounced_duplicate_scan()
|
||||
|
||||
# Generate KOReader sync checksums for the imported book
|
||||
if self.last_added_book_id is not None:
|
||||
self.generate_book_checksums(staged_path.stem, book_id=self.last_added_book_id)
|
||||
@@ -688,6 +783,9 @@ class NewBookProcessor:
|
||||
try:
|
||||
with sqlite3.connect(self.metadata_db, timeout=30) as con:
|
||||
cur = con.cursor()
|
||||
if not self._register_title_sort_function(con):
|
||||
print("[ingest-processor] INFO: Skipping timestamp adjust (title_sort SQL function unavailable).", flush=True)
|
||||
return
|
||||
# pre_import_max_timestamp may be None (empty library) -> update all rows where timestamp < last_modified
|
||||
if pre_import_max_timestamp is None:
|
||||
cur.execute('UPDATE books SET timestamp = last_modified WHERE timestamp < last_modified')
|
||||
@@ -708,6 +806,17 @@ class NewBookProcessor:
|
||||
if staged_path.exists():
|
||||
os.remove(staged_path)
|
||||
|
||||
def _validate_book_exists(self, book_id: int) -> bool:
|
||||
"""Check if a book with the given ID exists in the Calibre library"""
|
||||
try:
|
||||
with sqlite3.connect(self.metadata_db, timeout=30) as con:
|
||||
cur = con.cursor()
|
||||
row = cur.execute("SELECT id FROM books WHERE id = ?", (book_id,)).fetchone()
|
||||
return row is not None
|
||||
except Exception as e:
|
||||
print(f"[ingest-processor] ERROR: Failed to validate book_id {book_id}: {e}", flush=True)
|
||||
return False
|
||||
|
||||
def add_format_to_book(self, book_id:int, book_path:str) -> None:
|
||||
"""Attach a new format file to an existing Calibre book using calibredb add_format"""
|
||||
source_path = Path(book_path)
|
||||
@@ -716,6 +825,12 @@ class NewBookProcessor:
|
||||
self.backup(self.filepath, backup_type="failed") # Backup original file
|
||||
return
|
||||
|
||||
# Validate that the book exists before attempting to add format
|
||||
if not self._validate_book_exists(book_id):
|
||||
print(f"[ingest-processor] ERROR: Book ID {book_id} not found in library, cannot add format: {os.path.basename(book_path)}", flush=True)
|
||||
self.backup(self.filepath, backup_type="failed")
|
||||
return
|
||||
|
||||
# Stage file for import
|
||||
staged_path = Path(self.staging_dir) / source_path.name
|
||||
try:
|
||||
@@ -726,16 +841,17 @@ class NewBookProcessor:
|
||||
return
|
||||
|
||||
try:
|
||||
subprocess.run([
|
||||
result = subprocess.run([
|
||||
"calibredb", "add_format", str(book_id), str(staged_path), f"--library-path={self.library_dir}"
|
||||
], env=self.calibre_env, check=True)
|
||||
], env=self.calibre_env, check=True, capture_output=True, text=True)
|
||||
print(f"[ingest-processor] Added new format for book id {book_id}: {os.path.basename(str(staged_path))}", flush=True)
|
||||
if self.cwa_settings['auto_backup_imports']:
|
||||
self.backup(str(staged_path), backup_type="imported")
|
||||
# Optional post-add-format GDrive sync
|
||||
gdrive_sync_if_enabled()
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"[ingest-processor] Failed to add format for book id {book_id}: {os.path.basename(str(staged_path))}\nCALIBREDB EXIT/ERROR CODE: {e.returncode}\n{e.stderr}", flush=True)
|
||||
stderr_output = e.stderr if e.stderr else "No error details available"
|
||||
print(f"[ingest-processor] Failed to add format for book id {book_id}: {os.path.basename(str(staged_path))}\nCALIBREDB EXIT/ERROR CODE: {e.returncode}\nError details: {stderr_output}", flush=True)
|
||||
self.backup(str(staged_path), backup_type="failed")
|
||||
except Exception as e:
|
||||
print(f"[ingest-processor] Unexpected error while adding format for book id {book_id}: {e}", flush=True)
|
||||
@@ -850,7 +966,13 @@ class NewBookProcessor:
|
||||
'username': username,
|
||||
'title': actual_title,
|
||||
}
|
||||
resp = requests.post(url, json=payload, timeout=5, verify=False)
|
||||
resp = requests.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=get_internal_api_headers(),
|
||||
timeout=5,
|
||||
verify=False,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
run_at = resp.json().get('run_at', 'soon')
|
||||
@@ -976,7 +1098,12 @@ class NewBookProcessor:
|
||||
try:
|
||||
url = get_internal_api_url("/cwa-internal/reconnect-db")
|
||||
print("[ingest-processor] Refreshing Calibre-Web database session...", flush=True)
|
||||
resp = requests.post(url, timeout=5, verify=False)
|
||||
resp = requests.post(
|
||||
url,
|
||||
headers=get_internal_api_headers(),
|
||||
timeout=5,
|
||||
verify=False,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
print("[ingest-processor] Database session refresh enqueued", flush=True)
|
||||
else:
|
||||
@@ -986,6 +1113,72 @@ class NewBookProcessor:
|
||||
print("[ingest-processor] Continuing despite session refresh failure - books may require manual refresh", flush=True)
|
||||
|
||||
|
||||
def invalidate_duplicate_cache(self) -> None:
|
||||
"""Invalidate the duplicate detection cache after adding a new book
|
||||
|
||||
This marks the cache as stale (scan_pending=1) but does NOT trigger an automatic scan.
|
||||
Users must manually trigger a scan from the /duplicates page, or wait for scheduled scans.
|
||||
"""
|
||||
try:
|
||||
url = get_internal_api_url("/duplicates/invalidate-cache")
|
||||
resp = requests.post(
|
||||
url,
|
||||
headers=get_internal_api_headers(),
|
||||
timeout=5,
|
||||
verify=False,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
print("[ingest-processor] Duplicate cache invalidated", flush=True)
|
||||
else:
|
||||
print(f"[ingest-processor] WARN: Duplicate cache invalidation returned {resp.status_code}", flush=True)
|
||||
except Exception as e:
|
||||
# Don't fail the import if cache invalidation fails
|
||||
print(f"[ingest-processor] WARN: Failed to invalidate duplicate cache: {e}", flush=True)
|
||||
|
||||
|
||||
def schedule_debounced_duplicate_scan(self) -> None:
|
||||
"""Schedule a debounced background duplicate scan if enabled.
|
||||
|
||||
Uses a 60-second timer that resets on each new import to avoid repeated scans
|
||||
during batch ingest.
|
||||
"""
|
||||
try:
|
||||
enabled = bool(self.cwa_settings.get('duplicate_scan_enabled', 0))
|
||||
frequency = self.cwa_settings.get('duplicate_scan_frequency', 'manual')
|
||||
|
||||
if not enabled or frequency != 'after_import':
|
||||
return
|
||||
# Schedule in the long-lived web process so the debounce survives
|
||||
# the short-lived ingest process exiting.
|
||||
try:
|
||||
delay_seconds = int(self.cwa_settings.get('duplicate_scan_debounce_seconds', 30))
|
||||
delay_seconds = max(5, min(600, delay_seconds))
|
||||
url = get_internal_api_url("/cwa-internal/queue-duplicate-scan")
|
||||
payload = {"delay_seconds": delay_seconds}
|
||||
resp = requests.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=get_internal_api_headers(),
|
||||
timeout=5,
|
||||
verify=False,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
body = resp.json()
|
||||
if body.get('queued'):
|
||||
print("[ingest-processor] Debounced duplicate scan scheduled via web process", flush=True)
|
||||
elif body.get('skipped'):
|
||||
print("[ingest-processor] Duplicate scan scheduling skipped (disabled/manual)", flush=True)
|
||||
except Exception:
|
||||
print("[ingest-processor] Debounced duplicate scan scheduled via web process", flush=True)
|
||||
else:
|
||||
print(f"[ingest-processor] WARN: Duplicate scan scheduling returned {resp.status_code}", flush=True)
|
||||
except Exception as e:
|
||||
print(f"[ingest-processor] WARN: Failed to schedule duplicate scan via web API: {e}", flush=True)
|
||||
except Exception as e:
|
||||
print(f"[ingest-processor] WARN: Failed to schedule debounced duplicate scan: {e}", flush=True)
|
||||
|
||||
|
||||
def set_library_permissions(self):
|
||||
try:
|
||||
nsm = os.getenv("NETWORK_SHARE_MODE", "false").strip().lower() in ("1", "true", "yes", "on")
|
||||
@@ -1051,19 +1244,35 @@ def main(filepath=None):
|
||||
manifest = json.load(mf)
|
||||
action = manifest.get("action")
|
||||
if action == "add_format":
|
||||
success = False
|
||||
try:
|
||||
book_id = int(manifest.get("book_id", -1))
|
||||
except Exception:
|
||||
book_id = -1
|
||||
|
||||
if book_id > -1:
|
||||
nbp.add_format_to_book(book_id, filepath)
|
||||
# Validate book exists before attempting add_format
|
||||
if nbp._validate_book_exists(book_id):
|
||||
nbp.add_format_to_book(book_id, filepath)
|
||||
success = True
|
||||
else:
|
||||
print(f"[ingest-processor] ERROR: Book ID {book_id} not found in library for {os.path.basename(filepath)}", flush=True)
|
||||
nbp.backup(filepath, backup_type="failed")
|
||||
else:
|
||||
print(f"[ingest-processor] Invalid book_id in manifest for {os.path.basename(filepath)}", flush=True)
|
||||
# Cleanup file and manifest regardless of outcome
|
||||
print(f"[ingest-processor] ERROR: Invalid book_id in manifest for {os.path.basename(filepath)}", flush=True)
|
||||
nbp.backup(filepath, backup_type="failed")
|
||||
|
||||
# Cleanup manifest: delete on success, preserve on failure for debugging
|
||||
try:
|
||||
os.remove(manifest_path)
|
||||
except Exception:
|
||||
...
|
||||
if success:
|
||||
os.remove(manifest_path)
|
||||
else:
|
||||
failed_manifest_path = manifest_path.replace(".cwa.json", ".cwa.failed.json")
|
||||
os.rename(manifest_path, failed_manifest_path)
|
||||
print(f"[ingest-processor] Preserved failed manifest: {os.path.basename(failed_manifest_path)}", flush=True)
|
||||
except Exception as e:
|
||||
print(f"[ingest-processor] WARN: Failed to handle manifest cleanup: {e}", flush=True)
|
||||
|
||||
nbp.set_library_permissions()
|
||||
nbp.delete_current_file()
|
||||
return
|
||||
|
||||
+411
-53
@@ -17,6 +17,7 @@ import subprocess
|
||||
import logging
|
||||
import tempfile
|
||||
import atexit
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
import json
|
||||
import shutil
|
||||
@@ -29,6 +30,9 @@ from cwa_db import CWA_DB
|
||||
### Code adapted from https://github.com/innocenat/kindle-epub-fix
|
||||
### Translated from Javascript to Python & modified by crocodilestick
|
||||
|
||||
# Compile regex pattern once at module level for performance
|
||||
LANGUAGE_TAG_PATTERN = re.compile(r'^[a-z]{2,3}(-[a-z]{2,4})?$', re.IGNORECASE)
|
||||
|
||||
### Global Variables
|
||||
dirs_json = "/app/calibre-web-automated/dirs.json"
|
||||
change_logs_dir = "/app/calibre-web-automated/metadata_change_logs"
|
||||
@@ -133,7 +137,6 @@ class EPUBFixer:
|
||||
Returns: (book_id, format) or (None, 'EPUB')
|
||||
"""
|
||||
try:
|
||||
import re
|
||||
path_parts = str(file_path).split(os.sep)
|
||||
# Look for directory with format "Title (123)"
|
||||
for part in path_parts:
|
||||
@@ -169,7 +172,6 @@ class EPUBFixer:
|
||||
"""Calculate and store new checksum after modifying an EPUB file."""
|
||||
try:
|
||||
# Import the checksum calculation function
|
||||
import sys
|
||||
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
@@ -203,7 +205,6 @@ class EPUBFixer:
|
||||
con.close()
|
||||
except Exception as e:
|
||||
print_and_log(f"[cwa-kindle-epub-fixer] Warning: Failed to recalculate checksum: {e}", log=self.manually_triggered)
|
||||
import traceback
|
||||
print_and_log(traceback.format_exc(), log=self.manually_triggered)
|
||||
|
||||
|
||||
@@ -228,19 +229,36 @@ class EPUBFixer:
|
||||
self.binary_files[filename] = zip_ref.read(filename)
|
||||
|
||||
def fix_encoding(self):
|
||||
"""Add UTF-8 encoding declaration if missing"""
|
||||
"""Add UTF-8 encoding declaration if missing and fix malformed XML declarations"""
|
||||
encoding = '<?xml version="1.0" encoding="utf-8"?>'
|
||||
regex = r'^<\?xml\s+version=["\'][\d.]+["\']\s+encoding=["\'][a-zA-Z\d\-\.]+["\'].*?\?>'
|
||||
# Pattern to detect malformed XML declarations (excessive whitespace)
|
||||
malformed_xml_pattern = r'^<\?xml\s+version=["\'][\d.]+["\']\s{2,}encoding=["\'][a-zA-Z\d\-\.]+["\'].*?\?>'
|
||||
|
||||
for filename in list(self.files.keys()):
|
||||
ext = filename.split('.')[-1]
|
||||
if ext in ['html', 'xhtml']:
|
||||
html = self.files[filename]
|
||||
html = html.lstrip()
|
||||
if not re.match(regex, html, re.IGNORECASE):
|
||||
html = encoding + '\n' + html
|
||||
self.fixed_problems.append(f"Fixed encoding for file {filename}")
|
||||
self.files[filename] = html
|
||||
# Check HTML, XHTML, XML, OPF, and NCX files
|
||||
if ext in ['html', 'xhtml', 'xml', 'opf', 'ncx']:
|
||||
content = self.files[filename]
|
||||
content = content.lstrip()
|
||||
|
||||
# First, check for malformed XML declaration (double/triple spaces)
|
||||
if re.match(malformed_xml_pattern, content, re.IGNORECASE):
|
||||
# Replace malformed declaration with clean one
|
||||
content = re.sub(
|
||||
r'^<\?xml\s+version=["\'][\d.]+["\']\s+encoding=["\'][a-zA-Z\d\-\.]+["\'].*?\?>',
|
||||
encoding,
|
||||
content,
|
||||
count=1,
|
||||
flags=re.IGNORECASE
|
||||
)
|
||||
self.fixed_problems.append(f"Fixed malformed XML declaration in {filename}")
|
||||
# Then check if encoding declaration is missing
|
||||
elif not re.match(regex, content, re.IGNORECASE):
|
||||
content = encoding + '\n' + content
|
||||
self.fixed_problems.append(f"Added encoding declaration to {filename}")
|
||||
|
||||
self.files[filename] = content
|
||||
|
||||
def fix_body_id_link(self):
|
||||
"""Fix linking to body ID showing up as unresolved hyperlink"""
|
||||
@@ -266,75 +284,172 @@ class EPUBFixer:
|
||||
self.files[filename] = self.files[filename].replace(src, target)
|
||||
self.fixed_problems.append(f"Replaced link target {src} with {target} in file {filename}.")
|
||||
|
||||
def fix_book_language(self, default_language='en'):
|
||||
"""Fix language field not defined or not available"""
|
||||
def fix_book_language(self, default_language='en', epub_path=None):
|
||||
"""Fix language field - preserves valid tags, only fixes truly invalid ones"""
|
||||
# From https://kdp.amazon.com/en_US/help/topic/G200673300
|
||||
# NOTE: Amazon's Send-to-Kindle only reads the FIRST 2 CHARACTERS of language tags.
|
||||
# This means zh-TW and zh-CN both become 'zh' (we cannot distinguish them).
|
||||
# We normalize region codes (de-DE → de) to match Amazon's behavior.
|
||||
allowed_languages = [
|
||||
# ISO 639-1
|
||||
# ISO 639-1 (2-character codes - what Amazon actually uses)
|
||||
'af', 'gsw', 'ar', 'eu', 'nb', 'br', 'ca', 'zh', 'kw', 'co', 'da', 'nl', 'stq', 'en', 'fi', 'fr', 'fy', 'gl',
|
||||
'de', 'gu', 'hi', 'is', 'ga', 'it', 'ja', 'lb', 'mr', 'ml', 'gv', 'frr', 'nb', 'nn', 'pl', 'pt', 'oc', 'rm',
|
||||
'de', 'gu', 'hi', 'is', 'ga', 'it', 'ja', 'lb', 'mr', 'ml', 'gv', 'frr', 'nn', 'pl', 'pt', 'oc', 'rm',
|
||||
'sco', 'gd', 'es', 'sv', 'ta', 'cy',
|
||||
# ISO 639-2
|
||||
# ISO 639-2 (3-character codes - also supported)
|
||||
'afr', 'ara', 'eus', 'baq', 'nob', 'bre', 'cat', 'zho', 'chi', 'cor', 'cos', 'dan', 'nld', 'dut', 'eng', 'fin',
|
||||
'fra', 'fre', 'fry', 'glg', 'deu', 'ger', 'guj', 'hin', 'isl', 'ice', 'gle', 'ita', 'jpn', 'ltz', 'mar', 'mal',
|
||||
'glv', 'nor', 'nno', 'por', 'oci', 'roh', 'gla', 'spa', 'swe', 'tam', 'cym', 'wel',
|
||||
]
|
||||
|
||||
# Find OPF file
|
||||
if 'META-INF/container.xml' not in self.files:
|
||||
print('Cannot find META-INF/container.xml')
|
||||
return
|
||||
|
||||
container_xml = minidom.parseString(self.files['META-INF/container.xml'])
|
||||
opf_filename = None
|
||||
for rootfile in container_xml.getElementsByTagName('rootfile'):
|
||||
if rootfile.getAttribute('media-type') == 'application/oebps-package+xml':
|
||||
opf_filename = rootfile.getAttribute('full-path')
|
||||
break
|
||||
|
||||
# Read OPF file
|
||||
if not opf_filename or opf_filename not in self.files:
|
||||
print('Cannot find OPF file!')
|
||||
return
|
||||
|
||||
try:
|
||||
# Find OPF file
|
||||
if 'META-INF/container.xml' not in self.files:
|
||||
print('Cannot find META-INF/container.xml')
|
||||
return
|
||||
|
||||
container_xml = minidom.parseString(self.files['META-INF/container.xml'])
|
||||
opf_filename = None
|
||||
for rootfile in container_xml.getElementsByTagName('rootfile'):
|
||||
if rootfile.getAttribute('media-type') == 'application/oebps-package+xml':
|
||||
opf_filename = rootfile.getAttribute('full-path')
|
||||
break
|
||||
|
||||
# Read OPF file
|
||||
if not opf_filename or opf_filename not in self.files:
|
||||
print('Cannot find OPF file!')
|
||||
return
|
||||
|
||||
opf = minidom.parseString(self.files[opf_filename])
|
||||
language_tags = opf.getElementsByTagName('dc:language')
|
||||
language = default_language
|
||||
original_language = 'undefined'
|
||||
language = None
|
||||
original_language = None
|
||||
|
||||
# Check if language tag exists and has content
|
||||
if not language_tags or not language_tags[0].firstChild:
|
||||
# Use default language if no language tag exists or tag is empty
|
||||
self.fixed_problems.append(f"No language tag found. Setting to default: {default_language}")
|
||||
# No language tag - try to detect from Calibre metadata, else use default
|
||||
language = self._detect_language_from_metadata(epub_path) or default_language
|
||||
self.fixed_problems.append(f"No language tag found. Setting to: {language}")
|
||||
else:
|
||||
language = language_tags[0].firstChild.nodeValue
|
||||
original_language = language
|
||||
|
||||
simplified_lang = language.split('-')[0].lower()
|
||||
if simplified_lang not in allowed_languages:
|
||||
# If language is not supported, use default
|
||||
language = default_language
|
||||
self.fixed_problems.append(f"Unsupported language {original_language}. Changed to {default_language}")
|
||||
# Language tag exists - extract and validate
|
||||
original_language = language_tags[0].firstChild.nodeValue.strip()
|
||||
|
||||
# First check if the language looks like a valid code format (case-insensitive)
|
||||
# Valid: en, de, zh, en-US, en-us, de-DE, zh-TW, eng, deu, zho
|
||||
# Invalid: Unknown, undefined, garbage, 12345
|
||||
if LANGUAGE_TAG_PATTERN.match(original_language):
|
||||
# Looks like a proper language tag - extract and normalize base language code
|
||||
simplified_lang = original_language.split('-')[0].lower()
|
||||
|
||||
if simplified_lang in allowed_languages:
|
||||
# Valid language code - use it
|
||||
language = simplified_lang
|
||||
|
||||
# If original had region code or different case, note the normalization
|
||||
if original_language.lower() != language and '-' in original_language:
|
||||
self.fixed_problems.append(f"Normalized language from {original_language} to {language} (Amazon only reads base code)")
|
||||
elif original_language != language:
|
||||
self.fixed_problems.append(f"Normalized language from {original_language} to {language} (case standardization)")
|
||||
else:
|
||||
# Looks like a language tag but not in Amazon's allowed list
|
||||
detected = self._detect_language_from_metadata(epub_path)
|
||||
if detected:
|
||||
language = detected
|
||||
self.fixed_problems.append(f"Unsupported language '{original_language}'. Detected from metadata: {language}")
|
||||
else:
|
||||
language = default_language
|
||||
self.fixed_problems.append(f"Unsupported language '{original_language}'. Using default: {language}")
|
||||
else:
|
||||
# Doesn't look like a language tag at all (e.g., "Unknown", "garbage")
|
||||
detected = self._detect_language_from_metadata(epub_path)
|
||||
if detected:
|
||||
language = detected
|
||||
self.fixed_problems.append(f"Invalid language tag '{original_language}'. Detected from metadata: {language}")
|
||||
else:
|
||||
language = default_language
|
||||
self.fixed_problems.append(f"Invalid language tag '{original_language}'. Using default: {language}")
|
||||
|
||||
# Update or create language tag
|
||||
if not language_tags:
|
||||
# Create new tag
|
||||
language_tag = opf.createElement('dc:language')
|
||||
text_node = opf.createTextNode(language)
|
||||
language_tag.appendChild(text_node)
|
||||
metadata = opf.getElementsByTagName('metadata')[0]
|
||||
metadata.appendChild(language_tag)
|
||||
else:
|
||||
# Update existing tag
|
||||
if language_tags[0].firstChild:
|
||||
language_tags[0].firstChild.nodeValue = language
|
||||
else:
|
||||
text_node = opf.createTextNode(language)
|
||||
language_tags[0].appendChild(text_node)
|
||||
|
||||
if language != original_language:
|
||||
self.files[opf_filename] = opf.toxml()
|
||||
self.fixed_problems.append(f"Changed document language from {original_language} to {language}")
|
||||
# Only write if we actually changed something
|
||||
if original_language != language or not original_language:
|
||||
# Use regex replacement to preserve XML formatting instead of minidom.toxml()
|
||||
# This prevents attribute reordering which can break Amazon's parser
|
||||
opf_content = self.files[opf_filename]
|
||||
|
||||
if not language_tags:
|
||||
# Need to add language tag - insert before </metadata>
|
||||
opf_content = opf_content.replace(
|
||||
'</metadata>',
|
||||
f' <dc:language>{language}</dc:language>\n </metadata>'
|
||||
)
|
||||
else:
|
||||
# Replace existing language tag content
|
||||
opf_content = re.sub(
|
||||
r'<dc:language>.*?</dc:language>',
|
||||
f'<dc:language>{language}</dc:language>',
|
||||
opf_content,
|
||||
count=1,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
self.files[opf_filename] = opf_content
|
||||
|
||||
except Exception as e:
|
||||
print(f'Error trying to parse OPF file as XML: {e}')
|
||||
print_and_log(f'[cwa-kindle-epub-fixer] Skipping language validation - EPUB has non-standard structure: {e}', log=self.manually_triggered)
|
||||
|
||||
def _detect_language_from_metadata(self, epub_path=None):
|
||||
"""Attempt to detect language from Calibre's metadata.db"""
|
||||
try:
|
||||
# Extract book_id from the EPUB file path
|
||||
if not epub_path:
|
||||
return None
|
||||
|
||||
book_id, _ = self._extract_book_info_from_path(epub_path)
|
||||
if not book_id:
|
||||
return None
|
||||
|
||||
# Query metadata.db for language
|
||||
metadb_path = self._get_metadata_db_path()
|
||||
con = sqlite3.connect(metadb_path, timeout=30)
|
||||
cur = con.cursor()
|
||||
|
||||
# Get language from books table via languages link table
|
||||
result = cur.execute(
|
||||
'''SELECT languages.lang_code
|
||||
FROM books
|
||||
JOIN books_languages_link ON books.id = books_languages_link.book
|
||||
JOIN languages ON books_languages_link.lang_code = languages.id
|
||||
WHERE books.id = ?
|
||||
LIMIT 1''',
|
||||
(book_id,)
|
||||
).fetchone()
|
||||
|
||||
con.close()
|
||||
|
||||
if result and result[0]:
|
||||
lang = result[0].lower().split('-')[0] # Normalize
|
||||
print_and_log(f"[cwa-kindle-epub-fixer] Detected language '{lang}' from Calibre metadata", log=self.manually_triggered)
|
||||
return lang
|
||||
|
||||
except Exception as e:
|
||||
# Silent fail - this is just a helpful fallback
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def fix_stray_img(self):
|
||||
"""Fix stray IMG tags"""
|
||||
@@ -354,6 +469,238 @@ class EPUBFixer:
|
||||
self.fixed_problems.append(f"Remove stray image tag(s) in {filename}")
|
||||
self.files[filename] = dom.toxml()
|
||||
|
||||
def strip_embedded_fonts(self):
|
||||
"""Remove embedded font files and @font-face CSS declarations for Kindle compatibility"""
|
||||
# Remove font files from binary files
|
||||
font_extensions = ('.ttf', '.otf', '.woff', '.woff2', '.eot')
|
||||
fonts_removed = []
|
||||
|
||||
for filename in list(self.binary_files.keys()):
|
||||
if filename.lower().endswith(font_extensions):
|
||||
del self.binary_files[filename]
|
||||
fonts_removed.append(filename)
|
||||
|
||||
if fonts_removed:
|
||||
self.fixed_problems.append(f"Removed {len(fonts_removed)} embedded font file(s) for Kindle compatibility")
|
||||
|
||||
# Also remove font references from OPF manifest
|
||||
opf_path = 'content.opf'
|
||||
if opf_path in self.files:
|
||||
opf_content = self.files[opf_path]
|
||||
for font_file in fonts_removed:
|
||||
# Remove manifest item for this font
|
||||
# Match: <item href="fonts/00001.ttf" id="..." media-type="..."/>
|
||||
pattern = re.compile(
|
||||
r'<item[^>]*href=["\']' + re.escape(font_file) + r'["\'][^>]*/?>',
|
||||
re.IGNORECASE
|
||||
)
|
||||
opf_content = pattern.sub('', opf_content)
|
||||
|
||||
self.files[opf_path] = opf_content
|
||||
|
||||
# Remove @font-face declarations from CSS files
|
||||
font_face_pattern = re.compile(r'@font-face\s*\{[^}]*\}', re.IGNORECASE | re.DOTALL)
|
||||
|
||||
for filename in list(self.files.keys()):
|
||||
if filename.endswith('.css'):
|
||||
original_css = self.files[filename]
|
||||
cleaned_css = font_face_pattern.sub('', original_css)
|
||||
|
||||
if cleaned_css != original_css:
|
||||
self.files[filename] = cleaned_css
|
||||
self.fixed_problems.append(f"Removed @font-face declarations from {filename}")
|
||||
|
||||
def remove_javascript(self):
|
||||
"""Remove JavaScript code for Kindle compatibility (not supported)"""
|
||||
script_pattern = re.compile(r'<script[^>]*>.*?</script>', re.IGNORECASE | re.DOTALL)
|
||||
|
||||
for filename in list(self.files.keys()):
|
||||
ext = filename.split('.')[-1]
|
||||
if ext in ['html', 'xhtml', 'htm']:
|
||||
original_content = self.files[filename]
|
||||
cleaned_content = script_pattern.sub('', original_content)
|
||||
|
||||
if cleaned_content != original_content:
|
||||
self.files[filename] = cleaned_content
|
||||
self.fixed_problems.append(f"Removed JavaScript from {filename}")
|
||||
|
||||
def validate_images(self):
|
||||
"""Validate images for Kindle compatibility and report issues"""
|
||||
issues = []
|
||||
total_size = 0
|
||||
|
||||
# Supported formats by Kindle
|
||||
supported_formats = {
|
||||
b'\xff\xd8\xff': 'JPEG',
|
||||
b'\x89PNG': 'PNG',
|
||||
b'GIF87a': 'GIF',
|
||||
b'GIF89a': 'GIF'
|
||||
}
|
||||
|
||||
for filename in list(self.binary_files.keys()):
|
||||
ext = filename.split('.')[-1].lower()
|
||||
if ext in ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp']:
|
||||
file_data = self.binary_files[filename]
|
||||
file_size = len(file_data)
|
||||
total_size += file_size
|
||||
|
||||
# Check for unsupported formats
|
||||
if ext in ['svg', 'webp']:
|
||||
issues.append(f"{filename}: {ext.upper()} format has limited Kindle support")
|
||||
|
||||
# Check individual file size (warn if > 2MB)
|
||||
if file_size > 2 * 1024 * 1024:
|
||||
size_mb = file_size / (1024 * 1024)
|
||||
issues.append(f"{filename}: Large image ({size_mb:.1f}MB) may cause issues")
|
||||
|
||||
# Verify actual format matches extension
|
||||
format_detected = None
|
||||
for magic_bytes, format_name in supported_formats.items():
|
||||
if file_data.startswith(magic_bytes):
|
||||
format_detected = format_name
|
||||
break
|
||||
|
||||
if format_detected and ext in ['jpg', 'jpeg'] and format_detected != 'JPEG':
|
||||
issues.append(f"{filename}: File type mismatch (ext: {ext}, actual: {format_detected})")
|
||||
elif format_detected and ext == 'png' and format_detected != 'PNG':
|
||||
issues.append(f"{filename}: File type mismatch (ext: {ext}, actual: {format_detected})")
|
||||
|
||||
if issues:
|
||||
for issue in issues:
|
||||
self.fixed_problems.append(f"Image validation warning: {issue}")
|
||||
|
||||
# Check total EPUB size (warn if approaching 50MB uncompressed)
|
||||
total_size_mb = total_size / (1024 * 1024)
|
||||
if total_size_mb > 40:
|
||||
self.fixed_problems.append(f"Warning: Total image size is {total_size_mb:.1f}MB (Kindle works best with <50MB total)")
|
||||
|
||||
def validate_css(self):
|
||||
"""Validate CSS and only fix actual syntax errors, warn about potential Kindle issues"""
|
||||
for filename in list(self.files.keys()):
|
||||
if filename.endswith('.css'):
|
||||
original_css = self.files[filename]
|
||||
issues_found = []
|
||||
|
||||
# Check for syntax errors that would break rendering
|
||||
# 1. Unclosed braces
|
||||
open_braces = original_css.count('{')
|
||||
close_braces = original_css.count('}')
|
||||
if open_braces != close_braces:
|
||||
issues_found.append(f"CSS syntax error: mismatched braces ({open_braces} open, {close_braces} close)")
|
||||
|
||||
# 2. Invalid @import statements (must be at top)
|
||||
lines = original_css.split('\n')
|
||||
import_after_rules = False
|
||||
seen_rule = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith('/*') and not stripped.startswith('*/'):
|
||||
if '@import' in stripped:
|
||||
if seen_rule:
|
||||
import_after_rules = True
|
||||
elif stripped.startswith('@') or '{' in stripped:
|
||||
seen_rule = True
|
||||
|
||||
if import_after_rules:
|
||||
issues_found.append("CSS syntax error: @import must appear before other rules")
|
||||
|
||||
# 3. Check for common Kindle-problematic features (warning only, don't remove)
|
||||
kindle_warnings = []
|
||||
if re.search(r'position\s*:\s*(absolute|fixed)', original_css, re.IGNORECASE):
|
||||
kindle_warnings.append("absolute/fixed positioning")
|
||||
if re.search(r'@media', original_css, re.IGNORECASE):
|
||||
kindle_warnings.append("media queries")
|
||||
if re.search(r'javascript:', original_css, re.IGNORECASE):
|
||||
kindle_warnings.append("javascript URLs")
|
||||
|
||||
# Only report if there are actual syntax errors
|
||||
if issues_found:
|
||||
for issue in issues_found:
|
||||
self.fixed_problems.append(f"CSS validation: {issue} in {filename}")
|
||||
|
||||
# Add informational note about Kindle compatibility (not counted as a fix needing action)
|
||||
if kindle_warnings:
|
||||
warning_str = ', '.join(kindle_warnings)
|
||||
print_and_log(f"[cwa-kindle-epub-fixer] Note: {filename} uses {warning_str} (may render differently on Kindle)", log=self.manually_triggered)
|
||||
|
||||
def strip_amazon_identifiers(self):
|
||||
"""Remove ASIN/Amazon identifiers and Calibre metadata from OPF file.
|
||||
|
||||
Amazon may reject books that already have an ASIN in their system.
|
||||
Also removes Calibre-specific metadata that's not needed for Kindle.
|
||||
"""
|
||||
opf_path = 'content.opf'
|
||||
if opf_path not in self.files:
|
||||
return
|
||||
|
||||
opf_content = self.files[opf_path]
|
||||
|
||||
try:
|
||||
dom = minidom.parseString(opf_content)
|
||||
package = dom.getElementsByTagName('package')[0]
|
||||
metadata = dom.getElementsByTagName('metadata')[0]
|
||||
|
||||
changes_made = False
|
||||
|
||||
# Remove Calibre namespace from package tag
|
||||
if package.hasAttribute('xmlns:calibre'):
|
||||
package.removeAttribute('xmlns:calibre')
|
||||
changes_made = True
|
||||
print_and_log("[cwa-kindle-epub-fixer] Removing Calibre namespace from package tag", log=self.manually_triggered)
|
||||
|
||||
# Remove Calibre namespace from metadata tag
|
||||
if metadata.hasAttribute('xmlns:calibre'):
|
||||
metadata.removeAttribute('xmlns:calibre')
|
||||
changes_made = True
|
||||
|
||||
# Remove Amazon/MOBI-ASIN identifiers using regex (preserve structure)
|
||||
identifiers_removed = []
|
||||
|
||||
# Match AMAZON and MOBI-ASIN identifiers
|
||||
asin_pattern = re.compile(
|
||||
r'<dc:identifier[^>]*opf:scheme=["\'](?:AMAZON|MOBI-ASIN|mobi-asin|calibre)["\'][^>]*>.*?</dc:identifier>',
|
||||
re.IGNORECASE | re.DOTALL
|
||||
)
|
||||
|
||||
matches = asin_pattern.findall(opf_content)
|
||||
if matches:
|
||||
for match in matches:
|
||||
# Extract scheme and value for logging
|
||||
scheme_match = re.search(r'opf:scheme=["\']([^"\']+)["\']', match)
|
||||
if scheme_match:
|
||||
identifiers_removed.append(scheme_match.group(1))
|
||||
|
||||
opf_content = asin_pattern.sub('', opf_content)
|
||||
changes_made = True
|
||||
|
||||
if identifiers_removed:
|
||||
print_and_log(f"[cwa-kindle-epub-fixer] Removing Amazon/Calibre identifiers: {', '.join(identifiers_removed)}", log=self.manually_triggered)
|
||||
self.fixed_problems.append(f"Removed {len(identifiers_removed)} Amazon/Calibre identifier(s)")
|
||||
|
||||
# Remove Calibre-specific meta tags using regex (preserve structure)
|
||||
calibre_meta_pattern = re.compile(
|
||||
r'<meta[^>]*name=["\']calibre:[^"\']+["\'][^>]*/>',
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
calibre_matches = calibre_meta_pattern.findall(opf_content)
|
||||
if calibre_matches:
|
||||
opf_content = calibre_meta_pattern.sub('', opf_content)
|
||||
changes_made = True
|
||||
print_and_log(f"[cwa-kindle-epub-fixer] Removing {len(calibre_matches)} Calibre meta tag(s)", log=self.manually_triggered)
|
||||
self.fixed_problems.append(f"Removed {len(calibre_matches)} Calibre meta tag(s)")
|
||||
|
||||
# Remove xmlns:calibre from metadata tag if present
|
||||
if 'xmlns:calibre=' in opf_content:
|
||||
opf_content = re.sub(r'\s*xmlns:calibre="[^"]*"', '', opf_content)
|
||||
changes_made = True
|
||||
|
||||
if changes_made:
|
||||
self.files[opf_path] = opf_content
|
||||
|
||||
except Exception as e:
|
||||
print_and_log(f"[cwa-kindle-epub-fixer] Warning: Could not strip Amazon identifiers: {e}", log=self.manually_triggered)
|
||||
|
||||
def write_epub(self, output_path):
|
||||
"""Write EPUB file"""
|
||||
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zip_ref:
|
||||
@@ -419,12 +766,23 @@ class EPUBFixer:
|
||||
# Run fixing procedures
|
||||
print_and_log("[cwa-kindle-epub-fixer] Checking linking to body ID to prevent unresolved hyperlinks...", log=self.manually_triggered)
|
||||
self.fix_body_id_link()
|
||||
print_and_log("[cwa-kindle-epub-fixer] Checking language field tag is valid...", log=self.manually_triggered)
|
||||
self.fix_book_language(default_language)
|
||||
print_and_log("[cwa-kindle-epub-fixer] Checking for stray images...", log=self.manually_triggered)
|
||||
self.fix_stray_img()
|
||||
print_and_log("[cwa-kindle-epub-fixer] Checking UTF-8 encoding declaration...", log=self.manually_triggered)
|
||||
self.fix_encoding()
|
||||
print_and_log("[cwa-kindle-epub-fixer] Checking language field tag is valid...", log=self.manually_triggered)
|
||||
self.fix_book_language(default_language, input_path)
|
||||
print_and_log("[cwa-kindle-epub-fixer] Checking for stray images...", log=self.manually_triggered)
|
||||
self.fix_stray_img()
|
||||
|
||||
# New Kindle-specific fixes
|
||||
print_and_log("[cwa-kindle-epub-fixer] Stripping embedded fonts for Kindle compatibility...", log=self.manually_triggered)
|
||||
self.strip_embedded_fonts()
|
||||
print_and_log("[cwa-kindle-epub-fixer] Removing JavaScript (not supported on Kindle)...", log=self.manually_triggered)
|
||||
self.remove_javascript()
|
||||
print_and_log("[cwa-kindle-epub-fixer] Validating images for Kindle compatibility...", log=self.manually_triggered)
|
||||
self.validate_images()
|
||||
print_and_log("[cwa-kindle-epub-fixer] Validating CSS syntax...", log=self.manually_triggered)
|
||||
self.validate_css()
|
||||
# NOTE: Skipping strip_amazon_identifiers() - users want complete metadata preserved
|
||||
|
||||
# Notify user and/or write to log
|
||||
self.export_issue_summary(input_path)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user