Bootstrap Novela 2.0 implementation and docs
This commit is contained in:
commit
0ae181706d
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.files/
|
||||||
|
|
||||||
|
.last-branch
|
||||||
269
build-and-push.sh
Executable file
269
build-and-push.sh
Executable file
@ -0,0 +1,269 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# build-and-push.sh
|
||||||
|
# Location: repo root (e.g. /docker/develop/novela)
|
||||||
|
#
|
||||||
|
# Purpose:
|
||||||
|
# - Automatic version bump:
|
||||||
|
# 1 = patch, 2 = minor, 3 = major, t = test
|
||||||
|
# - Test builds: only update :dev (no commit/tag)
|
||||||
|
# - Release builds: update version.txt, commit, tag, push (to the current branch)
|
||||||
|
# - Build & push Docker images for each service under ./containers/*
|
||||||
|
# - Preflight checks: Docker daemon up, logged in to registry, valid names/tags
|
||||||
|
# - Summary: show all images + tags built and pushed
|
||||||
|
# - Branch visibility:
|
||||||
|
# - Shows currently checked out branch (authoritative)
|
||||||
|
# - Reads .last-branch for info (if present) when BRANCH is not set
|
||||||
|
# - Writes the current branch back to .last-branch at the end
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# BRANCH=<branch> ./build-and-push.sh [bump] # BRANCH is optional; informative only
|
||||||
|
# ./build-and-push.sh [bump]
|
||||||
|
# If [bump] is omitted, you will be prompted (default = t).
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
DOCKER_REGISTRY="gitea.oskamp.info"
|
||||||
|
DOCKER_NAMESPACE="ivooskamp"
|
||||||
|
|
||||||
|
VERSION_FILE="version.txt"
|
||||||
|
START_VERSION="v0.1.0"
|
||||||
|
COMPOSE_DIR="containers"
|
||||||
|
LAST_BRANCH_FILE=".last-branch" # stored in repo root
|
||||||
|
|
||||||
|
# --- Input: prompt if missing ------------------------------------------------
|
||||||
|
BUMP="${1:-}"
|
||||||
|
if [[ -z "${BUMP}" ]]; then
|
||||||
|
echo "Select bump type: [1] patch, [2] minor, [3] major, [t] test (default: t)"
|
||||||
|
read -r BUMP
|
||||||
|
BUMP="${BUMP:-t}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$BUMP" != "1" && "$BUMP" != "2" && "$BUMP" != "3" && "$BUMP" != "t" ]]; then
|
||||||
|
echo "[ERROR] Unknown bump type '$BUMP' (use 1, 2, 3, or t)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Helpers -----------------------------------------------------------------
|
||||||
|
read_version() {
|
||||||
|
if [[ -f "$VERSION_FILE" ]]; then
|
||||||
|
tr -d ' \t\n\r' < "$VERSION_FILE"
|
||||||
|
else
|
||||||
|
echo "$START_VERSION"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_version() {
|
||||||
|
echo "$1" > "$VERSION_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
bump_version() {
|
||||||
|
local cur="$1"
|
||||||
|
local kind="$2"
|
||||||
|
local core="${cur#v}"
|
||||||
|
IFS='.' read -r MA MI PA <<< "$core"
|
||||||
|
case "$kind" in
|
||||||
|
1) PA=$((PA + 1));;
|
||||||
|
2) MI=$((MI + 1)); PA=0;;
|
||||||
|
3) MA=$((MA + 1)); MI=0; PA=0;;
|
||||||
|
*) echo "[ERROR] Unknown bump kind"; exit 1;;
|
||||||
|
esac
|
||||||
|
echo "v${MA}.${MI}.${PA}"
|
||||||
|
}
|
||||||
|
|
||||||
|
check_docker_ready() {
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
echo "[ERROR] Docker daemon not reachable. Is Docker running and do you have permission to use it?"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_registry_login() {
|
||||||
|
local cfg="${HOME}/.docker/config.json"
|
||||||
|
if [[ ! -f "$cfg" ]]; then
|
||||||
|
echo "[ERROR] Docker config not found at $cfg. Please login: docker login ${DOCKER_REGISTRY}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q "\"${DOCKER_REGISTRY}\"" "$cfg"; then
|
||||||
|
echo "[ERROR] No registry auth found for ${DOCKER_REGISTRY}. Please run: docker login ${DOCKER_REGISTRY}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_repo_component() {
|
||||||
|
local comp="$1"
|
||||||
|
if [[ ! "$comp" =~ ^[a-z0-9]+([._-][a-z0-9]+)*$ ]]; then
|
||||||
|
echo "[ERROR] Invalid repository component '$comp'."
|
||||||
|
echo " Must match: ^[a-z0-9]+([._-][a-z0-9]+)*$ (lowercase, digits, ., _, - as separators)."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_tag() {
|
||||||
|
local tag="$1"
|
||||||
|
local len="${#tag}"
|
||||||
|
if (( len < 1 || len > 128 )); then
|
||||||
|
echo "[ERROR] Invalid tag length ($len). Must be between 1 and 128 characters."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ ! "$tag" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]]; then
|
||||||
|
echo "[ERROR] Invalid tag '$tag'. Allowed: [A-Za-z0-9_.-], must start with alphanumeric or underscore."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Preflight ---------------------------------------------------------------
|
||||||
|
if [[ ! -d ".git" ]]; then
|
||||||
|
echo "[ERROR] Not a git repository (.git missing)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$COMPOSE_DIR" ]]; then
|
||||||
|
echo "[ERROR] '$COMPOSE_DIR' directory missing. Expected ./containers/<service>/ with a Dockerfile."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
check_docker_ready
|
||||||
|
ensure_registry_login
|
||||||
|
validate_repo_component "$DOCKER_NAMESPACE"
|
||||||
|
|
||||||
|
# Detect currently checked out branch (authoritative for this script)
|
||||||
|
DETECTED_BRANCH="$(git branch --show-current 2>/dev/null || true)"
|
||||||
|
if [[ -z "$DETECTED_BRANCH" ]]; then
|
||||||
|
DETECTED_BRANCH="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
if [[ -z "$DETECTED_BRANCH" ]]; then
|
||||||
|
# Try to derive from upstream
|
||||||
|
UPSTREAM_REF_DERIVED="$(git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || true)"
|
||||||
|
if [[ -n "$UPSTREAM_REF_DERIVED" ]]; then
|
||||||
|
DETECTED_BRANCH="${UPSTREAM_REF_DERIVED#origin/}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ -z "$DETECTED_BRANCH" ]]; then
|
||||||
|
DETECTED_BRANCH="main"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Optional signals: BRANCH env and .last-branch (informational only)
|
||||||
|
ENV_BRANCH="${BRANCH:-}"
|
||||||
|
LAST_BRANCH_FILE_PATH="$(pwd)/$LAST_BRANCH_FILE"
|
||||||
|
LAST_BRANCH_VALUE=""
|
||||||
|
if [[ -z "$ENV_BRANCH" && -f "$LAST_BRANCH_FILE_PATH" ]]; then
|
||||||
|
LAST_BRANCH_VALUE="$(tr -d ' \t\n\r' < "$LAST_BRANCH_FILE_PATH")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
UPSTREAM_REF="$(git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || echo "origin/$DETECTED_BRANCH")"
|
||||||
|
HEAD_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")"
|
||||||
|
|
||||||
|
echo "[INFO] Repo: $(pwd)"
|
||||||
|
echo "[INFO] Current branch: $DETECTED_BRANCH"
|
||||||
|
echo "[INFO] Upstream: $UPSTREAM_REF"
|
||||||
|
echo "[INFO] HEAD (sha): $HEAD_SHA"
|
||||||
|
|
||||||
|
if [[ -n "$ENV_BRANCH" && "$ENV_BRANCH" != "$DETECTED_BRANCH" ]]; then
|
||||||
|
echo "[WARNING] BRANCH='$ENV_BRANCH' differs from checked out branch '$DETECTED_BRANCH'."
|
||||||
|
echo "[WARNING] This script does not switch branches; continuing on '$DETECTED_BRANCH'."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$LAST_BRANCH_VALUE" && "$LAST_BRANCH_VALUE" != "$DETECTED_BRANCH" && -z "$ENV_BRANCH" ]]; then
|
||||||
|
echo "[INFO] .last-branch suggests '$LAST_BRANCH_VALUE', but current checkout is '$DETECTED_BRANCH'."
|
||||||
|
echo "[INFO] If you intended to build '$LAST_BRANCH_VALUE', switch branches first (use update-and-build.sh)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Versioning --------------------------------------------------------------
|
||||||
|
CURRENT_VERSION="$(read_version)"
|
||||||
|
NEW_VERSION="$CURRENT_VERSION"
|
||||||
|
DO_TAG_AND_BUMP=true
|
||||||
|
|
||||||
|
if [[ "$BUMP" == "t" ]]; then
|
||||||
|
echo "[INFO] Test build: keeping version $CURRENT_VERSION; will only update :dev."
|
||||||
|
DO_TAG_AND_BUMP=false
|
||||||
|
else
|
||||||
|
NEW_VERSION="$(bump_version "$CURRENT_VERSION" "$BUMP")"
|
||||||
|
echo "[INFO] New version: $NEW_VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $DO_TAG_AND_BUMP; then
|
||||||
|
validate_tag "$NEW_VERSION"
|
||||||
|
fi
|
||||||
|
validate_tag "latest"
|
||||||
|
|
||||||
|
# --- Version update + VCS ops (release builds only) --------------------------
|
||||||
|
if $DO_TAG_AND_BUMP; then
|
||||||
|
echo "[INFO] Writing $NEW_VERSION to $VERSION_FILE"
|
||||||
|
write_version "$NEW_VERSION"
|
||||||
|
|
||||||
|
echo "[INFO] Git add + commit (branch: $DETECTED_BRANCH)"
|
||||||
|
git add "$VERSION_FILE"
|
||||||
|
git commit -m "Release $NEW_VERSION on branch $DETECTED_BRANCH (bump type $BUMP)"
|
||||||
|
|
||||||
|
echo "[INFO] Git tag $NEW_VERSION"
|
||||||
|
git tag -a "$NEW_VERSION" -m "Release $NEW_VERSION"
|
||||||
|
|
||||||
|
echo "[INFO] Git push + tags"
|
||||||
|
git push origin "$DETECTED_BRANCH"
|
||||||
|
git push --tags
|
||||||
|
else
|
||||||
|
echo "[INFO] Skipping commit/tagging (test build)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Build & push per service ------------------------------------------------
|
||||||
|
shopt -s nullglob
|
||||||
|
services=( "$COMPOSE_DIR"/* )
|
||||||
|
if [[ ${#services[@]} -eq 0 ]]; then
|
||||||
|
echo "[ERROR] No services found under $COMPOSE_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BUILT_IMAGES=()
|
||||||
|
|
||||||
|
for svc_path in "${services[@]}"; do
|
||||||
|
[[ -d "$svc_path" ]] || continue
|
||||||
|
svc="$(basename "$svc_path")"
|
||||||
|
dockerfile="$svc_path/Dockerfile"
|
||||||
|
|
||||||
|
validate_repo_component "$svc"
|
||||||
|
|
||||||
|
if [[ ! -f "$dockerfile" ]]; then
|
||||||
|
echo "[WARNING] Skipping '${svc}': Dockerfile not found in ${svc_path}"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
IMAGE_BASE="${DOCKER_REGISTRY}/${DOCKER_NAMESPACE}/${svc}"
|
||||||
|
|
||||||
|
if $DO_TAG_AND_BUMP; then
|
||||||
|
echo "============================================================"
|
||||||
|
echo "[INFO] Building ${svc} -> tags: ${NEW_VERSION}, latest"
|
||||||
|
echo "============================================================"
|
||||||
|
docker build -t "${IMAGE_BASE}:${NEW_VERSION}" -t "${IMAGE_BASE}:dev" "$svc_path"
|
||||||
|
docker push "${IMAGE_BASE}:${NEW_VERSION}"
|
||||||
|
docker push "${IMAGE_BASE}:dev"
|
||||||
|
BUILT_IMAGES+=("${IMAGE_BASE}:${NEW_VERSION}" "${IMAGE_BASE}:dev")
|
||||||
|
else
|
||||||
|
echo "============================================================"
|
||||||
|
echo "[INFO] Test build ${svc} -> tag: latest"
|
||||||
|
echo "============================================================"
|
||||||
|
docker build -t "${IMAGE_BASE}:dev" "$svc_path"
|
||||||
|
docker push "${IMAGE_BASE}:dev"
|
||||||
|
BUILT_IMAGES+=("${IMAGE_BASE}:dev")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Persist current branch to .last-branch ----------------------------------
|
||||||
|
# (This helps script 1 to preselect next time, and is informative if you run script 2 standalone)
|
||||||
|
echo "$DETECTED_BRANCH" > "$LAST_BRANCH_FILE_PATH"
|
||||||
|
|
||||||
|
# --- Summary -----------------------------------------------------------------
|
||||||
|
echo ""
|
||||||
|
echo "============================================================"
|
||||||
|
echo "[SUMMARY] Build & push complete (branch: $DETECTED_BRANCH)"
|
||||||
|
if $DO_TAG_AND_BUMP; then
|
||||||
|
echo "[INFO] Release version: $NEW_VERSION"
|
||||||
|
else
|
||||||
|
echo "[INFO] Test build (no version bump)"
|
||||||
|
fi
|
||||||
|
echo "[INFO] Images pushed:"
|
||||||
|
for img in "${BUILT_IMAGES[@]}"; do
|
||||||
|
echo " - $img"
|
||||||
|
done
|
||||||
|
echo "============================================================"
|
||||||
17
containers/novela/Dockerfile
Normal file
17
containers/novela/Dockerfile
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
libmagic1 \
|
||||||
|
unrar-free \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt /app/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||||
|
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
BIN
containers/novela/__pycache__/cbr.cpython-311.pyc
Normal file
BIN
containers/novela/__pycache__/cbr.cpython-311.pyc
Normal file
Binary file not shown.
BIN
containers/novela/__pycache__/db.cpython-311.pyc
Normal file
BIN
containers/novela/__pycache__/db.cpython-311.pyc
Normal file
Binary file not shown.
BIN
containers/novela/__pycache__/epub.cpython-311.pyc
Normal file
BIN
containers/novela/__pycache__/epub.cpython-311.pyc
Normal file
Binary file not shown.
BIN
containers/novela/__pycache__/main.cpython-311.pyc
Normal file
BIN
containers/novela/__pycache__/main.cpython-311.pyc
Normal file
Binary file not shown.
BIN
containers/novela/__pycache__/migrations.cpython-311.pyc
Normal file
BIN
containers/novela/__pycache__/migrations.cpython-311.pyc
Normal file
Binary file not shown.
BIN
containers/novela/__pycache__/pdf.cpython-311.pyc
Normal file
BIN
containers/novela/__pycache__/pdf.cpython-311.pyc
Normal file
Binary file not shown.
BIN
containers/novela/__pycache__/security.cpython-311.pyc
Normal file
BIN
containers/novela/__pycache__/security.cpython-311.pyc
Normal file
Binary file not shown.
BIN
containers/novela/__pycache__/xhtml.cpython-311.pyc
Normal file
BIN
containers/novela/__pycache__/xhtml.cpython-311.pyc
Normal file
Binary file not shown.
61
containers/novela/cbr.py
Normal file
61
containers/novela/cbr.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import rarfile
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
|
SUPPORTED_IMG = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _is_cbz(path: Path) -> bool:
|
||||||
|
return path.suffix.lower() == ".cbz"
|
||||||
|
|
||||||
|
|
||||||
|
def cbr_page_list(path: Path) -> list[str]:
|
||||||
|
if _is_cbz(path):
|
||||||
|
with zipfile.ZipFile(path) as zf:
|
||||||
|
names = [n for n in zf.namelist() if Path(n).suffix.lower() in SUPPORTED_IMG]
|
||||||
|
else:
|
||||||
|
with rarfile.RarFile(path) as rf:
|
||||||
|
names = [n for n in rf.namelist() if Path(n).suffix.lower() in SUPPORTED_IMG]
|
||||||
|
return sorted(names)
|
||||||
|
|
||||||
|
|
||||||
|
def cbr_page_count(path: Path) -> int:
|
||||||
|
return len(cbr_page_list(path))
|
||||||
|
|
||||||
|
|
||||||
|
def cbr_get_page(path: Path, page_num: int) -> tuple[bytes, str]:
|
||||||
|
pages = cbr_page_list(path)
|
||||||
|
if page_num < 0 or page_num >= len(pages):
|
||||||
|
raise IndexError("Page out of range")
|
||||||
|
name = pages[page_num]
|
||||||
|
ext = Path(name).suffix.lower().lstrip(".")
|
||||||
|
mime = {
|
||||||
|
"jpg": "image/jpeg",
|
||||||
|
"jpeg": "image/jpeg",
|
||||||
|
"png": "image/png",
|
||||||
|
"webp": "image/webp",
|
||||||
|
"gif": "image/gif",
|
||||||
|
"bmp": "image/bmp",
|
||||||
|
}.get(ext, "image/jpeg")
|
||||||
|
|
||||||
|
if _is_cbz(path):
|
||||||
|
with zipfile.ZipFile(path) as zf:
|
||||||
|
return zf.read(name), mime
|
||||||
|
with rarfile.RarFile(path) as rf:
|
||||||
|
return rf.read(name), mime
|
||||||
|
|
||||||
|
|
||||||
|
def cbr_cover_thumb(path: Path) -> bytes:
|
||||||
|
data, _ = cbr_get_page(path, 0)
|
||||||
|
with Image.open(BytesIO(data)) as im:
|
||||||
|
im = ImageOps.exif_transpose(im)
|
||||||
|
if im.mode not in ("RGB", "RGBA"):
|
||||||
|
im = im.convert("RGB")
|
||||||
|
thumb = ImageOps.fit(im, (300, 450), method=Image.Resampling.LANCZOS)
|
||||||
|
out = BytesIO()
|
||||||
|
thumb.save(out, format="WEBP", quality=82, method=6)
|
||||||
|
return out.getvalue()
|
||||||
55
containers/novela/db.py
Normal file
55
containers/novela/db.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import os
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2 import pool
|
||||||
|
|
||||||
|
_pool: pool.ThreadedConnectionPool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _db_config() -> dict:
|
||||||
|
return {
|
||||||
|
"host": os.environ.get("POSTGRES_HOST", "postgres"),
|
||||||
|
"port": int(os.environ.get("POSTGRES_PORT", 5432)),
|
||||||
|
"dbname": os.environ.get("POSTGRES_DB", "novela"),
|
||||||
|
"user": os.environ.get("POSTGRES_USER", "novela"),
|
||||||
|
"password": os.environ.get("POSTGRES_PASSWORD", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def init_pool(minconn: int = 2, maxconn: int = 10) -> None:
|
||||||
|
global _pool
|
||||||
|
if _pool is None:
|
||||||
|
_pool = pool.ThreadedConnectionPool(minconn=minconn, maxconn=maxconn, **_db_config())
|
||||||
|
|
||||||
|
|
||||||
|
def close_pool() -> None:
|
||||||
|
global _pool
|
||||||
|
if _pool is not None:
|
||||||
|
_pool.closeall()
|
||||||
|
_pool = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_conn():
|
||||||
|
global _pool
|
||||||
|
if _pool is None:
|
||||||
|
init_pool()
|
||||||
|
return _pool.getconn() # type: ignore[union-attr]
|
||||||
|
|
||||||
|
|
||||||
|
def release_conn(conn) -> None:
|
||||||
|
if _pool is not None and conn is not None:
|
||||||
|
_pool.putconn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_db_conn():
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
release_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def direct_connect():
|
||||||
|
return psycopg2.connect(**_db_config())
|
||||||
355
containers/novela/epub.py
Normal file
355
containers/novela/epub.py
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
import io
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
|
from html import escape as he
|
||||||
|
|
||||||
|
|
||||||
|
def detect_image_format(data: bytes, base: str) -> tuple[str, str]:
|
||||||
|
"""Return (filename_with_ext, media_type) detected from image magic bytes.
|
||||||
|
|
||||||
|
base -- filename stem without extension, e.g. 'cover' or 'ch001_img002'
|
||||||
|
"""
|
||||||
|
if data[:2] == b'\xff\xd8':
|
||||||
|
return f"{base}.jpg", "image/jpeg"
|
||||||
|
if data[:8] == b'\x89PNG\r\n\x1a\n':
|
||||||
|
return f"{base}.png", "image/png"
|
||||||
|
if data[:4] == b'RIFF' and data[8:12] == b'WEBP':
|
||||||
|
return f"{base}.webp", "image/webp"
|
||||||
|
if data[:3] == b'GIF':
|
||||||
|
return f"{base}.gif", "image/gif"
|
||||||
|
return f"{base}.jpg", "image/jpeg" # fallback
|
||||||
|
|
||||||
|
|
||||||
|
def add_cover_to_epub(epub_path, cover_data: bytes) -> None:
|
||||||
|
"""Add a cover image to an existing EPUB and remove the Cover Missing tag."""
|
||||||
|
cover_filename, cover_media_type = detect_image_format(cover_data, "cover")
|
||||||
|
|
||||||
|
# Read existing zip into memory
|
||||||
|
with open(epub_path, "rb") as f:
|
||||||
|
original = f.read()
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(io.BytesIO(original), "r") as zin, \
|
||||||
|
zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
|
||||||
|
# Copy mimetype uncompressed first
|
||||||
|
info = zin.getinfo("mimetype")
|
||||||
|
zout.writestr(zipfile.ZipInfo("mimetype"), zin.read("mimetype"), compress_type=zipfile.ZIP_STORED)
|
||||||
|
|
||||||
|
for item in zin.infolist():
|
||||||
|
if item.filename == "mimetype":
|
||||||
|
continue
|
||||||
|
data = zin.read(item.filename)
|
||||||
|
|
||||||
|
if item.filename == "OEBPS/content.opf":
|
||||||
|
data = _patch_opf(data.decode("utf-8"), cover_filename, cover_media_type).encode("utf-8")
|
||||||
|
|
||||||
|
zout.writestr(item, data)
|
||||||
|
|
||||||
|
# Add the cover image
|
||||||
|
zout.writestr(f"OEBPS/Images/{cover_filename}", cover_data)
|
||||||
|
|
||||||
|
with open(epub_path, "wb") as f:
|
||||||
|
f.write(buf.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_opf(opf: str, cover_filename: str, cover_media_type: str) -> str:
|
||||||
|
"""Insert cover into OPF manifest/metadata and remove Cover Missing dc:subject."""
|
||||||
|
# Remove "Cover Missing" dc:subject
|
||||||
|
opf = re.sub(r'\s*<dc:subject>Cover Missing</dc:subject>', '', opf)
|
||||||
|
|
||||||
|
# Add cover manifest item before </manifest>
|
||||||
|
cover_item = f'<item id="cover-img" href="Images/{cover_filename}" media-type="{cover_media_type}"/>'
|
||||||
|
opf = opf.replace("</manifest>", f' {cover_item}\n </manifest>')
|
||||||
|
|
||||||
|
# Add cover meta before </metadata>
|
||||||
|
cover_meta = '<meta name="cover" content="cover-img"/>'
|
||||||
|
opf = opf.replace("</metadata>", f' {cover_meta}\n </metadata>')
|
||||||
|
|
||||||
|
return opf
|
||||||
|
|
||||||
|
|
||||||
|
def make_chapter_xhtml(title: str, content_html: str, chapter_num: int) -> str:
|
||||||
|
t = he(title)
|
||||||
|
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
||||||
|
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||||
|
<title>{t}</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="../Styles/style.css"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2 class="chapter-title">{t}</h2>
|
||||||
|
{content_html}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def make_intro_xhtml(book_title: str, author: str, book_info: dict) -> str:
|
||||||
|
"""Generate the intro page XHTML with genres, description, source and date."""
|
||||||
|
parts = []
|
||||||
|
# Optional illustration from the story index page (e.g. awesomedude.org)
|
||||||
|
if book_info.get("index_image_name"):
|
||||||
|
img = he(book_info["index_image_name"])
|
||||||
|
parts.append(f'<div class="intro-image"><img src="../Images/{img}" alt="" style="max-width:100%;"/></div>')
|
||||||
|
if book_info.get("genres"):
|
||||||
|
parts.append(f'<p><strong>Genres:</strong> {he(", ".join(book_info["genres"]))}</p>')
|
||||||
|
if book_info.get("subgenres"):
|
||||||
|
parts.append(f'<p><strong>Sub-genres:</strong> {he(", ".join(book_info["subgenres"]))}</p>')
|
||||||
|
if book_info.get("tags"):
|
||||||
|
parts.append(f'<p><strong>Tags:</strong> {he(", ".join(book_info["tags"]))}</p>')
|
||||||
|
if book_info.get("description"):
|
||||||
|
parts.append("<hr/>")
|
||||||
|
for para in book_info["description"].split("\n\n"):
|
||||||
|
if para.strip():
|
||||||
|
parts.append(f"<p>{he(para.strip())}</p>")
|
||||||
|
parts.append("<hr/>")
|
||||||
|
if book_info.get("source_url"):
|
||||||
|
parts.append(f'<p><strong>Source:</strong> {he(book_info["source_url"])}</p>')
|
||||||
|
if book_info.get("updated_date"):
|
||||||
|
parts.append(f'<p><strong>Updated:</strong> {he(book_info["updated_date"])}</p>')
|
||||||
|
content = "\n".join(parts)
|
||||||
|
t = he(book_title)
|
||||||
|
a = he(author)
|
||||||
|
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
||||||
|
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||||
|
<title>{t}</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="../Styles/style.css"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{t}</h1>
|
||||||
|
<p class="author">by {a}</p>
|
||||||
|
{content}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def make_epub(
|
||||||
|
book_title: str,
|
||||||
|
author: str,
|
||||||
|
chapters: list[dict],
|
||||||
|
cover_data: bytes | None,
|
||||||
|
break_img_data: bytes,
|
||||||
|
book_id: str,
|
||||||
|
book_info: dict | None = None,
|
||||||
|
) -> bytes:
|
||||||
|
"""Build a complete EPUB 2.0 in-memory and return the bytes."""
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
# mimetype must be first and uncompressed
|
||||||
|
zf.writestr(
|
||||||
|
zipfile.ZipInfo("mimetype"),
|
||||||
|
"application/epub+zip",
|
||||||
|
compress_type=zipfile.ZIP_STORED,
|
||||||
|
)
|
||||||
|
|
||||||
|
zf.writestr(
|
||||||
|
"META-INF/container.xml",
|
||||||
|
"""<?xml version="1.0"?>
|
||||||
|
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||||
|
<rootfiles>
|
||||||
|
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||||
|
</rootfiles>
|
||||||
|
</container>""",
|
||||||
|
)
|
||||||
|
|
||||||
|
css = open("static/epub-style.css", "r", encoding="utf-8").read()
|
||||||
|
zf.writestr("OEBPS/Styles/style.css", css)
|
||||||
|
zf.writestr("OEBPS/Images/break.png", break_img_data)
|
||||||
|
|
||||||
|
info = book_info or {}
|
||||||
|
|
||||||
|
# Optional intro illustration (e.g. index page image from awesomedude.org)
|
||||||
|
if info.get("index_image_data"):
|
||||||
|
zf.writestr(f"OEBPS/Images/{info['index_image_name']}", info["index_image_data"])
|
||||||
|
|
||||||
|
has_cover = cover_data is not None
|
||||||
|
cover_filename = ""
|
||||||
|
cover_media_type = ""
|
||||||
|
if has_cover:
|
||||||
|
cover_filename, cover_media_type = detect_image_format(cover_data, "cover")
|
||||||
|
zf.writestr(f"OEBPS/Images/{cover_filename}", cover_data)
|
||||||
|
|
||||||
|
zf.writestr("OEBPS/Text/intro.xhtml", make_intro_xhtml(book_title, author, info))
|
||||||
|
|
||||||
|
# Chapter images
|
||||||
|
for ch in chapters:
|
||||||
|
for img in ch.get("images", []):
|
||||||
|
zf.writestr(img["epub_path"], img["data"])
|
||||||
|
|
||||||
|
chapter_files = []
|
||||||
|
for i, ch in enumerate(chapters, 1):
|
||||||
|
fname = f"chapter{i:03d}.xhtml"
|
||||||
|
zf.writestr(f"OEBPS/Text/{fname}", ch["xhtml"])
|
||||||
|
chapter_files.append((fname, ch["title"]))
|
||||||
|
|
||||||
|
# Manifest
|
||||||
|
manifest_items = []
|
||||||
|
if has_cover:
|
||||||
|
manifest_items.append(
|
||||||
|
f'<item id="cover-img" href="Images/{cover_filename}" media-type="{cover_media_type}"/>'
|
||||||
|
)
|
||||||
|
# Chapter images
|
||||||
|
for ch in chapters:
|
||||||
|
for img in ch.get("images", []):
|
||||||
|
img_id = img["epub_path"].split("/")[-1].replace(".", "_")
|
||||||
|
manifest_items.append(
|
||||||
|
f'<item id="{img_id}" href="{img["epub_path"].replace("OEBPS/", "")}"'
|
||||||
|
f' media-type="{img["media_type"]}"/>'
|
||||||
|
)
|
||||||
|
if info.get("index_image_name"):
|
||||||
|
manifest_items.append(
|
||||||
|
f'<item id="intro-img" href="Images/{info["index_image_name"]}"'
|
||||||
|
f' media-type="{info["index_image_mime"]}"/>'
|
||||||
|
)
|
||||||
|
manifest_items.append('<item id="break-img" href="Images/break.png" media-type="image/png"/>')
|
||||||
|
manifest_items.append('<item id="css" href="Styles/style.css" media-type="text/css"/>')
|
||||||
|
manifest_items.append('<item id="intro" href="Text/intro.xhtml" media-type="application/xhtml+xml"/>')
|
||||||
|
for i, (fname, _) in enumerate(chapter_files, 1):
|
||||||
|
manifest_items.append(f'<item id="ch{i:03d}" href="Text/{fname}" media-type="application/xhtml+xml"/>')
|
||||||
|
manifest_items.append('<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>')
|
||||||
|
|
||||||
|
spine_items = ['<itemref idref="intro"/>'] + [
|
||||||
|
f'<itemref idref="ch{i:03d}"/>' for i in range(1, len(chapter_files) + 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
cover_meta = f'<meta name="cover" content="cover-img"/>' if has_cover else ""
|
||||||
|
|
||||||
|
subject_items = "".join(
|
||||||
|
f"\n <dc:subject>{he(g)}</dc:subject>"
|
||||||
|
for g in info.get("genres", []) + info.get("subgenres", []) + info.get("tags", [])
|
||||||
|
)
|
||||||
|
desc_item = (
|
||||||
|
f"\n <dc:description>{he(info['description'].replace(chr(10), ' '))}</dc:description>"
|
||||||
|
if info.get("description") else ""
|
||||||
|
)
|
||||||
|
date_item = (
|
||||||
|
f"\n <dc:date opf:event=\"modification\">{he(info['updated_date'])}</dc:date>"
|
||||||
|
if info.get("updated_date") else ""
|
||||||
|
)
|
||||||
|
source_item = (
|
||||||
|
f"\n <dc:source>{he(info['source_url'])}</dc:source>"
|
||||||
|
if info.get("source_url") else ""
|
||||||
|
)
|
||||||
|
publisher_item = (
|
||||||
|
f"\n <dc:publisher>{he(info['publisher'])}</dc:publisher>"
|
||||||
|
if info.get("publisher") else ""
|
||||||
|
)
|
||||||
|
series_items = ""
|
||||||
|
if info.get("series"):
|
||||||
|
s = he(info["series"])
|
||||||
|
idx = int(info.get("series_index", 1))
|
||||||
|
series_items = (
|
||||||
|
f'\n <meta name="calibre:series" content="{s}"/>'
|
||||||
|
f'\n <meta name="calibre:series_index" content="{idx}"/>'
|
||||||
|
)
|
||||||
|
status_item = (
|
||||||
|
f'\n <meta name="publication_status" content="{he(info["publication_status"])}"/>'
|
||||||
|
if info.get("publication_status") else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
opf = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="BookId">
|
||||||
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
|
||||||
|
<dc:title>{he(book_title)}</dc:title>
|
||||||
|
<dc:creator opf:role="aut">{he(author)}</dc:creator>
|
||||||
|
<dc:language>en</dc:language>
|
||||||
|
<dc:identifier id="BookId">{book_id}</dc:identifier>
|
||||||
|
{cover_meta}{subject_items}{desc_item}{date_item}{source_item}{publisher_item}{series_items}{status_item}
|
||||||
|
</metadata>
|
||||||
|
<manifest>
|
||||||
|
{"".join(manifest_items)}
|
||||||
|
</manifest>
|
||||||
|
<spine toc="ncx">
|
||||||
|
{"".join(spine_items)}
|
||||||
|
</spine>
|
||||||
|
</package>"""
|
||||||
|
zf.writestr("OEBPS/content.opf", opf)
|
||||||
|
|
||||||
|
# TOC NCX
|
||||||
|
nav_points = [
|
||||||
|
""" <navPoint id="intro" playOrder="1">
|
||||||
|
<navLabel><text>Book Info</text></navLabel>
|
||||||
|
<content src="Text/intro.xhtml"/>
|
||||||
|
</navPoint>"""
|
||||||
|
]
|
||||||
|
for i, (fname, title) in enumerate(chapter_files, 1):
|
||||||
|
nav_points.append(
|
||||||
|
f""" <navPoint id="ch{i:03d}" playOrder="{i + 1}">
|
||||||
|
<navLabel><text>{he(title)}</text></navLabel>
|
||||||
|
<content src="Text/{fname}"/>
|
||||||
|
</navPoint>"""
|
||||||
|
)
|
||||||
|
|
||||||
|
ncx = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN"
|
||||||
|
"http://www.daisy.org/z3986/2005/ncx-2005-1.dtd">
|
||||||
|
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
|
||||||
|
<head>
|
||||||
|
<meta name="dtb:uid" content="{book_id}"/>
|
||||||
|
<meta name="dtb:depth" content="1"/>
|
||||||
|
<meta name="dtb:totalPageCount" content="0"/>
|
||||||
|
<meta name="dtb:maxPageNumber" content="0"/>
|
||||||
|
</head>
|
||||||
|
<docTitle><text>{he(book_title)}</text></docTitle>
|
||||||
|
<navMap>
|
||||||
|
{"".join(nav_points)}
|
||||||
|
</navMap>
|
||||||
|
</ncx>"""
|
||||||
|
zf.writestr("OEBPS/toc.ncx", ncx)
|
||||||
|
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def read_epub_file(epub_path, internal_path: str) -> str:
|
||||||
|
"""Read a single file from the EPUB zip and return it as a UTF-8 string."""
|
||||||
|
with zipfile.ZipFile(epub_path, "r") as z:
|
||||||
|
return z.read(internal_path).decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def write_epub_file(epub_path, internal_path: str, content: str) -> None:
|
||||||
|
"""Replace a single file inside the EPUB zip (full zip rewrite).
|
||||||
|
|
||||||
|
If OEBPS/Images/break.png is missing from the zip it is added automatically,
|
||||||
|
so break-image inserts made in the editor render correctly in older EPUBs.
|
||||||
|
"""
|
||||||
|
with open(epub_path, "rb") as f:
|
||||||
|
original = f.read()
|
||||||
|
|
||||||
|
break_img_path = "OEBPS/Images/break.png"
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(io.BytesIO(original), "r") as zin, \
|
||||||
|
zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
|
||||||
|
zout.writestr(
|
||||||
|
zipfile.ZipInfo("mimetype"), zin.read("mimetype"),
|
||||||
|
compress_type=zipfile.ZIP_STORED,
|
||||||
|
)
|
||||||
|
|
||||||
|
names = zin.namelist()
|
||||||
|
has_break = break_img_path in names
|
||||||
|
|
||||||
|
for item in zin.infolist():
|
||||||
|
if item.filename == "mimetype":
|
||||||
|
continue
|
||||||
|
if item.filename == internal_path:
|
||||||
|
zout.writestr(item, content.encode("utf-8"))
|
||||||
|
else:
|
||||||
|
zout.writestr(item, zin.read(item.filename))
|
||||||
|
|
||||||
|
if not has_break:
|
||||||
|
try:
|
||||||
|
zout.writestr(break_img_path, open("static/break.png", "rb").read())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
with open(epub_path, "wb") as f:
|
||||||
|
f.write(buf.getvalue())
|
||||||
42
containers/novela/main.py
Normal file
42
containers/novela/main.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from db import close_pool, init_pool
|
||||||
|
from migrations import run_migrations
|
||||||
|
from routers import (
|
||||||
|
backup_router,
|
||||||
|
editor_router,
|
||||||
|
grabber_router,
|
||||||
|
library_router,
|
||||||
|
reader_router,
|
||||||
|
settings_router,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
init_pool()
|
||||||
|
run_migrations()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
close_pool()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
|
app.include_router(library_router)
|
||||||
|
app.include_router(reader_router)
|
||||||
|
app.include_router(editor_router)
|
||||||
|
app.include_router(grabber_router)
|
||||||
|
app.include_router(settings_router)
|
||||||
|
app.include_router(backup_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def index_redirect():
|
||||||
|
return RedirectResponse(url="/home", status_code=302)
|
||||||
203
containers/novela/migrations.py
Normal file
203
containers/novela/migrations.py
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from db import direct_connect
|
||||||
|
|
||||||
|
_DEFAULT_REGEX = [
|
||||||
|
r"^\s*[\*\-]{3,}\s*$",
|
||||||
|
r"^\s*[·•◦‣⁃]\s*[·•◦‣⁃]\s*[·•◦‣⁃]\s*$",
|
||||||
|
r"^\s*~{2,}\s*$",
|
||||||
|
r"^\s*={3,}\s*$",
|
||||||
|
r"^\s*#{3,}\s*$",
|
||||||
|
r"^\s*[oO0]{1,3}\s*$",
|
||||||
|
r"^\s*[-–—]\s*[oO0]\s*[-–—]\s*$",
|
||||||
|
r"^\s*[<>]+\s*[·•*]\s*[<>]+\s*$",
|
||||||
|
]
|
||||||
|
_DEFAULT_CSS = [
|
||||||
|
"hr",
|
||||||
|
"separator",
|
||||||
|
"section-break",
|
||||||
|
"divider",
|
||||||
|
"break",
|
||||||
|
"chapterbreak",
|
||||||
|
"scene-break",
|
||||||
|
"scenebreak",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _exec(sql: str) -> None:
|
||||||
|
conn = direct_connect()
|
||||||
|
try:
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(sql)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_create_library() -> None:
|
||||||
|
_exec(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS library (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
filename VARCHAR(600) UNIQUE NOT NULL,
|
||||||
|
media_type VARCHAR(10) NOT NULL DEFAULT 'epub',
|
||||||
|
title VARCHAR(500),
|
||||||
|
author VARCHAR(255),
|
||||||
|
publisher VARCHAR(255),
|
||||||
|
series VARCHAR(500),
|
||||||
|
series_index INTEGER DEFAULT 0,
|
||||||
|
publication_status VARCHAR(100),
|
||||||
|
has_cover BOOLEAN DEFAULT FALSE,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
source_url VARCHAR(1000),
|
||||||
|
publish_date DATE,
|
||||||
|
archived BOOLEAN DEFAULT FALSE,
|
||||||
|
want_to_read BOOLEAN DEFAULT FALSE,
|
||||||
|
needs_review BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_create_book_tags() -> None:
|
||||||
|
_exec(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS book_tags (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
filename VARCHAR(600) NOT NULL REFERENCES library(filename) ON DELETE CASCADE,
|
||||||
|
tag VARCHAR(255) NOT NULL,
|
||||||
|
tag_type VARCHAR(20) NOT NULL,
|
||||||
|
UNIQUE (filename, tag, tag_type)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
_exec("CREATE INDEX IF NOT EXISTS idx_book_tags_filename ON book_tags (filename)")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_create_reading_progress() -> None:
|
||||||
|
_exec(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS reading_progress (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
filename VARCHAR(600) UNIQUE NOT NULL REFERENCES library(filename) ON DELETE CASCADE,
|
||||||
|
cfi TEXT,
|
||||||
|
page INTEGER,
|
||||||
|
progress INTEGER DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_create_reading_sessions() -> None:
|
||||||
|
_exec(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS reading_sessions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
filename VARCHAR(600) NOT NULL REFERENCES library(filename) ON DELETE CASCADE,
|
||||||
|
read_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
_exec("CREATE INDEX IF NOT EXISTS idx_reading_sessions_filename ON reading_sessions (filename)")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_create_library_cover_cache() -> None:
|
||||||
|
_exec(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS library_cover_cache (
|
||||||
|
filename VARCHAR(600) PRIMARY KEY REFERENCES library(filename) ON DELETE CASCADE,
|
||||||
|
mime_type VARCHAR(100) NOT NULL,
|
||||||
|
thumb_webp BYTEA NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_create_credentials() -> None:
|
||||||
|
_exec(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS credentials (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
site VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
username VARCHAR(255) NOT NULL,
|
||||||
|
password VARCHAR(255) NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_create_break_patterns() -> None:
|
||||||
|
_exec(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS break_patterns (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
pattern_type VARCHAR(20) NOT NULL,
|
||||||
|
pattern TEXT NOT NULL,
|
||||||
|
enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
is_default BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE (pattern_type, pattern)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_seed_break_patterns() -> None:
|
||||||
|
conn = direct_connect()
|
||||||
|
try:
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
for pat in _DEFAULT_REGEX:
|
||||||
|
re.compile(pat)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO break_patterns (pattern_type, pattern, is_default)
|
||||||
|
VALUES ('regex', %s, TRUE)
|
||||||
|
ON CONFLICT (pattern_type, pattern) DO NOTHING
|
||||||
|
""",
|
||||||
|
(pat,),
|
||||||
|
)
|
||||||
|
for pat in _DEFAULT_CSS:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO break_patterns (pattern_type, pattern, is_default)
|
||||||
|
VALUES ('css_class', %s, TRUE)
|
||||||
|
ON CONFLICT (pattern_type, pattern) DO NOTHING
|
||||||
|
""",
|
||||||
|
(pat,),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_create_backup_log() -> None:
|
||||||
|
_exec(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS backup_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
status VARCHAR(20) NOT NULL,
|
||||||
|
files_count INTEGER,
|
||||||
|
size_bytes BIGINT,
|
||||||
|
error_msg TEXT,
|
||||||
|
started_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
finished_at TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations() -> None:
|
||||||
|
migrate_create_library()
|
||||||
|
migrate_create_book_tags()
|
||||||
|
migrate_create_reading_progress()
|
||||||
|
migrate_create_reading_sessions()
|
||||||
|
migrate_create_library_cover_cache()
|
||||||
|
migrate_create_credentials()
|
||||||
|
migrate_create_break_patterns()
|
||||||
|
migrate_create_backup_log()
|
||||||
|
migrate_seed_break_patterns()
|
||||||
68
containers/novela/pdf.py
Normal file
68
containers/novela/pdf.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import fitz
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
|
COVER_W = 300
|
||||||
|
COVER_H = 450
|
||||||
|
|
||||||
|
|
||||||
|
def pdf_page_count(path: Path) -> int:
|
||||||
|
with fitz.open(path) as doc:
|
||||||
|
return doc.page_count
|
||||||
|
|
||||||
|
|
||||||
|
def pdf_render_page(path: Path, page_num: int, dpi: int = 150) -> bytes:
|
||||||
|
with fitz.open(path) as doc:
|
||||||
|
if page_num < 0 or page_num >= doc.page_count:
|
||||||
|
raise IndexError("Page out of range")
|
||||||
|
page = doc.load_page(page_num)
|
||||||
|
mat = fitz.Matrix(dpi / 72.0, dpi / 72.0)
|
||||||
|
pix = page.get_pixmap(matrix=mat, alpha=False)
|
||||||
|
return pix.tobytes("png")
|
||||||
|
|
||||||
|
|
||||||
|
def _webp_thumb_from_image(path: Path) -> bytes:
|
||||||
|
with Image.open(path) as im:
|
||||||
|
im = ImageOps.exif_transpose(im)
|
||||||
|
if im.mode not in ("RGB", "RGBA"):
|
||||||
|
im = im.convert("RGB")
|
||||||
|
thumb = ImageOps.fit(im, (COVER_W, COVER_H), method=Image.Resampling.LANCZOS)
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
out = BytesIO()
|
||||||
|
thumb.save(out, format="WEBP", quality=82, method=6)
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def pdf_cover_thumb(path: Path) -> bytes:
|
||||||
|
with fitz.open(path) as doc:
|
||||||
|
if doc.page_count == 0:
|
||||||
|
raise ValueError("PDF has no pages")
|
||||||
|
page = doc.load_page(0)
|
||||||
|
pix = page.get_pixmap(matrix=fitz.Matrix(1.5, 1.5), alpha=False)
|
||||||
|
tmp = path.with_suffix(".cover.tmp.png")
|
||||||
|
try:
|
||||||
|
pix.save(tmp)
|
||||||
|
return _webp_thumb_from_image(tmp)
|
||||||
|
finally:
|
||||||
|
if tmp.exists():
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def pdf_scan_metadata(path: Path) -> dict:
|
||||||
|
with fitz.open(path) as doc:
|
||||||
|
meta = doc.metadata or {}
|
||||||
|
return {
|
||||||
|
"title": (meta.get("title") or path.stem or "").strip(),
|
||||||
|
"author": (meta.get("author") or "").strip(),
|
||||||
|
"publisher": (meta.get("producer") or "").strip(),
|
||||||
|
"description": (meta.get("subject") or "").strip(),
|
||||||
|
"source_url": "",
|
||||||
|
"series": "",
|
||||||
|
"series_index": 0,
|
||||||
|
"publication_status": "",
|
||||||
|
"has_cover": doc.page_count > 0,
|
||||||
|
"subjects": [],
|
||||||
|
"publish_date": "",
|
||||||
|
}
|
||||||
14
containers/novela/requirements.txt
Normal file
14
containers/novela/requirements.txt
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
fastapi==0.115.5
|
||||||
|
uvicorn[standard]==0.32.1
|
||||||
|
httpx==0.27.2
|
||||||
|
beautifulsoup4==4.12.3
|
||||||
|
lxml==5.3.0
|
||||||
|
python-multipart==0.0.12
|
||||||
|
psycopg2-binary==2.9.10
|
||||||
|
jinja2==3.1.4
|
||||||
|
Pillow==11.0.0
|
||||||
|
pymupdf==1.24.0
|
||||||
|
rarfile==4.2
|
||||||
|
dropbox==12.0.2
|
||||||
|
apscheduler==3.10.4
|
||||||
|
cryptography==44.0.1
|
||||||
15
containers/novela/routers/__init__.py
Normal file
15
containers/novela/routers/__init__.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from routers.backup import router as backup_router
|
||||||
|
from routers.editor import router as editor_router
|
||||||
|
from routers.grabber import router as grabber_router
|
||||||
|
from routers.library import router as library_router
|
||||||
|
from routers.reader import router as reader_router
|
||||||
|
from routers.settings import router as settings_router
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"library_router",
|
||||||
|
"reader_router",
|
||||||
|
"editor_router",
|
||||||
|
"grabber_router",
|
||||||
|
"backup_router",
|
||||||
|
"settings_router",
|
||||||
|
]
|
||||||
BIN
containers/novela/routers/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
containers/novela/routers/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
containers/novela/routers/__pycache__/backup.cpython-311.pyc
Normal file
BIN
containers/novela/routers/__pycache__/backup.cpython-311.pyc
Normal file
Binary file not shown.
BIN
containers/novela/routers/__pycache__/common.cpython-311.pyc
Normal file
BIN
containers/novela/routers/__pycache__/common.cpython-311.pyc
Normal file
Binary file not shown.
BIN
containers/novela/routers/__pycache__/editor.cpython-311.pyc
Normal file
BIN
containers/novela/routers/__pycache__/editor.cpython-311.pyc
Normal file
Binary file not shown.
BIN
containers/novela/routers/__pycache__/grabber.cpython-311.pyc
Normal file
BIN
containers/novela/routers/__pycache__/grabber.cpython-311.pyc
Normal file
Binary file not shown.
BIN
containers/novela/routers/__pycache__/library.cpython-311.pyc
Normal file
BIN
containers/novela/routers/__pycache__/library.cpython-311.pyc
Normal file
Binary file not shown.
BIN
containers/novela/routers/__pycache__/reader.cpython-311.pyc
Normal file
BIN
containers/novela/routers/__pycache__/reader.cpython-311.pyc
Normal file
Binary file not shown.
BIN
containers/novela/routers/__pycache__/settings.cpython-311.pyc
Normal file
BIN
containers/novela/routers/__pycache__/settings.cpython-311.pyc
Normal file
Binary file not shown.
359
containers/novela/routers/backup.py
Normal file
359
containers/novela/routers/backup.py
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
|
||||||
|
import dropbox
|
||||||
|
from dropbox.exceptions import ApiError, AuthError
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from db import get_db_conn
|
||||||
|
from security import decrypt_value, encrypt_value, is_encrypted_value
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
LIBRARY_DIR = Path(os.environ.get("LIBRARY_DIR", "library"))
|
||||||
|
CONFIG_DIR = Path(os.environ.get("CONFIG_DIR", "config"))
|
||||||
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
MANIFEST_PATH = CONFIG_DIR / "backup_manifest.json"
|
||||||
|
DROPBOX_ROOT = (os.environ.get("DROPBOX_BACKUP_ROOT", "/novela") or "/novela").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_manifest() -> dict[str, dict[str, float | int]]:
|
||||||
|
if not MANIFEST_PATH.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_manifest(manifest: dict[str, dict[str, float | int]]) -> None:
|
||||||
|
MANIFEST_PATH.write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_dropbox_token() -> str:
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT username, password FROM credentials WHERE site = 'dropbox' LIMIT 1")
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
username_raw, password_raw = row
|
||||||
|
username = decrypt_value(username_raw)
|
||||||
|
password = decrypt_value(password_raw)
|
||||||
|
|
||||||
|
if not is_encrypted_value(username_raw) or not is_encrypted_value(password_raw):
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE credentials
|
||||||
|
SET username = %s, password = %s, updated_at = NOW()
|
||||||
|
WHERE site = 'dropbox'
|
||||||
|
""",
|
||||||
|
(encrypt_value(username), encrypt_value(password)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (password or username or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _dbx() -> dropbox.Dropbox:
|
||||||
|
token = _load_dropbox_token()
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError("Dropbox token not found in credentials (site='dropbox').")
|
||||||
|
client = dropbox.Dropbox(token, timeout=120)
|
||||||
|
try:
|
||||||
|
client.users_get_current_account()
|
||||||
|
except AuthError as e:
|
||||||
|
raise RuntimeError(f"Dropbox auth failed: {e}")
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_dropbox_dir(client: dropbox.Dropbox, path: str) -> None:
|
||||||
|
if not path or path == "/":
|
||||||
|
return
|
||||||
|
parts = [p for p in path.split("/") if p]
|
||||||
|
cur = ""
|
||||||
|
for p in parts:
|
||||||
|
cur += "/" + p
|
||||||
|
try:
|
||||||
|
client.files_create_folder_v2(cur)
|
||||||
|
except ApiError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _dropbox_upload_bytes(client: dropbox.Dropbox, target_path: str, data: bytes) -> int:
|
||||||
|
parent = str(Path(target_path).parent).replace("\\", "/")
|
||||||
|
if not parent.startswith("/"):
|
||||||
|
parent = "/" + parent
|
||||||
|
_ensure_dropbox_dir(client, parent)
|
||||||
|
client.files_upload(data, target_path, mode=dropbox.files.WriteMode.overwrite, mute=True)
|
||||||
|
return len(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_library_files() -> list[Path]:
|
||||||
|
if not LIBRARY_DIR.exists():
|
||||||
|
return []
|
||||||
|
return [p for p in LIBRARY_DIR.rglob("*") if p.is_file()]
|
||||||
|
|
||||||
|
|
||||||
|
def _current_file_state(path: Path) -> dict[str, float | int]:
|
||||||
|
st = path.stat()
|
||||||
|
return {"mtime": st.st_mtime, "size": st.st_size}
|
||||||
|
|
||||||
|
|
||||||
|
def _pg_dump_cmd(tmp_path: Path) -> list[str]:
|
||||||
|
return [
|
||||||
|
"pg_dump",
|
||||||
|
"-h",
|
||||||
|
os.environ.get("POSTGRES_HOST", "postgres"),
|
||||||
|
"-p",
|
||||||
|
str(os.environ.get("POSTGRES_PORT", "5432")),
|
||||||
|
"-U",
|
||||||
|
os.environ.get("POSTGRES_USER", "novela"),
|
||||||
|
"-d",
|
||||||
|
os.environ.get("POSTGRES_DB", "novela"),
|
||||||
|
"-f",
|
||||||
|
str(tmp_path),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _run_pg_dump() -> tuple[bytes, str]:
|
||||||
|
db = os.environ.get("POSTGRES_DB", "novela")
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PGPASSWORD"] = os.environ.get("POSTGRES_PASSWORD", "")
|
||||||
|
|
||||||
|
with NamedTemporaryFile(suffix=".sql", delete=False) as tmp:
|
||||||
|
tmp_path = Path(tmp.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cmd = _pg_dump_cmd(tmp_path)
|
||||||
|
proc = subprocess.run(cmd, env=env, capture_output=True, text=True)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
stderr = (proc.stderr or "").strip()
|
||||||
|
raise RuntimeError(f"pg_dump failed: {stderr or 'unknown error'}")
|
||||||
|
data = tmp_path.read_bytes()
|
||||||
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||||
|
return data, f"{db}-{stamp}.sql"
|
||||||
|
finally:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_backup_log_running() -> int:
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO backup_log (status, started_at)
|
||||||
|
VALUES ('running', NOW())
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return int(cur.fetchone()[0])
|
||||||
|
|
||||||
|
|
||||||
|
def _finish_backup_log(log_id: int, *, status: str, files_count: int | None, size_bytes: int | None, error_msg: str | None) -> None:
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE backup_log
|
||||||
|
SET status = %s,
|
||||||
|
files_count = %s,
|
||||||
|
size_bytes = %s,
|
||||||
|
error_msg = %s,
|
||||||
|
finished_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(status, files_count, size_bytes, error_msg, log_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_backup_internal(*, dry_run: bool) -> tuple[int, int]:
|
||||||
|
client = None if dry_run else _dbx()
|
||||||
|
manifest = _load_manifest()
|
||||||
|
files = _iter_library_files()
|
||||||
|
|
||||||
|
uploaded_count = 0
|
||||||
|
uploaded_size = 0
|
||||||
|
new_manifest: dict[str, dict[str, float | int]] = {}
|
||||||
|
|
||||||
|
library_root = f"{DROPBOX_ROOT}/library"
|
||||||
|
if client is not None:
|
||||||
|
_ensure_dropbox_dir(client, library_root)
|
||||||
|
|
||||||
|
for path in files:
|
||||||
|
rel = path.relative_to(LIBRARY_DIR).as_posix()
|
||||||
|
state = _current_file_state(path)
|
||||||
|
new_manifest[rel] = state
|
||||||
|
|
||||||
|
if manifest.get(rel) == state:
|
||||||
|
continue
|
||||||
|
|
||||||
|
data = path.read_bytes()
|
||||||
|
target = f"{library_root}/{rel}"
|
||||||
|
if client is not None:
|
||||||
|
uploaded_size += _dropbox_upload_bytes(client, target, data)
|
||||||
|
else:
|
||||||
|
uploaded_size += len(data)
|
||||||
|
uploaded_count += 1
|
||||||
|
|
||||||
|
dump_data, dump_name = _run_pg_dump()
|
||||||
|
dump_target = f"{DROPBOX_ROOT}/postgres/{dump_name}"
|
||||||
|
if client is not None:
|
||||||
|
uploaded_size += _dropbox_upload_bytes(client, dump_target, dump_data)
|
||||||
|
else:
|
||||||
|
uploaded_size += len(dump_data)
|
||||||
|
uploaded_count += 1
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
_save_manifest(new_manifest)
|
||||||
|
return uploaded_count, uploaded_size
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/backup", response_class=HTMLResponse)
|
||||||
|
async def backup_page(request: Request):
|
||||||
|
template = "backup.html"
|
||||||
|
if not Path("templates/backup.html").exists():
|
||||||
|
template = "settings.html"
|
||||||
|
return templates.TemplateResponse(request, template, {"active": "backup"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/backup/health")
|
||||||
|
async def backup_health():
|
||||||
|
token_present = bool(_load_dropbox_token())
|
||||||
|
pg_dump_path = shutil.which("pg_dump")
|
||||||
|
|
||||||
|
dropbox_ok = False
|
||||||
|
dropbox_error = None
|
||||||
|
if token_present:
|
||||||
|
try:
|
||||||
|
_dbx()
|
||||||
|
dropbox_ok = True
|
||||||
|
except Exception as e:
|
||||||
|
dropbox_error = str(e)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"token_present": token_present,
|
||||||
|
"dropbox_ok": dropbox_ok,
|
||||||
|
"dropbox_error": dropbox_error,
|
||||||
|
"pg_dump_available": bool(pg_dump_path),
|
||||||
|
"pg_dump_path": pg_dump_path,
|
||||||
|
"library_exists": LIBRARY_DIR.exists(),
|
||||||
|
"library_path": str(LIBRARY_DIR.resolve()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/backup/status")
|
||||||
|
async def backup_status():
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, status, files_count, size_bytes, error_msg, started_at, finished_at
|
||||||
|
FROM backup_log
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return {"status": "never"}
|
||||||
|
return {
|
||||||
|
"id": row[0],
|
||||||
|
"status": row[1],
|
||||||
|
"files_count": row[2],
|
||||||
|
"size_bytes": row[3],
|
||||||
|
"error_msg": row[4],
|
||||||
|
"started_at": row[5].isoformat() if row[5] else None,
|
||||||
|
"finished_at": row[6].isoformat() if row[6] else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/backup/history")
|
||||||
|
async def backup_history():
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, status, files_count, size_bytes, error_msg, started_at, finished_at
|
||||||
|
FROM backup_log
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r[0],
|
||||||
|
"status": r[1],
|
||||||
|
"files_count": r[2],
|
||||||
|
"size_bytes": r[3],
|
||||||
|
"error_msg": r[4],
|
||||||
|
"started_at": r[5].isoformat() if r[5] else None,
|
||||||
|
"finished_at": r[6].isoformat() if r[6] else None,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/backup/run")
|
||||||
|
async def run_backup(request: Request):
|
||||||
|
body = {}
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
dry_run = bool(body.get("dry_run", False))
|
||||||
|
|
||||||
|
log_id = _insert_backup_log_running()
|
||||||
|
try:
|
||||||
|
files_count, size_bytes = _run_backup_internal(dry_run=dry_run)
|
||||||
|
_finish_backup_log(
|
||||||
|
log_id,
|
||||||
|
status="success",
|
||||||
|
files_count=files_count,
|
||||||
|
size_bytes=size_bytes,
|
||||||
|
error_msg=None,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"backup_id": log_id,
|
||||||
|
"status": "success",
|
||||||
|
"dry_run": dry_run,
|
||||||
|
"files_count": files_count,
|
||||||
|
"size_bytes": size_bytes,
|
||||||
|
"finished_at": _now_iso(),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
_finish_backup_log(
|
||||||
|
log_id,
|
||||||
|
status="error",
|
||||||
|
files_count=None,
|
||||||
|
size_bytes=None,
|
||||||
|
error_msg=str(e),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"backup_id": log_id,
|
||||||
|
"status": "error",
|
||||||
|
"dry_run": dry_run,
|
||||||
|
"error": str(e),
|
||||||
|
"finished_at": _now_iso(),
|
||||||
|
}
|
||||||
431
containers/novela/routers/common.py
Normal file
431
containers/novela/routers/common.py
Normal file
@ -0,0 +1,431 @@
|
|||||||
|
import base64
|
||||||
|
import html as _html
|
||||||
|
import io
|
||||||
|
import posixpath
|
||||||
|
import re
|
||||||
|
import zipfile as zf
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||||
|
|
||||||
|
from cbr import cbr_cover_thumb, cbr_page_count
|
||||||
|
from db import get_db_conn
|
||||||
|
from pdf import pdf_cover_thumb, pdf_page_count, pdf_scan_metadata
|
||||||
|
|
||||||
|
LIBRARY_DIR = Path("library")
|
||||||
|
LIBRARY_DIR.mkdir(exist_ok=True)
|
||||||
|
LIBRARY_ROOT = LIBRARY_DIR.resolve()
|
||||||
|
COVER_W = 300
|
||||||
|
COVER_H = 450
|
||||||
|
|
||||||
|
|
||||||
|
def clean_segment(value: str, fallback: str, max_len: int) -> str:
|
||||||
|
txt = re.sub(r"\s+", " ", (value or "").strip())
|
||||||
|
txt = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", txt)
|
||||||
|
txt = re.sub(r"\.+$", "", txt).strip()
|
||||||
|
return (txt or fallback)[:max_len]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_library_path(filename: str) -> Path | None:
|
||||||
|
rel = Path(filename)
|
||||||
|
if rel.is_absolute() or any(part in {"", ".", ".."} for part in rel.parts):
|
||||||
|
return None
|
||||||
|
candidate = (LIBRARY_DIR / rel).resolve()
|
||||||
|
try:
|
||||||
|
candidate.relative_to(LIBRARY_ROOT)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def media_type_from_suffix(path: Path) -> str:
|
||||||
|
ext = path.suffix.lower()
|
||||||
|
if ext == ".epub":
|
||||||
|
return "epub"
|
||||||
|
if ext == ".pdf":
|
||||||
|
return "pdf"
|
||||||
|
if ext in {".cbr", ".cbz"}:
|
||||||
|
return "cbr"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_series_index(value: int | str | None) -> int:
|
||||||
|
try:
|
||||||
|
return max(1, min(999, int(value or 1)))
|
||||||
|
except Exception:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def make_rel_path(*, media_type: str, publisher: str, author: str, title: str, series: str, series_index: int | str | None) -> Path:
|
||||||
|
if media_type == "epub":
|
||||||
|
pub = clean_segment(publisher, "Unknown Publisher", 80)
|
||||||
|
auth = clean_segment(author, "Unknown Author", 80)
|
||||||
|
ttl = clean_segment(title, "Untitled", 140)
|
||||||
|
series_name = clean_segment(series, "", 80)
|
||||||
|
if series_name:
|
||||||
|
return Path("epub") / pub / auth / "Series" / series_name / f"{coerce_series_index(series_index):03d} - {ttl}.epub"
|
||||||
|
return Path("epub") / pub / auth / "Stories" / f"{ttl}.epub"
|
||||||
|
|
||||||
|
if media_type == "pdf":
|
||||||
|
auth = clean_segment(author, "Unknown Author", 80)
|
||||||
|
ttl = clean_segment(title, "Untitled", 140)
|
||||||
|
return Path("pdf") / auth / f"{ttl}.pdf"
|
||||||
|
|
||||||
|
auth = clean_segment(author, "Unknown", 80)
|
||||||
|
ttl = clean_segment(title, "Untitled", 140)
|
||||||
|
return Path("comics") / auth / f"{ttl}.cbr"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_unique_rel_path(rel_path: Path) -> Path:
|
||||||
|
candidate = rel_path
|
||||||
|
suffix = candidate.suffix
|
||||||
|
stem = candidate.stem
|
||||||
|
counter = 2
|
||||||
|
while (LIBRARY_DIR / candidate).exists():
|
||||||
|
candidate = rel_path.with_name(f"{stem} ({counter}){suffix}")
|
||||||
|
counter += 1
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def extract_cover_from_epub(epub_path: Path) -> tuple[bytes, str] | None:
|
||||||
|
try:
|
||||||
|
with zf.ZipFile(epub_path, "r") as z:
|
||||||
|
names = z.namelist()
|
||||||
|
cover = next((n for n in names if "/Images/cover." in n or n.lower().endswith("/cover.jpg")), "")
|
||||||
|
if not cover:
|
||||||
|
return None
|
||||||
|
data = z.read(cover)
|
||||||
|
ext = Path(cover).suffix.lower()
|
||||||
|
mt = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".gif": "image/gif",
|
||||||
|
}.get(ext, "image/jpeg")
|
||||||
|
return data, mt
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def make_cover_thumb_webp(image_bytes: bytes) -> bytes:
|
||||||
|
with Image.open(io.BytesIO(image_bytes)) as im:
|
||||||
|
im = ImageOps.exif_transpose(im)
|
||||||
|
if im.mode not in ("RGB", "RGBA"):
|
||||||
|
im = im.convert("RGB")
|
||||||
|
thumb = ImageOps.fit(im, (COVER_W, COVER_H), method=Image.Resampling.LANCZOS, centering=(0.5, 0.5))
|
||||||
|
out = io.BytesIO()
|
||||||
|
thumb.save(out, format="WEBP", quality=82, method=6)
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_cover_cache(conn, filename: str, mime_type: str, thumb_webp: bytes) -> None:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO library_cover_cache (filename, mime_type, thumb_webp, updated_at)
|
||||||
|
VALUES (%s, %s, %s, NOW())
|
||||||
|
ON CONFLICT (filename) DO UPDATE SET
|
||||||
|
mime_type = EXCLUDED.mime_type,
|
||||||
|
thumb_webp = EXCLUDED.thumb_webp,
|
||||||
|
updated_at = NOW()
|
||||||
|
""",
|
||||||
|
(filename, mime_type, psycopg2.Binary(thumb_webp)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_cover_cache_for_book(conn, filename: str, full_path: Path, media_type: str) -> bool:
|
||||||
|
try:
|
||||||
|
if media_type == "epub":
|
||||||
|
raw = extract_cover_from_epub(full_path)
|
||||||
|
if not raw:
|
||||||
|
return False
|
||||||
|
data, mt = raw
|
||||||
|
thumb = make_cover_thumb_webp(data)
|
||||||
|
upsert_cover_cache(conn, filename, mt, thumb)
|
||||||
|
return True
|
||||||
|
if media_type == "pdf":
|
||||||
|
thumb = pdf_cover_thumb(full_path)
|
||||||
|
upsert_cover_cache(conn, filename, "image/webp", thumb)
|
||||||
|
return True
|
||||||
|
if media_type == "cbr":
|
||||||
|
thumb = cbr_cover_thumb(full_path)
|
||||||
|
upsert_cover_cache(conn, filename, "image/webp", thumb)
|
||||||
|
return True
|
||||||
|
except (UnidentifiedImageError, OSError, ValueError, RuntimeError):
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def prune_empty_dirs(start_dir: Path) -> None:
|
||||||
|
cur = start_dir.resolve()
|
||||||
|
try:
|
||||||
|
cur.relative_to(LIBRARY_ROOT)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
while cur != LIBRARY_ROOT:
|
||||||
|
try:
|
||||||
|
cur.rmdir()
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
cur = cur.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _find_opf_path(names: set[str], container_xml: str | None) -> str | None:
|
||||||
|
opf_path = "OEBPS/content.opf"
|
||||||
|
if container_xml:
|
||||||
|
m = re.search(r"full-path\s*=\s*['\"]([^'\"]+)['\"]", container_xml)
|
||||||
|
if m:
|
||||||
|
opf_path = m.group(1)
|
||||||
|
if opf_path in names:
|
||||||
|
return opf_path
|
||||||
|
candidates = sorted(n for n in names if n.lower().endswith(".opf"))
|
||||||
|
return candidates[0] if candidates else None
|
||||||
|
|
||||||
|
|
||||||
|
def scan_epub(path: Path) -> dict:
|
||||||
|
out = {
|
||||||
|
"has_cover": False,
|
||||||
|
"series": "",
|
||||||
|
"series_index": 0,
|
||||||
|
"title": "",
|
||||||
|
"publication_status": "",
|
||||||
|
"author": "",
|
||||||
|
"publisher": "",
|
||||||
|
"source_url": "",
|
||||||
|
"publish_date": "",
|
||||||
|
"subjects": [],
|
||||||
|
"description": "",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with zf.ZipFile(path, "r") as z:
|
||||||
|
names = set(z.namelist())
|
||||||
|
out["has_cover"] = extract_cover_from_epub(path) is not None
|
||||||
|
container_xml = z.read("META-INF/container.xml").decode("utf-8", errors="replace") if "META-INF/container.xml" in names else None
|
||||||
|
opf_path = _find_opf_path(names, container_xml)
|
||||||
|
if not opf_path or opf_path not in names:
|
||||||
|
return out
|
||||||
|
opf = z.read(opf_path).decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
def _find(pat: str) -> str:
|
||||||
|
m = re.search(pat, opf, re.DOTALL | re.IGNORECASE)
|
||||||
|
return _html.unescape(m.group(1).strip()) if m else ""
|
||||||
|
|
||||||
|
out["title"] = _find(r"<(?:dc:)?title[^>]*>(.*?)</(?:dc:)?title>")
|
||||||
|
out["author"] = _find(r"<(?:dc:)?creator[^>]*>(.*?)</(?:dc:)?creator>")
|
||||||
|
out["publisher"] = _find(r"<(?:dc:)?publisher[^>]*>(.*?)</(?:dc:)?publisher>")
|
||||||
|
out["source_url"] = _find(r"<(?:dc:)?source[^>]*>(.*?)</(?:dc:)?source>")
|
||||||
|
out["description"] = _find(r"<(?:dc:)?description[^>]*>(.*?)</(?:dc:)?description>")
|
||||||
|
|
||||||
|
m = re.search(r'<meta[^>]*name="calibre:series"[^>]*content="([^"]+)"', opf, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
out["series"] = _html.unescape(m.group(1).strip())
|
||||||
|
m = re.search(r'<meta[^>]*name="calibre:series_index"[^>]*content="([^"]+)"', opf, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
out["series_index"] = int(float(m.group(1)))
|
||||||
|
except Exception:
|
||||||
|
out["series_index"] = 0
|
||||||
|
m = re.search(r'<meta[^>]*name="publication_status"[^>]*content="([^"]+)"', opf, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
out["publication_status"] = _html.unescape(m.group(1).strip())
|
||||||
|
|
||||||
|
pd = _find(r"<(?:dc:)?date[^>]*>(.*?)</(?:dc:)?date>")
|
||||||
|
if pd:
|
||||||
|
date_candidate = pd.split("T", 1)[0]
|
||||||
|
try:
|
||||||
|
out["publish_date"] = datetime.fromisoformat(date_candidate).date().isoformat()
|
||||||
|
except Exception:
|
||||||
|
out["publish_date"] = ""
|
||||||
|
|
||||||
|
out["subjects"] = [
|
||||||
|
_html.unescape(s.strip())
|
||||||
|
for s in re.findall(r"<(?:dc:)?subject[^>]*>(.*?)</(?:dc:)?subject>", opf, re.DOTALL | re.IGNORECASE)
|
||||||
|
if s.strip()
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def scan_media(path: Path) -> dict:
|
||||||
|
mt = media_type_from_suffix(path)
|
||||||
|
if mt == "epub":
|
||||||
|
meta = scan_epub(path)
|
||||||
|
elif mt == "pdf":
|
||||||
|
meta = pdf_scan_metadata(path)
|
||||||
|
elif mt == "cbr":
|
||||||
|
meta = {
|
||||||
|
"title": path.stem,
|
||||||
|
"author": "",
|
||||||
|
"publisher": "",
|
||||||
|
"series": "",
|
||||||
|
"series_index": 0,
|
||||||
|
"publication_status": "",
|
||||||
|
"has_cover": cbr_page_count(path) > 0,
|
||||||
|
"description": "",
|
||||||
|
"source_url": "",
|
||||||
|
"publish_date": "",
|
||||||
|
"subjects": [],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
meta = {}
|
||||||
|
meta["media_type"] = mt
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_book(conn, filename: str, meta: dict, tags: list[tuple[str, str]] | None = None) -> None:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO library (filename, media_type, title, author, publisher, has_cover,
|
||||||
|
series, series_index, publication_status, source_url,
|
||||||
|
publish_date, description, needs_review, want_to_read, updated_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, NOW())
|
||||||
|
ON CONFLICT (filename) DO UPDATE SET
|
||||||
|
media_type = EXCLUDED.media_type,
|
||||||
|
title = COALESCE(NULLIF(EXCLUDED.title, ''), library.title),
|
||||||
|
author = COALESCE(NULLIF(EXCLUDED.author, ''), library.author),
|
||||||
|
publisher = COALESCE(NULLIF(EXCLUDED.publisher, ''), library.publisher),
|
||||||
|
has_cover = (library.has_cover OR EXCLUDED.has_cover),
|
||||||
|
series = COALESCE(NULLIF(EXCLUDED.series, ''), library.series),
|
||||||
|
series_index = CASE WHEN COALESCE(EXCLUDED.series_index, 0) > 0 THEN EXCLUDED.series_index ELSE library.series_index END,
|
||||||
|
publication_status = COALESCE(NULLIF(EXCLUDED.publication_status, ''), library.publication_status),
|
||||||
|
source_url = COALESCE(NULLIF(EXCLUDED.source_url, ''), library.source_url),
|
||||||
|
publish_date = COALESCE(EXCLUDED.publish_date, library.publish_date),
|
||||||
|
description = COALESCE(NULLIF(EXCLUDED.description, ''), library.description),
|
||||||
|
updated_at = NOW()
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
filename,
|
||||||
|
meta.get("media_type", "epub"),
|
||||||
|
meta.get("title", ""),
|
||||||
|
meta.get("author", ""),
|
||||||
|
meta.get("publisher", ""),
|
||||||
|
bool(meta.get("has_cover", False)),
|
||||||
|
meta.get("series", ""),
|
||||||
|
meta.get("series_index", 0),
|
||||||
|
meta.get("publication_status", ""),
|
||||||
|
meta.get("source_url", ""),
|
||||||
|
meta.get("publish_date") or None,
|
||||||
|
meta.get("description", ""),
|
||||||
|
bool(meta.get("needs_review", False)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if tags is not None:
|
||||||
|
cur.execute("DELETE FROM book_tags WHERE filename = %s", (filename,))
|
||||||
|
rows = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for tag, ttype in tags:
|
||||||
|
t = (tag or "").strip()
|
||||||
|
tp = (ttype or "").strip()
|
||||||
|
if not t or not tp:
|
||||||
|
continue
|
||||||
|
key = (t.casefold(), tp)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
rows.append((filename, t, tp))
|
||||||
|
if rows:
|
||||||
|
cur.executemany(
|
||||||
|
"INSERT INTO book_tags (filename, tag, tag_type) VALUES (%s, %s, %s) ON CONFLICT (filename, tag, tag_type) DO NOTHING",
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_library_json() -> list[dict]:
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT l.filename, l.media_type, l.title, l.author, l.publisher, l.has_cover,
|
||||||
|
l.series, l.series_index, l.publication_status, l.want_to_read,
|
||||||
|
l.archived, l.needs_review, l.updated_at,
|
||||||
|
rp.progress, rp.cfi, rp.page,
|
||||||
|
COUNT(rs.id)::int AS read_count,
|
||||||
|
MAX(rs.read_at) AS last_read
|
||||||
|
FROM library l
|
||||||
|
LEFT JOIN reading_progress rp ON rp.filename = l.filename
|
||||||
|
LEFT JOIN reading_sessions rs ON rs.filename = l.filename
|
||||||
|
GROUP BY l.filename, l.media_type, l.title, l.author, l.publisher, l.has_cover,
|
||||||
|
l.series, l.series_index, l.publication_status, l.want_to_read,
|
||||||
|
l.archived, l.needs_review, l.updated_at, rp.progress, rp.cfi, rp.page
|
||||||
|
ORDER BY COALESCE(l.publisher, ''), COALESCE(l.author, ''), COALESCE(l.series, ''), l.series_index, COALESCE(l.title, '')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.execute("SELECT filename, tag, tag_type FROM book_tags ORDER BY tag")
|
||||||
|
tags = cur.fetchall()
|
||||||
|
cur.execute("SELECT filename FROM library_cover_cache")
|
||||||
|
cached = {r[0] for r in cur.fetchall()}
|
||||||
|
|
||||||
|
tag_map: dict[str, list[dict]] = {}
|
||||||
|
for filename, tag, tag_type in tags:
|
||||||
|
tag_map.setdefault(filename, []).append({"tag": tag, "tag_type": tag_type})
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"filename": r[0],
|
||||||
|
"media_type": r[1],
|
||||||
|
"title": r[2] or "",
|
||||||
|
"author": r[3] or "",
|
||||||
|
"publisher": r[4] or "",
|
||||||
|
"has_cover": bool(r[5]),
|
||||||
|
"has_cached_cover": r[0] in cached,
|
||||||
|
"series": r[6] or "",
|
||||||
|
"series_index": r[7] or 0,
|
||||||
|
"publication_status": r[8] or "",
|
||||||
|
"want_to_read": bool(r[9]),
|
||||||
|
"archived": bool(r[10]),
|
||||||
|
"needs_review": bool(r[11]),
|
||||||
|
"updated_at": r[12].isoformat() if r[12] else None,
|
||||||
|
"progress": r[13] or 0,
|
||||||
|
"progress_cfi": r[14],
|
||||||
|
"page": r[15],
|
||||||
|
"read_count": r[16] or 0,
|
||||||
|
"last_read": r[17].isoformat() if r[17] else None,
|
||||||
|
"tags": tag_map.get(r[0], []),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_cover_missing_tag(conn, filename: str, has_cover: bool) -> None:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
if has_cover:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM book_tags WHERE filename = %s AND tag = 'Cover Missing' AND tag_type = 'tag'",
|
||||||
|
(filename,),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO book_tags (filename, tag, tag_type)
|
||||||
|
VALUES (%s, 'Cover Missing', 'tag')
|
||||||
|
ON CONFLICT (filename, tag, tag_type) DO NOTHING
|
||||||
|
""",
|
||||||
|
(filename,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_site(raw: str) -> str:
|
||||||
|
raw = (raw or "").strip()
|
||||||
|
if "://" in raw:
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
raw = urlparse(raw).netloc
|
||||||
|
return re.sub(r"^www\.", "", raw).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def relative_file_info(path: Path) -> dict:
|
||||||
|
stat = path.stat()
|
||||||
|
return {
|
||||||
|
"size": stat.st_size,
|
||||||
|
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
434
containers/novela/routers/editor.py
Normal file
434
containers/novela/routers/editor.py
Normal file
@ -0,0 +1,434 @@
|
|||||||
|
import html as _html
|
||||||
|
import posixpath
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
import zipfile as zf
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from db import get_db_conn
|
||||||
|
from epub import read_epub_file, write_epub_file
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
OUTPUT_DIR = Path("library")
|
||||||
|
OUTPUT_ROOT = OUTPUT_DIR.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_output_path(filename: str) -> Path | None:
|
||||||
|
rel = Path(filename)
|
||||||
|
if rel.is_absolute() or any(part in {"", ".", ".."} for part in rel.parts):
|
||||||
|
return None
|
||||||
|
candidate = (OUTPUT_DIR / rel).resolve()
|
||||||
|
try:
|
||||||
|
candidate.relative_to(OUTPUT_ROOT)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(base_dir: str, rel: str) -> str:
|
||||||
|
rel = (rel or "").split("#", 1)[0].strip()
|
||||||
|
if not rel:
|
||||||
|
return ""
|
||||||
|
joined = posixpath.normpath(posixpath.join(base_dir, rel))
|
||||||
|
return joined.lstrip("./")
|
||||||
|
|
||||||
|
|
||||||
|
def _epub_spine(path: Path) -> list[dict]:
|
||||||
|
with zf.ZipFile(path, "r") as z:
|
||||||
|
names = set(z.namelist())
|
||||||
|
|
||||||
|
opf_path = "OEBPS/content.opf"
|
||||||
|
try:
|
||||||
|
container_xml = z.read("META-INF/container.xml").decode("utf-8", errors="replace")
|
||||||
|
m = re.search(r"full-path\\s*=\\s*['\"]([^'\"]+)['\"]", container_xml)
|
||||||
|
if m:
|
||||||
|
opf_path = m.group(1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if opf_path not in names:
|
||||||
|
candidates = [n for n in names if n.lower().endswith(".opf")]
|
||||||
|
if not candidates:
|
||||||
|
return []
|
||||||
|
opf_path = sorted(candidates)[0]
|
||||||
|
|
||||||
|
opf_xml = z.read(opf_path).decode("utf-8", errors="replace")
|
||||||
|
opf = BeautifulSoup(opf_xml, "xml")
|
||||||
|
opf_dir = posixpath.dirname(opf_path)
|
||||||
|
|
||||||
|
manifest: dict[str, str] = {}
|
||||||
|
for item in opf.find_all("item"):
|
||||||
|
iid = item.get("id")
|
||||||
|
href = item.get("href")
|
||||||
|
if iid and href:
|
||||||
|
manifest[iid] = _norm(opf_dir, href)
|
||||||
|
|
||||||
|
spine_idrefs: list[str] = []
|
||||||
|
spine_tag = opf.find("spine")
|
||||||
|
toc_id = spine_tag.get("toc") if spine_tag else None
|
||||||
|
if spine_tag:
|
||||||
|
for ir in spine_tag.find_all("itemref"):
|
||||||
|
rid = ir.get("idref")
|
||||||
|
if rid:
|
||||||
|
spine_idrefs.append(rid)
|
||||||
|
|
||||||
|
hrefs = [manifest[rid] for rid in spine_idrefs if rid in manifest]
|
||||||
|
href_to_title: dict[str, str] = {}
|
||||||
|
|
||||||
|
ncx_path = ""
|
||||||
|
if toc_id and toc_id in manifest:
|
||||||
|
ncx_path = manifest[toc_id]
|
||||||
|
elif "toc.ncx" in names:
|
||||||
|
ncx_path = "toc.ncx"
|
||||||
|
elif "OEBPS/toc.ncx" in names:
|
||||||
|
ncx_path = "OEBPS/toc.ncx"
|
||||||
|
|
||||||
|
if ncx_path and ncx_path in names:
|
||||||
|
try:
|
||||||
|
ncx_xml = z.read(ncx_path).decode("utf-8", errors="replace")
|
||||||
|
ncx = BeautifulSoup(ncx_xml, "xml")
|
||||||
|
ncx_dir = posixpath.dirname(ncx_path)
|
||||||
|
for np in ncx.find_all("navPoint"):
|
||||||
|
content = np.find("content")
|
||||||
|
label_tag = np.find("text")
|
||||||
|
src = content.get("src") if content else ""
|
||||||
|
label = label_tag.get_text(strip=True) if label_tag else ""
|
||||||
|
if src and label:
|
||||||
|
href_to_title[_norm(ncx_dir, src)] = _html.unescape(label)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
chapters = []
|
||||||
|
for i, href in enumerate(hrefs):
|
||||||
|
base = posixpath.basename(href)
|
||||||
|
title = href_to_title.get(href, re.sub(r"\.(xhtml|html|htm)$", "", base, flags=re.I))
|
||||||
|
chapters.append({"index": i, "title": title or f"Chapter {i+1}", "href": href})
|
||||||
|
return chapters
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_href(base_dir: str, rel: str) -> str:
|
||||||
|
rel = (rel or "").split("#", 1)[0].strip()
|
||||||
|
if not rel:
|
||||||
|
return ""
|
||||||
|
return posixpath.normpath(posixpath.join(base_dir, rel)).lstrip("./")
|
||||||
|
|
||||||
|
|
||||||
|
def _find_opf_path(names: set[str], container_xml: str | None) -> str | None:
|
||||||
|
opf_path = "OEBPS/content.opf"
|
||||||
|
if container_xml:
|
||||||
|
m = re.search(r"full-path\s*=\s*['\"]([^'\"]+)['\"]", container_xml)
|
||||||
|
if m:
|
||||||
|
opf_path = m.group(1)
|
||||||
|
if opf_path in names:
|
||||||
|
return opf_path
|
||||||
|
candidates = sorted(n for n in names if n.lower().endswith(".opf"))
|
||||||
|
return candidates[0] if candidates else None
|
||||||
|
|
||||||
|
|
||||||
|
def _make_new_chapter_xhtml(title: str) -> str:
|
||||||
|
safe_title = _html.escape((title or "New chapter").strip() or "New chapter")
|
||||||
|
return (
|
||||||
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||||||
|
"<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\"\n"
|
||||||
|
" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">\n"
|
||||||
|
"<html xmlns=\"http://www.w3.org/1999/xhtml\" xml:lang=\"en\">\n"
|
||||||
|
"<head>\n"
|
||||||
|
" <meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\"/>\n"
|
||||||
|
f" <title>{safe_title}</title>\n"
|
||||||
|
" <link rel=\"stylesheet\" type=\"text/css\" href=\"../Styles/style.css\"/>\n"
|
||||||
|
"</head>\n"
|
||||||
|
"<body>\n"
|
||||||
|
f" <h2 class=\"chapter-title\">{safe_title}</h2>\n"
|
||||||
|
" <p></p>\n"
|
||||||
|
"</body>\n"
|
||||||
|
"</html>\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_epub_entries(epub_path: Path, updates: dict[str, bytes], remove_paths: set[str] | None = None) -> None:
|
||||||
|
remove_paths = set(remove_paths or set())
|
||||||
|
tmp = epub_path.with_suffix(".tmp.epub")
|
||||||
|
with zf.ZipFile(epub_path, "r") as zin, zf.ZipFile(tmp, "w", compression=zf.ZIP_DEFLATED) as zout:
|
||||||
|
names = zin.namelist()
|
||||||
|
for name in names:
|
||||||
|
if name in remove_paths:
|
||||||
|
continue
|
||||||
|
if name in updates:
|
||||||
|
zout.writestr(name, updates[name])
|
||||||
|
else:
|
||||||
|
zout.writestr(name, zin.read(name))
|
||||||
|
for name, data in updates.items():
|
||||||
|
if name not in names:
|
||||||
|
zout.writestr(name, data)
|
||||||
|
tmp.replace(epub_path)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/library/editor/{filename:path}", response_class=HTMLResponse)
|
||||||
|
async def editor_page(filename: str, request: Request):
|
||||||
|
path = _resolve_output_path(filename)
|
||||||
|
if path is None or not path.exists():
|
||||||
|
return HTMLResponse("Not found", status_code=404)
|
||||||
|
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT title FROM library WHERE filename = %s", (filename,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
title = row[0] if row and row[0] else filename
|
||||||
|
|
||||||
|
return templates.TemplateResponse(request, "editor.html", {"filename": filename, "title": title})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/edit/chapter/{index:int}/{filename:path}")
|
||||||
|
async def get_edit_chapter(filename: str, index: int):
|
||||||
|
path = _resolve_output_path(filename)
|
||||||
|
if path is None or not path.exists():
|
||||||
|
return Response(status_code=404)
|
||||||
|
spine = _epub_spine(path)
|
||||||
|
if index < 0 or index >= len(spine):
|
||||||
|
return Response(status_code=404)
|
||||||
|
ch = spine[index]
|
||||||
|
content = read_epub_file(path, ch["href"])
|
||||||
|
return JSONResponse({"index": index, "href": ch["href"], "title": ch["title"], "content": content})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/edit/chapter/{index:int}/{filename:path}")
|
||||||
|
async def save_edit_chapter(filename: str, index: int, request: Request):
|
||||||
|
path = _resolve_output_path(filename)
|
||||||
|
if path is None:
|
||||||
|
return JSONResponse({"error": "not found"}, status_code=404)
|
||||||
|
if not path.exists():
|
||||||
|
return JSONResponse({"error": "File not found"}, status_code=404)
|
||||||
|
body = await request.json()
|
||||||
|
content = body.get("content", "")
|
||||||
|
if not content:
|
||||||
|
return JSONResponse({"error": "No content"}, status_code=400)
|
||||||
|
spine = _epub_spine(path)
|
||||||
|
if index < 0 or index >= len(spine):
|
||||||
|
return JSONResponse({"error": "Chapter not found"}, status_code=404)
|
||||||
|
href = spine[index]["href"]
|
||||||
|
try:
|
||||||
|
write_epub_file(path, href, content)
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
return JSONResponse({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/edit/chapter/add/{filename:path}")
|
||||||
|
async def add_edit_chapter(filename: str, request: Request):
|
||||||
|
path = _resolve_output_path(filename)
|
||||||
|
if path is None:
|
||||||
|
return JSONResponse({"error": "not found"}, status_code=404)
|
||||||
|
if not path.exists():
|
||||||
|
return JSONResponse({"error": "File not found"}, status_code=404)
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
title = (body.get("title") or "New chapter").strip() or "New chapter"
|
||||||
|
after_index = body.get("after_index", -1)
|
||||||
|
try:
|
||||||
|
after_index = int(after_index)
|
||||||
|
except Exception:
|
||||||
|
after_index = -1
|
||||||
|
|
||||||
|
with zf.ZipFile(path, "r") as z:
|
||||||
|
names = set(z.namelist())
|
||||||
|
container_xml = z.read("META-INF/container.xml").decode("utf-8", errors="replace") if "META-INF/container.xml" in names else None
|
||||||
|
opf_path = _find_opf_path(names, container_xml)
|
||||||
|
if not opf_path:
|
||||||
|
return JSONResponse({"error": "OPF not found"}, status_code=400)
|
||||||
|
|
||||||
|
opf_xml = z.read(opf_path).decode("utf-8", errors="replace")
|
||||||
|
opf = BeautifulSoup(opf_xml, "xml")
|
||||||
|
opf_dir = posixpath.dirname(opf_path)
|
||||||
|
|
||||||
|
manifest = {}
|
||||||
|
for item in opf.find_all("item"):
|
||||||
|
iid = item.get("id")
|
||||||
|
href = item.get("href")
|
||||||
|
if iid and href:
|
||||||
|
manifest[iid] = _norm_href(opf_dir, href)
|
||||||
|
|
||||||
|
spine_tag = opf.find("spine")
|
||||||
|
if not spine_tag:
|
||||||
|
return JSONResponse({"error": "Invalid OPF spine"}, status_code=400)
|
||||||
|
itemrefs = spine_tag.find_all("itemref")
|
||||||
|
current_len = len(itemrefs)
|
||||||
|
|
||||||
|
if after_index < -1:
|
||||||
|
after_index = -1
|
||||||
|
if after_index >= current_len:
|
||||||
|
after_index = current_len - 1
|
||||||
|
|
||||||
|
ref_dir_rel = "Text"
|
||||||
|
if current_len > 0 and after_index >= 0:
|
||||||
|
ref_idref = itemrefs[after_index].get("idref", "")
|
||||||
|
ref_abs = manifest.get(ref_idref, "")
|
||||||
|
if ref_abs:
|
||||||
|
ref_rel = posixpath.relpath(ref_abs, opf_dir)
|
||||||
|
ref_dir_rel = posixpath.dirname(ref_rel) or ""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
stem = f"chapter_added_{uuid.uuid4().hex[:8]}"
|
||||||
|
rel = posixpath.join(ref_dir_rel, f"{stem}.xhtml") if ref_dir_rel else f"{stem}.xhtml"
|
||||||
|
abs_path = _norm_href(opf_dir, rel)
|
||||||
|
if abs_path not in names:
|
||||||
|
break
|
||||||
|
|
||||||
|
existing_ids = {item.get("id") for item in opf.find_all("item") if item.get("id")}
|
||||||
|
i = 1
|
||||||
|
new_id = f"ch_add_{i:03d}"
|
||||||
|
while new_id in existing_ids:
|
||||||
|
i += 1
|
||||||
|
new_id = f"ch_add_{i:03d}"
|
||||||
|
|
||||||
|
manifest_tag = opf.find("manifest")
|
||||||
|
if not manifest_tag:
|
||||||
|
return JSONResponse({"error": "Invalid OPF manifest"}, status_code=400)
|
||||||
|
|
||||||
|
new_item = opf.new_tag("item")
|
||||||
|
new_item["id"] = new_id
|
||||||
|
new_item["href"] = rel
|
||||||
|
new_item["media-type"] = "application/xhtml+xml"
|
||||||
|
manifest_tag.append(new_item)
|
||||||
|
|
||||||
|
new_itemref = opf.new_tag("itemref")
|
||||||
|
new_itemref["idref"] = new_id
|
||||||
|
if after_index >= 0 and after_index + 1 < len(itemrefs):
|
||||||
|
itemrefs[after_index + 1].insert_before(new_itemref)
|
||||||
|
else:
|
||||||
|
spine_tag.append(new_itemref)
|
||||||
|
|
||||||
|
toc_id = spine_tag.get("toc")
|
||||||
|
ncx_path = manifest.get(toc_id, "") if toc_id else ""
|
||||||
|
if not ncx_path:
|
||||||
|
for item in opf.find_all("item"):
|
||||||
|
mt = (item.get("media-type") or "").lower()
|
||||||
|
if mt == "application/x-dtbncx+xml" and item.get("href"):
|
||||||
|
ncx_path = _norm_href(opf_dir, item.get("href"))
|
||||||
|
break
|
||||||
|
|
||||||
|
updates: dict[str, bytes] = {opf_path: str(opf).encode("utf-8")}
|
||||||
|
if ncx_path and ncx_path in names:
|
||||||
|
ncx_xml = z.read(ncx_path).decode("utf-8", errors="replace")
|
||||||
|
ncx = BeautifulSoup(ncx_xml, "xml")
|
||||||
|
nav_map = ncx.find("navMap")
|
||||||
|
if nav_map:
|
||||||
|
nav_points = nav_map.find_all("navPoint")
|
||||||
|
np = ncx.new_tag("navPoint")
|
||||||
|
np["id"] = f"{new_id}_nav"
|
||||||
|
label = ncx.new_tag("navLabel")
|
||||||
|
text = ncx.new_tag("text")
|
||||||
|
text.string = title
|
||||||
|
label.append(text)
|
||||||
|
content = ncx.new_tag("content")
|
||||||
|
ncx_dir = posixpath.dirname(ncx_path)
|
||||||
|
content["src"] = posixpath.relpath(abs_path, ncx_dir)
|
||||||
|
np.append(label)
|
||||||
|
np.append(content)
|
||||||
|
|
||||||
|
insert_pos = after_index + 1
|
||||||
|
if 0 <= insert_pos < len(nav_points):
|
||||||
|
nav_points[insert_pos].insert_before(np)
|
||||||
|
else:
|
||||||
|
nav_map.append(np)
|
||||||
|
|
||||||
|
for idx, node in enumerate(nav_map.find_all("navPoint"), 1):
|
||||||
|
node["playOrder"] = str(idx)
|
||||||
|
|
||||||
|
updates[ncx_path] = str(ncx).encode("utf-8")
|
||||||
|
|
||||||
|
updates[abs_path] = _make_new_chapter_xhtml(title).encode("utf-8")
|
||||||
|
_rewrite_epub_entries(path, updates)
|
||||||
|
|
||||||
|
new_spine = _epub_spine(path)
|
||||||
|
new_index = min(max(after_index + 1, 0), max(len(new_spine) - 1, 0))
|
||||||
|
return JSONResponse({"ok": True, "index": new_index, "count": len(new_spine)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/edit/chapter/{index:int}/{filename:path}")
|
||||||
|
async def delete_edit_chapter(filename: str, index: int):
|
||||||
|
path = _resolve_output_path(filename)
|
||||||
|
if path is None:
|
||||||
|
return JSONResponse({"error": "not found"}, status_code=404)
|
||||||
|
if not path.exists():
|
||||||
|
return JSONResponse({"error": "File not found"}, status_code=404)
|
||||||
|
|
||||||
|
with zf.ZipFile(path, "r") as z:
|
||||||
|
names = set(z.namelist())
|
||||||
|
container_xml = z.read("META-INF/container.xml").decode("utf-8", errors="replace") if "META-INF/container.xml" in names else None
|
||||||
|
opf_path = _find_opf_path(names, container_xml)
|
||||||
|
if not opf_path:
|
||||||
|
return JSONResponse({"error": "OPF not found"}, status_code=400)
|
||||||
|
|
||||||
|
opf_xml = z.read(opf_path).decode("utf-8", errors="replace")
|
||||||
|
opf = BeautifulSoup(opf_xml, "xml")
|
||||||
|
opf_dir = posixpath.dirname(opf_path)
|
||||||
|
|
||||||
|
manifest = {}
|
||||||
|
for item in opf.find_all("item"):
|
||||||
|
iid = item.get("id")
|
||||||
|
href = item.get("href")
|
||||||
|
if iid and href:
|
||||||
|
manifest[iid] = _norm_href(opf_dir, href)
|
||||||
|
|
||||||
|
spine_tag = opf.find("spine")
|
||||||
|
if not spine_tag:
|
||||||
|
return JSONResponse({"error": "Invalid OPF spine"}, status_code=400)
|
||||||
|
itemrefs = spine_tag.find_all("itemref")
|
||||||
|
|
||||||
|
if index < 0 or index >= len(itemrefs):
|
||||||
|
return JSONResponse({"error": "Chapter not found"}, status_code=404)
|
||||||
|
if len(itemrefs) <= 1:
|
||||||
|
return JSONResponse({"error": "Cannot delete the last chapter"}, status_code=400)
|
||||||
|
|
||||||
|
target_idref = itemrefs[index].get("idref", "")
|
||||||
|
target_href = manifest.get(target_idref, "")
|
||||||
|
if not target_href:
|
||||||
|
return JSONResponse({"error": "Chapter target missing in manifest"}, status_code=400)
|
||||||
|
|
||||||
|
itemrefs[index].decompose()
|
||||||
|
|
||||||
|
manifest_tag = opf.find("manifest")
|
||||||
|
if manifest_tag:
|
||||||
|
for item in manifest_tag.find_all("item"):
|
||||||
|
if item.get("id") == target_idref:
|
||||||
|
item.decompose()
|
||||||
|
break
|
||||||
|
|
||||||
|
toc_id = spine_tag.get("toc")
|
||||||
|
ncx_path = manifest.get(toc_id, "") if toc_id else ""
|
||||||
|
if not ncx_path:
|
||||||
|
for item in opf.find_all("item"):
|
||||||
|
mt = (item.get("media-type") or "").lower()
|
||||||
|
if mt == "application/x-dtbncx+xml" and item.get("href"):
|
||||||
|
ncx_path = _norm_href(opf_dir, item.get("href"))
|
||||||
|
break
|
||||||
|
|
||||||
|
updates: dict[str, bytes] = {opf_path: str(opf).encode("utf-8")}
|
||||||
|
remove_paths: set[str] = {target_href}
|
||||||
|
|
||||||
|
if ncx_path and ncx_path in names:
|
||||||
|
ncx_xml = z.read(ncx_path).decode("utf-8", errors="replace")
|
||||||
|
ncx = BeautifulSoup(ncx_xml, "xml")
|
||||||
|
nav_map = ncx.find("navMap")
|
||||||
|
if nav_map:
|
||||||
|
ncx_dir = posixpath.dirname(ncx_path)
|
||||||
|
for np in nav_map.find_all("navPoint"):
|
||||||
|
content = np.find("content")
|
||||||
|
src = content.get("src") if content else ""
|
||||||
|
if src and _norm_href(ncx_dir, src) == target_href:
|
||||||
|
np.decompose()
|
||||||
|
for idx, node in enumerate(nav_map.find_all("navPoint"), 1):
|
||||||
|
node["playOrder"] = str(idx)
|
||||||
|
updates[ncx_path] = str(ncx).encode("utf-8")
|
||||||
|
|
||||||
|
_rewrite_epub_entries(path, updates, remove_paths)
|
||||||
|
new_spine = _epub_spine(path)
|
||||||
|
new_index = min(index, max(len(new_spine) - 1, 0))
|
||||||
|
return JSONResponse({"ok": True, "index": new_index, "count": len(new_spine)})
|
||||||
473
containers/novela/routers/grabber.py
Normal file
473
containers/novela/routers/grabber.py
Normal file
@ -0,0 +1,473 @@
|
|||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import traceback
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from bs4 import Tag
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from db import get_db_conn
|
||||||
|
from epub import detect_image_format, make_chapter_xhtml, make_epub
|
||||||
|
from routers.common import (
|
||||||
|
LIBRARY_DIR,
|
||||||
|
ensure_cover_cache_for_book,
|
||||||
|
ensure_cover_missing_tag,
|
||||||
|
ensure_unique_rel_path,
|
||||||
|
make_rel_path,
|
||||||
|
normalize_site,
|
||||||
|
upsert_book,
|
||||||
|
)
|
||||||
|
from scrapers import get_scraper
|
||||||
|
from scrapers.base import HEADERS
|
||||||
|
from security import decrypt_value, encrypt_value, is_encrypted_value
|
||||||
|
from xhtml import configure_break_patterns, element_to_xhtml, is_break_element
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
JOBS: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_all_credentials() -> dict:
|
||||||
|
out = {}
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT site, username, password FROM credentials ORDER BY site")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
for site, username_raw, password_raw in rows:
|
||||||
|
username = decrypt_value(username_raw)
|
||||||
|
password = decrypt_value(password_raw)
|
||||||
|
out[site] = {"username": username, "password": password}
|
||||||
|
|
||||||
|
if not is_encrypted_value(username_raw) or not is_encrypted_value(password_raw):
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE credentials
|
||||||
|
SET username = %s, password = %s, updated_at = NOW()
|
||||||
|
WHERE site = %s
|
||||||
|
""",
|
||||||
|
(encrypt_value(username), encrypt_value(password), site),
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _domain(url: str) -> str:
|
||||||
|
raw = (url or "").strip()
|
||||||
|
if "://" in raw:
|
||||||
|
raw = urlparse(raw).netloc
|
||||||
|
return normalize_site(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_break_patterns() -> None:
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT pattern_type, pattern FROM break_patterns WHERE enabled = TRUE ORDER BY id"
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
configure_break_patterns(
|
||||||
|
regex_strings=[r[1] for r in rows if r[0] == "regex"],
|
||||||
|
css_classes=[r[1] for r in rows if r[0] == "css_class"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _next_series_index(series: str) -> int:
|
||||||
|
if not series:
|
||||||
|
return 1
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COALESCE(MAX(series_index), 0) FROM library WHERE series = %s",
|
||||||
|
(series,),
|
||||||
|
)
|
||||||
|
return (cur.fetchone()[0] or 0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/grabber", response_class=HTMLResponse)
|
||||||
|
async def grabber_page(request: Request):
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
tmpl = "grabber.html" if Path("templates/grabber.html").exists() else "index.html"
|
||||||
|
return templates.TemplateResponse(request, tmpl, {"active": "grabber"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/convert", response_class=HTMLResponse)
|
||||||
|
async def convert_page(request: Request):
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
tmpl = "grabber.html" if Path("templates/grabber.html").exists() else "index.html"
|
||||||
|
return templates.TemplateResponse(request, tmpl, {"active": "grabber"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/credentials-manager", response_class=HTMLResponse)
|
||||||
|
async def credentials_manager_page(request: Request):
|
||||||
|
return templates.TemplateResponse(request, "credentials.html", {"active": "credentials"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/debug", response_class=HTMLResponse)
|
||||||
|
async def debug_page(request: Request):
|
||||||
|
return templates.TemplateResponse(request, "debug.html", {"active": "debug"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/debug/run")
|
||||||
|
async def debug_run(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
url = (body.get("url") or "").strip()
|
||||||
|
if not url:
|
||||||
|
return {"error": "No URL provided"}
|
||||||
|
|
||||||
|
creds = _load_all_credentials().get(_domain(url), {})
|
||||||
|
username = creds.get("username", "")
|
||||||
|
password = creds.get("password", "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
scraper = get_scraper(url)
|
||||||
|
except ValueError as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
result: dict = {}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(headers=HEADERS, follow_redirects=True, timeout=30) as client:
|
||||||
|
if username:
|
||||||
|
await scraper.login(client, username, password)
|
||||||
|
book = await scraper.fetch_book_info(client, url)
|
||||||
|
result = {
|
||||||
|
"title": book.get("title", ""),
|
||||||
|
"author": book.get("author", ""),
|
||||||
|
"publisher": book.get("publisher", ""),
|
||||||
|
"series": book.get("series", ""),
|
||||||
|
"chapter_count": len(book.get("chapters", [])),
|
||||||
|
"chapter_method": book.get("chapter_method", ""),
|
||||||
|
"genres": book.get("genres", []),
|
||||||
|
"subgenres": book.get("subgenres", []),
|
||||||
|
"tags": book.get("tags", []),
|
||||||
|
"description": book.get("description", ""),
|
||||||
|
"publication_status": book.get("publication_status", ""),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
result["error"] = traceback.format_exc()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/credentials")
|
||||||
|
async def get_credentials():
|
||||||
|
return _load_all_credentials()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/credentials")
|
||||||
|
async def save_credential(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
site = normalize_site(body.get("site", ""))
|
||||||
|
if not site:
|
||||||
|
return {"error": "No site provided"}
|
||||||
|
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO credentials (site, username, password, updated_at)
|
||||||
|
VALUES (%s, %s, %s, NOW())
|
||||||
|
ON CONFLICT (site) DO UPDATE
|
||||||
|
SET username = EXCLUDED.username,
|
||||||
|
password = EXCLUDED.password,
|
||||||
|
updated_at = NOW()
|
||||||
|
""",
|
||||||
|
(site, encrypt_value(body.get("username", "")), encrypt_value(body.get("password", ""))),
|
||||||
|
)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/credentials/{site:path}")
|
||||||
|
async def delete_credential(site: str):
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("DELETE FROM credentials WHERE site = %s", (normalize_site(site),))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preload")
|
||||||
|
async def preload(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
url = (body.get("url") or "").strip()
|
||||||
|
if not url:
|
||||||
|
return {"error": "No URL provided"}
|
||||||
|
|
||||||
|
creds = _load_all_credentials().get(_domain(url), {})
|
||||||
|
username = creds.get("username", "")
|
||||||
|
password = creds.get("password", "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
scraper = get_scraper(url)
|
||||||
|
except ValueError as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(headers=HEADERS, follow_redirects=True, timeout=30) as client:
|
||||||
|
if username:
|
||||||
|
await scraper.login(client, username, password)
|
||||||
|
book = await scraper.fetch_book_info(client, url)
|
||||||
|
series = book.get("series", "")
|
||||||
|
hint = int(book.get("series_index_hint", 0) or 0)
|
||||||
|
return {
|
||||||
|
"title": book.get("title", ""),
|
||||||
|
"author": book.get("author", ""),
|
||||||
|
"publisher": book.get("publisher", ""),
|
||||||
|
"series": series,
|
||||||
|
"series_index_next": hint if hint else _next_series_index(series),
|
||||||
|
"genres": book.get("genres", []),
|
||||||
|
"subgenres": book.get("subgenres", []),
|
||||||
|
"tags": book.get("tags", []),
|
||||||
|
"description": book.get("description", ""),
|
||||||
|
"updated_date": book.get("updated_date", ""),
|
||||||
|
"publication_status": book.get("publication_status", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def scrape_book(job_id: str, url: str, username: str, password: str) -> None:
|
||||||
|
job = JOBS[job_id]
|
||||||
|
|
||||||
|
def send(event: str, data: dict):
|
||||||
|
job["events"].append({"event": event, "data": data})
|
||||||
|
|
||||||
|
try:
|
||||||
|
await _run_scrape(job_id, url, username, password, send)
|
||||||
|
except Exception as e:
|
||||||
|
send("error", {"message": f"Unexpected error: {e}"})
|
||||||
|
job["done"] = True
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_scrape(job_id: str, url: str, username: str, password: str, send) -> None:
|
||||||
|
job = JOBS[job_id]
|
||||||
|
send("status", {"message": "Connecting..."})
|
||||||
|
|
||||||
|
scraper = get_scraper(url)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(headers=HEADERS, follow_redirects=True, timeout=30) as client:
|
||||||
|
if username:
|
||||||
|
send("status", {"message": "Logging in..."})
|
||||||
|
await scraper.login(client, username, password)
|
||||||
|
|
||||||
|
book = await scraper.fetch_book_info(client, url)
|
||||||
|
book_title = book["title"]
|
||||||
|
author = book["author"]
|
||||||
|
send("meta", {"title": book_title, "author": author})
|
||||||
|
|
||||||
|
if not book.get("chapters"):
|
||||||
|
send("error", {"message": "No chapters found. Check the URL or credentials."})
|
||||||
|
job["done"] = True
|
||||||
|
return
|
||||||
|
|
||||||
|
send("chapters", {"chapters": [c["title"] for c in book["chapters"]]})
|
||||||
|
send("status", {"message": f"Found {len(book['chapters'])} chapters. Downloading..."})
|
||||||
|
|
||||||
|
cover_data: bytes | None = job.pop("cover_upload", None)
|
||||||
|
|
||||||
|
tags = list(book.get("tags", []))
|
||||||
|
if len(book["chapters"]) < 4 and "Shorts" not in tags:
|
||||||
|
tags.append("Shorts")
|
||||||
|
if cover_data is None and "Cover Missing" not in tags:
|
||||||
|
tags.append("Cover Missing")
|
||||||
|
|
||||||
|
status_map = {"Long-Term Hold": "Hiatus"}
|
||||||
|
pub_status = status_map.get(book.get("publication_status", ""), book.get("publication_status", ""))
|
||||||
|
|
||||||
|
series = book.get("series", "")
|
||||||
|
series_index = int(job.get("series_index", 1) or 1)
|
||||||
|
|
||||||
|
updated_date_override = (job.pop("updated_date_override", "") or "").strip()
|
||||||
|
final_updated_date = (
|
||||||
|
updated_date_override
|
||||||
|
or book.get("updated_date", "")
|
||||||
|
or datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
)
|
||||||
|
|
||||||
|
book_info = {
|
||||||
|
"genres": book.get("genres", []),
|
||||||
|
"subgenres": book.get("subgenres", []),
|
||||||
|
"tags": tags,
|
||||||
|
"description": book.get("description", ""),
|
||||||
|
"updated_date": final_updated_date,
|
||||||
|
"source_url": book.get("source_url", ""),
|
||||||
|
"publisher": book.get("publisher", ""),
|
||||||
|
"series": series,
|
||||||
|
"series_index": series_index,
|
||||||
|
"publication_status": pub_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
_load_break_patterns()
|
||||||
|
break_img_data = open("static/break.png", "rb").read()
|
||||||
|
|
||||||
|
chapters = []
|
||||||
|
for i, ch in enumerate(book["chapters"], 1):
|
||||||
|
send("progress", {"current": i, "total": len(book["chapters"]), "title": ch["title"]})
|
||||||
|
try:
|
||||||
|
ch_data = await scraper.fetch_chapter(client, ch)
|
||||||
|
content_el = ch_data["content_el"]
|
||||||
|
|
||||||
|
chapter_images = []
|
||||||
|
if content_el:
|
||||||
|
img_counter = 1
|
||||||
|
for img_tag in content_el.find_all("img"):
|
||||||
|
if is_break_element(img_tag):
|
||||||
|
continue
|
||||||
|
src = img_tag.get("src", "")
|
||||||
|
if not src or src.startswith("data:"):
|
||||||
|
img_tag.decompose()
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
img_resp = await client.get(urljoin(ch["url"], src))
|
||||||
|
if img_resp.status_code == 200:
|
||||||
|
img_name, img_mime = detect_image_format(
|
||||||
|
img_resp.content, f"ch{i:03d}_img{img_counter:03d}"
|
||||||
|
)
|
||||||
|
img_tag["src"] = f"../Images/{img_name}"
|
||||||
|
img_tag["alt"] = img_tag.get("alt", "")
|
||||||
|
chapter_images.append(
|
||||||
|
{
|
||||||
|
"epub_path": f"OEBPS/Images/{img_name}",
|
||||||
|
"data": img_resp.content,
|
||||||
|
"media_type": img_mime,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
img_counter += 1
|
||||||
|
else:
|
||||||
|
img_tag.decompose()
|
||||||
|
except Exception:
|
||||||
|
img_tag.decompose()
|
||||||
|
|
||||||
|
xhtml_parts = []
|
||||||
|
if content_el:
|
||||||
|
all_p = content_el.find_all("p")
|
||||||
|
empty_p = sum(
|
||||||
|
1
|
||||||
|
for p in all_p
|
||||||
|
if not [c for c in p.children if isinstance(c, Tag)]
|
||||||
|
and not p.get_text().replace("\xa0", "").strip()
|
||||||
|
)
|
||||||
|
filled_p = len(all_p) - empty_p
|
||||||
|
empty_p_is_spacer = filled_p > 0 and empty_p >= filled_p * 0.5
|
||||||
|
for child in content_el.children:
|
||||||
|
part = element_to_xhtml(child, empty_p_is_spacer=empty_p_is_spacer)
|
||||||
|
if part.strip():
|
||||||
|
xhtml_parts.append(part)
|
||||||
|
|
||||||
|
content_xhtml = "\n".join(xhtml_parts)
|
||||||
|
chapter_xhtml = make_chapter_xhtml(ch_data["title"], content_xhtml, i)
|
||||||
|
chapters.append({"title": ch_data["title"], "xhtml": chapter_xhtml, "images": chapter_images})
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
except Exception as e:
|
||||||
|
send("warning", {"message": f"Chapter {i} skipped: {e}"})
|
||||||
|
|
||||||
|
if not chapters:
|
||||||
|
send("error", {"message": "No chapters could be processed."})
|
||||||
|
job["done"] = True
|
||||||
|
return
|
||||||
|
|
||||||
|
send("status", {"message": "Building EPUB..."})
|
||||||
|
book_id = str(uuid.uuid4())
|
||||||
|
epub_bytes = make_epub(book_title, author, chapters, cover_data, break_img_data, book_id, book_info)
|
||||||
|
|
||||||
|
rel = ensure_unique_rel_path(
|
||||||
|
make_rel_path(
|
||||||
|
media_type="epub",
|
||||||
|
publisher=book_info.get("publisher", ""),
|
||||||
|
author=author,
|
||||||
|
title=book_title,
|
||||||
|
series=series,
|
||||||
|
series_index=series_index,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
out_path = LIBRARY_DIR / rel
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path.write_bytes(epub_bytes)
|
||||||
|
|
||||||
|
rel_filename = rel.as_posix()
|
||||||
|
job["filename"] = rel_filename
|
||||||
|
|
||||||
|
book_meta = {
|
||||||
|
"media_type": "epub",
|
||||||
|
"has_cover": cover_data is not None,
|
||||||
|
"series": book_info.get("series", ""),
|
||||||
|
"series_index": series_index if book_info.get("series") else 0,
|
||||||
|
"title": book_title,
|
||||||
|
"publication_status": book_info.get("publication_status", ""),
|
||||||
|
"author": author,
|
||||||
|
"publisher": book_info.get("publisher", ""),
|
||||||
|
"source_url": book_info.get("source_url", ""),
|
||||||
|
"description": book_info.get("description", ""),
|
||||||
|
"publish_date": final_updated_date,
|
||||||
|
"needs_review": False,
|
||||||
|
}
|
||||||
|
book_tags = (
|
||||||
|
[(g, "genre") for g in book_info.get("genres", [])]
|
||||||
|
+ [(g, "subgenre") for g in book_info.get("subgenres", [])]
|
||||||
|
+ [(g, "tag") for g in book_info.get("tags", [])]
|
||||||
|
)
|
||||||
|
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn:
|
||||||
|
upsert_book(conn, rel_filename, book_meta, book_tags)
|
||||||
|
ensure_cover_missing_tag(conn, rel_filename, bool(book_meta["has_cover"]))
|
||||||
|
ensure_cover_cache_for_book(conn, rel_filename, out_path, "epub")
|
||||||
|
|
||||||
|
send("done", {"filename": rel_filename, "title": book_title, "chapters": len(chapters)})
|
||||||
|
job["done"] = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/convert")
|
||||||
|
async def convert(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
url = (body.get("url") or "").strip()
|
||||||
|
if not url:
|
||||||
|
return {"error": "No URL provided"}
|
||||||
|
|
||||||
|
creds = _load_all_credentials().get(_domain(url), {})
|
||||||
|
username = creds.get("username", "")
|
||||||
|
password = creds.get("password", "")
|
||||||
|
|
||||||
|
job_id = str(uuid.uuid4())
|
||||||
|
job: dict = {"events": [], "done": False, "filename": None}
|
||||||
|
|
||||||
|
cover_b64 = body.get("cover_b64")
|
||||||
|
if cover_b64:
|
||||||
|
try:
|
||||||
|
job["cover_upload"] = base64.b64decode(cover_b64)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
job["series_index"] = int(body.get("series_index", 1) or 1)
|
||||||
|
job["updated_date_override"] = (body.get("updated_date") or "").strip()
|
||||||
|
|
||||||
|
JOBS[job_id] = job
|
||||||
|
asyncio.create_task(scrape_book(job_id, url, username, password))
|
||||||
|
return {"job_id": job_id, "using_credentials": bool(username)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events/{job_id}")
|
||||||
|
async def events(job_id: str):
|
||||||
|
if job_id not in JOBS:
|
||||||
|
return StreamingResponse(iter([]), media_type="text/event-stream")
|
||||||
|
|
||||||
|
async def stream() -> AsyncGenerator[str, None]:
|
||||||
|
sent = 0
|
||||||
|
while True:
|
||||||
|
job = JOBS.get(job_id, {})
|
||||||
|
evts = job.get("events", [])
|
||||||
|
while sent < len(evts):
|
||||||
|
evt = evts[sent]
|
||||||
|
yield f"event: {evt['event']}\ndata: {json.dumps(evt['data'])}\n\n"
|
||||||
|
sent += 1
|
||||||
|
if job.get("done") and sent >= len(evts):
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
|
||||||
|
return StreamingResponse(stream(), media_type="text/event-stream")
|
||||||
383
containers/novela/routers/library.py
Normal file
383
containers/novela/routers/library.py
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
import base64
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, Request, UploadFile
|
||||||
|
from fastapi.responses import HTMLResponse, Response
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from PIL import UnidentifiedImageError
|
||||||
|
|
||||||
|
from db import get_db_conn
|
||||||
|
from epub import add_cover_to_epub
|
||||||
|
from routers.common import (
|
||||||
|
LIBRARY_DIR,
|
||||||
|
ensure_cover_cache_for_book,
|
||||||
|
ensure_cover_missing_tag,
|
||||||
|
ensure_unique_rel_path,
|
||||||
|
list_library_json,
|
||||||
|
make_cover_thumb_webp,
|
||||||
|
make_rel_path,
|
||||||
|
media_type_from_suffix,
|
||||||
|
prune_empty_dirs,
|
||||||
|
relative_file_info,
|
||||||
|
resolve_library_path,
|
||||||
|
scan_media,
|
||||||
|
upsert_book,
|
||||||
|
upsert_cover_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_files() -> list[Path]:
|
||||||
|
files: list[Path] = []
|
||||||
|
for ext in ("*.epub", "*.pdf", "*.cbr", "*.cbz"):
|
||||||
|
files.extend(LIBRARY_DIR.rglob(ext))
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_disk_to_db() -> int:
|
||||||
|
files = _collect_files()
|
||||||
|
synced = 0
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn:
|
||||||
|
for p in files:
|
||||||
|
rel = p.relative_to(LIBRARY_DIR).as_posix()
|
||||||
|
meta = scan_media(p)
|
||||||
|
if not meta.get("media_type"):
|
||||||
|
continue
|
||||||
|
tags = [(s, "subject") for s in meta.get("subjects", [])]
|
||||||
|
upsert_book(conn, rel, meta, tags)
|
||||||
|
ensure_cover_missing_tag(conn, rel, bool(meta.get("has_cover")))
|
||||||
|
if bool(meta.get("has_cover")):
|
||||||
|
ensure_cover_cache_for_book(conn, rel, p, meta["media_type"])
|
||||||
|
synced += 1
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT filename FROM library")
|
||||||
|
db_files = {r[0] for r in cur.fetchall()}
|
||||||
|
disk_files = {p.relative_to(LIBRARY_DIR).as_posix() for p in files}
|
||||||
|
for missing in db_files - disk_files:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("DELETE FROM library WHERE filename = %s", (missing,))
|
||||||
|
return synced
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/library", response_class=HTMLResponse)
|
||||||
|
async def library_page(request: Request):
|
||||||
|
return templates.TemplateResponse(request, "library.html", {"active": "library"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/library")
|
||||||
|
async def api_library():
|
||||||
|
_sync_disk_to_db()
|
||||||
|
books = list_library_json()
|
||||||
|
for b in books:
|
||||||
|
p = resolve_library_path(b["filename"])
|
||||||
|
if p and p.exists():
|
||||||
|
b.update(relative_file_info(p))
|
||||||
|
return books
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/library/rescan")
|
||||||
|
async def library_rescan():
|
||||||
|
scanned = _sync_disk_to_db()
|
||||||
|
return {"ok": True, "scanned": scanned}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/library/import")
|
||||||
|
async def library_import(files: list[UploadFile] = File(...)):
|
||||||
|
imported: list[str] = []
|
||||||
|
skipped: list[dict[str, str]] = []
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn:
|
||||||
|
for upload in files:
|
||||||
|
try:
|
||||||
|
name = upload.filename or "upload.bin"
|
||||||
|
suffix = Path(name).suffix.lower()
|
||||||
|
if suffix not in {".epub", ".pdf", ".cbr", ".cbz"}:
|
||||||
|
skipped.append({"file": name, "reason": "Unsupported file type"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
data = await upload.read()
|
||||||
|
if not data:
|
||||||
|
skipped.append({"file": name, "reason": "Empty upload"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
tmp = LIBRARY_DIR / f".import-{uuid.uuid4().hex}{suffix}"
|
||||||
|
tmp.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp.write_bytes(data)
|
||||||
|
|
||||||
|
meta = scan_media(tmp)
|
||||||
|
media_type = meta.get("media_type")
|
||||||
|
if not media_type:
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
skipped.append({"file": name, "reason": "Could not detect media type"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
rel = ensure_unique_rel_path(
|
||||||
|
make_rel_path(
|
||||||
|
media_type=media_type,
|
||||||
|
publisher=meta.get("publisher", ""),
|
||||||
|
author=meta.get("author", ""),
|
||||||
|
title=meta.get("title") or Path(name).stem,
|
||||||
|
series=meta.get("series", ""),
|
||||||
|
series_index=meta.get("series_index", 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
dest = LIBRARY_DIR / rel
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp.replace(dest)
|
||||||
|
|
||||||
|
rel_name = rel.as_posix()
|
||||||
|
meta["needs_review"] = True
|
||||||
|
tags = [(s, "subject") for s in meta.get("subjects", [])]
|
||||||
|
upsert_book(conn, rel_name, meta, tags)
|
||||||
|
ensure_cover_missing_tag(conn, rel_name, bool(meta.get("has_cover")))
|
||||||
|
ensure_cover_cache_for_book(conn, rel_name, dest, media_type)
|
||||||
|
imported.append(rel_name)
|
||||||
|
except Exception as e:
|
||||||
|
skipped.append({"file": upload.filename or "upload", "reason": str(e)})
|
||||||
|
finally:
|
||||||
|
await upload.close()
|
||||||
|
|
||||||
|
return {"ok": True, "imported": imported, "skipped": skipped}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/library/file/{filename:path}")
|
||||||
|
async def library_delete(filename: str):
|
||||||
|
full = resolve_library_path(filename)
|
||||||
|
if full is None:
|
||||||
|
return {"error": "Invalid filename"}
|
||||||
|
if not full.exists():
|
||||||
|
return {"error": "File not found"}
|
||||||
|
|
||||||
|
parent = full.parent
|
||||||
|
full.unlink()
|
||||||
|
prune_empty_dirs(parent)
|
||||||
|
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("DELETE FROM library WHERE filename = %s", (filename,))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/library/cover-cached/{filename:path}")
|
||||||
|
async def library_cover_cached(filename: str):
|
||||||
|
full = resolve_library_path(filename)
|
||||||
|
if full is None or not full.exists():
|
||||||
|
return Response(status_code=404)
|
||||||
|
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT thumb_webp FROM library_cover_cache WHERE filename = %s",
|
||||||
|
(filename,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row and row[0]:
|
||||||
|
return Response(content=bytes(row[0]), media_type="image/webp")
|
||||||
|
|
||||||
|
cur.execute("SELECT media_type FROM library WHERE filename = %s", (filename,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
mt = row[0] if row else media_type_from_suffix(full)
|
||||||
|
if not ensure_cover_cache_for_book(conn, filename, full, mt):
|
||||||
|
return Response(status_code=404)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT thumb_webp FROM library_cover_cache WHERE filename = %s",
|
||||||
|
(filename,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row and row[0]:
|
||||||
|
return Response(content=bytes(row[0]), media_type="image/webp")
|
||||||
|
return Response(status_code=404)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/library/cover/{filename:path}")
|
||||||
|
async def library_cover(filename: str):
|
||||||
|
full = resolve_library_path(filename)
|
||||||
|
if full is None or not full.exists():
|
||||||
|
return Response(status_code=404)
|
||||||
|
|
||||||
|
mt = media_type_from_suffix(full)
|
||||||
|
if mt == "epub":
|
||||||
|
from routers.common import extract_cover_from_epub
|
||||||
|
|
||||||
|
extracted = extract_cover_from_epub(full)
|
||||||
|
if not extracted:
|
||||||
|
return Response(status_code=404)
|
||||||
|
raw, mime = extracted
|
||||||
|
return Response(content=raw, media_type=mime)
|
||||||
|
|
||||||
|
if mt in {"pdf", "cbr"}:
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn:
|
||||||
|
if ensure_cover_cache_for_book(conn, filename, full, mt):
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT thumb_webp FROM library_cover_cache WHERE filename = %s",
|
||||||
|
(filename,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row and row[0]:
|
||||||
|
return Response(content=bytes(row[0]), media_type="image/webp")
|
||||||
|
return Response(status_code=404)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/library/cover/{filename:path}")
|
||||||
|
async def library_add_cover(filename: str, request: Request):
|
||||||
|
full = resolve_library_path(filename)
|
||||||
|
if full is None or not full.exists():
|
||||||
|
return {"error": "File not found"}
|
||||||
|
if media_type_from_suffix(full) != "epub":
|
||||||
|
return {"error": "Cover upload is only supported for EPUB"}
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
cover_b64 = body.get("cover_b64", "")
|
||||||
|
if not cover_b64:
|
||||||
|
return {"error": "No image provided"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
cover_data = base64.b64decode(cover_b64)
|
||||||
|
add_cover_to_epub(full, cover_data)
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO library (filename, media_type, has_cover, updated_at)
|
||||||
|
VALUES (%s, 'epub', TRUE, NOW())
|
||||||
|
ON CONFLICT (filename) DO UPDATE SET has_cover = TRUE, updated_at = NOW()
|
||||||
|
""",
|
||||||
|
(filename,),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
thumb = make_cover_thumb_webp(cover_data)
|
||||||
|
upsert_cover_cache(conn, filename, "image/webp", thumb)
|
||||||
|
except (UnidentifiedImageError, OSError, ValueError):
|
||||||
|
pass
|
||||||
|
ensure_cover_missing_tag(conn, filename, True)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/library/want-to-read/{filename:path}")
|
||||||
|
async def library_want_to_read(filename: str):
|
||||||
|
full = resolve_library_path(filename)
|
||||||
|
if full is None:
|
||||||
|
return {"error": "Invalid filename"}
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT want_to_read FROM library WHERE filename = %s", (filename,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return {"error": "Not found"}
|
||||||
|
val = not bool(row[0])
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE library SET want_to_read = %s, updated_at = NOW() WHERE filename = %s",
|
||||||
|
(val, filename),
|
||||||
|
)
|
||||||
|
return {"ok": True, "want_to_read": val}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/library/archive/{filename:path}")
|
||||||
|
async def library_archive(filename: str):
|
||||||
|
full = resolve_library_path(filename)
|
||||||
|
if full is None:
|
||||||
|
return {"error": "Invalid filename"}
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT archived FROM library WHERE filename = %s", (filename,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return {"error": "Not found"}
|
||||||
|
val = not bool(row[0])
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE library SET archived = %s, updated_at = NOW() WHERE filename = %s",
|
||||||
|
(val, filename),
|
||||||
|
)
|
||||||
|
return {"ok": True, "archived": val}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/home", response_class=HTMLResponse)
|
||||||
|
async def home_page(request: Request):
|
||||||
|
return templates.TemplateResponse(request, "home.html", {"active": "home"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/home")
|
||||||
|
async def api_home():
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT l.filename, l.title, l.author, l.media_type,
|
||||||
|
COALESCE(rp.progress, 0) AS progress,
|
||||||
|
MAX(rs.read_at) AS last_read
|
||||||
|
FROM library l
|
||||||
|
LEFT JOIN reading_progress rp ON rp.filename = l.filename
|
||||||
|
LEFT JOIN reading_sessions rs ON rs.filename = l.filename
|
||||||
|
GROUP BY l.filename, l.title, l.author, l.media_type, rp.progress
|
||||||
|
ORDER BY last_read DESC NULLS LAST, l.updated_at DESC
|
||||||
|
LIMIT 30
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return {
|
||||||
|
"continue_reading": [
|
||||||
|
{
|
||||||
|
"filename": r[0],
|
||||||
|
"title": r[1] or "",
|
||||||
|
"author": r[2] or "",
|
||||||
|
"media_type": r[3],
|
||||||
|
"progress": r[4] or 0,
|
||||||
|
"last_read": r[5].isoformat() if r[5] else None,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_class=HTMLResponse)
|
||||||
|
async def stats_page(request: Request):
|
||||||
|
return templates.TemplateResponse(request, "stats.html", {"active": "stats"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/stats")
|
||||||
|
async def api_stats():
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT COUNT(*)::int FROM library")
|
||||||
|
total_books = cur.fetchone()[0]
|
||||||
|
cur.execute("SELECT COUNT(*)::int FROM reading_sessions")
|
||||||
|
total_reads = cur.fetchone()[0]
|
||||||
|
cur.execute("SELECT COUNT(DISTINCT filename)::int FROM reading_sessions")
|
||||||
|
unique_books_read = cur.fetchone()[0]
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT media_type, COUNT(*)::int
|
||||||
|
FROM library
|
||||||
|
GROUP BY media_type
|
||||||
|
ORDER BY media_type
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
by_type = [{"media_type": r[0], "count": r[1]} for r in cur.fetchall()]
|
||||||
|
return {
|
||||||
|
"total_books": total_books,
|
||||||
|
"total_reads": total_reads,
|
||||||
|
"unique_books_read": unique_books_read,
|
||||||
|
"by_media_type": by_type,
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/library/list")
|
||||||
|
async def library_list_compat():
|
||||||
|
return await api_library()
|
||||||
993
containers/novela/routers/reader.py
Normal file
993
containers/novela/routers/reader.py
Normal file
@ -0,0 +1,993 @@
|
|||||||
|
"""
|
||||||
|
reader.py — In-browser EPUB reader routes.
|
||||||
|
|
||||||
|
Registered in main.py via app.include_router(reader.router).
|
||||||
|
Shared low-level helpers (_db_conn, _scan_epub) are defined locally to
|
||||||
|
avoid circular imports with main.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import html as _html
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import posixpath
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
import zipfile as zf
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from cbr import cbr_get_page
|
||||||
|
from epub import read_epub_file, write_epub_file
|
||||||
|
from pdf import pdf_render_page
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
OUTPUT_DIR = Path("library")
|
||||||
|
OUTPUT_ROOT = OUTPUT_DIR.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared helpers (local copies — avoids circular imports with main.py)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _db_conn():
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=os.environ.get("POSTGRES_HOST", "postgres"),
|
||||||
|
port=int(os.environ.get("POSTGRES_PORT", 5432)),
|
||||||
|
dbname=os.environ.get("POSTGRES_DB", "novela"),
|
||||||
|
user=os.environ.get("POSTGRES_USER", "novela"),
|
||||||
|
password=os.environ.get("POSTGRES_PASSWORD", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_epub(path: Path) -> dict:
|
||||||
|
"""Inspect an EPUB zip and return metadata dict."""
|
||||||
|
has_cover = False
|
||||||
|
series = ""
|
||||||
|
series_index = 0
|
||||||
|
title = ""
|
||||||
|
publication_status = ""
|
||||||
|
author = ""
|
||||||
|
publisher = ""
|
||||||
|
source_url = ""
|
||||||
|
publish_date = ""
|
||||||
|
subjects: list[str] = []
|
||||||
|
description = ""
|
||||||
|
try:
|
||||||
|
with zf.ZipFile(path, "r") as z:
|
||||||
|
names = set(z.namelist())
|
||||||
|
has_cover = any(n.lower().endswith((".jpg", ".jpeg", ".png", ".webp", ".gif")) and "cover" in n.lower() for n in names)
|
||||||
|
container_xml = z.read("META-INF/container.xml").decode("utf-8", errors="replace") if "META-INF/container.xml" in names else None
|
||||||
|
opf_path = _find_opf_path(names, container_xml)
|
||||||
|
if opf_path and opf_path in names:
|
||||||
|
opf = z.read(opf_path).decode("utf-8", errors="replace")
|
||||||
|
m = re.search(r'<(?:dc:)?title[^>]*>(.*?)</(?:dc:)?title>', opf, re.DOTALL | re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
title = _html.unescape(m.group(1).strip())
|
||||||
|
m = re.search(r'<(?:dc:)?creator[^>]*>(.*?)</(?:dc:)?creator>', opf, re.DOTALL | re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
author = _html.unescape(m.group(1).strip())
|
||||||
|
m = re.search(r'<(?:dc:)?publisher[^>]*>(.*?)</(?:dc:)?publisher>', opf, re.DOTALL | re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
publisher = _html.unescape(m.group(1).strip())
|
||||||
|
m = re.search(r'<meta[^>]*name="calibre:series"[^>]*content="([^"]+)"', opf, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
series = _html.unescape(m.group(1).strip())
|
||||||
|
mi = re.search(r'<meta[^>]*name="calibre:series_index"[^>]*content="([^"]+)"', opf, re.IGNORECASE)
|
||||||
|
if mi:
|
||||||
|
try:
|
||||||
|
series_index = int(float(mi.group(1)))
|
||||||
|
except Exception:
|
||||||
|
series_index = 0
|
||||||
|
ms = re.search(r'<meta[^>]*name="publication_status"[^>]*content="([^"]+)"', opf, re.IGNORECASE)
|
||||||
|
if ms:
|
||||||
|
publication_status = _html.unescape(ms.group(1).strip())
|
||||||
|
m = re.search(r'<(?:dc:)?source[^>]*>(.*?)</(?:dc:)?source>', opf, re.DOTALL | re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
source_url = _html.unescape(m.group(1).strip())
|
||||||
|
m = re.search(r'<(?:dc:)?date[^>]*>(.*?)</(?:dc:)?date>', opf, re.DOTALL | re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
publish_date = _html.unescape(m.group(1).strip())
|
||||||
|
date_candidate = publish_date.split('T', 1)[0]
|
||||||
|
try:
|
||||||
|
parsed_date = datetime.fromisoformat(date_candidate).date()
|
||||||
|
publish_date = parsed_date.isoformat() if parsed_date.year >= 1900 else ''
|
||||||
|
except Exception:
|
||||||
|
publish_date = ''
|
||||||
|
subjects = [
|
||||||
|
_html.unescape(s.strip())
|
||||||
|
for s in re.findall(r'<(?:dc:)?subject[^>]*>(.*?)</(?:dc:)?subject>', opf, re.DOTALL | re.IGNORECASE)
|
||||||
|
if s.strip()
|
||||||
|
]
|
||||||
|
m = re.search(r'<(?:dc:)?description[^>]*>(.*?)</(?:dc:)?description>', opf, re.DOTALL | re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
description = _html.unescape(m.group(1).strip())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"has_cover": has_cover,
|
||||||
|
"series": series,
|
||||||
|
"series_index": series_index,
|
||||||
|
"title": title,
|
||||||
|
"publication_status": publication_status,
|
||||||
|
"author": author,
|
||||||
|
"publisher": publisher,
|
||||||
|
"source_url": source_url,
|
||||||
|
"publish_date": publish_date,
|
||||||
|
"subjects": subjects,
|
||||||
|
"description": description,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# EPUB helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _epub_spine(path: Path) -> list[dict]:
|
||||||
|
"""Return ordered list of {index, title, href} for all spine items.
|
||||||
|
|
||||||
|
Supports both EPUB2 (toc.ncx) and EPUB3 (nav.xhtml), and respects
|
||||||
|
the OPF location declared in META-INF/container.xml.
|
||||||
|
"""
|
||||||
|
def _norm(base_dir: str, rel: str) -> str:
|
||||||
|
rel = (rel or '').split('#', 1)[0].strip()
|
||||||
|
if not rel:
|
||||||
|
return ''
|
||||||
|
joined = posixpath.normpath(posixpath.join(base_dir, rel))
|
||||||
|
return joined.lstrip('./')
|
||||||
|
|
||||||
|
with zf.ZipFile(path, 'r') as z:
|
||||||
|
names = set(z.namelist())
|
||||||
|
|
||||||
|
opf_path = 'OEBPS/content.opf'
|
||||||
|
try:
|
||||||
|
container_xml = z.read('META-INF/container.xml').decode('utf-8', errors='replace')
|
||||||
|
m = re.search(r"full-path\\s*=\\s*['\"]([^'\"]+)['\"]", container_xml)
|
||||||
|
if m:
|
||||||
|
opf_path = m.group(1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if opf_path not in names:
|
||||||
|
# fallback for malformed books
|
||||||
|
candidates = [n for n in names if n.lower().endswith('.opf')]
|
||||||
|
if not candidates:
|
||||||
|
return []
|
||||||
|
opf_path = sorted(candidates)[0]
|
||||||
|
|
||||||
|
opf_xml = z.read(opf_path).decode('utf-8', errors='replace')
|
||||||
|
opf = BeautifulSoup(opf_xml, 'xml')
|
||||||
|
opf_dir = posixpath.dirname(opf_path)
|
||||||
|
|
||||||
|
manifest: dict[str, str] = {}
|
||||||
|
for item in opf.find_all('item'):
|
||||||
|
iid = item.get('id')
|
||||||
|
href = item.get('href')
|
||||||
|
if iid and href:
|
||||||
|
manifest[iid] = _norm(opf_dir, href)
|
||||||
|
|
||||||
|
spine_idrefs: list[str] = []
|
||||||
|
spine_tag = opf.find('spine')
|
||||||
|
toc_id = spine_tag.get('toc') if spine_tag else None
|
||||||
|
if spine_tag:
|
||||||
|
for ir in spine_tag.find_all('itemref'):
|
||||||
|
rid = ir.get('idref')
|
||||||
|
if rid:
|
||||||
|
spine_idrefs.append(rid)
|
||||||
|
|
||||||
|
hrefs = [manifest[rid] for rid in spine_idrefs if rid in manifest]
|
||||||
|
|
||||||
|
href_to_title: dict[str, str] = {}
|
||||||
|
|
||||||
|
# EPUB2: NCX titles
|
||||||
|
ncx_path = ''
|
||||||
|
if toc_id and toc_id in manifest:
|
||||||
|
ncx_path = manifest[toc_id]
|
||||||
|
elif 'toc.ncx' in names:
|
||||||
|
ncx_path = 'toc.ncx'
|
||||||
|
elif 'OEBPS/toc.ncx' in names:
|
||||||
|
ncx_path = 'OEBPS/toc.ncx'
|
||||||
|
|
||||||
|
if ncx_path and ncx_path in names:
|
||||||
|
try:
|
||||||
|
ncx_xml = z.read(ncx_path).decode('utf-8', errors='replace')
|
||||||
|
ncx = BeautifulSoup(ncx_xml, 'xml')
|
||||||
|
ncx_dir = posixpath.dirname(ncx_path)
|
||||||
|
for np in ncx.find_all('navPoint'):
|
||||||
|
content = np.find('content')
|
||||||
|
label_tag = np.find('text')
|
||||||
|
src = content.get('src') if content else ''
|
||||||
|
label = label_tag.get_text(strip=True) if label_tag else ''
|
||||||
|
if src and label:
|
||||||
|
href_to_title[_norm(ncx_dir, src)] = _html.unescape(label)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# EPUB3: nav.xhtml titles (fallback)
|
||||||
|
if not href_to_title:
|
||||||
|
nav_item = None
|
||||||
|
for item in opf.find_all('item'):
|
||||||
|
props = (item.get('properties') or '').split()
|
||||||
|
if 'nav' in props:
|
||||||
|
nav_item = item
|
||||||
|
break
|
||||||
|
if nav_item and nav_item.get('href'):
|
||||||
|
nav_path = _norm(opf_dir, nav_item.get('href'))
|
||||||
|
if nav_path in names:
|
||||||
|
try:
|
||||||
|
nav_xml = z.read(nav_path).decode('utf-8', errors='replace')
|
||||||
|
nav = BeautifulSoup(nav_xml, 'lxml')
|
||||||
|
nav_dir = posixpath.dirname(nav_path)
|
||||||
|
for a in nav.select('nav a[href]'):
|
||||||
|
src = a.get('href', '')
|
||||||
|
label = a.get_text(' ', strip=True)
|
||||||
|
if src and label:
|
||||||
|
href_to_title[_norm(nav_dir, src)] = _html.unescape(label)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
chapters = []
|
||||||
|
for i, href in enumerate(hrefs):
|
||||||
|
base = posixpath.basename(href)
|
||||||
|
title = href_to_title.get(href, re.sub(r'\.(xhtml|html|htm)$', '', base, flags=re.I))
|
||||||
|
chapters.append({'index': i, 'title': title or f'Chapter {i+1}', 'href': href})
|
||||||
|
return chapters
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_href(base_dir: str, rel: str) -> str:
|
||||||
|
rel = (rel or '').split('#', 1)[0].strip()
|
||||||
|
if not rel:
|
||||||
|
return ''
|
||||||
|
return posixpath.normpath(posixpath.join(base_dir, rel)).lstrip('./')
|
||||||
|
|
||||||
|
|
||||||
|
def _find_opf_path(names: set[str], container_xml: str | None) -> str | None:
|
||||||
|
opf_path = 'OEBPS/content.opf'
|
||||||
|
if container_xml:
|
||||||
|
m = re.search(r'full-path\s*=\s*[\'"]([^\'"]+)[\'"]', container_xml)
|
||||||
|
if m:
|
||||||
|
opf_path = m.group(1)
|
||||||
|
if opf_path in names:
|
||||||
|
return opf_path
|
||||||
|
candidates = sorted(n for n in names if n.lower().endswith('.opf'))
|
||||||
|
return candidates[0] if candidates else None
|
||||||
|
|
||||||
|
|
||||||
|
def _make_new_chapter_xhtml(title: str) -> str:
|
||||||
|
safe_title = _html.escape((title or 'New chapter').strip() or 'New chapter')
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"\n'
|
||||||
|
' "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">\n'
|
||||||
|
'<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">\n'
|
||||||
|
'<head>\n'
|
||||||
|
' <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>\n'
|
||||||
|
f' <title>{safe_title}</title>\n'
|
||||||
|
' <link rel="stylesheet" type="text/css" href="../Styles/style.css"/>\n'
|
||||||
|
'</head>\n'
|
||||||
|
'<body>\n'
|
||||||
|
f' <h2 class="chapter-title">{safe_title}</h2>\n'
|
||||||
|
' <p></p>\n'
|
||||||
|
'</body>\n'
|
||||||
|
'</html>\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tag_local(name: str | None) -> str:
|
||||||
|
if not name:
|
||||||
|
return ''
|
||||||
|
return name.split(':', 1)[-1].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_epub_metadata(
|
||||||
|
epub_path: Path,
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
author: str,
|
||||||
|
publisher: str,
|
||||||
|
publication_status: str,
|
||||||
|
source_url: str,
|
||||||
|
publish_date: str,
|
||||||
|
description: str,
|
||||||
|
series: str,
|
||||||
|
series_index: int | str | None,
|
||||||
|
subjects: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""Write edited metadata back into OPF so DB and EPUB stay aligned."""
|
||||||
|
with zf.ZipFile(epub_path, 'r') as z:
|
||||||
|
names = set(z.namelist())
|
||||||
|
container_xml = z.read('META-INF/container.xml').decode('utf-8', errors='replace') if 'META-INF/container.xml' in names else None
|
||||||
|
opf_path = _find_opf_path(names, container_xml)
|
||||||
|
if not opf_path or opf_path not in names:
|
||||||
|
return
|
||||||
|
opf_xml = z.read(opf_path).decode('utf-8', errors='replace')
|
||||||
|
|
||||||
|
opf = BeautifulSoup(opf_xml, 'xml')
|
||||||
|
metadata = opf.find(lambda t: _tag_local(getattr(t, 'name', None)) == 'metadata')
|
||||||
|
if not metadata:
|
||||||
|
return
|
||||||
|
|
||||||
|
def set_dc(local_name: str, value: str) -> None:
|
||||||
|
existing = [t for t in metadata.find_all(lambda t: _tag_local(getattr(t, 'name', None)) == local_name)]
|
||||||
|
tag_name = existing[0].name if existing else f'dc:{local_name}'
|
||||||
|
for t in existing:
|
||||||
|
t.decompose()
|
||||||
|
if value:
|
||||||
|
nt = opf.new_tag(tag_name)
|
||||||
|
nt.string = value
|
||||||
|
metadata.append(nt)
|
||||||
|
|
||||||
|
def set_named_meta(key: str, value: str) -> None:
|
||||||
|
existing = [
|
||||||
|
t for t in metadata.find_all(lambda t: _tag_local(getattr(t, 'name', None)) == 'meta')
|
||||||
|
if (t.get('name') or '').strip() == key
|
||||||
|
]
|
||||||
|
tag_name = existing[0].name if existing else 'meta'
|
||||||
|
for t in existing:
|
||||||
|
t.decompose()
|
||||||
|
if value:
|
||||||
|
nt = opf.new_tag(tag_name)
|
||||||
|
nt['name'] = key
|
||||||
|
nt['content'] = value
|
||||||
|
metadata.append(nt)
|
||||||
|
|
||||||
|
set_dc('title', (title or '').strip())
|
||||||
|
set_dc('creator', (author or '').strip())
|
||||||
|
set_dc('publisher', (publisher or '').strip())
|
||||||
|
set_dc('source', (source_url or '').strip())
|
||||||
|
date_value = (publish_date or '').strip()
|
||||||
|
if date_value:
|
||||||
|
date_candidate = date_value.split('T', 1)[0]
|
||||||
|
try:
|
||||||
|
parsed_date = datetime.fromisoformat(date_candidate).date()
|
||||||
|
date_value = parsed_date.isoformat() if parsed_date.year >= 1900 else ''
|
||||||
|
except Exception:
|
||||||
|
date_value = ''
|
||||||
|
set_dc('date', date_value)
|
||||||
|
set_dc('description', (description or '').strip())
|
||||||
|
|
||||||
|
# Replace subjects from editor tags (genres + subgenres + tags).
|
||||||
|
for t in [t for t in metadata.find_all(lambda t: _tag_local(getattr(t, 'name', None)) == 'subject')]:
|
||||||
|
t.decompose()
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw in subjects:
|
||||||
|
val = (raw or '').strip()
|
||||||
|
if not val:
|
||||||
|
continue
|
||||||
|
key = val.casefold()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
nt = opf.new_tag('dc:subject')
|
||||||
|
nt.string = val
|
||||||
|
metadata.append(nt)
|
||||||
|
|
||||||
|
set_named_meta('publication_status', (publication_status or '').strip())
|
||||||
|
|
||||||
|
series_val = (series or '').strip()
|
||||||
|
set_named_meta('calibre:series', series_val)
|
||||||
|
if series_val:
|
||||||
|
set_named_meta('calibre:series_index', str(_coerce_series_index(series_index)))
|
||||||
|
else:
|
||||||
|
set_named_meta('calibre:series_index', '')
|
||||||
|
|
||||||
|
_rewrite_epub_entries(epub_path, {opf_path: str(opf).encode('utf-8')})
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_epub_entries(epub_path: Path, updates: dict[str, bytes], remove_paths: set[str] | None = None) -> None:
|
||||||
|
remove_paths = remove_paths or set()
|
||||||
|
with open(epub_path, 'rb') as f:
|
||||||
|
original = f.read()
|
||||||
|
|
||||||
|
out = io.BytesIO()
|
||||||
|
with zf.ZipFile(io.BytesIO(original), 'r') as zin, zf.ZipFile(out, 'w', zf.ZIP_DEFLATED) as zout:
|
||||||
|
existing = set()
|
||||||
|
for item in zin.infolist():
|
||||||
|
name = item.filename
|
||||||
|
existing.add(name)
|
||||||
|
if name in remove_paths:
|
||||||
|
continue
|
||||||
|
data = updates.get(name)
|
||||||
|
if data is None:
|
||||||
|
data = zin.read(name)
|
||||||
|
ctype = zf.ZIP_STORED if name == 'mimetype' else zf.ZIP_DEFLATED
|
||||||
|
zout.writestr(name, data, compress_type=ctype)
|
||||||
|
|
||||||
|
for name, data in updates.items():
|
||||||
|
if name in existing or name in remove_paths:
|
||||||
|
continue
|
||||||
|
ctype = zf.ZIP_STORED if name == 'mimetype' else zf.ZIP_DEFLATED
|
||||||
|
zout.writestr(name, data, compress_type=ctype)
|
||||||
|
|
||||||
|
with open(epub_path, 'wb') as f:
|
||||||
|
f.write(out.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_output_path(filename: str) -> Path | None:
|
||||||
|
rel = Path(filename)
|
||||||
|
if rel.is_absolute() or any(part in {"", ".", ".."} for part in rel.parts):
|
||||||
|
return None
|
||||||
|
candidate = (OUTPUT_DIR / rel).resolve()
|
||||||
|
try:
|
||||||
|
candidate.relative_to(OUTPUT_ROOT)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_empty_output_dirs(start_dir: Path) -> None:
|
||||||
|
"""Remove empty parent directories under OUTPUT_DIR, but never OUTPUT_DIR itself."""
|
||||||
|
try:
|
||||||
|
cur = start_dir.resolve()
|
||||||
|
cur.relative_to(OUTPUT_ROOT)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
while cur != OUTPUT_ROOT:
|
||||||
|
try:
|
||||||
|
cur.rmdir()
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
cur = cur.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_segment(value: str, fallback: str, max_len: int = 100) -> str:
|
||||||
|
txt = re.sub(r"\s+", " ", (value or "").strip())
|
||||||
|
txt = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", txt)
|
||||||
|
txt = re.sub(r"\.+$", "", txt).strip()
|
||||||
|
if not txt:
|
||||||
|
txt = fallback
|
||||||
|
return txt[:max_len]
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_series_index(value: int | str | None) -> int:
|
||||||
|
try:
|
||||||
|
return max(1, min(999, int(value or 1)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _make_rel_path(
|
||||||
|
*,
|
||||||
|
publisher: str,
|
||||||
|
author: str,
|
||||||
|
title: str,
|
||||||
|
series: str,
|
||||||
|
series_index: int | str | None,
|
||||||
|
) -> Path:
|
||||||
|
pub_dir = _clean_segment(publisher, "Unknown Publisher", 80)
|
||||||
|
author_dir = _clean_segment(author, "Unknown Author", 80)
|
||||||
|
clean_title = _clean_segment(title, "Untitled", 140)
|
||||||
|
clean_series = _clean_segment(series, "", 120)
|
||||||
|
if clean_series:
|
||||||
|
idx = _coerce_series_index(series_index)
|
||||||
|
filename = f"{idx:03d} - {clean_title}.epub"
|
||||||
|
return Path(pub_dir) / author_dir / "Series" / clean_series / filename
|
||||||
|
return Path(pub_dir) / author_dir / "Stories" / f"{clean_title}.epub"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_unique_rel_path(rel_path: Path, *, exclude: Path | None = None) -> Path:
|
||||||
|
base = rel_path.with_suffix(".epub")
|
||||||
|
candidate = base
|
||||||
|
counter = 2
|
||||||
|
while True:
|
||||||
|
full = (OUTPUT_DIR / candidate).resolve()
|
||||||
|
if exclude is not None and full == exclude.resolve():
|
||||||
|
return candidate
|
||||||
|
if not full.exists():
|
||||||
|
return candidate
|
||||||
|
candidate = base.with_name(f"{base.stem} ({counter}){base.suffix}")
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _guard(filename: str) -> bool:
|
||||||
|
"""Return True if the filename contains path-traversal characters."""
|
||||||
|
return "/" in filename or "\\" in filename or ".." in filename
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/library/epub/{filename:path}")
|
||||||
|
async def library_epub(filename: str):
|
||||||
|
"""Serve EPUB inline (no Content-Disposition: attachment) for the reader."""
|
||||||
|
path = _resolve_output_path(filename)
|
||||||
|
if path is None:
|
||||||
|
return Response(status_code=404)
|
||||||
|
if not path.exists():
|
||||||
|
return Response(status_code=404)
|
||||||
|
return FileResponse(path, media_type="application/epub+zip")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/library/chapters/{filename:path}")
|
||||||
|
async def get_chapter_list(filename: str):
|
||||||
|
path = _resolve_output_path(filename)
|
||||||
|
if path is None:
|
||||||
|
return Response(status_code=404)
|
||||||
|
if not path.exists():
|
||||||
|
return Response(status_code=404)
|
||||||
|
return _epub_spine(path)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/library/chapter/{index}/{filename:path}")
|
||||||
|
async def get_chapter_html(filename: str, index: int):
|
||||||
|
"""Extract a single chapter from the EPUB and return it as an HTML fragment."""
|
||||||
|
path = _resolve_output_path(filename)
|
||||||
|
if path is None:
|
||||||
|
return Response(status_code=404)
|
||||||
|
if not path.exists():
|
||||||
|
return Response(status_code=404)
|
||||||
|
spine = _epub_spine(path)
|
||||||
|
if index < 0 or index >= len(spine):
|
||||||
|
return Response(status_code=404)
|
||||||
|
href = spine[index]["href"]
|
||||||
|
with zf.ZipFile(path, "r") as z:
|
||||||
|
xhtml = z.read(href).decode("utf-8", errors="replace")
|
||||||
|
soup = BeautifulSoup(xhtml, "lxml")
|
||||||
|
body = soup.find("body")
|
||||||
|
if not body:
|
||||||
|
return Response("<p>No content.</p>", media_type="text/html")
|
||||||
|
# Rewrite relative image paths to the chapter-image API endpoint
|
||||||
|
href_dir = href.rsplit("/", 1)[0] # e.g. "OEBPS/Text"
|
||||||
|
for img in body.find_all("img"):
|
||||||
|
src = img.get("src", "")
|
||||||
|
if src and not src.startswith("http") and not src.startswith("data:"):
|
||||||
|
parts = href_dir.split("/") + src.split("/")
|
||||||
|
resolved: list[str] = []
|
||||||
|
for p in parts:
|
||||||
|
if p == "..":
|
||||||
|
if resolved:
|
||||||
|
resolved.pop()
|
||||||
|
else:
|
||||||
|
resolved.append(p)
|
||||||
|
img["src"] = f"/library/chapter-img/{'/'.join(resolved[1:])}?filename={filename}"
|
||||||
|
return Response(str(body), media_type="text/html")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/library/chapter-img/{path:path}")
|
||||||
|
async def get_chapter_image(path: str, filename: str):
|
||||||
|
"""Serve an image extracted from the EPUB zip."""
|
||||||
|
epub_path = _resolve_output_path(filename)
|
||||||
|
if epub_path is None:
|
||||||
|
return Response(status_code=404)
|
||||||
|
if not epub_path.exists():
|
||||||
|
return Response(status_code=404)
|
||||||
|
try:
|
||||||
|
with zf.ZipFile(epub_path, "r") as z:
|
||||||
|
data = z.read("OEBPS/" + path)
|
||||||
|
except KeyError:
|
||||||
|
return Response(status_code=404)
|
||||||
|
ext = path.rsplit(".", 1)[-1].lower()
|
||||||
|
mt = {"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png",
|
||||||
|
"webp": "image/webp", "gif": "image/gif"}.get(ext, "image/octet-stream")
|
||||||
|
return Response(content=data, media_type=mt)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/library/progress/{filename:path}")
|
||||||
|
async def get_progress(filename: str):
|
||||||
|
if _resolve_output_path(filename) is None:
|
||||||
|
return {"error": "Invalid filename"}
|
||||||
|
conn = _db_conn()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT cfi, page, progress FROM reading_progress WHERE filename = %s",
|
||||||
|
(filename,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return {"cfi": row[0], "progress": row[1] or 0} if row else {"cfi": None, "progress": 0}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/library/progress/{filename:path}")
|
||||||
|
async def clear_progress(filename: str):
|
||||||
|
"""Remove reading progress so the book returns to unread state.
|
||||||
|
|
||||||
|
Reading sessions (mark-as-read history) are intentionally left intact.
|
||||||
|
"""
|
||||||
|
if _resolve_output_path(filename) is None:
|
||||||
|
return {"error": "Invalid filename"}
|
||||||
|
conn = _db_conn()
|
||||||
|
try:
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("DELETE FROM reading_progress WHERE filename = %s", (filename,))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/library/progress/{filename:path}")
|
||||||
|
async def save_progress(filename: str, request: Request):
|
||||||
|
if _resolve_output_path(filename) is None:
|
||||||
|
return {"error": "Invalid filename"}
|
||||||
|
body = await request.json()
|
||||||
|
cfi = body.get("cfi", "")
|
||||||
|
page = body.get("page")
|
||||||
|
if page is not None:
|
||||||
|
try:
|
||||||
|
page = int(page)
|
||||||
|
except Exception:
|
||||||
|
page = None
|
||||||
|
progress = max(0, min(100, int(body.get("progress", 0))))
|
||||||
|
conn = _db_conn()
|
||||||
|
try:
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO reading_progress (filename, cfi, page, progress, updated_at)
|
||||||
|
VALUES (%s, %s, %s, %s, NOW())
|
||||||
|
ON CONFLICT (filename) DO UPDATE
|
||||||
|
SET cfi = EXCLUDED.cfi,
|
||||||
|
page = EXCLUDED.page,
|
||||||
|
progress = EXCLUDED.progress,
|
||||||
|
updated_at = NOW()
|
||||||
|
""",
|
||||||
|
(filename, cfi, page, progress),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/library/mark-read/{filename:path}")
|
||||||
|
async def library_mark_read(filename: str, request: Request):
|
||||||
|
if _resolve_output_path(filename) is None:
|
||||||
|
return {"error": "Invalid filename"}
|
||||||
|
path = _resolve_output_path(filename)
|
||||||
|
if path is None or not path.exists():
|
||||||
|
return {"error": "File not found"}
|
||||||
|
body = {}
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
read_at = body.get("read_at") # ISO datetime string, or None for now
|
||||||
|
conn = _db_conn()
|
||||||
|
try:
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
if read_at:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO reading_sessions (filename, read_at) VALUES (%s, %s)",
|
||||||
|
(filename, read_at),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO reading_sessions (filename) VALUES (%s)",
|
||||||
|
(filename,),
|
||||||
|
)
|
||||||
|
cur.execute("DELETE FROM reading_progress WHERE filename = %s", (filename,))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/library/book/{filename:path}", response_class=HTMLResponse)
|
||||||
|
async def book_detail_page(filename: str, request: Request):
|
||||||
|
path = _resolve_output_path(filename)
|
||||||
|
if path is None:
|
||||||
|
return HTMLResponse("Not found", status_code=404)
|
||||||
|
if not path.exists():
|
||||||
|
return HTMLResponse("Not found", status_code=404)
|
||||||
|
conn = _db_conn()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT title, author, publisher, has_cover, series, series_index,
|
||||||
|
publication_status, want_to_read, source_url, archived, publish_date, description
|
||||||
|
FROM library WHERE filename = %s
|
||||||
|
""",
|
||||||
|
(filename,),
|
||||||
|
)
|
||||||
|
lib_row = cur.fetchone()
|
||||||
|
if lib_row:
|
||||||
|
entry = {
|
||||||
|
"title": lib_row[0] or "",
|
||||||
|
"author": lib_row[1] or "",
|
||||||
|
"publisher": lib_row[2] or "",
|
||||||
|
"has_cover": lib_row[3] or False,
|
||||||
|
"series": lib_row[4] or "",
|
||||||
|
"series_index": lib_row[5] or 0,
|
||||||
|
"publication_status": lib_row[6] or "",
|
||||||
|
"want_to_read": lib_row[7] or False,
|
||||||
|
"source_url": lib_row[8] or "",
|
||||||
|
"archived": lib_row[9] or False,
|
||||||
|
"publish_date": lib_row[10].isoformat() if lib_row[10] else "",
|
||||||
|
"description": lib_row[11] or "",
|
||||||
|
}
|
||||||
|
# Supplement empty fields from EPUB metadata
|
||||||
|
if not entry["source_url"] or not entry["publish_date"] or not entry["description"]:
|
||||||
|
epub_meta = _scan_epub(path)
|
||||||
|
if not entry["source_url"]:
|
||||||
|
entry["source_url"] = epub_meta.get("source_url", "")
|
||||||
|
if not entry["publish_date"]:
|
||||||
|
entry["publish_date"] = epub_meta.get("publish_date", "")
|
||||||
|
if not entry["description"]:
|
||||||
|
entry["description"] = epub_meta.get("description", "")
|
||||||
|
else:
|
||||||
|
entry = _scan_epub(path)
|
||||||
|
entry.setdefault("want_to_read", False)
|
||||||
|
entry.setdefault("archived", False)
|
||||||
|
entry.setdefault("publish_date", "")
|
||||||
|
entry.setdefault("description", "")
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT tag, tag_type FROM book_tags WHERE filename = %s ORDER BY tag_type, tag",
|
||||||
|
(filename,),
|
||||||
|
)
|
||||||
|
genres: list[str] = []
|
||||||
|
subgenres: list[str] = []
|
||||||
|
tags_list: list[str] = []
|
||||||
|
rows = cur.fetchall()
|
||||||
|
for tag, tag_type in rows:
|
||||||
|
if tag_type == "genre":
|
||||||
|
genres.append(tag)
|
||||||
|
elif tag_type == "subgenre":
|
||||||
|
subgenres.append(tag)
|
||||||
|
else:
|
||||||
|
tags_list.append(tag)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
# Fallback for books where tags only exist in OPF after DB loss/rebuild.
|
||||||
|
epub_meta = _scan_epub(path)
|
||||||
|
for subject in epub_meta.get("subjects", []):
|
||||||
|
if subject not in tags_list:
|
||||||
|
tags_list.append(subject)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*)::int, MAX(read_at) FROM reading_sessions WHERE filename = %s",
|
||||||
|
(filename,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
read_count = row[0] or 0
|
||||||
|
last_read = row[1].isoformat() if row[1] else None
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT cfi, progress FROM reading_progress WHERE filename = %s",
|
||||||
|
(filename,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
progress = row[1] or 0 if row else 0
|
||||||
|
cfi = row[0] if row else None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return templates.TemplateResponse(request, "book.html", {
|
||||||
|
"active": "book",
|
||||||
|
"filename": filename,
|
||||||
|
"title": entry["title"],
|
||||||
|
"author": entry["author"],
|
||||||
|
"series": entry["series"],
|
||||||
|
"series_index": entry["series_index"],
|
||||||
|
"genres": genres,
|
||||||
|
"subgenres": subgenres,
|
||||||
|
"tags": tags_list,
|
||||||
|
"publisher": entry["publisher"],
|
||||||
|
"publication_status": entry["publication_status"],
|
||||||
|
"publish_date": entry.get("publish_date", ""),
|
||||||
|
"has_cover": entry["has_cover"],
|
||||||
|
"want_to_read": entry["want_to_read"],
|
||||||
|
"archived": entry["archived"],
|
||||||
|
"source_url": entry.get("source_url", ""),
|
||||||
|
"description": entry.get("description", ""),
|
||||||
|
"read_count": read_count,
|
||||||
|
"last_read": last_read,
|
||||||
|
"progress": progress,
|
||||||
|
"cfi": cfi,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/genres")
|
||||||
|
async def api_genres(type: str | None = None):
|
||||||
|
"""Return all distinct tags from book_tags, sorted alphabetically.
|
||||||
|
|
||||||
|
Optional ``type`` query parameter filters by tag_type (genre, subgenre, tag).
|
||||||
|
"""
|
||||||
|
conn = _db_conn()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
if type == "tag":
|
||||||
|
cur.execute(
|
||||||
|
"SELECT DISTINCT tag FROM book_tags WHERE tag_type IN ('tag', 'subject') ORDER BY tag"
|
||||||
|
)
|
||||||
|
elif type:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT DISTINCT tag FROM book_tags WHERE tag_type = %s ORDER BY tag",
|
||||||
|
(type,),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute("SELECT DISTINCT tag FROM book_tags ORDER BY tag")
|
||||||
|
result = [r[0] for r in cur.fetchall()]
|
||||||
|
return JSONResponse(result)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/library/book/{filename:path}")
|
||||||
|
async def book_update(filename: str, request: Request):
|
||||||
|
"""Update book metadata and tags, and rename/move the file when needed."""
|
||||||
|
old_path = _resolve_output_path(filename)
|
||||||
|
if old_path is None or not old_path.exists():
|
||||||
|
return JSONResponse({"error": "not found"}, status_code=404)
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
title = body.get("title", "")
|
||||||
|
author = body.get("author", "")
|
||||||
|
publisher = body.get("publisher", "")
|
||||||
|
series = body.get("series", "")
|
||||||
|
series_index = _coerce_series_index(body.get("series_index", 1))
|
||||||
|
|
||||||
|
target_rel = _make_rel_path(
|
||||||
|
publisher=publisher,
|
||||||
|
author=author,
|
||||||
|
title=title,
|
||||||
|
series=series,
|
||||||
|
series_index=series_index,
|
||||||
|
)
|
||||||
|
target_rel = _ensure_unique_rel_path(target_rel, exclude=old_path)
|
||||||
|
new_filename = target_rel.as_posix()
|
||||||
|
new_path = (OUTPUT_DIR / target_rel).resolve()
|
||||||
|
|
||||||
|
moved = False
|
||||||
|
old_parent_to_prune: Path | None = None
|
||||||
|
if new_path != old_path:
|
||||||
|
new_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
old_path.replace(new_path)
|
||||||
|
moved = True
|
||||||
|
old_parent_to_prune = old_path.parent
|
||||||
|
|
||||||
|
conn = _db_conn()
|
||||||
|
try:
|
||||||
|
_sync_epub_metadata(
|
||||||
|
new_path,
|
||||||
|
title=title,
|
||||||
|
author=author,
|
||||||
|
publisher=publisher,
|
||||||
|
publication_status=body.get("publication_status", ""),
|
||||||
|
source_url=body.get("source_url", ""),
|
||||||
|
publish_date=body.get("publish_date", ""),
|
||||||
|
description=body.get("description", ""),
|
||||||
|
series=series,
|
||||||
|
series_index=series_index if series else 0,
|
||||||
|
subjects=(body.get("genres", []) + body.get("subgenres", []) + body.get("tags", [])),
|
||||||
|
)
|
||||||
|
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT has_cover FROM library WHERE filename = %s", (filename,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
has_cover = bool(row[0]) if row and row[0] is not None else bool(_scan_epub(new_path if moved else old_path).get("has_cover", False))
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO library (
|
||||||
|
filename, title, author, publisher, has_cover,
|
||||||
|
series, series_index, publication_status,
|
||||||
|
source_url, publish_date, description,
|
||||||
|
archived, needs_review, updated_at
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, FALSE, NOW())
|
||||||
|
ON CONFLICT (filename) DO UPDATE SET
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
author = EXCLUDED.author,
|
||||||
|
publisher = EXCLUDED.publisher,
|
||||||
|
series = EXCLUDED.series,
|
||||||
|
series_index = EXCLUDED.series_index,
|
||||||
|
publication_status = EXCLUDED.publication_status,
|
||||||
|
source_url = EXCLUDED.source_url,
|
||||||
|
publish_date = EXCLUDED.publish_date,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
needs_review = FALSE,
|
||||||
|
updated_at = NOW()
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
new_filename,
|
||||||
|
title,
|
||||||
|
author,
|
||||||
|
publisher,
|
||||||
|
has_cover,
|
||||||
|
series,
|
||||||
|
series_index if series else 0,
|
||||||
|
body.get("publication_status", ""),
|
||||||
|
body.get("source_url", ""),
|
||||||
|
body.get("publish_date") or None,
|
||||||
|
body.get("description", ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_filename != filename:
|
||||||
|
cur.execute("UPDATE book_tags SET filename = %s WHERE filename = %s", (new_filename, filename))
|
||||||
|
cur.execute("UPDATE reading_progress SET filename = %s WHERE filename = %s", (new_filename, filename))
|
||||||
|
cur.execute("UPDATE reading_sessions SET filename = %s WHERE filename = %s", (new_filename, filename))
|
||||||
|
cur.execute("UPDATE library_cover_cache SET filename = %s WHERE filename = %s", (new_filename, filename))
|
||||||
|
cur.execute("DELETE FROM library WHERE filename = %s", (filename,))
|
||||||
|
|
||||||
|
cur.execute("DELETE FROM book_tags WHERE filename = %s", (new_filename,))
|
||||||
|
rows = (
|
||||||
|
[(new_filename, g, "genre") for g in body.get("genres", []) if g] +
|
||||||
|
[(new_filename, g, "subgenre") for g in body.get("subgenres", []) if g] +
|
||||||
|
[(new_filename, g, "tag") for g in body.get("tags", []) if g]
|
||||||
|
)
|
||||||
|
if rows:
|
||||||
|
cur.executemany(
|
||||||
|
"INSERT INTO book_tags (filename, tag, tag_type) VALUES (%s, %s, %s)"
|
||||||
|
" ON CONFLICT (filename, tag, tag_type) DO NOTHING",
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
if old_parent_to_prune is not None:
|
||||||
|
_prune_empty_output_dirs(old_parent_to_prune)
|
||||||
|
return JSONResponse({"ok": True, "filename": new_filename, "renamed": new_filename != filename})
|
||||||
|
except Exception as e:
|
||||||
|
if moved and new_path.exists() and not old_path.exists():
|
||||||
|
new_path.replace(old_path)
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/library/read/{filename:path}", response_class=HTMLResponse)
|
||||||
|
async def reader_page(filename: str, request: Request):
|
||||||
|
path = _resolve_output_path(filename)
|
||||||
|
if path is None:
|
||||||
|
return HTMLResponse("Not found", status_code=404)
|
||||||
|
if not path.exists():
|
||||||
|
return HTMLResponse("Not found", status_code=404)
|
||||||
|
conn = _db_conn()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT title FROM library WHERE filename = %s", (filename,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
title = row[0] if row and row[0] else filename
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return templates.TemplateResponse(request, "reader.html", {
|
||||||
|
"filename": filename,
|
||||||
|
"title": title,
|
||||||
|
"epub_url": f"/library/epub/{filename}",
|
||||||
|
})
|
||||||
|
|
||||||
|
@router.get("/library/pdf/{filename:path}")
|
||||||
|
async def library_pdf_page(filename: str, page: int = 0, dpi: int = 150):
|
||||||
|
path = _resolve_output_path(filename)
|
||||||
|
if path is None:
|
||||||
|
return JSONResponse({"error": "Invalid filename"}, status_code=400)
|
||||||
|
if not path.exists():
|
||||||
|
return JSONResponse({"error": "File not found"}, status_code=404)
|
||||||
|
if path.suffix.lower() != ".pdf":
|
||||||
|
return JSONResponse({"error": "Not a PDF file"}, status_code=400)
|
||||||
|
try:
|
||||||
|
data = pdf_render_page(path, page, dpi=dpi)
|
||||||
|
return Response(content=data, media_type="image/png")
|
||||||
|
except IndexError:
|
||||||
|
return JSONResponse({"error": "Page out of range"}, status_code=416)
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/library/cbr/{filename:path}/{page:int}")
|
||||||
|
async def library_cbr_page(filename: str, page: int):
|
||||||
|
path = _resolve_output_path(filename)
|
||||||
|
if path is None:
|
||||||
|
return JSONResponse({"error": "Invalid filename"}, status_code=400)
|
||||||
|
if not path.exists():
|
||||||
|
return JSONResponse({"error": "File not found"}, status_code=404)
|
||||||
|
if path.suffix.lower() not in {".cbr", ".cbz"}:
|
||||||
|
return JSONResponse({"error": "Not a CBR/CBZ file"}, status_code=400)
|
||||||
|
try:
|
||||||
|
data, mime = cbr_get_page(path, page)
|
||||||
|
return Response(content=data, media_type=mime)
|
||||||
|
except IndexError:
|
||||||
|
return JSONResponse({"error": "Page out of range"}, status_code=416)
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
104
containers/novela/routers/settings.py
Normal file
104
containers/novela/routers/settings.py
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from db import get_db_conn
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings", response_class=HTMLResponse)
|
||||||
|
async def settings_page(request: Request):
|
||||||
|
return templates.TemplateResponse(request, "settings.html", {"active": "settings"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/break-patterns")
|
||||||
|
async def get_break_patterns():
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, pattern_type, pattern, enabled, is_default FROM break_patterns ORDER BY id"
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r[0],
|
||||||
|
"pattern_type": r[1],
|
||||||
|
"pattern": r[2],
|
||||||
|
"enabled": r[3],
|
||||||
|
"is_default": r[4],
|
||||||
|
}
|
||||||
|
for r in cur.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/break-patterns")
|
||||||
|
async def add_break_pattern(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
ptype = (body.get("pattern_type") or "").strip()
|
||||||
|
pattern = (body.get("pattern") or "").strip()
|
||||||
|
if ptype not in ("regex", "css_class") or not pattern:
|
||||||
|
return {"error": "Invalid input"}
|
||||||
|
if ptype == "regex":
|
||||||
|
try:
|
||||||
|
re.compile(pattern)
|
||||||
|
except re.error as e:
|
||||||
|
return {"error": f"Invalid regex: {e}"}
|
||||||
|
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO break_patterns (pattern_type, pattern)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
ON CONFLICT (pattern_type, pattern) DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(ptype, pattern),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return {"error": "Pattern already exists"}
|
||||||
|
return {"ok": True, "id": row[0]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/api/break-patterns/{pid}")
|
||||||
|
async def update_break_pattern(pid: int, request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
if "enabled" in body:
|
||||||
|
cur.execute("UPDATE break_patterns SET enabled = %s WHERE id = %s", (bool(body["enabled"]), pid))
|
||||||
|
if "pattern" in body:
|
||||||
|
new_pat = (body.get("pattern") or "").strip()
|
||||||
|
cur.execute("SELECT pattern_type FROM break_patterns WHERE id = %s", (pid,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row and row[0] == "regex":
|
||||||
|
try:
|
||||||
|
re.compile(new_pat)
|
||||||
|
except re.error as e:
|
||||||
|
return {"error": f"Invalid regex: {e}"}
|
||||||
|
cur.execute("UPDATE break_patterns SET pattern = %s WHERE id = %s", (new_pat, pid))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/break-patterns/{pid}")
|
||||||
|
async def delete_break_pattern(pid: int):
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("DELETE FROM break_patterns WHERE id = %s", (pid,))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/reading-history")
|
||||||
|
async def reset_reading_history():
|
||||||
|
with get_db_conn() as conn:
|
||||||
|
with conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("DELETE FROM reading_sessions")
|
||||||
|
return {"ok": True}
|
||||||
17
containers/novela/scrapers/__init__.py
Normal file
17
containers/novela/scrapers/__init__.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from .base import BaseScraper
|
||||||
|
from .awesomedude import AwesomeDudeScraper
|
||||||
|
from .gayauthors import GayAuthorsScraper
|
||||||
|
|
||||||
|
# Register scrapers in priority order (first match wins)
|
||||||
|
_SCRAPERS: list[type[BaseScraper]] = [
|
||||||
|
AwesomeDudeScraper,
|
||||||
|
GayAuthorsScraper,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_scraper(url: str) -> BaseScraper:
|
||||||
|
"""Return the appropriate scraper instance for the given URL."""
|
||||||
|
for scraper_cls in _SCRAPERS:
|
||||||
|
if scraper_cls.matches(url):
|
||||||
|
return scraper_cls()
|
||||||
|
raise ValueError(f"No scraper available for URL: {url}")
|
||||||
BIN
containers/novela/scrapers/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
containers/novela/scrapers/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
containers/novela/scrapers/__pycache__/base.cpython-311.pyc
Normal file
BIN
containers/novela/scrapers/__pycache__/base.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
265
containers/novela/scrapers/awesomedude.py
Normal file
265
containers/novela/scrapers/awesomedude.py
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
import re
|
||||||
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from .base import BaseScraper
|
||||||
|
|
||||||
|
LAYOUT_RE = re.compile(
|
||||||
|
r"nav|menu|sidebar|header|footer|breadcrumb|pagination|"
|
||||||
|
r"comment|widget|aside|banner|ad|rating|follow|share",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
GENERIC_PAGE_TITLES = {"awesomedude home"}
|
||||||
|
|
||||||
|
|
||||||
|
class AwesomeDudeScraper(BaseScraper):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def matches(cls, url: str) -> bool:
|
||||||
|
return "awesomedude.org" in url
|
||||||
|
|
||||||
|
async def login(self, client: httpx.AsyncClient, username: str, password: str) -> bool:
|
||||||
|
return True # no login required
|
||||||
|
|
||||||
|
async def fetch_book_info(self, client: httpx.AsyncClient, url: str) -> dict:
|
||||||
|
r = await client.get(url)
|
||||||
|
soup = BeautifulSoup(r.text, "html.parser")
|
||||||
|
actual_url = str(r.url)
|
||||||
|
|
||||||
|
def _clean_title_from_url(page_url: str) -> str:
|
||||||
|
filename = urlparse(page_url).path.rsplit("/", 1)[-1]
|
||||||
|
stem = re.sub(r"\.html?$", "", filename, flags=re.I)
|
||||||
|
stem = stem.replace("_", " ").replace("-", " ").strip()
|
||||||
|
return stem.title() if stem else "Unknown title"
|
||||||
|
|
||||||
|
def _extract_author_from_byline(text: str) -> str | None:
|
||||||
|
line = re.sub(r"\s+", " ", text).strip()
|
||||||
|
m = re.match(r"^by\s+([A-Za-z][A-Za-z .'\-]{1,80})$", line, re.I)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return m.group(1).strip(" .,-")
|
||||||
|
|
||||||
|
# ── Title and author ──────────────────────────────────────────────
|
||||||
|
# Primary: <title> "Story Title by Author Name"
|
||||||
|
book_title = "Unknown title"
|
||||||
|
author = "Unknown author"
|
||||||
|
page_title_el = soup.find("title")
|
||||||
|
page_title_text = page_title_el.get_text(strip=True) if page_title_el else ""
|
||||||
|
m = re.match(r"^(.+?)\s+by\s+(.+)$", page_title_text, re.I)
|
||||||
|
if m:
|
||||||
|
book_title = m.group(1).strip()
|
||||||
|
author = m.group(2).strip()
|
||||||
|
elif page_title_text and page_title_text.strip().lower() not in GENERIC_PAGE_TITLES:
|
||||||
|
book_title = page_title_text
|
||||||
|
|
||||||
|
# Fallback: first h1 or h2 with "by" pattern
|
||||||
|
if author == "Unknown author":
|
||||||
|
for tag in soup.find_all(["h1", "h2"]):
|
||||||
|
text = tag.get_text(strip=True)
|
||||||
|
m = re.match(r"^(.+?)\s+by\s+(.+)$", text, re.I)
|
||||||
|
if m:
|
||||||
|
book_title = m.group(1).strip()
|
||||||
|
author = m.group(2).strip()
|
||||||
|
break
|
||||||
|
|
||||||
|
# Fallback: byline text ("By Author Name") in body.
|
||||||
|
if author == "Unknown author":
|
||||||
|
for text_node in soup.find_all(string=True):
|
||||||
|
candidate = _extract_author_from_byline(str(text_node))
|
||||||
|
if candidate:
|
||||||
|
author = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
# If page title is generic, derive story title from the URL slug.
|
||||||
|
if book_title == "Unknown title":
|
||||||
|
book_title = _clean_title_from_url(actual_url)
|
||||||
|
|
||||||
|
# ── Index image ───────────────────────────────────────────────────
|
||||||
|
# First image on the page that is not a tiny icon/button and is on
|
||||||
|
# the same domain. Used as an illustration in the Book Info page.
|
||||||
|
index_image_url = None
|
||||||
|
page_host = urlparse(actual_url).netloc
|
||||||
|
for img in soup.find_all("img"):
|
||||||
|
src = img.get("src", "")
|
||||||
|
if not src or src.startswith("data:"):
|
||||||
|
continue
|
||||||
|
full_src = urljoin(actual_url, src)
|
||||||
|
if urlparse(full_src).netloc != page_host:
|
||||||
|
continue
|
||||||
|
# Skip obviously tiny elements (buttons / spacers)
|
||||||
|
try:
|
||||||
|
if img.get("width") and int(img["width"]) < 60:
|
||||||
|
continue
|
||||||
|
if img.get("height") and int(img["height"]) < 60:
|
||||||
|
continue
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
index_image_url = full_src
|
||||||
|
break
|
||||||
|
|
||||||
|
# ── Chapter discovery ─────────────────────────────────────────────
|
||||||
|
# Scan for links to .htm/.html files in the same directory, excluding
|
||||||
|
# the index page itself.
|
||||||
|
base_dir = actual_url.rsplit("/", 1)[0] + "/"
|
||||||
|
chapter_links: list[dict] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for a in soup.find_all("a", href=True):
|
||||||
|
full = urljoin(actual_url, a["href"])
|
||||||
|
if (full.startswith(base_dir)
|
||||||
|
and re.search(r"\.html?(\?.*)?$", full, re.I)
|
||||||
|
and not re.search(r"/index\.html?$", full, re.I)
|
||||||
|
and full not in seen):
|
||||||
|
seen.add(full)
|
||||||
|
text = re.sub(r'\s+', ' ', a.get_text(separator=' ')).strip()
|
||||||
|
chapter_links.append({"url": full, "title": text, "book_title": book_title, "author": author})
|
||||||
|
|
||||||
|
if not chapter_links:
|
||||||
|
# Single-file story: the index page itself is the only chapter
|
||||||
|
chapter_links = [{"url": actual_url, "title": book_title, "book_title": book_title, "author": author}]
|
||||||
|
chapter_method = "single_page"
|
||||||
|
else:
|
||||||
|
chapter_method = "html_scan"
|
||||||
|
for i, c in enumerate(chapter_links, 1):
|
||||||
|
t = c["title"]
|
||||||
|
if re.match(r"^\d+$", t):
|
||||||
|
c["title"] = f"Chapter {t}"
|
||||||
|
elif not t or t.lower() == book_title.lower():
|
||||||
|
c["title"] = f"Chapter {i}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": book_title,
|
||||||
|
"author": author,
|
||||||
|
"publisher": "awesomedude.org",
|
||||||
|
"series": "",
|
||||||
|
"series_index_hint": 0,
|
||||||
|
"genres": [],
|
||||||
|
"subgenres": [],
|
||||||
|
"tags": [],
|
||||||
|
"description": "",
|
||||||
|
"updated_date": "",
|
||||||
|
"publication_status": "",
|
||||||
|
"source_url": url,
|
||||||
|
"chapters": chapter_links,
|
||||||
|
"chapter_method": chapter_method,
|
||||||
|
"index_image_url": index_image_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fetch_chapter(self, client: httpx.AsyncClient, ch: dict) -> dict:
|
||||||
|
cr = await client.get(ch["url"])
|
||||||
|
csoup = BeautifulSoup(cr.text, "html.parser")
|
||||||
|
title = ch["title"]
|
||||||
|
book_title_lc = ch.get("book_title", "").lower()
|
||||||
|
author_lc = ch.get("author", "").lower()
|
||||||
|
|
||||||
|
# Try to refine chapter title from an in-page heading,
|
||||||
|
# but skip the book title and "by Author" headings.
|
||||||
|
for tag in csoup.find_all(["h1", "h2", "h3"]):
|
||||||
|
text = re.sub(r'\s+', ' ', tag.get_text(separator=' ')).strip()
|
||||||
|
if not text or len(text) >= 120:
|
||||||
|
continue
|
||||||
|
text_lc = text.lower()
|
||||||
|
if re.search(r"\s+by\s+", text, re.I):
|
||||||
|
continue
|
||||||
|
if book_title_lc and book_title_lc in text_lc:
|
||||||
|
continue
|
||||||
|
if author_lc and author_lc in text_lc:
|
||||||
|
continue
|
||||||
|
title = text
|
||||||
|
break
|
||||||
|
|
||||||
|
# Content extraction: prefer an element with a content-like id/class;
|
||||||
|
# fall back to the largest div/section not tagged as layout.
|
||||||
|
content_el = (
|
||||||
|
csoup.find(id=re.compile(r"^(chapter|story|content|text)[_-]?", re.I))
|
||||||
|
or csoup.find(class_=re.compile(r"story.?text|chapter.?text|post.?content|entry.?content", re.I))
|
||||||
|
or csoup.find("article")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not content_el:
|
||||||
|
candidates = [
|
||||||
|
el for el in csoup.find_all(["div", "article", "section"])
|
||||||
|
if not re.search(LAYOUT_RE, " ".join(el.get("class", [])))
|
||||||
|
and not re.search(LAYOUT_RE, el.get("id", ""))
|
||||||
|
]
|
||||||
|
if candidates:
|
||||||
|
content_el = max(candidates, key=lambda el: len(el.get_text(" ", strip=True)))
|
||||||
|
|
||||||
|
body = csoup.find("body")
|
||||||
|
if body:
|
||||||
|
body_p_count = len(body.find_all("p"))
|
||||||
|
body_text_len = len(body.get_text(" ", strip=True))
|
||||||
|
selected_p_count = len(content_el.find_all("p")) if content_el else 0
|
||||||
|
selected_text_len = len(content_el.get_text(" ", strip=True)) if content_el else 0
|
||||||
|
|
||||||
|
# Some awesomedude pages keep story text as direct <p> children of body.
|
||||||
|
# If the selected container is too small, use body instead.
|
||||||
|
if body_p_count >= 6 and (
|
||||||
|
not content_el
|
||||||
|
or selected_p_count < 3
|
||||||
|
or selected_text_len < int(body_text_len * 0.35)
|
||||||
|
):
|
||||||
|
content_el = body
|
||||||
|
|
||||||
|
# Last resort: entire body
|
||||||
|
if not content_el:
|
||||||
|
content_el = body
|
||||||
|
|
||||||
|
# Remove known site nav containers when body is used as content root.
|
||||||
|
if content_el and content_el.name == "body":
|
||||||
|
for nav_el in content_el.select(".storynavbg, .storynav, .storynavlink, .clearme"):
|
||||||
|
nav_el.decompose()
|
||||||
|
|
||||||
|
# Strip leading non-story front matter (title/byline/header image blocks).
|
||||||
|
# Keep the first substantial story paragraph intact.
|
||||||
|
if content_el:
|
||||||
|
normalized_title = re.sub(r"\s+", " ", book_title_lc).strip()
|
||||||
|
normalized_author = re.sub(r"\s+", " ", author_lc).strip()
|
||||||
|
|
||||||
|
for child in list(content_el.children):
|
||||||
|
if not hasattr(child, "get_text"):
|
||||||
|
continue # NavigableString whitespace
|
||||||
|
|
||||||
|
text = child.get_text(" ", strip=True)
|
||||||
|
if not text:
|
||||||
|
if hasattr(child, "decompose"):
|
||||||
|
child.decompose()
|
||||||
|
continue
|
||||||
|
|
||||||
|
text_lc_norm = re.sub(r"\s+", " ", text.lower()).strip()
|
||||||
|
is_byline = bool(re.match(r"^by\s+[A-Za-z]", text, re.I))
|
||||||
|
is_title_line = bool(
|
||||||
|
normalized_title
|
||||||
|
and (
|
||||||
|
text_lc_norm == normalized_title
|
||||||
|
or text_lc_norm.startswith(f"{normalized_title} by ")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is_author_line = bool(
|
||||||
|
normalized_author
|
||||||
|
and (
|
||||||
|
text_lc_norm == normalized_author
|
||||||
|
or text_lc_norm == f"by {normalized_author}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is_media_only = getattr(child, "find", lambda *_: None)("img") is not None and len(text_lc_norm) < 80
|
||||||
|
|
||||||
|
if (
|
||||||
|
(is_title_line and len(text_lc_norm) <= 200)
|
||||||
|
or (is_author_line and len(text_lc_norm) <= 120)
|
||||||
|
or (is_byline and len(text_lc_norm) <= 120)
|
||||||
|
or is_media_only
|
||||||
|
):
|
||||||
|
child.decompose()
|
||||||
|
continue
|
||||||
|
|
||||||
|
break # first substantive paragraph reached
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": title,
|
||||||
|
"content_el": content_el,
|
||||||
|
"selector_id": content_el.get("id") if content_el else None,
|
||||||
|
"selector_class": " ".join(content_el.get("class", [])) if content_el else None,
|
||||||
|
}
|
||||||
58
containers/novela/scrapers/base.py
Normal file
58
containers/novela/scrapers/base.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BaseScraper(ABC):
|
||||||
|
"""Abstract base class for all site scrapers.
|
||||||
|
|
||||||
|
To add support for a new site:
|
||||||
|
1. Create a new file in scrapers/ (e.g. scrapers/mysite.py)
|
||||||
|
2. Subclass BaseScraper and implement all abstract methods
|
||||||
|
3. Register the class in scrapers/__init__.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
def matches(cls, url: str) -> bool:
|
||||||
|
"""Return True if this scraper handles the given URL."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def login(self, client: httpx.AsyncClient, username: str, password: str) -> bool:
|
||||||
|
"""Perform site login. Returns True on success, False on failure."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def fetch_book_info(self, client: httpx.AsyncClient, url: str) -> dict:
|
||||||
|
"""Fetch the story index page and return all book metadata + chapter list.
|
||||||
|
|
||||||
|
Returns a dict with:
|
||||||
|
title str — book title
|
||||||
|
author str — author name
|
||||||
|
genres list[str] — primary genres
|
||||||
|
subgenres list[str] — sub-genres
|
||||||
|
tags list[str] — tags
|
||||||
|
description str — blurb / synopsis
|
||||||
|
updated_date str — last updated, YYYY-MM-DD or ""
|
||||||
|
source_url str — canonical story URL
|
||||||
|
chapters list[dict] — [{url: str, title: str}, ...]
|
||||||
|
chapter_method str — "html_scan" or "fallback_numeric"
|
||||||
|
|
||||||
|
Note: cover is not scraped. It is supplied by the user at convert time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def fetch_chapter(self, client: httpx.AsyncClient, ch: dict) -> dict:
|
||||||
|
"""Fetch a chapter page and extract its content element.
|
||||||
|
|
||||||
|
ch is a dict with at least {url: str, title: str}.
|
||||||
|
|
||||||
|
Returns a dict with:
|
||||||
|
title str — chapter title (may be refined from slug/heading)
|
||||||
|
content_el Tag|None — BeautifulSoup element containing story text
|
||||||
|
selector_id str|None — id attribute of the matched element (for debug)
|
||||||
|
selector_class str|None — class string of the matched element (for debug)
|
||||||
|
"""
|
||||||
236
containers/novela/scrapers/gayauthors.py
Normal file
236
containers/novela/scrapers/gayauthors.py
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
import re
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from .base import BaseScraper
|
||||||
|
|
||||||
|
GA_BASE = "https://www.gayauthors.org"
|
||||||
|
|
||||||
|
LAYOUT_RE = re.compile(
|
||||||
|
r"nav|menu|sidebar|header|footer|breadcrumb|pagination|"
|
||||||
|
r"comment|react|widget|aside|banner|ad|rating|follow|tag|share",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GayAuthorsScraper(BaseScraper):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def matches(cls, url: str) -> bool:
|
||||||
|
return "gayauthors.org" in url
|
||||||
|
|
||||||
|
async def login(self, client: httpx.AsyncClient, username: str, password: str) -> bool:
|
||||||
|
# GET login page first to follow any www. → non-www. redirect, then POST to that URL
|
||||||
|
lr = await client.get(GA_BASE + "/login/")
|
||||||
|
login_post_url = str(lr.url)
|
||||||
|
lsoup = BeautifulSoup(lr.text, "html.parser")
|
||||||
|
csrf_input = lsoup.find("input", {"name": "csrfKey"})
|
||||||
|
csrf = csrf_input.get("value", "") if csrf_input else ""
|
||||||
|
await client.post(login_post_url, data={
|
||||||
|
"auth": username,
|
||||||
|
"password": password,
|
||||||
|
"csrfKey": csrf,
|
||||||
|
"remember_me": "1",
|
||||||
|
"_processLogin": "usernamepassword",
|
||||||
|
"submit": "Sign In",
|
||||||
|
})
|
||||||
|
return any(c.name == "ips4_member_id" for c in client.cookies.jar)
|
||||||
|
|
||||||
|
async def fetch_book_info(self, client: httpx.AsyncClient, url: str) -> dict:
|
||||||
|
r = await client.get(url)
|
||||||
|
soup = BeautifulSoup(r.text, "html.parser")
|
||||||
|
|
||||||
|
# Title — use itemprop="name" to avoid badge text being included
|
||||||
|
title_el = soup.find(attrs={"itemprop": "name"}) or soup.find("h1") or soup.find("h2")
|
||||||
|
book_title = title_el.get_text(strip=True) if title_el else "Unknown title"
|
||||||
|
|
||||||
|
# Series — detect badge in h1; two known tooltip formats:
|
||||||
|
# 'Book "N" of the "Series Name" series'
|
||||||
|
# 'This Story belongs to the world of: Series Name'
|
||||||
|
series = ""
|
||||||
|
series_index_hint = 0
|
||||||
|
h1 = soup.find("h1")
|
||||||
|
if h1:
|
||||||
|
badge = h1.find(class_=re.compile(r"\bipsBadge\b"))
|
||||||
|
if badge:
|
||||||
|
tooltip = badge.get("title", "")
|
||||||
|
m = re.search(r'Book\s+"(\d+)"\s+of\s+the\s+"(.+?)"\s+series', tooltip, re.I)
|
||||||
|
if m:
|
||||||
|
series_index_hint = int(m.group(1))
|
||||||
|
series = m.group(2).strip() + " (Series)"
|
||||||
|
else:
|
||||||
|
m = re.search(r"belongs to the world of:\s*(.+)", tooltip, re.I)
|
||||||
|
if m:
|
||||||
|
series = m.group(1).strip() + " (Series)"
|
||||||
|
|
||||||
|
# Author
|
||||||
|
author_el = (
|
||||||
|
soup.find(attrs={"itemprop": "author"})
|
||||||
|
or soup.find("a", {"rel": "author"})
|
||||||
|
or soup.find(class_=re.compile(r"author", re.I))
|
||||||
|
)
|
||||||
|
author = author_el.get_text(strip=True) if author_el else "Unknown author"
|
||||||
|
|
||||||
|
# Genres and sub-genres
|
||||||
|
genres: list[str] = []
|
||||||
|
subgenres: list[str] = []
|
||||||
|
for div in soup.find_all("div"):
|
||||||
|
div_text = div.get_text(separator=" ", strip=True)
|
||||||
|
if re.match(r"^Genres\s*:", div_text) and not re.match(r"^Sub-?genres\s*:", div_text):
|
||||||
|
genres = [a.get_text(strip=True) for a in div.find_all("a", attrs={"itemprop": "genre"})]
|
||||||
|
elif re.match(r"^Sub-?genres\s*:", div_text):
|
||||||
|
subgenres = [a.get_text(strip=True) for a in div.find_all("a", attrs={"itemprop": "genre"})]
|
||||||
|
|
||||||
|
# Tags
|
||||||
|
tags: list[str] = []
|
||||||
|
tags_ul = soup.find("ul", class_=re.compile(r"\bipsTags\b", re.I))
|
||||||
|
if tags_ul:
|
||||||
|
tags = [
|
||||||
|
span.get_text(strip=True).title()
|
||||||
|
for span in tags_ul.find_all("span", class_="ipsTag")
|
||||||
|
]
|
||||||
|
|
||||||
|
# Description (from ipsComment_content > ipsColumn_fluid)
|
||||||
|
description = ""
|
||||||
|
desc_container = soup.find(class_="ipsComment_content")
|
||||||
|
if desc_container:
|
||||||
|
fluid = desc_container.find(class_="ipsColumn_fluid")
|
||||||
|
if fluid:
|
||||||
|
paras = [
|
||||||
|
p.get_text(strip=True) for p in fluid.find_all("p")
|
||||||
|
if p.get_text().replace("\xa0", "").strip()
|
||||||
|
]
|
||||||
|
description = "\n\n".join(paras)
|
||||||
|
|
||||||
|
# Updated date: "Updated: MM/DD/YYYY" → "YYYY-MM-DD"
|
||||||
|
updated_date = ""
|
||||||
|
date_match = re.search(r"Updated:\s*(\d{2}/\d{2}/\d{4})", soup.get_text())
|
||||||
|
if date_match:
|
||||||
|
m, d, y = date_match.group(1).split("/")
|
||||||
|
updated_date = f"{y}-{m}-{d}"
|
||||||
|
|
||||||
|
# Publication status — ipsBadge_style* link inside /stories/browse/status/ href
|
||||||
|
status_el = soup.find("a", class_=re.compile(r"\bipsBadge_style\d+\b"),
|
||||||
|
href=re.compile(r"/stories/browse/status/", re.I))
|
||||||
|
publication_status = status_el.get_text(strip=True) if status_el else ""
|
||||||
|
|
||||||
|
# Chapter discovery
|
||||||
|
# Primary: scan HTML for direct child path segments of the story URL
|
||||||
|
base_story_url = url.rstrip("/")
|
||||||
|
base_norm = re.sub(r"://www\.", "://", base_story_url)
|
||||||
|
actual_page_url = str(r.url)
|
||||||
|
chapter_links = []
|
||||||
|
for a in soup.find_all("a", href=True):
|
||||||
|
href = a["href"]
|
||||||
|
full = urljoin(actual_page_url, href)
|
||||||
|
full_norm = re.sub(r"://www\.", "://", full)
|
||||||
|
suffix = full_norm[len(base_norm):]
|
||||||
|
if full_norm.startswith(base_norm + "/") and re.match(r"^/[\w-]+/?$", suffix) and "?" not in full:
|
||||||
|
text = re.sub(r"^\s*First\s+Chapter\s*", "", a.get_text(strip=True), flags=re.I).strip()
|
||||||
|
if full not in [c["url"] for c in chapter_links] and text:
|
||||||
|
chapter_links.append({"url": full, "title": text})
|
||||||
|
# Normalise "N. Title" → "Chapter N - Title"; if remainder already starts with "Chapter N", just strip prefix
|
||||||
|
# Titles with no leading number get "Chapter N - " from the URL slug (e.g. first chapter after badge strip)
|
||||||
|
for c in chapter_links:
|
||||||
|
m = re.match(r"^(\d+)[\.\)]\s*(.+)$", c["title"])
|
||||||
|
if m:
|
||||||
|
num, rest = m.group(1), m.group(2).strip()
|
||||||
|
if re.match(r"Chapter\s+\d", rest, re.I):
|
||||||
|
c["title"] = rest
|
||||||
|
else:
|
||||||
|
c["title"] = f"Chapter {num} - {rest}"
|
||||||
|
elif not re.match(r"Chapter\s+\d", c["title"], re.I):
|
||||||
|
slug = c["url"].rstrip("/").split("/")[-1]
|
||||||
|
if slug.isdigit():
|
||||||
|
c["title"] = f"Chapter {slug} - {c['title']}"
|
||||||
|
|
||||||
|
# Fallback: sequential numeric URLs based on chapter count from page text
|
||||||
|
fallback_used = False
|
||||||
|
if not chapter_links:
|
||||||
|
fallback_used = True
|
||||||
|
count_match = re.search(r'(\d+)\s+[Cc]hapters?', soup.get_text())
|
||||||
|
chapter_count = int(count_match.group(1)) if count_match else 0
|
||||||
|
chapter_links = [
|
||||||
|
{"url": f"{base_story_url}/{i}/", "title": f"Chapter {i}"}
|
||||||
|
for i in range(1, chapter_count + 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Strip series name prefix from title (e.g. "Series Name: Actual Title" → "Actual Title")
|
||||||
|
if series:
|
||||||
|
series_base = re.sub(r"\s*\(Series\)$", "", series, flags=re.I).strip()
|
||||||
|
book_title = re.sub(
|
||||||
|
r"^" + re.escape(series_base) + r"\s*[:–—-]\s*",
|
||||||
|
"", book_title, flags=re.I
|
||||||
|
).strip() or book_title
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": book_title,
|
||||||
|
"author": author,
|
||||||
|
"publisher": "Gay Author Story Archive",
|
||||||
|
"series": series,
|
||||||
|
"series_index_hint": series_index_hint,
|
||||||
|
"genres": genres,
|
||||||
|
"subgenres": subgenres,
|
||||||
|
"tags": tags,
|
||||||
|
"description": description,
|
||||||
|
"updated_date": updated_date,
|
||||||
|
"publication_status": publication_status,
|
||||||
|
"source_url": url,
|
||||||
|
"chapters": chapter_links,
|
||||||
|
"chapter_method": "fallback_numeric" if fallback_used else "html_scan",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fetch_chapter(self, client: httpx.AsyncClient, ch: dict) -> dict:
|
||||||
|
cr = await client.get(ch["url"])
|
||||||
|
csoup = BeautifulSoup(cr.text, "html.parser")
|
||||||
|
|
||||||
|
# Derive title from h1#chapterTitle (e.g. "Story - 1. My Name is Nick")
|
||||||
|
title = ch["title"]
|
||||||
|
chapter_h1 = csoup.find("h1", id="chapterTitle")
|
||||||
|
if chapter_h1:
|
||||||
|
raw = chapter_h1.get_text(strip=True)
|
||||||
|
m = re.search(r"(\d+)[\.\)]\s*(.+)$", raw)
|
||||||
|
if m:
|
||||||
|
num, rest = m.group(1), m.group(2).strip()
|
||||||
|
# Avoid "Chapter 1 - Chapter 1:" when rest already names the chapter
|
||||||
|
if re.match(r"Chapter\s+\d", rest, re.I):
|
||||||
|
title = rest
|
||||||
|
else:
|
||||||
|
title = f"Chapter {num} - {rest}"
|
||||||
|
elif title.startswith("Chapter "):
|
||||||
|
# Fallback: refine generic "Chapter N" placeholder from slug or heading
|
||||||
|
slug = str(cr.url).rstrip("/").split("/")[-1]
|
||||||
|
slug_title = re.sub(r"^\d+-", "", slug).replace("-", " ").title()
|
||||||
|
if slug_title and not slug_title.strip().isdigit():
|
||||||
|
title = slug_title
|
||||||
|
title_heading = csoup.find(class_=re.compile(r"chapter.?title|entry.?title|post.?title", re.I))
|
||||||
|
if title_heading:
|
||||||
|
title = title_heading.get_text(strip=True)
|
||||||
|
|
||||||
|
# Content extraction (most-specific to least-specific)
|
||||||
|
content_el = (
|
||||||
|
csoup.find(id=re.compile(r"^(chapter|story)[_-]?text$", re.I))
|
||||||
|
or csoup.find(attrs={"data-role": re.compile(r"chapter|story|content|text", re.I)})
|
||||||
|
or csoup.find(class_=re.compile(
|
||||||
|
r"cBBCodePost|ipsBBCode|cBBCode|story.?text|chapter.?text|post.?content|entry.?content", re.I
|
||||||
|
))
|
||||||
|
or csoup.find("article")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not content_el:
|
||||||
|
candidates = [
|
||||||
|
el for el in csoup.find_all(["div", "article", "section"])
|
||||||
|
if not re.search(LAYOUT_RE, " ".join(el.get("class", [])))
|
||||||
|
and not re.search(LAYOUT_RE, el.get("id", ""))
|
||||||
|
]
|
||||||
|
if candidates:
|
||||||
|
content_el = max(candidates, key=lambda el: len(el.get_text()))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": title,
|
||||||
|
"content_el": content_el,
|
||||||
|
"selector_id": content_el.get("id") if content_el else None,
|
||||||
|
"selector_class": " ".join(content_el.get("class", [])) if content_el else None,
|
||||||
|
}
|
||||||
43
containers/novela/security.py
Normal file
43
containers/novela/security.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
_PREFIX = "enc$"
|
||||||
|
|
||||||
|
|
||||||
|
def _master_secret() -> str:
|
||||||
|
return (
|
||||||
|
os.environ.get("NOVELA_MASTER_KEY")
|
||||||
|
or os.environ.get("POSTGRES_PASSWORD")
|
||||||
|
or "novela-default-key-change-me"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fernet() -> Fernet:
|
||||||
|
digest = hashlib.sha256(_master_secret().encode("utf-8")).digest()
|
||||||
|
key = base64.urlsafe_b64encode(digest)
|
||||||
|
return Fernet(key)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_value(value: str | None) -> str:
|
||||||
|
raw = value or ""
|
||||||
|
token = _fernet().encrypt(raw.encode("utf-8")).decode("utf-8")
|
||||||
|
return _PREFIX + token
|
||||||
|
|
||||||
|
|
||||||
|
def is_encrypted_value(value: str | None) -> bool:
|
||||||
|
return bool(value) and str(value).startswith(_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_value(value: str | None) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
if not value.startswith(_PREFIX):
|
||||||
|
return value
|
||||||
|
token = value[len(_PREFIX) :]
|
||||||
|
try:
|
||||||
|
return _fernet().decrypt(token.encode("utf-8")).decode("utf-8")
|
||||||
|
except InvalidToken:
|
||||||
|
return ""
|
||||||
282
containers/novela/static/book.css
Normal file
282
containers/novela/static/book.css
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
/* ── Novela — Book detail page styles ─────────────────────────────────── */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b;
|
||||||
|
--border: #2e2a24; --accent: #c8783a; --accent2: #e8a063;
|
||||||
|
--text: #e8e2d9; --text-dim: #8a8278; --text-faint: #4a453e;
|
||||||
|
--success: #6baa6b; --warning: #c8a03a; --error: #c85a3a;
|
||||||
|
--radius: 6px; --sidebar: 220px;
|
||||||
|
--mono: 'DM Mono', monospace; --serif: 'Libre Baskerville', Georgia, serif;
|
||||||
|
}
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
|
||||||
|
|
||||||
|
.main {
|
||||||
|
margin-left: var(--sidebar);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem 2.5rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero layout ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.book-hero {
|
||||||
|
display: flex;
|
||||||
|
gap: 2.5rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cover */
|
||||||
|
.cover-area { flex-shrink: 0; }
|
||||||
|
.cover-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 180px; height: 270px;
|
||||||
|
border-radius: var(--radius); overflow: hidden; background: var(--surface2);
|
||||||
|
}
|
||||||
|
.cover-wrap img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.cover-wrap canvas { width: 100%; height: 100%; display: block; }
|
||||||
|
|
||||||
|
/* Want to Read star under cover */
|
||||||
|
.btn-wtr {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
background: none; border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 0.35rem 0.7rem;
|
||||||
|
font-family: var(--mono); font-size: 0.7rem;
|
||||||
|
color: var(--text-dim); cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
width: 100%; justify-content: center;
|
||||||
|
}
|
||||||
|
.btn-wtr:hover { color: var(--warning); border-color: var(--warning); }
|
||||||
|
.btn-wtr.active { color: var(--warning); border-color: var(--warning); }
|
||||||
|
|
||||||
|
/* Info panel */
|
||||||
|
.book-info { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.book-title {
|
||||||
|
font-size: 1.6rem; font-weight: 700; line-height: 1.2; margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.book-author {
|
||||||
|
font-family: var(--mono); font-size: 0.85rem; color: var(--text-dim); margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.book-author a { color: inherit; text-decoration: none; }
|
||||||
|
.book-author a:hover { color: var(--accent2); }
|
||||||
|
.publisher-link { color: inherit; text-decoration: none; }
|
||||||
|
.publisher-link:hover { color: var(--accent2); }
|
||||||
|
|
||||||
|
.meta-grid { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 1.25rem; }
|
||||||
|
.meta-row { display: flex; gap: 0.75rem; font-family: var(--mono); font-size: 0.78rem; }
|
||||||
|
.meta-label { color: var(--text-dim); min-width: 7rem; flex-shrink: 0; }
|
||||||
|
.meta-value { color: var(--text); }
|
||||||
|
|
||||||
|
.book-description {
|
||||||
|
white-space: break-spaces;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-dim);
|
||||||
|
width: 100%;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-pill {
|
||||||
|
display: inline-block; font-family: var(--mono); font-size: 0.62rem;
|
||||||
|
padding: 0.15rem 0.5rem; border-radius: 3px;
|
||||||
|
background: var(--surface2); color: var(--text-dim);
|
||||||
|
border: 1px solid var(--border); margin: 0 0.2rem 0.2rem 0;
|
||||||
|
}
|
||||||
|
a.tag-pill {
|
||||||
|
text-decoration: none; cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
a.tag-pill:hover { color: var(--accent2); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-block; font-family: var(--mono); font-size: 0.62rem;
|
||||||
|
padding: 0.15rem 0.5rem; border-radius: 3px;
|
||||||
|
}
|
||||||
|
.status-complete { background: rgba(107,170,107,0.12); color: var(--success); border: 1px solid rgba(107,170,107,0.25); }
|
||||||
|
.status-ongoing { background: rgba(200,160,58,0.12); color: var(--warning); border: 1px solid rgba(200,160,58,0.25); }
|
||||||
|
.status-hiatus { background: rgba(200,160,58,0.12); color: var(--warning); border: 1px solid rgba(200,160,58,0.25); }
|
||||||
|
|
||||||
|
/* Progress */
|
||||||
|
.progress-section { margin-bottom: 1.25rem; }
|
||||||
|
.progress-label { font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim); margin-bottom: 0.35rem; }
|
||||||
|
.progress-bar-wrap { height: 5px; background: var(--surface2); border-radius: 100px; overflow: hidden; margin-bottom: 0.3rem; }
|
||||||
|
.progress-bar-fill { height: 100%; background: var(--accent); border-radius: 100px; }
|
||||||
|
.progress-pct { font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* Reading stats */
|
||||||
|
.read-stats { font-family: var(--mono); font-size: 0.72rem; color: var(--text-dim); margin-bottom: 1.25rem; }
|
||||||
|
.read-stats span { color: var(--accent); }
|
||||||
|
|
||||||
|
/* Action buttons */
|
||||||
|
.action-row { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.5rem;
|
||||||
|
padding: 0.65rem 1.25rem;
|
||||||
|
background: var(--accent); color: #0f0e0c;
|
||||||
|
border: none; border-radius: var(--radius);
|
||||||
|
font-family: var(--mono); font-size: 0.8rem; font-weight: 500;
|
||||||
|
cursor: pointer; text-decoration: none; transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: var(--accent2); }
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.5rem;
|
||||||
|
padding: 0.65rem 1.25rem;
|
||||||
|
background: var(--surface2); color: var(--text-dim);
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
font-family: var(--mono); font-size: 0.8rem;
|
||||||
|
cursor: pointer; text-decoration: none;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { color: var(--text); border-color: var(--text-faint); }
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.5rem;
|
||||||
|
padding: 0.65rem 1.25rem;
|
||||||
|
background: var(--surface2); color: var(--error);
|
||||||
|
border: 1px solid rgba(200,90,58,0.35); border-radius: var(--radius);
|
||||||
|
font-family: var(--mono); font-size: 0.8rem;
|
||||||
|
cursor: pointer; text-decoration: none;
|
||||||
|
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: rgba(200,90,58,0.1); border-color: var(--error); }
|
||||||
|
|
||||||
|
/* ── Edit panel ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.edit-backdrop { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 200; }
|
||||||
|
.edit-backdrop.open { display: block; }
|
||||||
|
.edit-panel {
|
||||||
|
position: fixed; top: 0; right: -480px; width: 460px; max-width: 96vw;
|
||||||
|
height: 100vh; background: var(--surface); border-left: 1px solid var(--border);
|
||||||
|
padding: 1.75rem 1.5rem; overflow-y: auto; z-index: 201;
|
||||||
|
transition: right 0.22s ease; display: flex; flex-direction: column; gap: 1.1rem;
|
||||||
|
}
|
||||||
|
.edit-panel.open { right: 0; }
|
||||||
|
.edit-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.25rem; }
|
||||||
|
.edit-panel-title { font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent); }
|
||||||
|
.edit-close { background: none; border: none; color: var(--text-dim); cursor: pointer; padding: 0.2rem; line-height: 1; }
|
||||||
|
.edit-close:hover { color: var(--text); }
|
||||||
|
.edit-field { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
|
.edit-label { font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim); letter-spacing: 0.06em; text-transform: uppercase; }
|
||||||
|
.edit-input {
|
||||||
|
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
color: var(--text); font-family: var(--mono); font-size: 0.82rem;
|
||||||
|
padding: 0.5rem 0.7rem; outline: none; transition: border-color 0.15s; width: 100%;
|
||||||
|
}
|
||||||
|
.edit-input:focus { border-color: var(--accent); }
|
||||||
|
.edit-textarea {
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 7.5rem;
|
||||||
|
}
|
||||||
|
.edit-select {
|
||||||
|
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
color: var(--text); font-family: var(--mono); font-size: 0.82rem;
|
||||||
|
padding: 0.5rem 0.7rem; outline: none; width: 100%;
|
||||||
|
transition: border-color 0.15s; appearance: none;
|
||||||
|
}
|
||||||
|
.edit-select:focus { border-color: var(--accent); }
|
||||||
|
.edit-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
||||||
|
|
||||||
|
/* Genre tag input */
|
||||||
|
.genre-box {
|
||||||
|
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
padding: 0.35rem 0.5rem; display: flex; flex-wrap: wrap; gap: 0.35rem;
|
||||||
|
align-items: center; cursor: text; transition: border-color 0.15s; min-height: 2.5rem;
|
||||||
|
}
|
||||||
|
.genre-box:focus-within { border-color: var(--accent); }
|
||||||
|
.genre-tag {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.3rem;
|
||||||
|
background: var(--surface2); border: 1px solid var(--border);
|
||||||
|
border-radius: 3px; padding: 0.15rem 0.45rem;
|
||||||
|
font-family: var(--mono); font-size: 0.7rem; color: var(--text);
|
||||||
|
}
|
||||||
|
.genre-tag-x { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 0.85rem; line-height: 1; padding: 0; }
|
||||||
|
.genre-tag-x:hover { color: var(--error); }
|
||||||
|
.genre-input { background: none; border: none; outline: none; color: var(--text); font-family: var(--mono); font-size: 0.82rem; min-width: 120px; flex: 1; }
|
||||||
|
.genre-dropdown {
|
||||||
|
position: absolute; background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); max-height: 180px; overflow-y: auto;
|
||||||
|
z-index: 300; width: 100%; margin-top: 2px; box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.genre-option { padding: 0.45rem 0.75rem; font-family: var(--mono); font-size: 0.8rem; color: var(--text-dim); cursor: pointer; }
|
||||||
|
.genre-option:hover, .genre-option.active { background: var(--surface2); color: var(--text); }
|
||||||
|
.genre-wrap { position: relative; }
|
||||||
|
|
||||||
|
.edit-footer { display: flex; gap: 0.6rem; justify-content: flex-end; padding-top: 0.5rem; border-top: 1px solid var(--border); margin-top: auto; }
|
||||||
|
|
||||||
|
/* ── Modals ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.modal-backdrop { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 100; align-items: center; justify-content: center; }
|
||||||
|
.modal-backdrop.open { display: flex; }
|
||||||
|
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.5rem 2rem; max-width: 380px; width: 90%; }
|
||||||
|
.modal h3 { font-family: var(--mono); font-size: 0.9rem; margin-bottom: 0.75rem; }
|
||||||
|
.modal p { font-family: var(--mono); font-size: 0.75rem; color: var(--text-dim); margin-bottom: 1.25rem; line-height: 1.6; }
|
||||||
|
.modal-actions { display: flex; gap: 0.6rem; justify-content: flex-end; }
|
||||||
|
.modal-field { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 1.25rem; }
|
||||||
|
.modal-label { font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim); letter-spacing: 0.06em; text-transform: uppercase; }
|
||||||
|
.modal-input {
|
||||||
|
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
color: var(--text); font-family: var(--mono); font-size: 0.82rem;
|
||||||
|
padding: 0.5rem 0.7rem; outline: none; transition: border-color 0.15s; width: 100%; color-scheme: dark;
|
||||||
|
}
|
||||||
|
.modal-input:focus { border-color: var(--accent); }
|
||||||
|
.date-row { display: grid; grid-template-columns: 3fr 2fr 2fr; gap: 0.5rem; }
|
||||||
|
.date-time-row { margin-top: 0.75rem; }
|
||||||
|
.date-field { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||||
|
.date-sub-label { font-family: var(--mono); font-size: 0.6rem; color: var(--text-faint); }
|
||||||
|
.date-input {
|
||||||
|
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
color: var(--text); font-family: var(--mono); font-size: 0.82rem;
|
||||||
|
padding: 0.5rem 0.5rem; outline: none; width: 100%;
|
||||||
|
text-align: center; transition: border-color 0.15s; -moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
.date-input::-webkit-inner-spin-button,
|
||||||
|
.date-input::-webkit-outer-spin-button { -webkit-appearance: none; }
|
||||||
|
.date-input:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.divider { border: none; border-top: 1px solid var(--border); margin: 1.5rem 0; }
|
||||||
|
.section-label { font-family: var(--mono); font-size: 0.6rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-dim); margin-bottom: 0.75rem; }
|
||||||
|
.description-text { font-size: 0.88rem; color: var(--text-dim); line-height: 1.8; }
|
||||||
|
.description-text p + p { margin-top: 0.75rem; }
|
||||||
|
|
||||||
|
/* ── Responsive ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main {
|
||||||
|
margin-left: 0;
|
||||||
|
padding: 4rem 1rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-hero {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-wrap { width: 160px; height: 240px; }
|
||||||
|
.btn-wtr { width: 160px; }
|
||||||
|
|
||||||
|
.book-info { width: 100%; }
|
||||||
|
|
||||||
|
.book-title { font-size: 1.3rem; }
|
||||||
|
|
||||||
|
.meta-label { min-width: 5.5rem; }
|
||||||
|
|
||||||
|
.action-row { gap: 0.5rem; }
|
||||||
|
.btn-primary, .btn-secondary, .btn-danger {
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-panel { width: 100vw; max-width: 100vw; right: -100vw; }
|
||||||
|
}
|
||||||
304
containers/novela/static/book.js
Normal file
304
containers/novela/static/book.js
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
/* ── Novela — Book detail page script ─────────────────────────────────── */
|
||||||
|
/* Requires: BOOK global defined inline before this script is loaded */
|
||||||
|
|
||||||
|
const { filename, title, author } = BOOK;
|
||||||
|
|
||||||
|
// ── Placeholder cover ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const canvas = document.getElementById('cover-canvas');
|
||||||
|
canvas.width = 180;
|
||||||
|
canvas.height = 270;
|
||||||
|
|
||||||
|
const COVER_PALETTES = [
|
||||||
|
['#1a2a3a','#4a8caa'],['#2a1a1a','#aa4a4a'],['#1a2a1a','#4aaa6a'],['#2a1a2a','#8a4aaa'],
|
||||||
|
['#2a2a1a','#aaa04a'],['#1a2a2a','#4aaa9a'],['#2a1a14','#c8783a'],['#141a2a','#5a78c8'],
|
||||||
|
];
|
||||||
|
function strHash(s) {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
||||||
|
return Math.abs(h);
|
||||||
|
}
|
||||||
|
function makePlaceholderCover(cv, ttl, auth) {
|
||||||
|
const w = cv.width || 180, h = cv.height || 270;
|
||||||
|
const ctx = cv.getContext('2d');
|
||||||
|
const [bg, fg] = COVER_PALETTES[strHash(ttl) % COVER_PALETTES.length];
|
||||||
|
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||||
|
ctx.fillStyle = fg; ctx.globalAlpha = 0.15; ctx.fillRect(0, 0, w, h * 0.08); ctx.globalAlpha = 1;
|
||||||
|
ctx.fillStyle = fg; ctx.fillRect(w * 0.12, h * 0.12, w * 0.04, h * 0.55);
|
||||||
|
ctx.fillStyle = '#e8e2d9';
|
||||||
|
ctx.font = `bold ${Math.round(w * 0.105)}px 'Libre Baskerville', Georgia, serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
const words = ttl.split(' '); let line = '', lines = [];
|
||||||
|
for (const word of words) {
|
||||||
|
const test = line ? line + ' ' + word : word;
|
||||||
|
if (ctx.measureText(test).width > w * 0.72 && line) { lines.push(line); line = word; }
|
||||||
|
else line = test;
|
||||||
|
}
|
||||||
|
if (line) lines.push(line);
|
||||||
|
lines = lines.slice(0, 4);
|
||||||
|
const lineH = Math.round(w * 0.12);
|
||||||
|
const startY = h * 0.28 - ((lines.length - 1) * lineH) / 2;
|
||||||
|
lines.forEach((l, i) => ctx.fillText(l, w * 0.55, startY + i * lineH));
|
||||||
|
ctx.fillStyle = fg; ctx.font = `${Math.round(w * 0.075)}px 'DM Mono', monospace`;
|
||||||
|
ctx.globalAlpha = 0.85;
|
||||||
|
const a = auth.length > 18 ? auth.slice(0, 17) + '…' : auth;
|
||||||
|
ctx.fillText(a, w * 0.55, h * 0.86);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => makePlaceholderCover(canvas, title, author));
|
||||||
|
if (BOOK.has_cover) {
|
||||||
|
const img = document.getElementById('cover-img');
|
||||||
|
if (img && img.complete && img.naturalWidth > 0) canvas.style.display = 'none';
|
||||||
|
else if (img) img.onload = () => canvas.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Want to Read toggle ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function toggleWtr() {
|
||||||
|
const resp = await fetch(`/library/want-to-read/${encodeURIComponent(filename)}`, { method: 'POST' });
|
||||||
|
const result = await resp.json();
|
||||||
|
if (result.error) return;
|
||||||
|
const btn = document.getElementById('wtr-btn');
|
||||||
|
const svg = document.getElementById('wtr-svg');
|
||||||
|
btn.classList.toggle('active', result.want_to_read);
|
||||||
|
svg.setAttribute('fill', result.want_to_read ? 'currentColor' : 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mark as Read modal ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openMarkReadModal() {
|
||||||
|
const now = new Date();
|
||||||
|
document.getElementById('read-year').value = now.getFullYear();
|
||||||
|
document.getElementById('read-month').value = now.getMonth() + 1;
|
||||||
|
document.getElementById('read-day').value = now.getDate();
|
||||||
|
document.getElementById('read-time').value = '';
|
||||||
|
document.getElementById('mark-read-modal').classList.add('open');
|
||||||
|
document.getElementById('read-year').focus();
|
||||||
|
document.getElementById('read-year').select();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('read-year').addEventListener('input', function() {
|
||||||
|
if (this.value.length === 4) { const m = document.getElementById('read-month'); m.focus(); m.select(); }
|
||||||
|
});
|
||||||
|
document.getElementById('read-month').addEventListener('input', function() {
|
||||||
|
if (this.value.length === 2) { const d = document.getElementById('read-day'); d.focus(); d.select(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function confirmMarkRead() {
|
||||||
|
const year = document.getElementById('read-year').value;
|
||||||
|
const month = String(document.getElementById('read-month').value).padStart(2, '0');
|
||||||
|
const day = String(document.getElementById('read-day').value).padStart(2, '0');
|
||||||
|
const time = document.getElementById('read-time').value;
|
||||||
|
const body = (year && month && day)
|
||||||
|
? { read_at: `${year}-${month}-${day}T${time || '12:00'}:00` }
|
||||||
|
: {};
|
||||||
|
await fetch(`/library/mark-read/${encodeURIComponent(filename)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mark as unread ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function markUnread() {
|
||||||
|
const resp = await fetch(`/library/progress/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||||
|
if ((await resp.json()).ok) window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Archive toggle ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function toggleArchive() {
|
||||||
|
const resp = await fetch(`/library/archive/${encodeURIComponent(filename)}`, { method: 'POST' });
|
||||||
|
const result = await resp.json();
|
||||||
|
if (result.error) return;
|
||||||
|
const btn = document.getElementById('archive-btn');
|
||||||
|
btn.innerHTML = btn.innerHTML.replace(
|
||||||
|
result.archived ? 'Archive' : 'Unarchive',
|
||||||
|
result.archived ? 'Unarchive' : 'Archive'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add cover ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function uploadCover(input) {
|
||||||
|
const file = input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
const b64 = e.target.result.split(',')[1];
|
||||||
|
const resp = await fetch(`/library/cover/${encodeURIComponent(filename)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ cover_b64: b64 }),
|
||||||
|
});
|
||||||
|
if (resp.ok) window.location.reload();
|
||||||
|
else alert('Cover upload failed.');
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.getElementById('delete-title').textContent = title;
|
||||||
|
async function confirmDelete() {
|
||||||
|
const resp = await fetch(`/library/file/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||||
|
if (resp.ok) window.location.href = '/library';
|
||||||
|
else alert('Delete failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PillInput — reusable tag pill input with autocomplete ──────────────────
|
||||||
|
|
||||||
|
class PillInput {
|
||||||
|
constructor(boxId, inputId, dropdownId) {
|
||||||
|
this.box = document.getElementById(boxId);
|
||||||
|
this.input = document.getElementById(inputId);
|
||||||
|
this.dropdown = document.getElementById(dropdownId);
|
||||||
|
this.values = [];
|
||||||
|
this.all = [];
|
||||||
|
this.ddIndex = -1;
|
||||||
|
this.box.addEventListener('click', () => this.input.focus());
|
||||||
|
this.input.addEventListener('input', () => this._onInput());
|
||||||
|
this.input.addEventListener('keydown', (e) => this._onKeydown(e));
|
||||||
|
this.input.addEventListener('blur', () => setTimeout(() => this._hideDropdown(), 150));
|
||||||
|
}
|
||||||
|
|
||||||
|
set(values) { this.values = [...values]; this._render(); }
|
||||||
|
setSuggestions(all) { this.all = all; }
|
||||||
|
getValues() { return [...this.values]; }
|
||||||
|
|
||||||
|
_render() {
|
||||||
|
[...this.box.querySelectorAll('.genre-tag')].forEach(t => t.remove());
|
||||||
|
this.values.forEach((v, i) => {
|
||||||
|
const pill = document.createElement('span');
|
||||||
|
pill.className = 'genre-tag';
|
||||||
|
pill.innerHTML = `${v} <button class="genre-tag-x" type="button">×</button>`;
|
||||||
|
pill.querySelector('.genre-tag-x').onclick = () => { this.values.splice(i, 1); this._render(); };
|
||||||
|
this.box.insertBefore(pill, this.input);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_add(v) {
|
||||||
|
v = v.trim();
|
||||||
|
if (v && !this.values.includes(v)) { this.values.push(v); this._render(); }
|
||||||
|
this.input.value = '';
|
||||||
|
this._hideDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
_showDropdown(items) {
|
||||||
|
if (!items.length) { this.dropdown.style.display = 'none'; return; }
|
||||||
|
this.dropdown.innerHTML = items.map(g =>
|
||||||
|
`<div class="genre-option" data-val="${g.replace(/"/g,'"')}">${g}</div>`
|
||||||
|
).join('');
|
||||||
|
this.dropdown.querySelectorAll('.genre-option').forEach(el => {
|
||||||
|
el.onmousedown = (e) => { e.preventDefault(); this._add(el.dataset.val); };
|
||||||
|
});
|
||||||
|
this.dropdown.style.display = 'block';
|
||||||
|
this.ddIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
_hideDropdown() {
|
||||||
|
this.dropdown.style.display = 'none';
|
||||||
|
this.ddIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
_onInput() {
|
||||||
|
const q = this.input.value.trim().toLowerCase();
|
||||||
|
if (!q) { this._hideDropdown(); return; }
|
||||||
|
const matches = this.all.filter(g => g.toLowerCase().includes(q) && !this.values.includes(g));
|
||||||
|
this._showDropdown(matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
_onKeydown(e) {
|
||||||
|
const opts = this.dropdown.querySelectorAll('.genre-option');
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.ddIndex = Math.min(this.ddIndex + 1, opts.length - 1);
|
||||||
|
opts.forEach((o, i) => o.classList.toggle('active', i === this.ddIndex));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.ddIndex = Math.max(this.ddIndex - 1, -1);
|
||||||
|
opts.forEach((o, i) => o.classList.toggle('active', i === this.ddIndex));
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (this.ddIndex >= 0 && opts[this.ddIndex]) this._add(opts[this.ddIndex].dataset.val);
|
||||||
|
else if (this.input.value.trim()) this._add(this.input.value);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
this._hideDropdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const genreInput = new PillInput('genre-box', 'genre-input', 'genre-dropdown');
|
||||||
|
const subgenreInput = new PillInput('subgenre-box', 'subgenre-input', 'subgenre-dropdown');
|
||||||
|
const tagInput = new PillInput('tag-box', 'tag-input', 'tag-dropdown');
|
||||||
|
|
||||||
|
// ── Edit panel ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function openEdit() {
|
||||||
|
const [allGenres, allSubgenres, allTags] = await Promise.all([
|
||||||
|
fetch('/api/genres?type=genre').then(r => r.json()),
|
||||||
|
fetch('/api/genres?type=subgenre').then(r => r.json()),
|
||||||
|
fetch('/api/genres?type=tag').then(r => r.json()),
|
||||||
|
]);
|
||||||
|
genreInput.setSuggestions(allGenres);
|
||||||
|
subgenreInput.setSuggestions(allSubgenres);
|
||||||
|
tagInput.setSuggestions(allTags);
|
||||||
|
|
||||||
|
document.getElementById('ed-title').value = BOOK.title;
|
||||||
|
document.getElementById('ed-author').value = BOOK.author;
|
||||||
|
document.getElementById('ed-publisher').value = BOOK.publisher;
|
||||||
|
document.getElementById('ed-series').value = BOOK.series;
|
||||||
|
document.getElementById('ed-series-index').value = BOOK.series_index;
|
||||||
|
document.getElementById('ed-status').value = BOOK.publication_status;
|
||||||
|
document.getElementById('ed-url').value = BOOK.source_url;
|
||||||
|
document.getElementById('ed-publish-date').value = BOOK.publish_date;
|
||||||
|
document.getElementById('ed-description').value = BOOK.description;
|
||||||
|
|
||||||
|
genreInput.set(BOOK.genres);
|
||||||
|
subgenreInput.set(BOOK.subgenres);
|
||||||
|
tagInput.set(BOOK.tags);
|
||||||
|
|
||||||
|
document.getElementById('edit-backdrop').classList.add('open');
|
||||||
|
document.getElementById('edit-panel').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEdit() {
|
||||||
|
document.getElementById('edit-backdrop').classList.remove('open');
|
||||||
|
document.getElementById('edit-panel').classList.remove('open');
|
||||||
|
genreInput._hideDropdown();
|
||||||
|
subgenreInput._hideDropdown();
|
||||||
|
tagInput._hideDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
const body = {
|
||||||
|
title: document.getElementById('ed-title').value,
|
||||||
|
author: document.getElementById('ed-author').value,
|
||||||
|
publisher: document.getElementById('ed-publisher').value,
|
||||||
|
series: document.getElementById('ed-series').value,
|
||||||
|
series_index: document.getElementById('ed-series-index').value,
|
||||||
|
publication_status: document.getElementById('ed-status').value,
|
||||||
|
source_url: document.getElementById('ed-url').value,
|
||||||
|
publish_date: document.getElementById('ed-publish-date').value,
|
||||||
|
description: document.getElementById('ed-description').value,
|
||||||
|
genres: genreInput.getValues(),
|
||||||
|
subgenres: subgenreInput.getValues(),
|
||||||
|
tags: tagInput.getValues(),
|
||||||
|
};
|
||||||
|
const resp = await fetch(`/library/book/${encodeURIComponent(filename)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
if (resp.ok && result.filename) {
|
||||||
|
window.location.href = `/library/book/${encodeURIComponent(result.filename)}`;
|
||||||
|
} else if (resp.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert(result.error || 'Save failed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
containers/novela/static/break.png
Normal file
BIN
containers/novela/static/break.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
206
containers/novela/static/editor.css
Normal file
206
containers/novela/static/editor.css
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b;
|
||||||
|
--border: #2e2a24; --accent: #c8783a; --text: #e8e2d9;
|
||||||
|
--text-dim: #8a8278; --text-faint: #4a453e;
|
||||||
|
--success: #6baa6b; --danger: #c85a5a;
|
||||||
|
--radius: 6px;
|
||||||
|
--mono: 'DM Mono', monospace;
|
||||||
|
--header-h: 50px;
|
||||||
|
--panel-w: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--mono); overflow: hidden; }
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.editor-header {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0;
|
||||||
|
height: var(--header-h);
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex; align-items: center;
|
||||||
|
padding: 0 1rem; gap: 0.75rem;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-back {
|
||||||
|
font-size: 0.72rem; color: var(--text-dim); text-decoration: none;
|
||||||
|
display: flex; align-items: center; gap: 0.35rem;
|
||||||
|
flex-shrink: 0; transition: color 0.12s; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.header-back:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.header-chapter {
|
||||||
|
flex: 1; font-size: 0.72rem; color: var(--text-faint);
|
||||||
|
text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.save-status {
|
||||||
|
font-size: 0.65rem; color: var(--text-faint);
|
||||||
|
min-width: 5rem; text-align: right;
|
||||||
|
}
|
||||||
|
.save-status.dirty { color: var(--accent); }
|
||||||
|
.save-status.saving { color: var(--text-faint); }
|
||||||
|
.save-status.saved { color: var(--success); }
|
||||||
|
.save-status.error { color: var(--danger); }
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
padding: 0.3rem 0.9rem;
|
||||||
|
background: var(--accent); border: none; border-radius: var(--radius);
|
||||||
|
font-family: var(--mono); font-size: 0.72rem; color: #fff;
|
||||||
|
cursor: pointer; transition: opacity 0.12s;
|
||||||
|
}
|
||||||
|
.btn-save:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||||
|
.btn-save:not(:disabled):hover { opacity: 0.85; }
|
||||||
|
|
||||||
|
.btn-save-all {
|
||||||
|
display: flex; align-items: center;
|
||||||
|
padding: 0.3rem 0.9rem;
|
||||||
|
background: none; border: 1px solid var(--accent); border-radius: var(--radius);
|
||||||
|
font-family: var(--mono); font-size: 0.72rem; color: var(--accent);
|
||||||
|
cursor: pointer; transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.btn-save-all:hover { background: rgba(200,120,58,0.12); }
|
||||||
|
|
||||||
|
.btn-break {
|
||||||
|
display: flex; align-items: center; gap: 0.35rem;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
background: none; border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
font-family: var(--mono); font-size: 0.68rem;
|
||||||
|
color: var(--text-dim); cursor: pointer;
|
||||||
|
transition: color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
.btn-break:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||||
|
.btn-break:not(:disabled):hover { color: var(--text); border-color: var(--text-faint); }
|
||||||
|
|
||||||
|
.btn-replace {
|
||||||
|
display: flex; align-items: center; gap: 0.35rem;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
background: none; border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
font-family: var(--mono); font-size: 0.68rem;
|
||||||
|
color: var(--text-dim); cursor: pointer;
|
||||||
|
transition: color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
.btn-replace:hover { color: var(--text); border-color: var(--text-faint); }
|
||||||
|
|
||||||
|
|
||||||
|
.btn-add-page,
|
||||||
|
.btn-del-page {
|
||||||
|
display: flex; align-items: center; gap: 0.35rem;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
background: none; border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
font-family: var(--mono); font-size: 0.68rem;
|
||||||
|
color: var(--text-dim); cursor: pointer;
|
||||||
|
transition: color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
.btn-add-page:hover { color: var(--text); border-color: var(--text-faint); }
|
||||||
|
.btn-del-page { color: var(--danger); border-color: rgba(200,90,90,0.35); }
|
||||||
|
.btn-del-page:disabled,
|
||||||
|
.btn-add-page:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||||
|
.btn-del-page:not(:disabled):hover { background: rgba(200,90,90,0.1); border-color: var(--danger); }
|
||||||
|
|
||||||
|
/* ── Find & Replace modal ── */
|
||||||
|
.modal-backdrop {
|
||||||
|
display: none;
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.55);
|
||||||
|
z-index: 200;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.modal-backdrop.open { display: flex; }
|
||||||
|
.modal {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: 420px; max-width: 90vw;
|
||||||
|
display: flex; flex-direction: column; gap: 1rem;
|
||||||
|
}
|
||||||
|
.modal-title {
|
||||||
|
font-size: 0.78rem; font-weight: 500; color: var(--text);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.modal-field { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||||
|
.modal-label { font-size: 0.68rem; color: var(--text-dim); }
|
||||||
|
.modal-input {
|
||||||
|
background: var(--surface2); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 0.4rem 0.6rem;
|
||||||
|
font-family: var(--mono); font-size: 0.78rem; color: var(--text);
|
||||||
|
outline: none; width: 100%;
|
||||||
|
}
|
||||||
|
.modal-input:focus { border-color: var(--accent); }
|
||||||
|
.modal-options { display: flex; gap: 1.5rem; }
|
||||||
|
.modal-opt {
|
||||||
|
display: flex; align-items: center; gap: 0.4rem;
|
||||||
|
font-size: 0.72rem; color: var(--text-dim); cursor: pointer;
|
||||||
|
}
|
||||||
|
.modal-opt input { accent-color: var(--accent); cursor: pointer; }
|
||||||
|
.modal-progress {
|
||||||
|
font-size: 0.72rem; color: var(--text-faint);
|
||||||
|
min-height: 1.2rem;
|
||||||
|
}
|
||||||
|
.modal-progress.ok { color: var(--success); }
|
||||||
|
.modal-progress.error { color: var(--danger); }
|
||||||
|
.modal-actions { display: flex; justify-content: flex-end; gap: 0.6rem; }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 0.35rem 1rem;
|
||||||
|
background: var(--accent); border: none; border-radius: var(--radius);
|
||||||
|
font-family: var(--mono); font-size: 0.72rem; color: #fff;
|
||||||
|
cursor: pointer; transition: opacity 0.12s;
|
||||||
|
}
|
||||||
|
.btn-primary:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||||
|
.btn-primary:not(:disabled):hover { opacity: 0.85; }
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.35rem 0.9rem;
|
||||||
|
background: none; border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
font-family: var(--mono); font-size: 0.72rem; color: var(--text-dim);
|
||||||
|
cursor: pointer; transition: color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { color: var(--text); border-color: var(--text-faint); }
|
||||||
|
|
||||||
|
/* ── Two-panel layout ── */
|
||||||
|
.editor-body {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--header-h); left: 0; right: 0; bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chapter panel ── */
|
||||||
|
.chapter-panel {
|
||||||
|
width: var(--panel-w); flex-shrink: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-panel-title {
|
||||||
|
padding: 0.75rem 1rem 0.55rem;
|
||||||
|
font-size: 0.65rem; letter-spacing: 0.1em; text-transform: uppercase;
|
||||||
|
color: var(--accent); flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-list { flex: 1; overflow-y: auto; }
|
||||||
|
|
||||||
|
.chapter-item {
|
||||||
|
padding: 0.55rem 0.75rem 0.55rem 1rem;
|
||||||
|
font-size: 0.72rem; color: var(--text-dim);
|
||||||
|
cursor: pointer; border-bottom: 1px solid var(--border);
|
||||||
|
transition: background 0.1s, color 0.1s;
|
||||||
|
display: flex; align-items: center; gap: 0.4rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.chapter-item:hover { background: var(--surface2); color: var(--text); }
|
||||||
|
.chapter-item.active { background: var(--surface2); color: var(--text); }
|
||||||
|
.chapter-item .dirty-dot {
|
||||||
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: var(--accent); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chapter-item-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ── Monaco container ── */
|
||||||
|
.editor-pane { flex: 1; overflow: hidden; }
|
||||||
430
containers/novela/static/editor.js
Normal file
430
containers/novela/static/editor.js
Normal file
@ -0,0 +1,430 @@
|
|||||||
|
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } });
|
||||||
|
|
||||||
|
const { filename } = EDITOR;
|
||||||
|
|
||||||
|
let editor = null;
|
||||||
|
let chapters = [];
|
||||||
|
let currentIndex = -1;
|
||||||
|
let dirty = new Set(); // indices with unsaved changes
|
||||||
|
let pendingContent = new Map(); // index -> modified content not yet saved
|
||||||
|
let loadingChapter = false; // suppress dirty events during setValue
|
||||||
|
let saving = false;
|
||||||
|
|
||||||
|
// ── Init Monaco ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
require(['vs/editor/editor.main'], function () {
|
||||||
|
editor = monaco.editor.create(document.getElementById('editor-pane'), {
|
||||||
|
language: 'xml',
|
||||||
|
theme: 'vs-dark',
|
||||||
|
wordWrap: 'on',
|
||||||
|
minimap: { enabled: true },
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: "'DM Mono', monospace",
|
||||||
|
lineNumbers: 'on',
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
automaticLayout: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.onDidChangeModelContent(() => {
|
||||||
|
if (loadingChapter) return;
|
||||||
|
if (currentIndex >= 0) {
|
||||||
|
dirty.add(currentIndex);
|
||||||
|
renderChapterList();
|
||||||
|
setStatus('dirty', 'Unsaved changes');
|
||||||
|
document.getElementById('btn-save').disabled = false;
|
||||||
|
updateSaveAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ctrl+S / Cmd+S
|
||||||
|
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, saveChapter);
|
||||||
|
|
||||||
|
loadChapterList();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Chapter list ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadChapterList(targetIndex = 0) {
|
||||||
|
const resp = await fetch(`/library/chapters/${encodeURIComponent(filename)}`);
|
||||||
|
if (!resp.ok) {
|
||||||
|
setStatus('error', 'Failed to load chapters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chapters = await resp.json();
|
||||||
|
if (!Array.isArray(chapters)) chapters = [];
|
||||||
|
|
||||||
|
if (chapters.length === 0) {
|
||||||
|
currentIndex = -1;
|
||||||
|
dirty.clear();
|
||||||
|
pendingContent.clear();
|
||||||
|
renderChapterList();
|
||||||
|
document.getElementById('header-chapter').textContent = 'No chapters';
|
||||||
|
document.getElementById('btn-save').disabled = true;
|
||||||
|
document.getElementById('btn-break').disabled = true;
|
||||||
|
document.getElementById('btn-del-page').disabled = true;
|
||||||
|
if (editor) { loadingChapter = true; editor.setValue(''); loadingChapter = false; }
|
||||||
|
updateSaveAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = Math.min(Math.max(targetIndex, 0), chapters.length - 1);
|
||||||
|
renderChapterList();
|
||||||
|
await loadChapter(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChapterList() {
|
||||||
|
const el = document.getElementById('chapter-list');
|
||||||
|
el.innerHTML = '';
|
||||||
|
chapters.forEach((ch, i) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'chapter-item' + (i === currentIndex ? ' active' : '');
|
||||||
|
item.innerHTML =
|
||||||
|
(dirty.has(i) ? '<span class="dirty-dot"></span>' : '') +
|
||||||
|
`<span class="chapter-item-title">${esc(ch.title)}</span>`;
|
||||||
|
item.onclick = () => switchChapter(i);
|
||||||
|
el.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load / switch ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function switchChapter(index) {
|
||||||
|
if (index === currentIndex) return;
|
||||||
|
// Preserve current editor content in pending cache before switching (never lose changes)
|
||||||
|
if (dirty.has(currentIndex) && editor) {
|
||||||
|
pendingContent.set(currentIndex, editor.getValue());
|
||||||
|
}
|
||||||
|
loadChapter(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChapter(index) {
|
||||||
|
setStatus('', '');
|
||||||
|
document.getElementById('btn-save').disabled = true;
|
||||||
|
document.getElementById('btn-break').disabled = true;
|
||||||
|
document.getElementById('btn-del-page').disabled = true;
|
||||||
|
document.getElementById('header-chapter').textContent = 'Loading…';
|
||||||
|
|
||||||
|
let content, title;
|
||||||
|
|
||||||
|
if (pendingContent.has(index)) {
|
||||||
|
content = pendingContent.get(index);
|
||||||
|
title = chapters[index]?.title ?? '';
|
||||||
|
} else {
|
||||||
|
const resp = await fetch(`/api/edit/chapter/${index}/${encodeURIComponent(filename)}`);
|
||||||
|
if (!resp.ok) { setStatus('error', 'Load failed'); return; }
|
||||||
|
const data = await resp.json();
|
||||||
|
content = data.content;
|
||||||
|
title = data.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentIndex = index;
|
||||||
|
|
||||||
|
loadingChapter = true;
|
||||||
|
editor.setValue(content);
|
||||||
|
editor.setScrollTop(0);
|
||||||
|
loadingChapter = false;
|
||||||
|
|
||||||
|
// Restore dirty state based on whether we loaded from pending cache
|
||||||
|
if (dirty.has(index)) {
|
||||||
|
document.getElementById('btn-save').disabled = false;
|
||||||
|
setStatus('dirty', 'Unsaved changes');
|
||||||
|
} else {
|
||||||
|
document.getElementById('btn-save').disabled = true;
|
||||||
|
setStatus('', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderChapterList();
|
||||||
|
document.getElementById('header-chapter').textContent = title;
|
||||||
|
document.getElementById('btn-break').disabled = false;
|
||||||
|
document.getElementById('btn-del-page').disabled = chapters.length <= 1;
|
||||||
|
updateSaveAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save (current chapter) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function saveChapter() {
|
||||||
|
if (currentIndex < 0 || saving) return;
|
||||||
|
saving = true;
|
||||||
|
document.getElementById('btn-save').disabled = true;
|
||||||
|
setStatus('saving', 'Saving…');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`/api/edit/chapter/${currentIndex}/${encodeURIComponent(filename)}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: editor.getValue() }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.ok) {
|
||||||
|
dirty.delete(currentIndex);
|
||||||
|
pendingContent.delete(currentIndex);
|
||||||
|
renderChapterList();
|
||||||
|
setStatus('saved', 'Saved');
|
||||||
|
setTimeout(() => setStatus('', ''), 2000);
|
||||||
|
updateSaveAll();
|
||||||
|
} else {
|
||||||
|
setStatus('error', data.error || 'Save failed');
|
||||||
|
document.getElementById('btn-save').disabled = false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setStatus('error', 'Save failed');
|
||||||
|
document.getElementById('btn-save').disabled = false;
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save all pending ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function saveAllChapters() {
|
||||||
|
if (saving) return;
|
||||||
|
saving = true;
|
||||||
|
const btn = document.getElementById('btn-save-all');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
setStatus('saving', 'Saving all…');
|
||||||
|
|
||||||
|
// Flush current editor content into pendingContent first
|
||||||
|
if (currentIndex >= 0 && dirty.has(currentIndex)) {
|
||||||
|
pendingContent.set(currentIndex, editor.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
const indices = [...dirty];
|
||||||
|
for (const i of indices) {
|
||||||
|
const content = pendingContent.has(i)
|
||||||
|
? pendingContent.get(i)
|
||||||
|
: (i === currentIndex ? editor.getValue() : null);
|
||||||
|
if (!content) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`/api/edit/chapter/${i}/${encodeURIComponent(filename)}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.ok) {
|
||||||
|
dirty.delete(i);
|
||||||
|
pendingContent.delete(i);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setStatus('error', `Save failed on chapter ${i + 1}`);
|
||||||
|
saving = false;
|
||||||
|
updateSaveAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload current chapter display to reflect saved state
|
||||||
|
if (currentIndex >= 0) {
|
||||||
|
loadingChapter = true;
|
||||||
|
editor.setValue(editor.getValue()); // no-op, just clears dirty for display
|
||||||
|
loadingChapter = false;
|
||||||
|
document.getElementById('btn-save').disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderChapterList();
|
||||||
|
setStatus('saved', 'All saved');
|
||||||
|
setTimeout(() => setStatus('', ''), 2000);
|
||||||
|
saving = false;
|
||||||
|
updateSaveAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSaveAll() {
|
||||||
|
const btn = document.getElementById('btn-save-all');
|
||||||
|
if (!btn) return;
|
||||||
|
const count = dirty.size;
|
||||||
|
if (count > 1) {
|
||||||
|
btn.style.display = 'flex';
|
||||||
|
btn.textContent = `Save all (${count})`;
|
||||||
|
} else {
|
||||||
|
btn.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Insert break ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function insertBreak() {
|
||||||
|
if (!editor || currentIndex < 0) return;
|
||||||
|
const pos = editor.getPosition();
|
||||||
|
editor.executeEdits('insert-break', [{
|
||||||
|
range: new monaco.Range(pos.lineNumber, pos.column, pos.lineNumber, pos.column),
|
||||||
|
text: '\n<center><img src="../Images/break.png" style="height:15px;"/></center>\n',
|
||||||
|
forceMoveMarkers: true,
|
||||||
|
}]);
|
||||||
|
editor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ── Add / delete chapter ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function addChapter() {
|
||||||
|
if (saving) return;
|
||||||
|
if (dirty.size > 0) {
|
||||||
|
alert('Save pending changes before adding a page.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const title = prompt('Title for new page:', `New chapter ${Math.max(chapters.length + 1, 1)}`);
|
||||||
|
if (title === null) return;
|
||||||
|
|
||||||
|
const resp = await fetch(`/api/edit/chapter/add/${encodeURIComponent(filename)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title, after_index: currentIndex }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok || !data.ok) {
|
||||||
|
setStatus('error', data.error || 'Add page failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dirty.clear();
|
||||||
|
pendingContent.clear();
|
||||||
|
await loadChapterList(data.index ?? Math.max(currentIndex + 1, 0));
|
||||||
|
setStatus('saved', 'Page added');
|
||||||
|
setTimeout(() => setStatus('', ''), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteChapter() {
|
||||||
|
if (saving || currentIndex < 0) return;
|
||||||
|
if (chapters.length <= 1) {
|
||||||
|
alert('Cannot delete the last page.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dirty.size > 0) {
|
||||||
|
alert('Save pending changes before deleting a page.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chTitle = chapters[currentIndex]?.title || `chapter ${currentIndex + 1}`;
|
||||||
|
if (!confirm(`Delete page "${chTitle}"?`)) return;
|
||||||
|
|
||||||
|
const resp = await fetch(`/api/edit/chapter/${currentIndex}/${encodeURIComponent(filename)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok || !data.ok) {
|
||||||
|
setStatus('error', data.error || 'Delete page failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dirty.clear();
|
||||||
|
pendingContent.clear();
|
||||||
|
await loadChapterList(data.index ?? Math.max(currentIndex - 1, 0));
|
||||||
|
setStatus('saved', 'Page deleted');
|
||||||
|
setTimeout(() => setStatus('', ''), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Find & Replace all chapters ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openReplaceModal() {
|
||||||
|
document.getElementById('replace-modal').classList.add('open');
|
||||||
|
document.getElementById('rp-search').focus();
|
||||||
|
document.getElementById('rp-progress').textContent = '';
|
||||||
|
document.getElementById('rp-progress').className = 'modal-progress';
|
||||||
|
document.getElementById('rp-run').disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeReplaceModal() {
|
||||||
|
document.getElementById('replace-modal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape') closeReplaceModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function replaceInAllChapters() {
|
||||||
|
const searchVal = document.getElementById('rp-search').value;
|
||||||
|
if (!searchVal) return;
|
||||||
|
const replaceVal = document.getElementById('rp-replace').value;
|
||||||
|
const useRegex = document.getElementById('rp-regex').checked;
|
||||||
|
const caseSens = document.getElementById('rp-case').checked;
|
||||||
|
|
||||||
|
const runBtn = document.getElementById('rp-run');
|
||||||
|
const prog = document.getElementById('rp-progress');
|
||||||
|
runBtn.disabled = true;
|
||||||
|
|
||||||
|
let pattern;
|
||||||
|
try {
|
||||||
|
pattern = useRegex
|
||||||
|
? new RegExp(searchVal, caseSens ? 'g' : 'gi')
|
||||||
|
: new RegExp(searchVal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), caseSens ? 'g' : 'gi');
|
||||||
|
} catch (e) {
|
||||||
|
prog.className = 'modal-progress error';
|
||||||
|
prog.textContent = 'Invalid regex: ' + e.message;
|
||||||
|
runBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalOccurrences = 0;
|
||||||
|
let chaptersChanged = 0;
|
||||||
|
|
||||||
|
// Flush current editor content into pending before we start
|
||||||
|
if (currentIndex >= 0) {
|
||||||
|
pendingContent.set(currentIndex, editor.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < chapters.length; i++) {
|
||||||
|
prog.className = 'modal-progress';
|
||||||
|
prog.textContent = `Checking chapter ${i + 1} / ${chapters.length}…`;
|
||||||
|
|
||||||
|
let original;
|
||||||
|
if (pendingContent.has(i)) {
|
||||||
|
original = pendingContent.get(i);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/edit/chapter/${i}/${encodeURIComponent(filename)}`);
|
||||||
|
if (!resp.ok) continue;
|
||||||
|
const data = await resp.json();
|
||||||
|
original = data.content;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count occurrences
|
||||||
|
let count = 0;
|
||||||
|
const updated = original.replace(pattern, m => { count++; return replaceVal; });
|
||||||
|
if (count === 0) continue;
|
||||||
|
|
||||||
|
totalOccurrences += count;
|
||||||
|
chaptersChanged++;
|
||||||
|
pendingContent.set(i, updated);
|
||||||
|
dirty.add(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload current chapter from pending cache if it was changed
|
||||||
|
if (dirty.has(currentIndex) && pendingContent.has(currentIndex)) {
|
||||||
|
loadingChapter = true;
|
||||||
|
editor.setValue(pendingContent.get(currentIndex));
|
||||||
|
loadingChapter = false;
|
||||||
|
document.getElementById('btn-save').disabled = false;
|
||||||
|
setStatus('dirty', 'Unsaved changes');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderChapterList();
|
||||||
|
updateSaveAll();
|
||||||
|
|
||||||
|
prog.className = totalOccurrences > 0 ? 'modal-progress ok' : 'modal-progress';
|
||||||
|
prog.textContent = totalOccurrences > 0
|
||||||
|
? `${totalOccurrences} replacement${totalOccurrences !== 1 ? 's' : ''} in ${chaptersChanged} chapter${chaptersChanged !== 1 ? 's' : ''} — not saved yet.`
|
||||||
|
: 'No matches found.';
|
||||||
|
runBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function setStatus(cls, text) {
|
||||||
|
const el = document.getElementById('save-status');
|
||||||
|
el.className = 'save-status' + (cls ? ' ' + cls : '');
|
||||||
|
el.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
254
containers/novela/static/epub-style.css
Normal file
254
containers/novela/static/epub-style.css
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
/* This defines styles and classes used in the book */
|
||||||
|
@page {
|
||||||
|
margin: 10px;
|
||||||
|
}
|
||||||
|
html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p,
|
||||||
|
blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, img,
|
||||||
|
ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b, u, i, center,
|
||||||
|
fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td,
|
||||||
|
article, aside, canvas, details, embed, figure, figcaption, footer, header,
|
||||||
|
hgroup, menu, nav, output, ruby, section, summary, time, mark, audio, video, ol,
|
||||||
|
ul, li, dl, dt, dd {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
font-size: 100%;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
line-height: 1.2;
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
text-indent: 0;
|
||||||
|
margin: 1em 0;
|
||||||
|
widows: 2;
|
||||||
|
orphans: 2;
|
||||||
|
}
|
||||||
|
a, a:visited {
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
sup {
|
||||||
|
vertical-align: super;
|
||||||
|
font-size: smaller;
|
||||||
|
}
|
||||||
|
sub {
|
||||||
|
vertical-align: sub;
|
||||||
|
font-size: smaller;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 3em 0 0 0;
|
||||||
|
font-size: 2em;
|
||||||
|
page-break-before: always;
|
||||||
|
line-height: 150%;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 1.5em 0 0 0;
|
||||||
|
font-size: 1.5em;
|
||||||
|
line-height: 135%;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 1.3em 0 0 0;
|
||||||
|
font-size: 1.3em;
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
margin: 1.2em 0 0 0;
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
h5 {
|
||||||
|
margin: 1.1em 0 0 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
h6 {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
text-indent: 0;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: bold;
|
||||||
|
page-break-after: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol, ul {
|
||||||
|
margin: 1em 0 0 1.7em;
|
||||||
|
}
|
||||||
|
li > ol, li > ul {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
blockquote {
|
||||||
|
margin: 1em 0 1em 1.7em;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family: Menlo, Monaco, 'Lucida Console', Consolas, monospace;
|
||||||
|
font-size: 85%;
|
||||||
|
margin: 0;
|
||||||
|
hyphens: manual;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
margin: 1em 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
pre code {
|
||||||
|
padding: 0;
|
||||||
|
overflow: visible;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
}
|
||||||
|
.sourceCode {
|
||||||
|
background-color: transparent;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
hr {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border: none;
|
||||||
|
height: 1px;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
margin: 1em 0;
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
table caption {
|
||||||
|
margin-bottom: 0.75em;
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
border-top: 1px solid #1a1a1a;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
padding: 0.25em 0.5em 0.25em 0.5em;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
border-top: 1px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
margin-bottom: 4em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#TOC li {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
#TOC ul {
|
||||||
|
padding-left: 1.3em;
|
||||||
|
}
|
||||||
|
#TOC > ul {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
#TOC a:not(:hover) {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
span.smallcaps {
|
||||||
|
font-variant: small-caps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* This is the most compatible CSS, but it only allows two columns: */
|
||||||
|
div.column {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.hanging-indent {
|
||||||
|
margin-left: 1.5em;
|
||||||
|
text-indent: -1.5em;
|
||||||
|
}
|
||||||
|
ul.task-list {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
ul.task-list li input[type="checkbox"] {
|
||||||
|
width: 0.8em;
|
||||||
|
margin: 0 0.8em 0.2em -1.6em;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.display.math {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0.5rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For title, author, and date on the cover page */
|
||||||
|
h1.title { }
|
||||||
|
p.author { }
|
||||||
|
p.date { }
|
||||||
|
|
||||||
|
nav#toc ol, nav#landmarks ol {
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 1em;
|
||||||
|
}
|
||||||
|
nav#toc ol li, nav#landmarks ol li {
|
||||||
|
list-style-type: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
a.footnote-ref {
|
||||||
|
vertical-align: super;
|
||||||
|
}
|
||||||
|
em, em em em, em em em em em {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
em em, em em em em {
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
q {
|
||||||
|
quotes: "“" "”" "‘" "’";
|
||||||
|
}
|
||||||
|
@media screen {
|
||||||
|
.sourceCode {
|
||||||
|
overflow: visible !important;
|
||||||
|
white-space: pre-wrap !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================= */
|
||||||
|
/* Custom colors for subheadings and chat (Kavita) */
|
||||||
|
/* <span class="subheading">Tussentitel</span> */
|
||||||
|
/* <span class="chat">“Dit is een chatregel.”</span> */
|
||||||
|
/* ================================================= */
|
||||||
|
|
||||||
|
/* bestaande regels */
|
||||||
|
span.subheading {
|
||||||
|
color: rgb(224, 62, 45) !important;
|
||||||
|
font-weight: bold !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.chat {
|
||||||
|
color: rgb(230, 126, 35) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* nieuwe regels voor vet binnen je spans */
|
||||||
|
span.subheading strong,
|
||||||
|
span.subheading b {
|
||||||
|
color: rgb(224, 62, 45) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.chat strong,
|
||||||
|
span.chat b {
|
||||||
|
color: rgb(230, 126, 35) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eventueel ook voor dark mode */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
span.subheading,
|
||||||
|
span.subheading strong,
|
||||||
|
span.subheading b {
|
||||||
|
color: rgb(241, 90, 76) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.chat,
|
||||||
|
span.chat strong,
|
||||||
|
span.chat b {
|
||||||
|
color: rgb(243, 156, 18) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
456
containers/novela/static/library.css
Normal file
456
containers/novela/static/library.css
Normal file
@ -0,0 +1,456 @@
|
|||||||
|
/* ── Novela — Library page styles ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0f0e0c;
|
||||||
|
--surface: #1a1815;
|
||||||
|
--surface2: #221f1b;
|
||||||
|
--border: #2e2a24;
|
||||||
|
--accent: #c8783a;
|
||||||
|
--accent2: #e8a063;
|
||||||
|
--text: #e8e2d9;
|
||||||
|
--text-dim: #8a8278;
|
||||||
|
--text-faint: #4a453e;
|
||||||
|
--success: #6baa6b;
|
||||||
|
--warning: #c8a03a;
|
||||||
|
--error: #c85a3a;
|
||||||
|
--radius: 6px;
|
||||||
|
--sidebar: 220px;
|
||||||
|
--mono: 'DM Mono', monospace;
|
||||||
|
--serif: 'Libre Baskerville', Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--serif);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main content ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.main {
|
||||||
|
margin-left: var(--sidebar);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem 2.5rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-title {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-dropzone {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: rgba(34, 31, 27, 0.45);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
margin-bottom: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.import-dropzone:hover { border-color: var(--accent); }
|
||||||
|
.import-dropzone.dragover {
|
||||||
|
border-color: var(--accent2);
|
||||||
|
background: rgba(200, 120, 58, 0.12);
|
||||||
|
}
|
||||||
|
.import-dropzone.uploading {
|
||||||
|
opacity: 0.8;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
.import-title {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--accent2);
|
||||||
|
}
|
||||||
|
.import-sub {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cover grid ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.cover-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge: status top-right */
|
||||||
|
.badge-status {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.4rem;
|
||||||
|
right: 0.4rem;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.badge-complete { background: rgba(107,170,107,0.18); color: var(--success); }
|
||||||
|
.badge-ongoing { background: rgba(200,160,58,0.18); color: var(--warning); }
|
||||||
|
.badge-hiatus { background: rgba(200,160,58,0.18); color: var(--warning); }
|
||||||
|
|
||||||
|
/* Star: want-to-read top-left */
|
||||||
|
.btn-star {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.35rem;
|
||||||
|
left: 0.35rem;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(15,14,12,0.6);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-faint);
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
padding: 0;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.btn-star:hover { color: var(--warning); background: rgba(15,14,12,0.8); }
|
||||||
|
.btn-star.starred { color: var(--warning); }
|
||||||
|
|
||||||
|
/* Book info below cover */
|
||||||
|
.book-info { padding: 0.5rem 0.2rem 0; }
|
||||||
|
.book-title {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.3;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.book-author {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.book-series {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.series-index { color: var(--accent2); }
|
||||||
|
|
||||||
|
/* Read count pill */
|
||||||
|
.read-pill {
|
||||||
|
position: absolute; bottom: 0.35rem; right: 0.35rem;
|
||||||
|
background: rgba(200,120,58,0.88); color: #0f0e0c;
|
||||||
|
font-family: var(--mono); font-size: 0.6rem; font-weight: 500;
|
||||||
|
padding: 0.1rem 0.38rem; border-radius: 3px; z-index: 2; pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reading progress mini bar at bottom of cover */
|
||||||
|
.progress-mini {
|
||||||
|
position: absolute; bottom: 0; left: 0; right: 0;
|
||||||
|
height: 3px; z-index: 2; pointer-events: none;
|
||||||
|
background: rgba(200,120,58,0.25);
|
||||||
|
}
|
||||||
|
.progress-mini-fill { height: 100%; background: var(--accent); }
|
||||||
|
|
||||||
|
/* ── Dialogs ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.65);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.overlay.visible { display: flex; }
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 420px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
.dialog-title {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.dialog-title.del { color: var(--error); }
|
||||||
|
.dialog-title.cover { color: var(--warning); }
|
||||||
|
.dialog p { font-size: 0.88rem; color: var(--text-dim); margin-bottom: 1.25rem; }
|
||||||
|
.dialog p strong { color: var(--text); }
|
||||||
|
.dialog-actions { display: flex; gap: 0.75rem; justify-content: flex-end; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.btn-cancel { background: var(--surface2); color: var(--text-dim); border: 1px solid var(--border); }
|
||||||
|
.btn-cancel:hover { color: var(--text); }
|
||||||
|
.btn-confirm-del { background: rgba(200,90,58,0.15); color: var(--error); border: 1px solid rgba(200,90,58,0.3); }
|
||||||
|
.btn-confirm-del:hover { background: rgba(200,90,58,0.28); }
|
||||||
|
.btn-confirm-cover { background: rgba(200,160,58,0.15); color: var(--warning); border: 1px solid rgba(200,160,58,0.3); }
|
||||||
|
.btn-confirm-cover:hover { background: rgba(200,160,58,0.28); }
|
||||||
|
.btn-confirm-cover:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.cover-upload-area {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.cover-upload-area:hover { border-color: var(--warning); }
|
||||||
|
.cover-upload-area input[type="file"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; }
|
||||||
|
.cover-upload-label { font-family: var(--mono); font-size: 0.78rem; color: var(--text-dim); pointer-events: none; }
|
||||||
|
.cover-upload-label span { color: var(--warning); }
|
||||||
|
.cover-preview {
|
||||||
|
display: none; max-height: 160px; max-width: 110px;
|
||||||
|
border-radius: var(--radius); margin: 0 auto 0.5rem; object-fit: contain;
|
||||||
|
}
|
||||||
|
.cover-preview.visible { display: block; }
|
||||||
|
|
||||||
|
.spinner-inline {
|
||||||
|
display: none; width: 12px; height: 12px;
|
||||||
|
border: 2px solid var(--text-faint); border-top-color: var(--accent);
|
||||||
|
border-radius: 50%; animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ── Series view ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.series-card { display: flex; flex-direction: column; cursor: pointer; }
|
||||||
|
|
||||||
|
.series-cover-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: calc(100% - 12px);
|
||||||
|
aspect-ratio: 2/3;
|
||||||
|
overflow: visible;
|
||||||
|
margin-left: 2px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sci {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
border-radius: var(--radius); overflow: hidden;
|
||||||
|
background: var(--surface2); border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.sci canvas, .sci img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.sci-3 { transform: translate(10px, -10px) rotate(5deg); z-index: 1; }
|
||||||
|
.sci-2 { transform: translate(5px, -5px) rotate(2.5deg); z-index: 2; }
|
||||||
|
.sci-1 { z-index: 3; }
|
||||||
|
|
||||||
|
.series-info { padding: 0.65rem 0.2rem 0; }
|
||||||
|
.series-name {
|
||||||
|
font-size: 0.78rem; font-weight: 700; color: var(--text); line-height: 1.3;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
}
|
||||||
|
.series-meta {
|
||||||
|
font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim);
|
||||||
|
margin-top: 0.3rem; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.series-dots { display: flex; gap: 0.22rem; align-items: center; }
|
||||||
|
.series-dot { width: 7px; height: 7px; border-radius: 50%; }
|
||||||
|
.dot-read { background: var(--success); }
|
||||||
|
.dot-reading { background: var(--accent); }
|
||||||
|
.dot-unread { background: var(--border); }
|
||||||
|
|
||||||
|
/* ── Series detail ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.detail-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.75rem; }
|
||||||
|
.btn-back {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||||
|
padding: 0.3rem 0.65rem; border-radius: var(--radius);
|
||||||
|
font-family: var(--mono); font-size: 0.7rem;
|
||||||
|
background: var(--surface2); color: var(--text-dim);
|
||||||
|
border: 1px solid var(--border); cursor: pointer;
|
||||||
|
transition: color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
.btn-back:hover { color: var(--text); border-color: var(--text-faint); }
|
||||||
|
|
||||||
|
.series-slot { display: flex; flex-direction: column; }
|
||||||
|
.slot-index-label {
|
||||||
|
font-family: var(--mono); font-size: 0.6rem; color: var(--accent2);
|
||||||
|
text-align: center; margin-bottom: 0.2rem; letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.slot-missing .cover-wrap { border: 2px dashed var(--border); display: flex; align-items: center; justify-content: center; }
|
||||||
|
.slot-missing-inner {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 0.4rem;
|
||||||
|
color: var(--text-faint); pointer-events: none; user-select: none;
|
||||||
|
}
|
||||||
|
.slot-missing-inner svg { opacity: 0.5; }
|
||||||
|
.slot-missing-inner span { font-family: var(--mono); font-size: 0.65rem; }
|
||||||
|
|
||||||
|
/* ── Authors list ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.author-list { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
|
.author-item {
|
||||||
|
display: flex; align-items: center; gap: 0.75rem;
|
||||||
|
padding: 0.55rem 0.75rem; border-radius: var(--radius);
|
||||||
|
cursor: pointer; border: 1px solid var(--border);
|
||||||
|
background: var(--surface); transition: background 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
.author-item:hover { background: var(--surface2); border-color: var(--text-faint); }
|
||||||
|
.author-avatar {
|
||||||
|
width: 32px; height: 32px; border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: var(--mono); font-size: 0.72rem; font-weight: 700;
|
||||||
|
flex-shrink: 0; color: #0f0e0c;
|
||||||
|
}
|
||||||
|
.author-name { flex: 1; font-size: 0.82rem; color: var(--text); }
|
||||||
|
.author-count { font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim); white-space: nowrap; }
|
||||||
|
.author-chevron { color: var(--text-faint); margin-left: 0.25rem; }
|
||||||
|
|
||||||
|
/* ── Search bar ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||||
|
.search-icon { position: absolute; left: 0.5rem; color: var(--text-faint); pointer-events: none; }
|
||||||
|
.search-input {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); color: var(--text);
|
||||||
|
font-family: var(--mono); font-size: 0.78rem;
|
||||||
|
padding: 0.4rem 1.8rem 0.4rem 2rem;
|
||||||
|
outline: none; width: 220px;
|
||||||
|
transition: border-color 0.15s, width 0.2s;
|
||||||
|
}
|
||||||
|
.search-input:focus { border-color: var(--accent); width: 280px; }
|
||||||
|
.search-input::placeholder { color: var(--text-faint); }
|
||||||
|
.search-clear {
|
||||||
|
position: absolute; right: 0.4rem;
|
||||||
|
background: none; border: none; color: var(--text-faint);
|
||||||
|
cursor: pointer; font-size: 1rem; line-height: 1; padding: 0 0.1rem;
|
||||||
|
}
|
||||||
|
.search-clear:hover { color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main {
|
||||||
|
margin-left: 0;
|
||||||
|
padding: 4rem 1rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input { width: 100%; }
|
||||||
|
.search-input:focus { width: 100%; }
|
||||||
|
.search-wrap { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.author-item { padding: 0.5rem 0.6rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.publishers-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publisher-missing-wrap {
|
||||||
|
border: 1px solid rgba(200, 120, 58, 0.28);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publisher-missing-item {
|
||||||
|
background: rgba(200, 120, 58, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.publisher-divider {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 0.8rem;
|
||||||
|
}
|
||||||
979
containers/novela/static/library.js
Normal file
979
containers/novela/static/library.js
Normal file
@ -0,0 +1,979 @@
|
|||||||
|
/* ── Novela — Library page script ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
let allBooks = [];
|
||||||
|
let currentView = 'all';
|
||||||
|
let currentParam = null;
|
||||||
|
let pendingDelete = null;
|
||||||
|
let coverTargetFilename = null;
|
||||||
|
let coverB64 = null;
|
||||||
|
let importInProgress = false;
|
||||||
|
const MISSING_PUBLISHER_KEY = '__missing__';
|
||||||
|
const MISSING_PUBLISHER_LABEL = 'No publisher';
|
||||||
|
|
||||||
|
|
||||||
|
// ── Placeholder cover generation ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function strHash(s) {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
||||||
|
return Math.abs(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
const COVER_PALETTES = [
|
||||||
|
['#1a2a3a', '#4a8caa'],
|
||||||
|
['#2a1a1a', '#aa4a4a'],
|
||||||
|
['#1a2a1a', '#4aaa6a'],
|
||||||
|
['#2a1a2a', '#8a4aaa'],
|
||||||
|
['#2a2a1a', '#aaa04a'],
|
||||||
|
['#1a2a2a', '#4aaa9a'],
|
||||||
|
['#2a1a14', '#c8783a'],
|
||||||
|
['#141a2a', '#5a78c8'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function makePlaceholderCover(canvas, title, author) {
|
||||||
|
const w = canvas.width = canvas.offsetWidth || 150;
|
||||||
|
const h = canvas.height = canvas.offsetHeight || 225;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
const [bg, fg] = COVER_PALETTES[strHash(title) % COVER_PALETTES.length];
|
||||||
|
ctx.fillStyle = bg;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
ctx.fillStyle = fg;
|
||||||
|
ctx.globalAlpha = 0.15;
|
||||||
|
ctx.fillRect(0, 0, w, h * 0.08);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
|
ctx.fillStyle = fg;
|
||||||
|
ctx.fillRect(w * 0.12, h * 0.12, w * 0.04, h * 0.55);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#e8e2d9';
|
||||||
|
ctx.font = `bold ${Math.round(w * 0.105)}px 'Libre Baskerville', Georgia, serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
wrapText(ctx, title, w * 0.55, h * 0.28, w * 0.72, Math.round(w * 0.12));
|
||||||
|
|
||||||
|
ctx.fillStyle = fg;
|
||||||
|
ctx.font = `${Math.round(w * 0.075)}px 'DM Mono', monospace`;
|
||||||
|
ctx.globalAlpha = 0.85;
|
||||||
|
ctx.fillText(truncate(author, 18), w * 0.55, h * 0.86);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapText(ctx, text, x, y, maxW, lineH) {
|
||||||
|
const words = text.split(' ');
|
||||||
|
let line = '';
|
||||||
|
let lines = [];
|
||||||
|
for (const word of words) {
|
||||||
|
const test = line ? line + ' ' + word : word;
|
||||||
|
if (ctx.measureText(test).width > maxW && line) { lines.push(line); line = word; }
|
||||||
|
else line = test;
|
||||||
|
}
|
||||||
|
if (line) lines.push(line);
|
||||||
|
lines = lines.slice(0, 4);
|
||||||
|
const startY = y - ((lines.length - 1) * lineH) / 2;
|
||||||
|
lines.forEach((l, i) => ctx.fillText(l, x, startY + i * lineH));
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
|
||||||
|
|
||||||
|
// ── Data loading ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadLibrary() {
|
||||||
|
const resp = await fetch('/library/list');
|
||||||
|
allBooks = await resp.json();
|
||||||
|
updateCounts();
|
||||||
|
renderGrid();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeBooks() { return allBooks.filter(b => !b.archived); }
|
||||||
|
function archivedBooks() { return allBooks.filter(b => b.archived); }
|
||||||
|
|
||||||
|
function updateCounts() {
|
||||||
|
const active = activeBooks();
|
||||||
|
const wtrCount = active.filter(b => b.want_to_read).length;
|
||||||
|
const seriesCount = new Set(active.filter(b => b.series).map(b => b.series)).size;
|
||||||
|
const authorCount = new Set(active.map(b => bookAuthor(b)).filter(Boolean)).size;
|
||||||
|
const publisherCount = new Set(active.map(b => bookPublisherKey(b))).size;
|
||||||
|
const newCount = active.filter(b => b.needs_review).length;
|
||||||
|
const archCount = archivedBooks().length;
|
||||||
|
document.getElementById('count-all').textContent = active.length || '';
|
||||||
|
document.getElementById('count-wtr').textContent = wtrCount || '';
|
||||||
|
document.getElementById('count-series').textContent = seriesCount || '';
|
||||||
|
document.getElementById('count-authors').textContent = authorCount || '';
|
||||||
|
document.getElementById('count-publishers').textContent = publisherCount || '';
|
||||||
|
const newEl = document.getElementById('count-new');
|
||||||
|
if (newEl) newEl.textContent = newCount || '';
|
||||||
|
const archEl = document.getElementById('count-archived');
|
||||||
|
if (archEl) archEl.textContent = archCount || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function bookAuthor(b) {
|
||||||
|
if (b.author) return b.author;
|
||||||
|
const parts = b.filename.replace(/\.epub$/, '').split('-');
|
||||||
|
return (parts[1] ?? '').replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function bookTitle(b) {
|
||||||
|
return b.title || (b.filename.replace(/\.epub$/, '').split('-')[2] ?? '').replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePublisherName(value) {
|
||||||
|
const v = (value || '').trim();
|
||||||
|
if (!v) return MISSING_PUBLISHER_KEY;
|
||||||
|
const low = v.toLowerCase();
|
||||||
|
if (low === 'unknown publisher') return MISSING_PUBLISHER_KEY;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publisherDisplayName(key) {
|
||||||
|
return key === MISSING_PUBLISHER_KEY ? MISSING_PUBLISHER_LABEL : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bookPublisherKey(b) {
|
||||||
|
return normalizePublisherName(b.publisher);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── View switching ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _viewUrl(view, param) {
|
||||||
|
if (view === 'wtr') return '/library#wtr';
|
||||||
|
if (view === 'series') return '/library#series';
|
||||||
|
if (view === 'series-detail') return '/library#series/' + encodeURIComponent(param || '');
|
||||||
|
if (view === 'authors') return '/library#authors';
|
||||||
|
if (view === 'author-detail') return '/library#authors/' + encodeURIComponent(param || '');
|
||||||
|
if (view === 'publishers') return '/library#publishers';
|
||||||
|
if (view === 'publisher-detail') return '/library#publishers/' + encodeURIComponent(param || '');
|
||||||
|
if (view === 'archived') return '/library#archived';
|
||||||
|
if (view === 'new') return '/library#new';
|
||||||
|
if (view === 'genre') return '/library#genre/' + encodeURIComponent(param || '');
|
||||||
|
return '/library';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _applyView(view, param) {
|
||||||
|
currentView = view;
|
||||||
|
currentParam = param || null;
|
||||||
|
|
||||||
|
// Clear search input when switching to a non-search view
|
||||||
|
if (view !== 'search') {
|
||||||
|
const si = document.getElementById('search-input');
|
||||||
|
if (si) { si.value = ''; document.getElementById('search-clear').style.display = 'none'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
['nav-all','nav-wtr','nav-new','nav-series','nav-authors','nav-publishers','nav-archived'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.classList.remove('active');
|
||||||
|
});
|
||||||
|
const activeMap = {
|
||||||
|
'all': 'nav-all', 'wtr': 'nav-wtr',
|
||||||
|
'series': 'nav-series', 'series-detail': 'nav-series',
|
||||||
|
'authors': 'nav-authors', 'author-detail': 'nav-authors',
|
||||||
|
'publishers': 'nav-publishers', 'publisher-detail': 'nav-publishers',
|
||||||
|
'new': 'nav-new',
|
||||||
|
'archived': 'nav-archived',
|
||||||
|
};
|
||||||
|
const el = document.getElementById(activeMap[view]);
|
||||||
|
if (el) el.classList.add('active');
|
||||||
|
|
||||||
|
document.getElementById('section-title').textContent =
|
||||||
|
view === 'all' ? 'All books' :
|
||||||
|
view === 'wtr' ? 'Want to Read' :
|
||||||
|
view === 'series' ? 'Series' :
|
||||||
|
view === 'series-detail' ? (param || '') :
|
||||||
|
view === 'authors' ? 'Authors' :
|
||||||
|
view === 'author-detail' ? (param || '') :
|
||||||
|
view === 'publishers' ? 'Publishers' :
|
||||||
|
view === 'publisher-detail' ? publisherDisplayName(param || '') :
|
||||||
|
view === 'new' ? 'New' :
|
||||||
|
view === 'archived' ? 'Archived' :
|
||||||
|
view === 'genre' ? `Genre: ${param || ''}` :
|
||||||
|
view === 'search' ? `Search: "${param || ''}"` : '';
|
||||||
|
|
||||||
|
const showBack = view === 'series-detail' || view === 'author-detail' || view === 'publisher-detail';
|
||||||
|
document.getElementById('back-btn').style.display = showBack ? '' : 'none';
|
||||||
|
|
||||||
|
renderGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchView(view, param) {
|
||||||
|
history.pushState({ view, param: param || null }, '', _viewUrl(view, param));
|
||||||
|
_applyView(view, param);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() { history.back(); }
|
||||||
|
|
||||||
|
window.addEventListener('popstate', e => {
|
||||||
|
if (e.state) _applyView(e.state.view, e.state.param);
|
||||||
|
else _applyView('all', null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Render dispatcher ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderGrid() {
|
||||||
|
const active = activeBooks();
|
||||||
|
if (currentView === 'all') renderBooksGrid(active);
|
||||||
|
else if (currentView === 'wtr') renderBooksGrid(active.filter(b => b.want_to_read));
|
||||||
|
else if (currentView === 'series') renderSeriesGrid();
|
||||||
|
else if (currentView === 'series-detail') renderSeriesDetail(currentParam);
|
||||||
|
else if (currentView === 'authors') renderAuthorsView();
|
||||||
|
else if (currentView === 'author-detail') renderAuthorDetail(currentParam);
|
||||||
|
else if (currentView === 'publishers') renderPublishersView();
|
||||||
|
else if (currentView === 'publisher-detail') renderPublisherDetail(currentParam);
|
||||||
|
else if (currentView === 'archived') renderBooksGrid(archivedBooks());
|
||||||
|
else if (currentView === 'new') renderBooksGrid(active.filter(b => b.needs_review));
|
||||||
|
else if (currentView === 'genre') renderGenreView(currentParam);
|
||||||
|
else if (currentView === 'search') renderSearchResults(currentParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Book grid (All / WTR / Author detail) ─────────────────────────────────
|
||||||
|
|
||||||
|
function renderBooksGrid(books) {
|
||||||
|
const container = document.getElementById('grid-container');
|
||||||
|
|
||||||
|
if (!books.length) {
|
||||||
|
container.innerHTML = `<div class="empty">${
|
||||||
|
currentView === 'wtr' ? 'No books marked as Want to Read. Star a book to add it here.' :
|
||||||
|
currentView === 'archived' ? 'No archived books. Archive a book from its detail page.' :
|
||||||
|
currentView === 'new' ? 'No newly imported books waiting for metadata review.' :
|
||||||
|
currentView === 'genre' ? `No books tagged "${esc(currentParam || '')}".` :
|
||||||
|
currentView === 'search' ? `No results for "${esc(currentParam || '')}".` :
|
||||||
|
'No EPUBs yet. Convert a story first.'
|
||||||
|
}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = 'cover-grid';
|
||||||
|
|
||||||
|
books.forEach(b => {
|
||||||
|
const author = bookAuthor(b);
|
||||||
|
const title = bookTitle(b);
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'book-card';
|
||||||
|
card.id = `card-${cssId(b.filename)}`;
|
||||||
|
|
||||||
|
const st = (b.publication_status || '').toLowerCase();
|
||||||
|
let statusBadge = '';
|
||||||
|
if (st === 'complete') {
|
||||||
|
statusBadge = `<div class="badge-status badge-complete" title="Complete">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
|
</div>`;
|
||||||
|
} else if (st === 'ongoing') {
|
||||||
|
statusBadge = `<div class="badge-status badge-ongoing" title="Ongoing">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
</div>`;
|
||||||
|
} else if (st === 'hiatus') {
|
||||||
|
statusBadge = `<div class="badge-status badge-hiatus" title="Hiatus">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><line x1="10" y1="9" x2="10" y2="15"/><line x1="14" y1="9" x2="14" y2="15"/><circle cx="12" cy="12" r="10"/></svg>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const starClass = b.want_to_read ? 'btn-star starred' : 'btn-star';
|
||||||
|
const seriesText = b.series
|
||||||
|
? `${esc(b.series)}${b.series_index ? ' <span class="series-index">[' + b.series_index + ']</span>' : ''}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="cover-wrap" id="wrap-${cssId(b.filename)}">
|
||||||
|
<canvas class="cover-canvas" id="canvas-${cssId(b.filename)}"></canvas>
|
||||||
|
<button class="${starClass}" id="star-${cssId(b.filename)}"
|
||||||
|
onclick="event.stopPropagation();toggleWtr('${jsEsc(b.filename)}')" title="Want to Read">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="${b.want_to_read ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2.5" id="star-svg-${cssId(b.filename)}">
|
||||||
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
${statusBadge}
|
||||||
|
${b.read_count > 0 ? `<div class="read-pill">${b.read_count}\u00d7</div>` : ''}
|
||||||
|
${b.progress > 0 ? `<div class="progress-mini"><div class="progress-mini-fill" style="width:${b.progress}%"></div></div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="book-info">
|
||||||
|
<div class="book-title">${esc(title)}</div>
|
||||||
|
<div class="book-author">${esc(author)}</div>
|
||||||
|
${seriesText ? `<div class="book-series">${seriesText}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
card.onclick = () => { location.href = `/library/book/${encodeURIComponent(b.filename)}`; };
|
||||||
|
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.appendChild(grid);
|
||||||
|
|
||||||
|
books.forEach(b => {
|
||||||
|
const author = bookAuthor(b);
|
||||||
|
const title = bookTitle(b);
|
||||||
|
const wrap = document.getElementById(`wrap-${cssId(b.filename)}`);
|
||||||
|
const canvas = document.getElementById(`canvas-${cssId(b.filename)}`);
|
||||||
|
if (b.has_cover) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.className = 'cover-img';
|
||||||
|
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
|
||||||
|
img.src = `/library/cover-cached/${encodeURIComponent(b.filename)}`;
|
||||||
|
img.alt = title;
|
||||||
|
if (b.has_cached_cover) {
|
||||||
|
canvas.style.display = 'none';
|
||||||
|
}
|
||||||
|
img.onload = () => { canvas.style.display = 'none'; };
|
||||||
|
img.onerror = () => {
|
||||||
|
canvas.style.display = 'block';
|
||||||
|
makePlaceholderCover(canvas, title, author);
|
||||||
|
};
|
||||||
|
wrap.insertBefore(img, wrap.firstChild);
|
||||||
|
}
|
||||||
|
if (!b.has_cover || !b.has_cached_cover) {
|
||||||
|
requestAnimationFrame(() => makePlaceholderCover(canvas, title, author));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Series grid ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function groupBySeries() {
|
||||||
|
const map = {};
|
||||||
|
for (const b of activeBooks()) {
|
||||||
|
if (!b.series) continue;
|
||||||
|
if (!map[b.series]) map[b.series] = [];
|
||||||
|
map[b.series].push(b);
|
||||||
|
}
|
||||||
|
for (const s of Object.values(map)) s.sort((a, b) => a.series_index - b.series_index);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bookDotStatus(b) {
|
||||||
|
if (b.progress > 0) return 'reading';
|
||||||
|
if (b.read_count > 0) return 'read';
|
||||||
|
return 'unread';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSeriesGrid() {
|
||||||
|
const map = groupBySeries();
|
||||||
|
const container = document.getElementById('grid-container');
|
||||||
|
const entries = Object.entries(map).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
|
||||||
|
if (!entries.length) {
|
||||||
|
container.innerHTML = '<div class="empty">No series found. Series metadata is read from the EPUB files.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = 'cover-grid';
|
||||||
|
|
||||||
|
entries.forEach(([seriesName, books]) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'series-card';
|
||||||
|
card.onclick = () => switchView('series-detail', seriesName);
|
||||||
|
|
||||||
|
const dotStatuses = books.map(bookDotStatus);
|
||||||
|
const maxDots = 10;
|
||||||
|
const visibleDots = dotStatuses.slice(0, maxDots);
|
||||||
|
const extraDots = dotStatuses.length - maxDots;
|
||||||
|
const dotsHtml = visibleDots.map(s =>
|
||||||
|
`<span class="series-dot dot-${s}" title="${s}"></span>`
|
||||||
|
).join('') + (extraDots > 0 ? `<span style="font-family:var(--mono);font-size:0.55rem;color:var(--text-faint)">+${extraDots}</span>` : '');
|
||||||
|
|
||||||
|
const stackBooks = books.slice(0, 3).reverse();
|
||||||
|
const stackId = cssId(seriesName);
|
||||||
|
let stackHtml = '';
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const depth = 3 - i;
|
||||||
|
const b = stackBooks[i];
|
||||||
|
if (b) {
|
||||||
|
stackHtml += `<div class="sci sci-${depth}" id="sci-${stackId}-${depth}">
|
||||||
|
<canvas id="sci-canvas-${stackId}-${depth}" style="width:100%;height:100%"></canvas>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="series-cover-wrap">${stackHtml}</div>
|
||||||
|
<div class="series-info">
|
||||||
|
<div class="series-name">${esc(seriesName)}</div>
|
||||||
|
<div class="series-meta">
|
||||||
|
<span>${books.length} book${books.length !== 1 ? 's' : ''}</span>
|
||||||
|
<div class="series-dots">${dotsHtml}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.appendChild(grid);
|
||||||
|
|
||||||
|
entries.forEach(([seriesName, books]) => {
|
||||||
|
const stackBooks = books.slice(0, 3).reverse();
|
||||||
|
const stackId = cssId(seriesName);
|
||||||
|
for (let i = 0; i < stackBooks.length; i++) {
|
||||||
|
const depth = 3 - i;
|
||||||
|
const b = stackBooks[i];
|
||||||
|
const canvas = document.getElementById(`sci-canvas-${stackId}-${depth}`);
|
||||||
|
const wrap = document.getElementById(`sci-${stackId}-${depth}`);
|
||||||
|
if (!canvas || !wrap) continue;
|
||||||
|
const author = bookAuthor(b);
|
||||||
|
const title = bookTitle(b);
|
||||||
|
if (b.has_cover) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
|
||||||
|
img.src = `/library/cover/${encodeURIComponent(b.filename)}`;
|
||||||
|
img.alt = title;
|
||||||
|
img.onload = () => { canvas.style.display = 'none'; };
|
||||||
|
img.onerror = () => { requestAnimationFrame(() => makePlaceholderCover(canvas, title, author)); };
|
||||||
|
wrap.insertBefore(img, wrap.firstChild);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => makePlaceholderCover(canvas, title, author));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Series detail ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getSeriesSlots(books) {
|
||||||
|
const indexed = books.filter(b => b.series_index > 0);
|
||||||
|
const unindexed = books.filter(b => b.series_index === 0 || !b.series_index);
|
||||||
|
if (indexed.length === 0) return books;
|
||||||
|
|
||||||
|
const byIndex = {};
|
||||||
|
for (const b of indexed) {
|
||||||
|
if (!byIndex[b.series_index]) byIndex[b.series_index] = [];
|
||||||
|
byIndex[b.series_index].push(b);
|
||||||
|
}
|
||||||
|
const min = Math.min(...indexed.map(b => b.series_index));
|
||||||
|
const max = Math.max(...indexed.map(b => b.series_index));
|
||||||
|
|
||||||
|
const slots = [];
|
||||||
|
for (let i = min; i <= max; i++) {
|
||||||
|
if (byIndex[i]) for (const b of byIndex[i]) slots.push(b);
|
||||||
|
else slots.push({ missing: true, series_index: i });
|
||||||
|
}
|
||||||
|
return [...unindexed, ...slots];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSeriesDetail(seriesName) {
|
||||||
|
const map = groupBySeries();
|
||||||
|
const books = map[seriesName] || [];
|
||||||
|
const slots = getSeriesSlots(books);
|
||||||
|
const container = document.getElementById('grid-container');
|
||||||
|
|
||||||
|
if (!slots.length) {
|
||||||
|
container.innerHTML = '<div class="empty">No books found in this series.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = 'cover-grid';
|
||||||
|
|
||||||
|
slots.forEach(slot => {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'series-slot' + (slot.missing ? ' slot-missing' : '');
|
||||||
|
|
||||||
|
if (slot.series_index) {
|
||||||
|
const lbl = document.createElement('div');
|
||||||
|
lbl.className = 'slot-index-label';
|
||||||
|
lbl.textContent = `#${slot.series_index}`;
|
||||||
|
wrapper.appendChild(lbl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slot.missing) {
|
||||||
|
wrapper.innerHTML += `
|
||||||
|
<div class="cover-wrap">
|
||||||
|
<div class="slot-missing-inner">
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
<span>Missing</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="book-info">
|
||||||
|
<div class="book-title" style="color:var(--text-faint)">Volume ${slot.series_index}</div>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
const b = slot;
|
||||||
|
const author = bookAuthor(b);
|
||||||
|
const title = bookTitle(b);
|
||||||
|
const cid = cssId(b.filename);
|
||||||
|
|
||||||
|
const st = (b.publication_status || '').toLowerCase();
|
||||||
|
let statusBadge = '';
|
||||||
|
if (st === 'complete') {
|
||||||
|
statusBadge = `<div class="badge-status badge-complete" title="Complete"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></div>`;
|
||||||
|
} else if (st === 'ongoing') {
|
||||||
|
statusBadge = `<div class="badge-status badge-ongoing" title="Ongoing"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></div>`;
|
||||||
|
} else if (st === 'hiatus') {
|
||||||
|
statusBadge = `<div class="badge-status badge-hiatus" title="Hiatus"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><line x1="10" y1="9" x2="10" y2="15"/><line x1="14" y1="9" x2="14" y2="15"/><circle cx="12" cy="12" r="10"/></svg></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookCard = document.createElement('div');
|
||||||
|
bookCard.className = 'book-card';
|
||||||
|
bookCard.style.cursor = 'pointer';
|
||||||
|
bookCard.onclick = () => { location.href = `/library/book/${encodeURIComponent(b.filename)}`; };
|
||||||
|
bookCard.innerHTML = `
|
||||||
|
<div class="cover-wrap" id="wrap-${cid}">
|
||||||
|
<canvas class="cover-canvas" id="canvas-${cid}"></canvas>
|
||||||
|
${statusBadge}
|
||||||
|
${b.read_count > 0 ? `<div class="read-pill">${b.read_count}\u00d7</div>` : ''}
|
||||||
|
${b.progress > 0 ? `<div class="progress-mini"><div class="progress-mini-fill" style="width:${b.progress}%"></div></div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="book-info">
|
||||||
|
<div class="book-title">${esc(title)}</div>
|
||||||
|
<div class="book-author">${esc(author)}</div>
|
||||||
|
</div>`;
|
||||||
|
wrapper.appendChild(bookCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.appendChild(wrapper);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.appendChild(grid);
|
||||||
|
|
||||||
|
slots.filter(s => !s.missing).forEach(b => {
|
||||||
|
const author = bookAuthor(b);
|
||||||
|
const title = bookTitle(b);
|
||||||
|
const canvas = document.getElementById(`canvas-${cssId(b.filename)}`);
|
||||||
|
const wrap = document.getElementById(`wrap-${cssId(b.filename)}`);
|
||||||
|
if (!canvas) return;
|
||||||
|
if (b.has_cover) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
|
||||||
|
img.src = `/library/cover/${encodeURIComponent(b.filename)}`;
|
||||||
|
img.alt = title;
|
||||||
|
img.onload = () => { canvas.style.display = 'none'; };
|
||||||
|
img.onerror = () => { requestAnimationFrame(() => makePlaceholderCover(canvas, title, author)); };
|
||||||
|
wrap.insertBefore(img, wrap.firstChild);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => makePlaceholderCover(canvas, title, author));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Authors list ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderAuthorsView() {
|
||||||
|
const container = document.getElementById('grid-container');
|
||||||
|
|
||||||
|
const authorMap = {};
|
||||||
|
for (const b of activeBooks()) {
|
||||||
|
const a = bookAuthor(b);
|
||||||
|
if (!a) continue;
|
||||||
|
if (!authorMap[a]) authorMap[a] = [];
|
||||||
|
authorMap[a].push(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.entries(authorMap).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
|
||||||
|
if (!entries.length) {
|
||||||
|
container.innerHTML = '<div class="empty">No authors found.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'author-list';
|
||||||
|
|
||||||
|
entries.forEach(([authorName, books]) => {
|
||||||
|
const initial = authorName.trim()[0]?.toUpperCase() || '?';
|
||||||
|
const [bg, fg] = COVER_PALETTES[strHash(authorName) % COVER_PALETTES.length];
|
||||||
|
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'author-item';
|
||||||
|
item.onclick = () => switchView('author-detail', authorName);
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="author-avatar" style="background:${bg};color:${fg}">${esc(initial)}</div>
|
||||||
|
<div class="author-name">${esc(authorName)}</div>
|
||||||
|
<div class="author-count">${books.length} book${books.length !== 1 ? 's' : ''}</div>
|
||||||
|
<svg class="author-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="9 18 15 12 9 6"/>
|
||||||
|
</svg>`;
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.appendChild(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderPublishersView() {
|
||||||
|
const container = document.getElementById('grid-container');
|
||||||
|
|
||||||
|
const publisherMap = {};
|
||||||
|
for (const b of activeBooks()) {
|
||||||
|
const key = bookPublisherKey(b);
|
||||||
|
if (!publisherMap[key]) publisherMap[key] = [];
|
||||||
|
publisherMap[key].push(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingBooks = publisherMap[MISSING_PUBLISHER_KEY] || [];
|
||||||
|
const filledEntries = Object.entries(publisherMap)
|
||||||
|
.filter(([key]) => key !== MISSING_PUBLISHER_KEY)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
|
||||||
|
if (!filledEntries.length && !missingBooks.length) {
|
||||||
|
container.innerHTML = '<div class="empty">No publishers found.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'publishers-wrap';
|
||||||
|
|
||||||
|
const missingList = document.createElement('div');
|
||||||
|
missingList.className = 'author-list publisher-missing-wrap';
|
||||||
|
const missingName = publisherDisplayName(MISSING_PUBLISHER_KEY);
|
||||||
|
const mInitial = missingName[0]?.toUpperCase() || '?';
|
||||||
|
const [mbg, mfg] = COVER_PALETTES[strHash(missingName) % COVER_PALETTES.length];
|
||||||
|
|
||||||
|
const missingItem = document.createElement('div');
|
||||||
|
missingItem.className = 'author-item publisher-missing-item';
|
||||||
|
missingItem.onclick = () => switchView('publisher-detail', MISSING_PUBLISHER_KEY);
|
||||||
|
missingItem.innerHTML = `
|
||||||
|
<div class="author-avatar" style="background:${mbg};color:${mfg}">${esc(mInitial)}</div>
|
||||||
|
<div class="author-name">${esc(missingName)}</div>
|
||||||
|
<div class="author-count">${missingBooks.length} book${missingBooks.length !== 1 ? 's' : ''}</div>
|
||||||
|
<svg class="author-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="9 18 15 12 9 6"/>
|
||||||
|
</svg>`;
|
||||||
|
missingList.appendChild(missingItem);
|
||||||
|
wrap.appendChild(missingList);
|
||||||
|
|
||||||
|
if (filledEntries.length) {
|
||||||
|
const divider = document.createElement('div');
|
||||||
|
divider.className = 'publisher-divider';
|
||||||
|
divider.textContent = 'Publishers';
|
||||||
|
wrap.appendChild(divider);
|
||||||
|
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'author-list';
|
||||||
|
|
||||||
|
filledEntries.forEach(([publisherKey, books]) => {
|
||||||
|
const publisherName = publisherDisplayName(publisherKey);
|
||||||
|
const initial = publisherName.trim()[0]?.toUpperCase() || '?';
|
||||||
|
const [bg, fg] = COVER_PALETTES[strHash(publisherName) % COVER_PALETTES.length];
|
||||||
|
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'author-item';
|
||||||
|
item.onclick = () => switchView('publisher-detail', publisherKey);
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="author-avatar" style="background:${bg};color:${fg}">${esc(initial)}</div>
|
||||||
|
<div class="author-name">${esc(publisherName)}</div>
|
||||||
|
<div class="author-count">${books.length} book${books.length !== 1 ? 's' : ''}</div>
|
||||||
|
<svg class="author-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="9 18 15 12 9 6"/>
|
||||||
|
</svg>`;
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
wrap.appendChild(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.appendChild(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Genre view ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderGenreView(tag) {
|
||||||
|
const books = activeBooks().filter(b => (b.genres || []).includes(tag));
|
||||||
|
renderBooksGrid(books);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Search ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderSearchResults(query) {
|
||||||
|
if (!query) { renderBooksGrid(activeBooks()); return; }
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const books = activeBooks().filter(b =>
|
||||||
|
bookTitle(b).toLowerCase().includes(q) ||
|
||||||
|
bookAuthor(b).toLowerCase().includes(q) ||
|
||||||
|
(b.genres || []).some(g => g.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
renderBooksGrid(books);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
document.getElementById('search-input').value = '';
|
||||||
|
document.getElementById('search-clear').style.display = 'none';
|
||||||
|
switchView('all');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Author detail ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderAuthorDetail(authorName) {
|
||||||
|
const books = allBooks
|
||||||
|
.filter(b => bookAuthor(b) === authorName)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const sa = a.series || '\uffff';
|
||||||
|
const sb = b.series || '\uffff';
|
||||||
|
if (sa !== sb) return sa.localeCompare(sb);
|
||||||
|
if (a.series_index !== b.series_index) return a.series_index - b.series_index;
|
||||||
|
return bookTitle(a).localeCompare(bookTitle(b));
|
||||||
|
});
|
||||||
|
renderBooksGrid(books);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderPublisherDetail(publisherName) {
|
||||||
|
const books = allBooks
|
||||||
|
.filter(b => bookPublisherKey(b) === normalizePublisherName(publisherName))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const sa = a.series || '\uffff';
|
||||||
|
const sb = b.series || '\uffff';
|
||||||
|
if (sa !== sb) return sa.localeCompare(sb);
|
||||||
|
if (a.series_index !== b.series_index) return a.series_index - b.series_index;
|
||||||
|
return bookTitle(a).localeCompare(bookTitle(b));
|
||||||
|
});
|
||||||
|
renderBooksGrid(books);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Want to Read toggle ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function toggleWtr(filename) {
|
||||||
|
const resp = await fetch(`/library/want-to-read/${encodeURIComponent(filename)}`, { method: 'POST' });
|
||||||
|
const result = await resp.json();
|
||||||
|
if (result.error) return;
|
||||||
|
|
||||||
|
const book = allBooks.find(b => b.filename === filename);
|
||||||
|
if (book) book.want_to_read = result.want_to_read;
|
||||||
|
|
||||||
|
const id = cssId(filename);
|
||||||
|
const btn = document.getElementById(`star-${id}`);
|
||||||
|
const svg = document.getElementById(`star-svg-${id}`);
|
||||||
|
if (btn) btn.className = result.want_to_read ? 'btn-star starred' : 'btn-star';
|
||||||
|
if (svg) svg.setAttribute('fill', result.want_to_read ? 'currentColor' : 'none');
|
||||||
|
|
||||||
|
if (currentView === 'wtr' && !result.want_to_read) {
|
||||||
|
const card = document.getElementById(`card-${id}`);
|
||||||
|
if (card) card.remove();
|
||||||
|
const grid = document.querySelector('.cover-grid');
|
||||||
|
if (grid && !grid.children.length) {
|
||||||
|
document.getElementById('grid-container').innerHTML =
|
||||||
|
'<div class="empty">No books marked as Want to Read.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function askDelete(filename) {
|
||||||
|
pendingDelete = filename;
|
||||||
|
document.getElementById('confirm-filename').textContent = filename;
|
||||||
|
document.getElementById('confirm-overlay').classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirm() {
|
||||||
|
pendingDelete = null;
|
||||||
|
document.getElementById('confirm-overlay').classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!pendingDelete) return;
|
||||||
|
const filename = pendingDelete;
|
||||||
|
closeConfirm();
|
||||||
|
await fetch(`/library/file/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||||
|
loadLibrary();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add cover ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openCoverDialog(filename) {
|
||||||
|
coverTargetFilename = filename;
|
||||||
|
coverB64 = null;
|
||||||
|
document.getElementById('cover-target-filename').textContent = filename;
|
||||||
|
document.getElementById('cover-file-input').value = '';
|
||||||
|
document.getElementById('cover-dialog-preview').classList.remove('visible');
|
||||||
|
document.getElementById('cover-upload-prompt').textContent = 'Click to select a cover image';
|
||||||
|
document.getElementById('cover-upload-btn').disabled = true;
|
||||||
|
document.getElementById('cover-overlay').classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCoverDialog() {
|
||||||
|
coverTargetFilename = null;
|
||||||
|
coverB64 = null;
|
||||||
|
document.getElementById('cover-overlay').classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCoverFileSelected() {
|
||||||
|
const file = document.getElementById('cover-file-input').files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = e => {
|
||||||
|
const dataUrl = e.target.result;
|
||||||
|
coverB64 = dataUrl.split(',')[1];
|
||||||
|
const preview = document.getElementById('cover-dialog-preview');
|
||||||
|
preview.src = dataUrl;
|
||||||
|
preview.classList.add('visible');
|
||||||
|
document.getElementById('cover-upload-prompt').textContent = file.name;
|
||||||
|
document.getElementById('cover-upload-btn').disabled = false;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadCover() {
|
||||||
|
if (!coverTargetFilename || !coverB64) return;
|
||||||
|
document.getElementById('cover-upload-btn').disabled = true;
|
||||||
|
document.getElementById('cover-upload-label').textContent = 'Uploading…';
|
||||||
|
document.getElementById('cover-spinner').style.display = 'inline-block';
|
||||||
|
|
||||||
|
const resp = await fetch(`/library/cover/${encodeURIComponent(coverTargetFilename)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ cover_b64: coverB64 }),
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
|
||||||
|
document.getElementById('cover-upload-label').textContent = 'Add cover';
|
||||||
|
document.getElementById('cover-spinner').style.display = 'none';
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
alert('Error: ' + result.error);
|
||||||
|
document.getElementById('cover-upload-btn').disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeCoverDialog();
|
||||||
|
loadLibrary();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rescan ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function rescanLibrary() {
|
||||||
|
const btn = document.getElementById('rescan-btn');
|
||||||
|
const label = document.getElementById('rescan-label');
|
||||||
|
btn.disabled = true;
|
||||||
|
label.textContent = 'Scanning…';
|
||||||
|
await fetch('/library/rescan', { method: 'POST' });
|
||||||
|
await loadLibrary();
|
||||||
|
btn.disabled = false;
|
||||||
|
label.textContent = 'Rescan library';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImportPicker() {
|
||||||
|
if (importInProgress) return;
|
||||||
|
const input = document.getElementById('import-file-input');
|
||||||
|
if (input) input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImportFilesSelected(fileList) {
|
||||||
|
if (!fileList || !fileList.length) return;
|
||||||
|
uploadImportedFiles(Array.from(fileList));
|
||||||
|
const input = document.getElementById('import-file-input');
|
||||||
|
if (input) input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImportedFiles(files) {
|
||||||
|
if (!files.length || importInProgress) return;
|
||||||
|
const zone = document.getElementById('import-dropzone');
|
||||||
|
const title = zone?.querySelector('.import-title');
|
||||||
|
const sub = zone?.querySelector('.import-sub');
|
||||||
|
|
||||||
|
importInProgress = true;
|
||||||
|
zone?.classList.add('uploading');
|
||||||
|
if (title) title.textContent = 'Importing EPUBs…';
|
||||||
|
if (sub) sub.textContent = `${files.length} file(s) selected`;
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
files.forEach(f => form.append('files', f));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/library/import', { method: 'POST', body: form });
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok || data.error) {
|
||||||
|
alert(data.error || 'Import failed.');
|
||||||
|
} else {
|
||||||
|
const importedCount = (data.imported || []).length;
|
||||||
|
const skippedCount = (data.skipped || []).length;
|
||||||
|
if (title) title.textContent = importedCount
|
||||||
|
? `Imported ${importedCount} EPUB(s)`
|
||||||
|
: 'No EPUBs imported';
|
||||||
|
if (sub) sub.textContent = skippedCount
|
||||||
|
? `${skippedCount} skipped`
|
||||||
|
: 'Ready for next import';
|
||||||
|
await loadLibrary();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert('Import failed.');
|
||||||
|
} finally {
|
||||||
|
importInProgress = false;
|
||||||
|
zone?.classList.remove('uploading');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (title) title.textContent = 'Drop EPUB files here';
|
||||||
|
if (sub) sub.textContent = 'or click to choose files';
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utilities ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
function jsEsc(s) { return String(s ?? '').replace(/\\/g, '\\\\').replace(/'/g, "\\'"); }
|
||||||
|
function cssId(filename) { return filename.replace(/[^a-zA-Z0-9_-]/g, '_'); }
|
||||||
|
|
||||||
|
// ── Search input ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let searchTimer = null;
|
||||||
|
document.getElementById('search-input').addEventListener('input', function() {
|
||||||
|
const q = this.value.trim();
|
||||||
|
document.getElementById('search-clear').style.display = q ? '' : 'none';
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => {
|
||||||
|
if (q) {
|
||||||
|
currentView = 'search';
|
||||||
|
currentParam = q;
|
||||||
|
['nav-all','nav-wtr','nav-new','nav-series','nav-authors','nav-publishers','nav-archived'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.classList.remove('active');
|
||||||
|
});
|
||||||
|
document.getElementById('section-title').textContent = `Search: "${q}"`;
|
||||||
|
document.getElementById('back-btn').style.display = 'none';
|
||||||
|
renderGrid();
|
||||||
|
} else {
|
||||||
|
switchView('all');
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Init ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const importZone = document.getElementById('import-dropzone');
|
||||||
|
if (importZone) {
|
||||||
|
['dragenter', 'dragover'].forEach(evt => {
|
||||||
|
importZone.addEventListener(evt, e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!importInProgress) importZone.classList.add('dragover');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
['dragleave', 'drop'].forEach(evt => {
|
||||||
|
importZone.addEventListener(evt, e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
importZone.classList.remove('dragover');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
importZone.addEventListener('drop', e => {
|
||||||
|
if (importInProgress) return;
|
||||||
|
const files = Array.from(e.dataTransfer?.files || []).filter(f => f.name.toLowerCase().endsWith('.epub'));
|
||||||
|
if (!files.length) return;
|
||||||
|
uploadImportedFiles(files);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadLibrary().then(() => {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
let view = 'all', param = null;
|
||||||
|
if (hash === 'wtr') view = 'wtr';
|
||||||
|
else if (hash === 'series') view = 'series';
|
||||||
|
else if (hash.startsWith('series/')) { view = 'series-detail'; param = decodeURIComponent(hash.slice(7)); }
|
||||||
|
else if (hash === 'authors') view = 'authors';
|
||||||
|
else if (hash.startsWith('authors/')) { view = 'author-detail'; param = decodeURIComponent(hash.slice(8)); }
|
||||||
|
else if (hash === 'publishers' || hash === 'publisher') view = 'publishers';
|
||||||
|
else if (hash.startsWith('publishers/')) { view = 'publisher-detail'; param = decodeURIComponent(hash.slice(11)); }
|
||||||
|
else if (hash.startsWith('publisher/')) { view = 'publisher-detail'; param = decodeURIComponent(hash.slice(10)); }
|
||||||
|
else if (hash === 'archived') view = 'archived';
|
||||||
|
else if (hash === 'new') view = 'new';
|
||||||
|
else if (hash.startsWith('genre/')) { view = 'genre'; param = decodeURIComponent(hash.slice(6)); }
|
||||||
|
history.replaceState({ view, param }, '', _viewUrl(view, param));
|
||||||
|
_applyView(view, param);
|
||||||
|
});
|
||||||
153
containers/novela/static/sidebar.css
Normal file
153
containers/novela/static/sidebar.css
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
/* ── Sidebar ── */
|
||||||
|
|
||||||
|
html {
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; bottom: 0;
|
||||||
|
width: var(--sidebar, 220px);
|
||||||
|
min-width: var(--sidebar, 220px);
|
||||||
|
max-width: var(--sidebar, 220px);
|
||||||
|
background: var(--surface);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.5rem 0.75rem;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
padding: 0 0.5rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.sidebar-logo h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.sidebar-logo h1 span { color: var(--accent); }
|
||||||
|
.sidebar-logo p {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section-label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.sidebar-nav li + li { margin-top: 0.15rem; }
|
||||||
|
.sidebar-nav a {
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.sidebar-nav a:hover { background: var(--surface2); color: var(--text); }
|
||||||
|
.sidebar-nav a.active { background: var(--surface2); color: var(--accent); }
|
||||||
|
.sidebar-nav a svg { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.sidebar-count {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-left: auto;
|
||||||
|
min-width: 2ch;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 0.85rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-bottom { margin-top: auto; }
|
||||||
|
|
||||||
|
.btn-rescan {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.btn-rescan:hover { background: var(--surface2); color: var(--text); }
|
||||||
|
.btn-rescan:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ── Mobile hamburger ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.sidebar-toggle {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0.75rem;
|
||||||
|
left: 0.75rem;
|
||||||
|
z-index: 50;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-dim);
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.sidebar-toggle:hover { color: var(--text); border-color: var(--text-faint); }
|
||||||
|
|
||||||
|
.sidebar-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
z-index: 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar-toggle { display: flex; }
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.22s ease;
|
||||||
|
z-index: 11;
|
||||||
|
}
|
||||||
|
.sidebar.open { transform: translateX(0); }
|
||||||
|
|
||||||
|
.sidebar-overlay.open { display: block; }
|
||||||
|
}
|
||||||
250
containers/novela/templates/_sidebar.html
Normal file
250
containers/novela/templates/_sidebar.html
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
<button class="sidebar-toggle" id="sidebar-toggle" onclick="toggleSidebar()" aria-label="Menu">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="sidebar-overlay" id="sidebar-overlay" onclick="closeSidebar()"></div>
|
||||||
|
|
||||||
|
<aside class="sidebar" id="sidebar">
|
||||||
|
<div class="sidebar-logo">
|
||||||
|
<a href="/home" style="text-decoration:none;color:inherit"><h1>No<span>vela</span></h1></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="sidebar-nav">
|
||||||
|
<li>
|
||||||
|
<a href="/home"{% if active == 'home' %} class="active"{% endif %}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||||
|
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||||
|
</svg>
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<hr class="sidebar-divider"/>
|
||||||
|
|
||||||
|
<div class="sidebar-section-label">Library</div>
|
||||||
|
<ul class="sidebar-nav"{% if active == 'library' %} id="lib-nav"{% endif %}>
|
||||||
|
<li>
|
||||||
|
<a href="{% if active == 'library' %}#{% else %}/library{% endif %}"
|
||||||
|
{% if active == 'library' %}id="nav-all" class="active" onclick="switchView('all'); return false;"
|
||||||
|
{% elif active == 'book' %}class="active"{% endif %}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
|
||||||
|
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
|
||||||
|
</svg>
|
||||||
|
All books
|
||||||
|
<span class="sidebar-count" id="count-all"></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% if active == 'library' %}#{% elif active == 'book' %}/library#wtr{% else %}/library#wtr{% endif %}"
|
||||||
|
{% if active == 'library' %}id="nav-wtr" onclick="switchView('wtr'); return false;"{% endif %}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
||||||
|
</svg>
|
||||||
|
Want to Read
|
||||||
|
<span class="sidebar-count" id="count-wtr"></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% if active == 'library' %}#{% else %}/library#new{% endif %}"
|
||||||
|
{% if active == 'library' %}id="nav-new" onclick="switchView('new'); return false;"{% endif %}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M12 3v18"/><path d="M3 12h18"/>
|
||||||
|
</svg>
|
||||||
|
New
|
||||||
|
<span class="sidebar-count" id="count-new"></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% if active == 'library' %}#{% else %}/library#series{% endif %}"
|
||||||
|
{% if active == 'library' %}id="nav-series" onclick="switchView('series'); return false;"{% endif %}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="2" y="3" width="6" height="18" rx="1"/>
|
||||||
|
<rect x="9" y="3" width="6" height="18" rx="1"/>
|
||||||
|
<rect x="16" y="3" width="6" height="18" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
Series
|
||||||
|
<span class="sidebar-count" id="count-series"></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% if active == 'library' %}#{% else %}/library#authors{% endif %}"
|
||||||
|
{% if active == 'library' %}id="nav-authors" onclick="switchView('authors'); return false;"{% endif %}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="9" cy="7" r="4"/>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||||
|
</svg>
|
||||||
|
Authors
|
||||||
|
<span class="sidebar-count" id="count-authors"></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% if active == 'library' %}#{% else %}/library#publishers{% endif %}"
|
||||||
|
{% if active == 'library' %}id="nav-publishers" onclick="switchView('publishers'); return false;"{% endif %}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M3 21h18"/>
|
||||||
|
<path d="M5 21V7l7-4 7 4v14"/>
|
||||||
|
<path d="M9 21v-6h6v6"/>
|
||||||
|
</svg>
|
||||||
|
Publishers
|
||||||
|
<span class="sidebar-count" id="count-publishers"></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% if active == 'library' %}#{% else %}/library#archived{% endif %}"
|
||||||
|
{% if active == 'library' %}id="nav-archived" onclick="switchView('archived'); return false;"{% endif %}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="21 8 21 21 3 21 3 8"/>
|
||||||
|
<rect x="1" y="3" width="22" height="5"/>
|
||||||
|
<line x1="10" y1="12" x2="14" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
Archived
|
||||||
|
<span class="sidebar-count" id="count-archived"></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/stats"{% if active == 'stats' %} class="active"{% endif %}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||||
|
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||||
|
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||||
|
</svg>
|
||||||
|
Statistics
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<hr class="sidebar-divider"/>
|
||||||
|
|
||||||
|
<div class="sidebar-section-label">Tools</div>
|
||||||
|
<ul class="sidebar-nav">
|
||||||
|
<li>
|
||||||
|
<a href="/convert"{% if active == 'convert' %} class="active"{% endif %}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
|
||||||
|
</svg>
|
||||||
|
Convert
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/credentials-manager"{% if active == 'credentials' %} class="active"{% endif %}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 010 14.14M4.93 4.93a10 10 0 000 14.14"/>
|
||||||
|
</svg>
|
||||||
|
Credentials
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/debug"{% if active == 'debug' %} class="active"{% endif %}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
|
||||||
|
</svg>
|
||||||
|
Debug
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/backup"{% if active == 'backup' %} class="active"{% endif %}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 8v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8"/>
|
||||||
|
<polyline points="1 8 12 2 23 8"/>
|
||||||
|
<path d="M12 22v-8"/>
|
||||||
|
</svg>
|
||||||
|
Backup
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/settings"{% if active == 'settings' %} class="active"{% endif %}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||||
|
</svg>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="sidebar-bottom">
|
||||||
|
<button class="btn-rescan" onclick="rescanLibraryGlobal()" id="rescan-btn">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<polyline points="23 4 23 10 17 10"/>
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||||
|
</svg>
|
||||||
|
<span id="rescan-label">Rescan library</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleSidebar() {
|
||||||
|
document.getElementById('sidebar').classList.toggle('open');
|
||||||
|
document.getElementById('sidebar-overlay').classList.toggle('open');
|
||||||
|
}
|
||||||
|
function closeSidebar() {
|
||||||
|
document.getElementById('sidebar').classList.remove('open');
|
||||||
|
document.getElementById('sidebar-overlay').classList.remove('open');
|
||||||
|
}
|
||||||
|
// Close sidebar on any nav link click (mobile)
|
||||||
|
document.querySelectorAll('.sidebar-nav a').forEach(a => {
|
||||||
|
a.addEventListener('click', () => { if (window.innerWidth <= 768) closeSidebar(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyLibraryCounts(books) {
|
||||||
|
const active = books.filter(b => !b.archived);
|
||||||
|
const wtrCount = active.filter(b => b.want_to_read).length;
|
||||||
|
const newCount = active.filter(b => b.needs_review).length;
|
||||||
|
const seriesCount = new Set(active.filter(b => b.series).map(b => b.series)).size;
|
||||||
|
const authorCount = new Set(active.map(b => b.author).filter(Boolean)).size;
|
||||||
|
const publisherCount = new Set(active.map(b => b.publisher).filter(Boolean)).size;
|
||||||
|
const archivedCount = books.filter(b => b.archived).length;
|
||||||
|
|
||||||
|
const setCount = (id, value) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = value || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
setCount('count-all', active.length);
|
||||||
|
setCount('count-wtr', wtrCount);
|
||||||
|
setCount('count-new', newCount);
|
||||||
|
setCount('count-series', seriesCount);
|
||||||
|
setCount('count-authors', authorCount);
|
||||||
|
setCount('count-publishers', publisherCount);
|
||||||
|
setCount('count-archived', archivedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshLibraryCounts() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/library/list');
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const books = await resp.json();
|
||||||
|
applyLibraryCounts(books);
|
||||||
|
} catch (_) {
|
||||||
|
// silently ignore; sidebar remains usable without counts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rescanLibraryGlobal() {
|
||||||
|
const btn = document.getElementById('rescan-btn');
|
||||||
|
const label = document.getElementById('rescan-label');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
if (label) label.textContent = 'Scanning…';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/library/rescan', { method: 'POST' });
|
||||||
|
if (!resp.ok) throw new Error('rescan failed');
|
||||||
|
await refreshLibraryCounts();
|
||||||
|
} catch (_) {
|
||||||
|
alert('Rescan failed.');
|
||||||
|
} finally {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
if (label) label.textContent = 'Rescan library';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshLibraryCounts();
|
||||||
|
</script>
|
||||||
274
containers/novela/templates/backup.html
Normal file
274
containers/novela/templates/backup.html
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Novela - Backup</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<link rel="stylesheet" href="/static/sidebar.css"/>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b;
|
||||||
|
--border: #2e2a24; --accent: #c8783a;
|
||||||
|
--text: #e8e2d9; --text-dim: #8a8278;
|
||||||
|
--ok: #7fbe7f; --warn: #d2b063; --err: #d0674c;
|
||||||
|
--sidebar: 220px; --radius: 8px;
|
||||||
|
--mono: 'DM Mono', monospace; --serif: 'Libre Baskerville', Georgia, serif;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
|
||||||
|
|
||||||
|
.main {
|
||||||
|
margin-left: var(--sidebar);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2.6rem 1rem 4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main { margin-left: 0; padding-top: 4rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
width: 100%; max-width: 860px;
|
||||||
|
font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase; color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 100%; max-width: 860px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.1rem 1.1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase; color: var(--accent);
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted { color: var(--text-dim); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.k { color: var(--text-dim); font-family: var(--mono); font-size: 0.72rem; }
|
||||||
|
.v { color: var(--text); font-family: var(--mono); font-size: 0.75rem; text-align: right; word-break: break-word; }
|
||||||
|
|
||||||
|
.actions { display: flex; gap: 0.6rem; flex-wrap: wrap; }
|
||||||
|
.btn {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: var(--accent); }
|
||||||
|
.btn.primary { border-color: rgba(200,120,58,0.45); background: rgba(200,120,58,0.12); }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.status-line { margin-top: 0.7rem; font-family: var(--mono); font-size: 0.74rem; }
|
||||||
|
.ok { color: var(--ok); }
|
||||||
|
.warn { color: var(--warn); }
|
||||||
|
.err { color: var(--err); }
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%; border-collapse: collapse;
|
||||||
|
font-family: var(--mono); font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
th { color: var(--text-dim); font-weight: 500; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include "_sidebar.html" %}
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div class="title">Backup</div>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head">Run</div>
|
||||||
|
<p class="muted" style="margin-top:0;margin-bottom:0.9rem;">
|
||||||
|
Gebruik <strong>Dry Run</strong> om zonder upload te valideren (inclusief `pg_dump`).
|
||||||
|
</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn" id="btn-dry" onclick="runBackup(true)">Run Dry Backup</button>
|
||||||
|
<button class="btn primary" id="btn-live" onclick="runBackup(false)">Run Live Backup</button>
|
||||||
|
<button class="btn" onclick="refreshAll()">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div class="status-line" id="run-result"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head">Health</div>
|
||||||
|
<div class="grid" id="health-grid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head">Latest Status</div>
|
||||||
|
<div class="grid" id="status-grid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head">History (Last 20)</div>
|
||||||
|
<div style="overflow:auto;">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Files</th>
|
||||||
|
<th>Bytes</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Finished</th>
|
||||||
|
<th>Error</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="history-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function esc(v) {
|
||||||
|
return String(v ?? '').replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowHtml(k, v) {
|
||||||
|
return `<div class="row"><div class="k">${esc(k)}</div><div class="v">${esc(v)}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtStatus(v) {
|
||||||
|
if (v === true || v === 'success') return 'OK';
|
||||||
|
if (v === false || v === 'error') return 'FAIL';
|
||||||
|
return v ?? '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHealth() {
|
||||||
|
const el = document.getElementById('health-grid');
|
||||||
|
el.innerHTML = rowHtml('Loading', '...');
|
||||||
|
const r = await fetch('/api/backup/health');
|
||||||
|
const d = await r.json();
|
||||||
|
el.innerHTML = [
|
||||||
|
rowHtml('Dropbox token', d.token_present ? 'present' : 'missing'),
|
||||||
|
rowHtml('Dropbox auth', fmtStatus(d.dropbox_ok)),
|
||||||
|
rowHtml('Dropbox error', d.dropbox_error || '-'),
|
||||||
|
rowHtml('pg_dump', d.pg_dump_available ? (d.pg_dump_path || 'available') : 'missing'),
|
||||||
|
rowHtml('Library exists', fmtStatus(d.library_exists)),
|
||||||
|
rowHtml('Library path', d.library_path || '-'),
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStatus() {
|
||||||
|
const el = document.getElementById('status-grid');
|
||||||
|
el.innerHTML = rowHtml('Loading', '...');
|
||||||
|
const r = await fetch('/api/backup/status');
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.status === 'never') {
|
||||||
|
el.innerHTML = rowHtml('Status', 'Never run');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = [
|
||||||
|
rowHtml('ID', d.id),
|
||||||
|
rowHtml('Status', d.status),
|
||||||
|
rowHtml('Files', d.files_count ?? '-'),
|
||||||
|
rowHtml('Bytes', d.size_bytes ?? '-'),
|
||||||
|
rowHtml('Started', d.started_at ?? '-'),
|
||||||
|
rowHtml('Finished', d.finished_at ?? '-'),
|
||||||
|
rowHtml('Error', d.error_msg ?? '-'),
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
const body = document.getElementById('history-body');
|
||||||
|
body.innerHTML = '<tr><td colspan="7">Loading...</td></tr>';
|
||||||
|
const r = await fetch('/api/backup/history');
|
||||||
|
const rows = await r.json();
|
||||||
|
if (!rows.length) {
|
||||||
|
body.innerHTML = '<tr><td colspan="7">No backup history yet.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.innerHTML = rows.map((x) => `
|
||||||
|
<tr>
|
||||||
|
<td>${esc(x.id)}</td>
|
||||||
|
<td>${esc(x.status)}</td>
|
||||||
|
<td>${esc(x.files_count ?? '-')}</td>
|
||||||
|
<td>${esc(x.size_bytes ?? '-')}</td>
|
||||||
|
<td>${esc(x.started_at ?? '-')}</td>
|
||||||
|
<td>${esc(x.finished_at ?? '-')}</td>
|
||||||
|
<td>${esc(x.error_msg ?? '-')}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBackup(dryRun) {
|
||||||
|
const btnDry = document.getElementById('btn-dry');
|
||||||
|
const btnLive = document.getElementById('btn-live');
|
||||||
|
const out = document.getElementById('run-result');
|
||||||
|
|
||||||
|
btnDry.disabled = true;
|
||||||
|
btnLive.disabled = true;
|
||||||
|
out.className = 'status-line warn';
|
||||||
|
out.textContent = dryRun ? 'Running dry backup...' : 'Running live backup...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/backup/run', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({dry_run: dryRun}),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.ok) {
|
||||||
|
out.className = 'status-line ok';
|
||||||
|
out.textContent = `Backup ${d.status}. id=${d.backup_id}, files=${d.files_count}, bytes=${d.size_bytes}, dry_run=${d.dry_run}`;
|
||||||
|
} else {
|
||||||
|
out.className = 'status-line err';
|
||||||
|
out.textContent = `Backup failed: ${d.error || 'unknown error'}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
out.className = 'status-line err';
|
||||||
|
out.textContent = `Request failed: ${e}`;
|
||||||
|
} finally {
|
||||||
|
btnDry.disabled = false;
|
||||||
|
btnLive.disabled = false;
|
||||||
|
await refreshAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
await Promise.all([loadHealth(), loadStatus(), loadHistory()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshAll();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
319
containers/novela/templates/book.html
Normal file
319
containers/novela/templates/book.html
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Novela — {{ title or filename }}</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<link rel="stylesheet" href="/static/sidebar.css"/>
|
||||||
|
<link rel="stylesheet" href="/static/book.css"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{% include "_sidebar.html" %}
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div class="book-hero">
|
||||||
|
<!-- Cover -->
|
||||||
|
<div class="cover-area">
|
||||||
|
<div class="cover-wrap" id="cover-wrap">
|
||||||
|
<canvas id="cover-canvas"></canvas>
|
||||||
|
{% if has_cover %}
|
||||||
|
<img src="/library/cover/{{ filename | urlencode }}" alt="{{ title }}" id="cover-img"
|
||||||
|
onerror="this.style.display='none'"/>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-wtr {% if want_to_read %}active{% endif %}" id="wtr-btn" onclick="toggleWtr()">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="{% if want_to_read %}currentColor{% else %}none{% endif %}" stroke="currentColor" stroke-width="2.5" id="wtr-svg">
|
||||||
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
||||||
|
</svg>
|
||||||
|
<span id="wtr-label">Want to Read</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="book-info">
|
||||||
|
<div class="book-title">{{ title or filename }}</div>
|
||||||
|
{% if author %}<div class="book-author"><a href="/library#authors/{{ author | urlencode }}">{{ author }}</a></div>{% endif %}
|
||||||
|
|
||||||
|
<div class="meta-grid">
|
||||||
|
{% if series %}
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Series</span>
|
||||||
|
<span class="meta-value">{{ series }}{% if series_index %} [{{ series_index }}]{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Publisher</span>
|
||||||
|
{% if publisher %}
|
||||||
|
<span class="meta-value"><a href="/library#publisher/{{ publisher | urlencode }}" class="publisher-link">{{ publisher }}</a></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="meta-value"><a href="/library#publisher/__missing__" class="publisher-link">No publisher</a></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if publication_status %}
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Status</span>
|
||||||
|
<span class="meta-value">
|
||||||
|
{% set st = publication_status | lower %}
|
||||||
|
<span class="status-pill {% if st == 'complete' %}status-complete{% elif st == 'ongoing' %}status-ongoing{% elif st == 'hiatus' %}status-hiatus{% endif %}">
|
||||||
|
{{ publication_status }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if publish_date %}
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Updated</span>
|
||||||
|
<span class="meta-value">{{ publish_date }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if genres %}
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Genres</span>
|
||||||
|
<span class="meta-value">{% for g in genres %}<a href="/library#genre/{{ g | urlencode }}" class="tag-pill">{{ g }}</a>{% endfor %}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if subgenres %}
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Sub-genres</span>
|
||||||
|
<span class="meta-value">{% for g in subgenres %}<a href="/library#genre/{{ g | urlencode }}" class="tag-pill">{{ g }}</a>{% endfor %}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if tags %}
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Tags</span>
|
||||||
|
<span class="meta-value">{% for g in tags %}<a href="/library#genre/{{ g | urlencode }}" class="tag-pill">{{ g }}</a>{% endfor %}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if description %}
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Description</span>
|
||||||
|
<div class="meta-value book-description">{{ description }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if source_url %}
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Bron</span>
|
||||||
|
<span class="meta-value">
|
||||||
|
<a href="{{ source_url }}" target="_blank" rel="noopener noreferrer"
|
||||||
|
style="color:var(--accent);font-family:var(--mono);font-size:0.78rem;word-break:break-all;">
|
||||||
|
{{ source_url }}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if progress > 0 %}
|
||||||
|
<div class="progress-section">
|
||||||
|
<div class="progress-label">Reading progress</div>
|
||||||
|
<div class="progress-bar-wrap">
|
||||||
|
<div class="progress-bar-fill" style="width: {{ progress }}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-pct">{{ progress }}% complete</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if read_count > 0 %}
|
||||||
|
<div class="read-stats">
|
||||||
|
Read <span>{{ read_count }}×</span>
|
||||||
|
{% if last_read %}
|
||||||
|
· Last read <span>{{ last_read[:10] }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="action-row">
|
||||||
|
<a class="btn-primary" href="/library/read/{{ filename | urlencode }}">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
|
||||||
|
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
|
||||||
|
</svg>
|
||||||
|
{% if progress > 0 %}Continue reading{% else %}Start reading{% endif %}
|
||||||
|
</a>
|
||||||
|
{% if progress > 0 %}
|
||||||
|
<button class="btn-secondary" onclick="markUnread()">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<polyline points="1 4 1 10 7 10"/>
|
||||||
|
<path d="M3.51 15a9 9 0 1 0 .49-4"/>
|
||||||
|
</svg>
|
||||||
|
Mark as unread
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<a class="btn-secondary" href="/download/{{ filename | urlencode }}">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
<button class="btn-secondary" onclick="openMarkReadModal()">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
|
</svg>
|
||||||
|
Mark as Read
|
||||||
|
</button>
|
||||||
|
<button class="btn-secondary {% if archived %}btn-archive-active{% endif %}" id="archive-btn" onclick="toggleArchive()">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<polyline points="21 8 21 21 3 21 3 8"/>
|
||||||
|
<rect x="1" y="3" width="22" height="5"/>
|
||||||
|
<line x1="10" y1="12" x2="14" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
{% if archived %}Unarchive{% else %}Archive{% endif %}
|
||||||
|
</button>
|
||||||
|
<button class="btn-secondary" onclick="openEdit()">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||||
|
</svg>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<a class="btn-secondary" href="/library/editor/{{ filename | urlencode }}">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<polyline points="16 18 22 12 16 6"/>
|
||||||
|
<polyline points="8 6 2 12 8 18"/>
|
||||||
|
</svg>
|
||||||
|
Edit EPUB
|
||||||
|
</a>
|
||||||
|
<input type="file" id="cover-input" accept="image/*" style="display:none" onchange="uploadCover(this)"/>
|
||||||
|
<button class="btn-secondary" onclick="document.getElementById('cover-input').click()">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||||
|
<polyline points="21 15 16 10 5 21"/>
|
||||||
|
</svg>
|
||||||
|
Add cover
|
||||||
|
</button>
|
||||||
|
<button class="btn-danger" onclick="document.getElementById('delete-modal').classList.add('open')">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<polyline points="3 6 5 6 21 6"/>
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||||||
|
<path d="M10 11v6"/><path d="M14 11v6"/>
|
||||||
|
<path d="M9 6V4h6v2"/>
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="edit-backdrop" id="edit-backdrop" onclick="closeEdit()"></div>
|
||||||
|
<div class="edit-panel" id="edit-panel">
|
||||||
|
<div class="edit-panel-header">
|
||||||
|
<span class="edit-panel-title">Edit metadata</span>
|
||||||
|
<button class="edit-close" onclick="closeEdit()">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="edit-field"><label class="edit-label">Title</label><input class="edit-input" id="ed-title" type="text"/></div>
|
||||||
|
<div class="edit-field"><label class="edit-label">Author</label><input class="edit-input" id="ed-author" type="text"/></div>
|
||||||
|
<div class="edit-field"><label class="edit-label">Publisher</label><input class="edit-input" id="ed-publisher" type="text"/></div>
|
||||||
|
<div class="edit-row">
|
||||||
|
<div class="edit-field"><label class="edit-label">Series</label><input class="edit-input" id="ed-series" type="text"/></div>
|
||||||
|
<div class="edit-field"><label class="edit-label">Volume</label><input class="edit-input" id="ed-series-index" type="number" min="0"/></div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-field">
|
||||||
|
<label class="edit-label">Status</label>
|
||||||
|
<select class="edit-select" id="ed-status">
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="Complete">Complete</option>
|
||||||
|
<option value="Ongoing">Ongoing</option>
|
||||||
|
<option value="Hiatus">Hiatus</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="edit-field"><label class="edit-label">Source URL</label><input class="edit-input" id="ed-url" type="url" placeholder="https://…"/></div>
|
||||||
|
<div class="edit-field"><label class="edit-label">Description</label><textarea class="edit-input edit-textarea" id="ed-description" rows="5" placeholder="Story description…"></textarea></div>
|
||||||
|
<div class="edit-field"><label class="edit-label">Updated</label><input class="edit-input" id="ed-publish-date" type="date" style="color-scheme:dark"/></div>
|
||||||
|
<div class="edit-field">
|
||||||
|
<label class="edit-label">Genres</label>
|
||||||
|
<div class="genre-wrap">
|
||||||
|
<div class="genre-box" id="genre-box">
|
||||||
|
<input class="genre-input" id="genre-input" type="text" placeholder="Add genre…" autocomplete="off"/>
|
||||||
|
</div>
|
||||||
|
<div class="genre-dropdown" id="genre-dropdown" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-field">
|
||||||
|
<label class="edit-label">Sub-genres</label>
|
||||||
|
<div class="genre-wrap">
|
||||||
|
<div class="genre-box" id="subgenre-box">
|
||||||
|
<input class="genre-input" id="subgenre-input" type="text" placeholder="Add sub-genre…" autocomplete="off"/>
|
||||||
|
</div>
|
||||||
|
<div class="genre-dropdown" id="subgenre-dropdown" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-field">
|
||||||
|
<label class="edit-label">Tags</label>
|
||||||
|
<div class="genre-wrap">
|
||||||
|
<div class="genre-box" id="tag-box">
|
||||||
|
<input class="genre-input" id="tag-input" type="text" placeholder="Add tag…" autocomplete="off"/>
|
||||||
|
</div>
|
||||||
|
<div class="genre-dropdown" id="tag-dropdown" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-footer">
|
||||||
|
<button class="btn-secondary" onclick="closeEdit()">Cancel</button>
|
||||||
|
<button class="btn-primary" onclick="saveEdit()">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="mark-read-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<h3>Mark as Read</h3>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label class="modal-label">Read on</label>
|
||||||
|
<div class="date-row">
|
||||||
|
<div class="date-field"><span class="date-sub-label">Year</span><input class="date-input" id="read-year" type="number" min="2000" max="2099" placeholder="YYYY" maxlength="4"/></div>
|
||||||
|
<div class="date-field"><span class="date-sub-label">Month</span><input class="date-input" id="read-month" type="number" min="1" max="12" placeholder="MM" maxlength="2"/></div>
|
||||||
|
<div class="date-field"><span class="date-sub-label">Day</span><input class="date-input" id="read-day" type="number" min="1" max="31" placeholder="DD" maxlength="2"/></div>
|
||||||
|
</div>
|
||||||
|
<div class="date-time-row">
|
||||||
|
<div class="date-field"><span class="date-sub-label">Time (optional, 24h)</span><input class="modal-input" id="read-time" type="time" style="color-scheme:dark"/></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-secondary" onclick="document.getElementById('mark-read-modal').classList.remove('open')">Cancel</button>
|
||||||
|
<button class="btn-primary" onclick="confirmMarkRead()">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="delete-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<h3>Delete book</h3>
|
||||||
|
<p>This will permanently delete the EPUB file and all reading progress for <strong id="delete-title"></strong>. This cannot be undone.</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-secondary" onclick="document.getElementById('delete-modal').classList.remove('open')">Cancel</button>
|
||||||
|
<button class="btn-danger" onclick="confirmDelete()">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const BOOK = {
|
||||||
|
filename: {{ filename | tojson }},
|
||||||
|
title: {{ (title or filename) | tojson }},
|
||||||
|
author: {{ (author or '') | tojson }},
|
||||||
|
publisher: {{ (publisher or '') | tojson }},
|
||||||
|
series: {{ (series or '') | tojson }},
|
||||||
|
series_index: {{ series_index or 0 }},
|
||||||
|
publication_status: {{ (publication_status or '') | tojson }},
|
||||||
|
source_url: {{ (source_url or '') | tojson }},
|
||||||
|
publish_date: {{ (publish_date or '') | tojson }},
|
||||||
|
description: {{ (description or '') | tojson }},
|
||||||
|
genres: {{ genres | tojson }},
|
||||||
|
subgenres: {{ subgenres | tojson }},
|
||||||
|
tags: {{ tags | tojson }},
|
||||||
|
has_cover: {{ 'true' if has_cover else 'false' }},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="/static/book.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
406
containers/novela/templates/credentials.html
Normal file
406
containers/novela/templates/credentials.html
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Novela — Credentials</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<link rel="stylesheet" href="/static/sidebar.css"/>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f0e0c;
|
||||||
|
--surface: #1a1815;
|
||||||
|
--surface2: #221f1b;
|
||||||
|
--border: #2e2a24;
|
||||||
|
--accent: #c8783a;
|
||||||
|
--accent2: #e8a063;
|
||||||
|
--text: #e8e2d9;
|
||||||
|
--text-dim: #8a8278;
|
||||||
|
--text-faint: #4a453e;
|
||||||
|
--success: #6baa6b;
|
||||||
|
--warning: #c8a03a;
|
||||||
|
--error: #c85a3a;
|
||||||
|
--radius: 6px;
|
||||||
|
--sidebar: 220px;
|
||||||
|
--mono: 'DM Mono', monospace;
|
||||||
|
--serif: 'Libre Baskerville', Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html, body { height: 100%; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--serif);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main content ── */
|
||||||
|
.main {
|
||||||
|
margin-left: var(--sidebar);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3rem 1rem 5rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main { margin-left: 0; padding: 4rem 1rem 4rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 620px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="url"] {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrap {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrap input { margin-bottom: 0; padding-right: 2.5rem; }
|
||||||
|
|
||||||
|
.toggle-pw {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.65rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
width: auto;
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-pw:hover { color: var(--text-dim); }
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.85rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #0f0e0c;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover { background: var(--accent2); }
|
||||||
|
button:active { transform: scale(0.99); }
|
||||||
|
|
||||||
|
/* Credential list */
|
||||||
|
.cred-list { list-style: none; }
|
||||||
|
|
||||||
|
.cred-item {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cred-item:first-child { padding-top: 0; }
|
||||||
|
.cred-item:last-child { border-bottom: none; padding-bottom: 0; }
|
||||||
|
|
||||||
|
.cred-item-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cred-site {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cred-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
width: auto;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text-dim);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm:hover { background: var(--border); color: var(--text); }
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: rgba(200, 90, 58, 0.12);
|
||||||
|
border-color: rgba(200, 90, 58, 0.3);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover { background: rgba(200, 90, 58, 0.25); }
|
||||||
|
|
||||||
|
.cred-detail {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cred-detail span { display: flex; gap: 0.5rem; align-items: center; }
|
||||||
|
.cred-detail .label { color: var(--text-faint); min-width: 5rem; }
|
||||||
|
|
||||||
|
.pw-mask { letter-spacing: 0.1em; }
|
||||||
|
|
||||||
|
.toggle-visible {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
width: auto;
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-visible:hover { color: var(--text-dim); background: none; }
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-feedback {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--success);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
min-height: 1.2em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{% include "_sidebar.html" %}
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
|
||||||
|
<!-- Saved credentials -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Saved Credentials</div>
|
||||||
|
<ul class="cred-list" id="cred-list">
|
||||||
|
<li class="empty-state" id="empty-msg">No credentials saved yet.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add / Edit -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title" id="form-title">Add Credentials</div>
|
||||||
|
|
||||||
|
<label for="f-site">Site (domain or key)</label>
|
||||||
|
<div style="font-family: var(--mono); font-size: 0.7rem; color: var(--text-faint); margin: -0.2rem 0 0.8rem;">Tip: use <code>dropbox</code> with the access token in Password.</div>
|
||||||
|
<input type="text" id="f-site" placeholder="e.g. example.com"/>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="f-username">Username</label>
|
||||||
|
<input type="text" id="f-username" autocomplete="off"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="f-password">Password</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<input type="password" id="f-password" autocomplete="off"/>
|
||||||
|
<button class="toggle-pw" type="button" onclick="toggleFormPw()" title="Show / hide">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onclick="submitForm()">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/>
|
||||||
|
<polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>
|
||||||
|
</svg>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<div class="form-feedback" id="form-feedback"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let allCredentials = {};
|
||||||
|
|
||||||
|
async function loadCredentials() {
|
||||||
|
const r = await fetch('/credentials');
|
||||||
|
allCredentials = await r.json();
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
const ul = document.getElementById('cred-list');
|
||||||
|
const empty = document.getElementById('empty-msg');
|
||||||
|
const sites = Object.keys(allCredentials);
|
||||||
|
|
||||||
|
if (sites.length === 0) {
|
||||||
|
ul.innerHTML = '';
|
||||||
|
ul.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.innerHTML = '';
|
||||||
|
sites.forEach(site => {
|
||||||
|
const creds = allCredentials[site];
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'cred-item';
|
||||||
|
li.id = `site-${CSS.escape(site)}`;
|
||||||
|
li.innerHTML = `
|
||||||
|
<div class="cred-item-header">
|
||||||
|
<span class="cred-site">${site}</span>
|
||||||
|
<div class="cred-actions">
|
||||||
|
<button class="btn-sm" onclick="editSite('${site}')">Edit</button>
|
||||||
|
<button class="btn-sm btn-danger" onclick="deleteSite('${site}')">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cred-detail">
|
||||||
|
<span><span class="label">Username</span>${creds.username || '—'}</span>
|
||||||
|
<span>
|
||||||
|
<span class="label">Password</span>
|
||||||
|
<span class="pw-mask" id="pw-${CSS.escape(site)}">●●●●●●●●</span>
|
||||||
|
<button class="toggle-visible" onclick="togglePw('${site}')">show</button>
|
||||||
|
</span>
|
||||||
|
</div>`;
|
||||||
|
ul.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePw(site) {
|
||||||
|
const el = document.getElementById(`pw-${CSS.escape(site)}`);
|
||||||
|
const btn = el.nextElementSibling;
|
||||||
|
if (btn.textContent === 'show') {
|
||||||
|
el.textContent = allCredentials[site].password || '(empty)';
|
||||||
|
btn.textContent = 'hide';
|
||||||
|
} else {
|
||||||
|
el.textContent = '●●●●●●●●';
|
||||||
|
btn.textContent = 'show';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editSite(site) {
|
||||||
|
document.getElementById('f-site').value = site;
|
||||||
|
document.getElementById('f-username').value = allCredentials[site].username || '';
|
||||||
|
document.getElementById('f-password').value = allCredentials[site].password || '';
|
||||||
|
document.getElementById('form-title').textContent = `Edit Credentials — ${site}`;
|
||||||
|
document.getElementById('f-site').focus();
|
||||||
|
document.getElementById('f-site').scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSite(site) {
|
||||||
|
if (!confirm(`Delete credentials for ${site}?`)) return;
|
||||||
|
await fetch(`/credentials/${encodeURIComponent(site)}`, { method: 'DELETE' });
|
||||||
|
delete allCredentials[site];
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm() {
|
||||||
|
const site = document.getElementById('f-site').value.trim().replace(/^www\./, '');
|
||||||
|
const username = document.getElementById('f-username').value.trim();
|
||||||
|
const password = document.getElementById('f-password').value;
|
||||||
|
|
||||||
|
if (!site) { alert('Please enter a site domain.'); return; }
|
||||||
|
|
||||||
|
await fetch('/credentials', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ site, username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
allCredentials[site] = { username, password };
|
||||||
|
renderList();
|
||||||
|
|
||||||
|
document.getElementById('f-site').value = '';
|
||||||
|
document.getElementById('f-username').value = '';
|
||||||
|
document.getElementById('f-password').value = '';
|
||||||
|
document.getElementById('form-title').textContent = 'Add Credentials';
|
||||||
|
|
||||||
|
const fb = document.getElementById('form-feedback');
|
||||||
|
fb.textContent = `Saved credentials for ${site}`;
|
||||||
|
setTimeout(() => fb.textContent = '', 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFormPw() {
|
||||||
|
const input = document.getElementById('f-password');
|
||||||
|
input.type = input.type === 'password' ? 'text' : 'password';
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCredentials();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
327
containers/novela/templates/debug.html
Normal file
327
containers/novela/templates/debug.html
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Novela — Debug</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<link rel="stylesheet" href="/static/sidebar.css"/>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f0e0c;
|
||||||
|
--surface: #1a1815;
|
||||||
|
--surface2: #221f1b;
|
||||||
|
--border: #2e2a24;
|
||||||
|
--accent: #c8783a;
|
||||||
|
--accent2: #e8a063;
|
||||||
|
--text: #e8e2d9;
|
||||||
|
--text-dim: #8a8278;
|
||||||
|
--text-faint: #4a453e;
|
||||||
|
--success: #6baa6b;
|
||||||
|
--warning: #c8a03a;
|
||||||
|
--error: #c85a3a;
|
||||||
|
--radius: 6px;
|
||||||
|
--sidebar: 220px;
|
||||||
|
--mono: 'DM Mono', monospace;
|
||||||
|
--serif: 'Libre Baskerville', Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html, body { height: 100%; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--serif);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main content ── */
|
||||||
|
.main {
|
||||||
|
margin-left: var(--sidebar);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3rem 1rem 5rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main { margin-left: 0; padding: 4rem 1rem 4rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 2rem;
|
||||||
|
width: 100%; max-width: 900px; margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 0.7rem; font-family: var(--mono);
|
||||||
|
letter-spacing: 0.12em; text-transform: uppercase;
|
||||||
|
color: var(--accent); margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block; font-size: 0.78rem; font-family: var(--mono);
|
||||||
|
color: var(--text-dim); margin-bottom: 0.4rem; letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="url"] {
|
||||||
|
width: 100%; background: var(--bg); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); color: var(--text);
|
||||||
|
font-family: var(--mono); font-size: 0.85rem;
|
||||||
|
padding: 0.65rem 0.85rem; outline: none;
|
||||||
|
transition: border-color 0.15s; margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
input:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
||||||
|
width: 100%; padding: 0.85rem; background: var(--accent); color: #0f0e0c;
|
||||||
|
border: none; border-radius: var(--radius); font-family: var(--mono);
|
||||||
|
font-size: 0.85rem; font-weight: 500; letter-spacing: 0.05em;
|
||||||
|
cursor: pointer; transition: background 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
button:hover { background: var(--accent2); }
|
||||||
|
button:active { transform: scale(0.99); }
|
||||||
|
button:disabled { background: var(--text-faint); cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Results */
|
||||||
|
#results { display: none; width: 100%; max-width: 900px; }
|
||||||
|
|
||||||
|
.section-card {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); margin-bottom: 1.5rem; overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0.75rem 1.25rem; background: var(--surface2);
|
||||||
|
border-bottom: 1px solid var(--border); cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header-title {
|
||||||
|
font-family: var(--mono); font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-toggle {
|
||||||
|
font-family: var(--mono); font-size: 0.7rem; color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-body { padding: 1.25rem; }
|
||||||
|
.section-body.collapsed { display: none; }
|
||||||
|
|
||||||
|
.meta-row { display: flex; gap: 0.75rem; margin-bottom: 0.5rem; font-family: var(--mono); font-size: 0.82rem; }
|
||||||
|
.meta-label { color: var(--text-faint); min-width: 6rem; }
|
||||||
|
.meta-value { color: var(--text); }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block; font-family: var(--mono); font-size: 0.68rem;
|
||||||
|
padding: 0.2rem 0.5rem; border-radius: 3px; letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.badge-ok { background: rgba(107,170,107,0.12); color: var(--success); border: 1px solid rgba(107,170,107,0.25); }
|
||||||
|
.badge-warn { background: rgba(200,160,58,0.12); color: var(--warning); border: 1px solid rgba(200,160,58,0.25); }
|
||||||
|
.badge-err { background: rgba(200,90,58,0.12); color: var(--error); border: 1px solid rgba(200,90,58,0.25); }
|
||||||
|
|
||||||
|
.chapter-table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 0.75rem; }
|
||||||
|
.chapter-table th { color: var(--accent); text-align: left; padding: 0.3rem 0.5rem; border-bottom: 1px solid var(--border); }
|
||||||
|
.chapter-table td { padding: 0.3rem 0.5rem; border-bottom: 1px solid var(--border); color: var(--text-dim); }
|
||||||
|
.chapter-table td:first-child { color: var(--text-faint); width: 3rem; }
|
||||||
|
.chapter-table a { color: var(--text-dim); font-size: 0.7rem; }
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
background: var(--bg); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 1rem;
|
||||||
|
font-family: var(--mono); font-size: 0.72rem;
|
||||||
|
color: var(--warning); white-space: pre-wrap; word-break: break-all;
|
||||||
|
max-height: 400px; overflow-y: auto; margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.code-block::-webkit-scrollbar { width: 4px; }
|
||||||
|
.code-block::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||||
|
|
||||||
|
.sub-label {
|
||||||
|
font-family: var(--mono); font-size: 0.7rem; color: var(--text-faint);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.1em; margin-top: 1rem; margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: none; width: 16px; height: 16px;
|
||||||
|
border: 2px solid var(--text-faint); border-top-color: var(--accent);
|
||||||
|
border-radius: 50%; animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{% include "_sidebar.html" %}
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Inspect URL</div>
|
||||||
|
<label for="url">Story overview page</label>
|
||||||
|
<input type="url" id="url" placeholder="https://..."/>
|
||||||
|
<button id="inspect-btn" onclick="runInspect()">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
|
||||||
|
</svg>
|
||||||
|
<span id="btn-label">Inspect</span>
|
||||||
|
<div class="spinner" id="spinner"></div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="results"></div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function runInspect() {
|
||||||
|
const url = document.getElementById('url').value.trim();
|
||||||
|
if (!url) { alert('Please enter a URL.'); return; }
|
||||||
|
|
||||||
|
document.getElementById('inspect-btn').disabled = true;
|
||||||
|
document.getElementById('btn-label').textContent = 'Inspecting...';
|
||||||
|
document.getElementById('spinner').style.display = 'block';
|
||||||
|
document.getElementById('results').style.display = 'none';
|
||||||
|
document.getElementById('results').innerHTML = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/debug/run', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url })
|
||||||
|
});
|
||||||
|
const d = await resp.json();
|
||||||
|
renderResults(d);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('results').innerHTML = `<div class="section-card"><div class="section-body"><span class="badge badge-err">Error: ${e}</span></div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('results').style.display = 'block';
|
||||||
|
document.getElementById('inspect-btn').disabled = false;
|
||||||
|
document.getElementById('btn-label').textContent = 'Inspect';
|
||||||
|
document.getElementById('spinner').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(title, bodyHtml, defaultOpen = true) {
|
||||||
|
const id = 'sec-' + Math.random().toString(36).slice(2);
|
||||||
|
return `
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-header" onclick="toggleSection('${id}')">
|
||||||
|
<span class="section-header-title">${title}</span>
|
||||||
|
<span class="section-toggle" id="${id}-toggle">${defaultOpen ? '▲ collapse' : '▼ expand'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body ${defaultOpen ? '' : 'collapsed'}" id="${id}">${bodyHtml}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSection(id) {
|
||||||
|
const body = document.getElementById(id);
|
||||||
|
const toggle = document.getElementById(id + '-toggle');
|
||||||
|
const collapsed = body.classList.toggle('collapsed');
|
||||||
|
toggle.textContent = collapsed ? '▼ expand' : '▲ collapse';
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '')
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function metaRow(label, value) {
|
||||||
|
return `<div class="meta-row"><span class="meta-label">${label}</span><span class="meta-value">${value}</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResults(d) {
|
||||||
|
if (d.error) {
|
||||||
|
document.getElementById('results').innerHTML = section('Error', `<div class="code-block">${esc(d.error)}</div>`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// Login
|
||||||
|
if (d.login) {
|
||||||
|
let body = '';
|
||||||
|
if (!d.login.attempted) {
|
||||||
|
body = `<span class="badge badge-warn">No credentials configured — fetching without login</span>`;
|
||||||
|
} else {
|
||||||
|
const ok = d.login.success;
|
||||||
|
body = metaRow('Username', esc(d.login.username))
|
||||||
|
+ metaRow('Status', `<span class="badge ${ok ? 'badge-ok' : 'badge-err'}">${ok ? '✓ Logged in' : '✗ Login failed'}</span>`);
|
||||||
|
}
|
||||||
|
html += section('Login', body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
if (d.meta) {
|
||||||
|
let metaBody = metaRow('Title', esc(d.meta.title))
|
||||||
|
+ metaRow('Author', esc(d.meta.author));
|
||||||
|
if (d.meta.publisher)
|
||||||
|
metaBody += metaRow('Publisher', esc(d.meta.publisher));
|
||||||
|
if (d.meta.series)
|
||||||
|
metaBody += metaRow('Series', esc(d.meta.series));
|
||||||
|
if (d.meta.genres && d.meta.genres.length)
|
||||||
|
metaBody += metaRow('Genres', esc(d.meta.genres.join(', ')));
|
||||||
|
if (d.meta.subgenres && d.meta.subgenres.length)
|
||||||
|
metaBody += metaRow('Sub-genres', esc(d.meta.subgenres.join(', ')));
|
||||||
|
if (d.meta.tags && d.meta.tags.length)
|
||||||
|
metaBody += metaRow('Tags', esc(d.meta.tags.join(', ')));
|
||||||
|
if (d.meta.updated_date)
|
||||||
|
metaBody += metaRow('Updated', esc(d.meta.updated_date));
|
||||||
|
if (d.meta.publication_status)
|
||||||
|
metaBody += metaRow('Status', esc(d.meta.publication_status));
|
||||||
|
if (d.meta.description) {
|
||||||
|
const paras = d.meta.description.split('\n\n').map(p => `<p style="margin:.35rem 0">${esc(p.trim())}</p>`).join('');
|
||||||
|
metaBody += metaRow('Description', `<div>${paras}</div>`);
|
||||||
|
}
|
||||||
|
metaBody += metaRow('Output filename', `<span style="color:var(--accent)">${esc(d.meta.filename)}</span>`);
|
||||||
|
html += section('Book metadata', metaBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chapters
|
||||||
|
if (d.chapters) {
|
||||||
|
const method = d.chapters.method === 'html_scan'
|
||||||
|
? '<span class="badge badge-ok">✓ HTML scan (logged in)</span>'
|
||||||
|
: '<span class="badge badge-warn">⚠ Fallback — sequential numeric</span>';
|
||||||
|
const rows = d.chapters.list.map((c, i) =>
|
||||||
|
`<tr><td>${i + 1}</td><td>${esc(c.title)}</td><td><a href="${esc(c.url)}" target="_blank">${esc(c.url)}</a></td></tr>`
|
||||||
|
).join('');
|
||||||
|
html += section(
|
||||||
|
`Chapters found: ${d.chapters.count}`,
|
||||||
|
`<div class="meta-row"><span class="meta-label">Method</span><span class="meta-value">${method}</span></div>
|
||||||
|
<table class="chapter-table" style="margin-top:.75rem">
|
||||||
|
<tr><th>#</th><th>Title</th><th>URL</th></tr>${rows}
|
||||||
|
</table>`,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First chapter
|
||||||
|
if (d.first_chapter) {
|
||||||
|
const fc = d.first_chapter;
|
||||||
|
if (fc.error) {
|
||||||
|
html += section(`First chapter: ${esc(fc.title)}`,
|
||||||
|
`<span class="badge badge-err">⚠ ${esc(fc.error)}</span>`);
|
||||||
|
} else {
|
||||||
|
html += section(
|
||||||
|
`First chapter: ${esc(fc.title)}`,
|
||||||
|
metaRow('URL', `<a href="${esc(fc.url)}" target="_blank">${esc(fc.url)}</a>`) +
|
||||||
|
metaRow('Selector id', esc(fc.selector_id ?? '—')) +
|
||||||
|
metaRow('Selector class', esc(fc.selector_class ?? '—')) +
|
||||||
|
`<div class="sub-label">Raw HTML from site</div>
|
||||||
|
<div class="code-block">${esc(fc.raw_html)}</div>
|
||||||
|
<div class="sub-label">Converted XHTML (what goes into EPUB)</div>
|
||||||
|
<div class="code-block">${esc(fc.converted_xhtml)}</div>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('results').innerHTML = html;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
97
containers/novela/templates/editor.html
Normal file
97
containers/novela/templates/editor.html
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Novela — Edit {{ title or filename }}</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<link rel="stylesheet" href="/static/editor.css"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="editor-header">
|
||||||
|
<a class="header-back" href="/library/book/{{ filename | urlencode }}">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<polyline points="15 18 9 12 15 6"/>
|
||||||
|
</svg>
|
||||||
|
{{ (title or filename) | truncate(30, True) }}
|
||||||
|
</a>
|
||||||
|
<div class="header-chapter" id="header-chapter">—</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn-add-page" id="btn-add-page" onclick="addChapter()" title="Add new chapter after current">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M12 5v14"/><path d="M5 12h14"/>
|
||||||
|
</svg>
|
||||||
|
Add page
|
||||||
|
</button>
|
||||||
|
<button class="btn-del-page" id="btn-del-page" onclick="deleteChapter()" title="Delete current chapter" disabled>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M7 6l1 14h8l1-14"/>
|
||||||
|
</svg>
|
||||||
|
Delete page
|
||||||
|
</button>
|
||||||
|
<button class="btn-break" id="btn-break" onclick="insertBreak()" title="Insert scene break at cursor" disabled>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="3" y1="12" x2="9" y2="12"/>
|
||||||
|
<circle cx="12" cy="12" r="2" fill="currentColor" stroke="none"/>
|
||||||
|
<line x1="15" y1="12" x2="21" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
Break
|
||||||
|
</button>
|
||||||
|
<button class="btn-replace" onclick="openReplaceModal()" title="Find & replace across all chapters">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
Replace
|
||||||
|
</button>
|
||||||
|
<span class="save-status" id="save-status"></span>
|
||||||
|
<button class="btn-save-all" id="btn-save-all" onclick="saveAllChapters()" style="display:none"></button>
|
||||||
|
<button class="btn-save" id="btn-save" onclick="saveChapter()" disabled>Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Two-panel body -->
|
||||||
|
<div class="editor-body">
|
||||||
|
<nav class="chapter-panel">
|
||||||
|
<div class="chapter-panel-title">Chapters</div>
|
||||||
|
<div class="chapter-list" id="chapter-list"></div>
|
||||||
|
</nav>
|
||||||
|
<div class="editor-pane" id="editor-pane"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Find & Replace modal -->
|
||||||
|
<div class="modal-backdrop" id="replace-modal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-title">Find & Replace — all chapters</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label class="modal-label">Search</label>
|
||||||
|
<input class="modal-input" id="rp-search" type="text" placeholder="Search…" autocomplete="off"/>
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label class="modal-label">Replace with</label>
|
||||||
|
<input class="modal-input" id="rp-replace" type="text" placeholder="Replace with…" autocomplete="off"/>
|
||||||
|
</div>
|
||||||
|
<div class="modal-options">
|
||||||
|
<label class="modal-opt"><input type="checkbox" id="rp-regex"/> Regex</label>
|
||||||
|
<label class="modal-opt"><input type="checkbox" id="rp-case"/> Case sensitive</label>
|
||||||
|
</div>
|
||||||
|
<div class="modal-progress" id="rp-progress"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-secondary" onclick="closeReplaceModal()">Cancel</button>
|
||||||
|
<button class="btn-primary" id="rp-run" onclick="replaceInAllChapters()">Replace all</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const EDITOR = {
|
||||||
|
filename: {{ filename | tojson }},
|
||||||
|
title: {{ (title or filename) | tojson }},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
|
||||||
|
<script src="/static/editor.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
562
containers/novela/templates/grabber.html
Normal file
562
containers/novela/templates/grabber.html
Normal file
@ -0,0 +1,562 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Novela</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<link rel="stylesheet" href="/static/sidebar.css"/>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f0e0c;
|
||||||
|
--surface: #1a1815;
|
||||||
|
--surface2: #221f1b;
|
||||||
|
--border: #2e2a24;
|
||||||
|
--accent: #c8783a;
|
||||||
|
--accent2: #e8a063;
|
||||||
|
--text: #e8e2d9;
|
||||||
|
--text-dim: #8a8278;
|
||||||
|
--text-faint: #4a453e;
|
||||||
|
--success: #6baa6b;
|
||||||
|
--warning: #c8a03a;
|
||||||
|
--error: #c85a3a;
|
||||||
|
--radius: 6px;
|
||||||
|
--sidebar: 220px;
|
||||||
|
--mono: 'DM Mono', monospace;
|
||||||
|
--serif: 'Libre Baskerville', Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html, body { height: 100%; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--serif);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main content ── */
|
||||||
|
.main {
|
||||||
|
margin-left: var(--sidebar);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3rem 1rem 5rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main { margin-left: 0; padding: 4rem 1rem 4rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 2rem;
|
||||||
|
width: 100%; max-width: 620px; margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 0.7rem; font-family: var(--mono);
|
||||||
|
letter-spacing: 0.12em; text-transform: uppercase;
|
||||||
|
color: var(--accent); margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block; font-size: 0.78rem; font-family: var(--mono);
|
||||||
|
color: var(--text-dim); margin-bottom: 0.4rem; letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="url"] {
|
||||||
|
width: 100%; background: var(--bg); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); color: var(--text);
|
||||||
|
font-family: var(--mono); font-size: 0.85rem;
|
||||||
|
padding: 0.65rem 0.85rem; outline: none;
|
||||||
|
transition: border-color 0.15s; margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
input[type="url"]:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
||||||
|
width: 100%; padding: 0.85rem; background: var(--accent); color: #0f0e0c;
|
||||||
|
border: none; border-radius: var(--radius); font-family: var(--mono);
|
||||||
|
font-size: 0.85rem; font-weight: 500; letter-spacing: 0.05em;
|
||||||
|
cursor: pointer; transition: background 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
button:hover { background: var(--accent2); }
|
||||||
|
button:active { transform: scale(0.99); }
|
||||||
|
button:disabled { background: var(--text-faint); cursor: not-allowed; }
|
||||||
|
|
||||||
|
.cred-status {
|
||||||
|
font-family: var(--mono); font-size: 0.75rem;
|
||||||
|
margin-top: -0.6rem; margin-bottom: 1rem;
|
||||||
|
padding: 0.4rem 0.7rem; border-radius: var(--radius); display: none;
|
||||||
|
}
|
||||||
|
.cred-status.found {
|
||||||
|
display: block; color: var(--success);
|
||||||
|
background: rgba(107,170,107,0.08); border: 1px solid rgba(107,170,107,0.2);
|
||||||
|
}
|
||||||
|
.cred-status.missing {
|
||||||
|
display: block; color: var(--text-faint);
|
||||||
|
background: var(--surface2); border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: none; width: 14px; height: 14px;
|
||||||
|
border: 2px solid rgba(15,14,12,0.3); border-top-color: #0f0e0c;
|
||||||
|
border-radius: 50%; animation: spin 0.7s linear infinite; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Metadata preview card */
|
||||||
|
#meta-card { display: none; }
|
||||||
|
#meta-card.visible { display: block; }
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
display: flex; gap: 0.75rem; margin-bottom: 0.5rem;
|
||||||
|
font-family: var(--mono); font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.meta-label { color: var(--text-faint); min-width: 7rem; flex-shrink: 0; }
|
||||||
|
.meta-value { color: var(--text); }
|
||||||
|
|
||||||
|
.description-text {
|
||||||
|
font-size: 0.85rem; color: var(--text-dim); line-height: 1.7;
|
||||||
|
max-height: 160px; overflow-y: auto; margin-bottom: 1rem;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
}
|
||||||
|
.description-text::-webkit-scrollbar { width: 4px; }
|
||||||
|
.description-text::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
border: none; border-top: 1px solid var(--border);
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cover upload */
|
||||||
|
.cover-upload-area {
|
||||||
|
border: 1px dashed var(--border); border-radius: var(--radius);
|
||||||
|
padding: 1.25rem; text-align: center; margin-bottom: 1.25rem;
|
||||||
|
cursor: pointer; transition: border-color 0.15s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.cover-upload-area:hover { border-color: var(--accent); }
|
||||||
|
.cover-upload-area input[type="file"] {
|
||||||
|
position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%;
|
||||||
|
}
|
||||||
|
.cover-upload-label {
|
||||||
|
font-family: var(--mono); font-size: 0.78rem; color: var(--text-dim);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.cover-upload-label span { color: var(--accent); }
|
||||||
|
.cover-preview {
|
||||||
|
display: none; max-height: 180px; max-width: 120px;
|
||||||
|
border-radius: var(--radius); margin: 0 auto 0.6rem;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.cover-preview.visible { display: block; }
|
||||||
|
.cover-filename {
|
||||||
|
font-family: var(--mono); font-size: 0.72rem; color: var(--success);
|
||||||
|
margin-top: 0.4rem; display: none;
|
||||||
|
}
|
||||||
|
.cover-filename.visible { display: block; }
|
||||||
|
|
||||||
|
/* Progress */
|
||||||
|
#progress-card { display: none; }
|
||||||
|
#progress-card.visible { display: block; }
|
||||||
|
|
||||||
|
.status-line {
|
||||||
|
font-family: var(--mono); font-size: 0.8rem;
|
||||||
|
color: var(--text-dim); margin-bottom: 1rem; min-height: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-wrap {
|
||||||
|
background: var(--bg); border: 1px solid var(--border);
|
||||||
|
border-radius: 100px; height: 6px; margin-bottom: 1.5rem; overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%; background: var(--accent); border-radius: 100px;
|
||||||
|
width: 0%; transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-list {
|
||||||
|
list-style: none; max-height: 260px; overflow-y: auto;
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg);
|
||||||
|
}
|
||||||
|
.chapter-list::-webkit-scrollbar { width: 4px; }
|
||||||
|
.chapter-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||||
|
|
||||||
|
.chapter-item {
|
||||||
|
display: flex; align-items: center; gap: 0.6rem;
|
||||||
|
padding: 0.5rem 0.75rem; font-family: var(--mono); font-size: 0.75rem;
|
||||||
|
color: var(--text-faint); border-bottom: 1px solid var(--border); transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.chapter-item:last-child { border-bottom: none; }
|
||||||
|
.chapter-item.done { color: var(--success); }
|
||||||
|
.chapter-item.active { color: var(--accent2); }
|
||||||
|
.chapter-item .dot {
|
||||||
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: currentColor; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chapter-item.active .dot { animation: pulse 1s infinite; }
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||||
|
|
||||||
|
.log-lines {
|
||||||
|
margin-top: 1rem; font-family: var(--mono); font-size: 0.72rem;
|
||||||
|
color: var(--text-faint); line-height: 1.8;
|
||||||
|
max-height: 120px; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.log-lines .warn { color: var(--warning); }
|
||||||
|
.log-lines .err { color: var(--error); }
|
||||||
|
|
||||||
|
/* Result */
|
||||||
|
#result-card { display: none; }
|
||||||
|
#result-card.visible { display: block; }
|
||||||
|
|
||||||
|
.result-meta {
|
||||||
|
font-size: 0.85rem; color: var(--text-dim);
|
||||||
|
margin-bottom: 1.25rem; line-height: 1.8;
|
||||||
|
}
|
||||||
|
.result-meta strong { color: var(--text); font-weight: 400; }
|
||||||
|
|
||||||
|
.download-btn { background: var(--success); }
|
||||||
|
.download-btn:hover { background: #82c082; }
|
||||||
|
.result-actions { display: flex; gap: 0.75rem; }
|
||||||
|
.result-actions button { width: auto; flex: 1; }
|
||||||
|
.btn-outline {
|
||||||
|
background: var(--surface2); color: var(--text-dim);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.btn-outline:hover { background: var(--surface); color: var(--text); border-color: var(--text-faint); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{% include "_sidebar.html" %}
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
|
||||||
|
<!-- Step 1: URL input -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Book URL</div>
|
||||||
|
<label for="url">Story overview page</label>
|
||||||
|
<input type="url" id="url" placeholder="https://..." oninput="checkUrlCredentials()"/>
|
||||||
|
<div class="cred-status" id="cred-status"></div>
|
||||||
|
<button id="load-btn" onclick="loadMeta()">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
|
||||||
|
</svg>
|
||||||
|
<span id="load-label">Load metadata</span>
|
||||||
|
<div class="spinner" id="load-spinner"></div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Metadata preview + cover upload + Convert -->
|
||||||
|
<div class="card" id="meta-card">
|
||||||
|
<div class="card-title">Book info</div>
|
||||||
|
|
||||||
|
<div id="meta-rows"></div>
|
||||||
|
|
||||||
|
<hr class="divider"/>
|
||||||
|
|
||||||
|
<label>Cover image</label>
|
||||||
|
<div class="cover-upload-area" id="cover-upload-area">
|
||||||
|
<input type="file" id="cover-file" accept="image/*" onchange="onCoverSelected()"/>
|
||||||
|
<img class="cover-preview" id="cover-preview" src="" alt="cover preview"/>
|
||||||
|
<div class="cover-upload-label">
|
||||||
|
<span id="cover-upload-text">Click to select a cover image</span>
|
||||||
|
</div>
|
||||||
|
<div class="cover-filename" id="cover-filename"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="convert-btn" onclick="startConvert()">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
<span id="convert-label">Convert</span>
|
||||||
|
<div class="spinner" id="convert-spinner"></div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress -->
|
||||||
|
<div class="card" id="progress-card">
|
||||||
|
<div class="card-title">Progress</div>
|
||||||
|
<div class="status-line" id="status-line">Connecting...</div>
|
||||||
|
<div class="progress-bar-wrap">
|
||||||
|
<div class="progress-bar" id="progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
<ul class="chapter-list" id="chapter-list"></ul>
|
||||||
|
<div class="log-lines" id="log-lines"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Result -->
|
||||||
|
<div class="card" id="result-card">
|
||||||
|
<div class="card-title">Done</div>
|
||||||
|
<div class="result-meta" id="result-meta"></div>
|
||||||
|
<div class="result-actions">
|
||||||
|
<button class="download-btn" id="download-btn">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/>
|
||||||
|
</svg>
|
||||||
|
Download EPUB
|
||||||
|
</button>
|
||||||
|
<button class="btn-outline" id="book-detail-btn">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/>
|
||||||
|
</svg>
|
||||||
|
Go to book
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentUrl = '';
|
||||||
|
let coverB64 = null;
|
||||||
|
|
||||||
|
// --- Credential status ---
|
||||||
|
async function checkUrlCredentials() {
|
||||||
|
const url = document.getElementById('url').value.trim();
|
||||||
|
const el = document.getElementById('cred-status');
|
||||||
|
if (!url) { el.className = 'cred-status'; return; }
|
||||||
|
try {
|
||||||
|
const domain = new URL(url).hostname.replace(/^www\./, '');
|
||||||
|
const r = await fetch('/credentials');
|
||||||
|
const creds = (await r.json())[domain];
|
||||||
|
if (creds !== undefined) {
|
||||||
|
el.className = 'cred-status found';
|
||||||
|
el.textContent = `✓ Credentials available for ${domain}${creds.username ? ' (' + creds.username + ')' : ''}`;
|
||||||
|
} else {
|
||||||
|
el.className = 'cred-status missing';
|
||||||
|
el.textContent = `No credentials configured for ${domain}`;
|
||||||
|
}
|
||||||
|
} catch (e) { el.className = 'cred-status'; }
|
||||||
|
}
|
||||||
|
checkUrlCredentials();
|
||||||
|
|
||||||
|
// --- Step 1: Load metadata ---
|
||||||
|
async function loadMeta() {
|
||||||
|
const url = document.getElementById('url').value.trim();
|
||||||
|
if (!url) { alert('Please enter a URL.'); return; }
|
||||||
|
|
||||||
|
currentUrl = url;
|
||||||
|
coverB64 = null;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
document.getElementById('meta-card').classList.remove('visible');
|
||||||
|
document.getElementById('progress-card').classList.remove('visible');
|
||||||
|
document.getElementById('result-card').classList.remove('visible');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/preload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
});
|
||||||
|
const d = await resp.json();
|
||||||
|
|
||||||
|
if (d.error) {
|
||||||
|
alert('Error loading metadata:\n' + d.error);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMeta(d);
|
||||||
|
document.getElementById('meta-card').classList.add('visible');
|
||||||
|
// Reset cover upload
|
||||||
|
document.getElementById('cover-file').value = '';
|
||||||
|
document.getElementById('cover-preview').classList.remove('visible');
|
||||||
|
document.getElementById('cover-filename').classList.remove('visible');
|
||||||
|
document.getElementById('cover-upload-text').textContent = 'Click to select a cover image';
|
||||||
|
} catch (e) {
|
||||||
|
alert('Failed to load metadata: ' + e);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(on) {
|
||||||
|
document.getElementById('load-btn').disabled = on;
|
||||||
|
document.getElementById('load-label').textContent = on ? 'Loading...' : 'Load metadata';
|
||||||
|
document.getElementById('load-spinner').style.display = on ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function metaRow(label, value) {
|
||||||
|
return `<div class="meta-row">
|
||||||
|
<span class="meta-label">${label}</span>
|
||||||
|
<span class="meta-value">${esc(value)}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMeta(d) {
|
||||||
|
let html = '';
|
||||||
|
html += metaRow('Title', d.title);
|
||||||
|
html += metaRow('Author', d.author);
|
||||||
|
if (d.publisher) html += metaRow('Publisher', d.publisher);
|
||||||
|
if (d.series) html += metaRow('Series', d.series);
|
||||||
|
if (d.series) {
|
||||||
|
html += `<div class="meta-row">
|
||||||
|
<span class="meta-label">Series index</span>
|
||||||
|
<span class="meta-value"><input type="number" id="series-index-input" min="1" value="${d.series_index_next}" style="width:5rem;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-family:var(--mono);font-size:0.82rem;padding:0.25rem 0.5rem;"/></span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if (d.genres && d.genres.length) html += metaRow('Genres', d.genres.join(', '));
|
||||||
|
if (d.subgenres && d.subgenres.length) html += metaRow('Sub-genres', d.subgenres.join(', '));
|
||||||
|
if (d.tags && d.tags.length) html += metaRow('Tags', d.tags.join(', '));
|
||||||
|
html += `<div class="meta-row">
|
||||||
|
<span class="meta-label">Updated</span>
|
||||||
|
<span class="meta-value"><input type="date" id="updated-date-input" value="${d.updated_date || ''}"
|
||||||
|
style="background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);
|
||||||
|
color:var(--text);font-family:var(--mono);font-size:0.82rem;
|
||||||
|
padding:0.25rem 0.5rem;color-scheme:dark;"/></span>
|
||||||
|
</div>`;
|
||||||
|
if (d.publication_status) html += metaRow('Status', d.publication_status);
|
||||||
|
if (d.description) {
|
||||||
|
const paras = d.description.split('\n\n')
|
||||||
|
.map(p => `<p style="margin:.4rem 0">${esc(p.trim())}</p>`).join('');
|
||||||
|
html += `<div class="meta-row"><span class="meta-label">Description</span>
|
||||||
|
<div class="description-text">${paras}</div></div>`;
|
||||||
|
}
|
||||||
|
html += metaRow('Filename', d.filename);
|
||||||
|
document.getElementById('meta-rows').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cover upload ---
|
||||||
|
function onCoverSelected() {
|
||||||
|
const file = document.getElementById('cover-file').files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = e => {
|
||||||
|
const dataUrl = e.target.result;
|
||||||
|
coverB64 = dataUrl.split(',')[1]; // strip "data:image/...;base64,"
|
||||||
|
|
||||||
|
const preview = document.getElementById('cover-preview');
|
||||||
|
preview.src = dataUrl;
|
||||||
|
preview.classList.add('visible');
|
||||||
|
|
||||||
|
document.getElementById('cover-upload-text').textContent = 'Click to replace cover';
|
||||||
|
document.getElementById('cover-filename').textContent = file.name;
|
||||||
|
document.getElementById('cover-filename').classList.add('visible');
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 2: Convert ---
|
||||||
|
async function startConvert() {
|
||||||
|
if (!currentUrl) return;
|
||||||
|
|
||||||
|
document.getElementById('convert-btn').disabled = true;
|
||||||
|
document.getElementById('convert-label').textContent = 'Starting...';
|
||||||
|
document.getElementById('convert-spinner').style.display = 'block';
|
||||||
|
document.getElementById('progress-card').classList.add('visible');
|
||||||
|
document.getElementById('result-card').classList.remove('visible');
|
||||||
|
document.getElementById('chapter-list').innerHTML = '';
|
||||||
|
document.getElementById('log-lines').innerHTML = '';
|
||||||
|
document.getElementById('progress-bar').style.width = '0%';
|
||||||
|
|
||||||
|
const body = { url: currentUrl };
|
||||||
|
if (coverB64) body.cover_b64 = coverB64;
|
||||||
|
const seriesInput = document.getElementById('series-index-input');
|
||||||
|
if (seriesInput) body.series_index = parseInt(seriesInput.value) || 1;
|
||||||
|
const dateInput = document.getElementById('updated-date-input');
|
||||||
|
if (dateInput) body.updated_date = dateInput.value || '';
|
||||||
|
|
||||||
|
const resp = await fetch('/convert', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const { job_id } = await resp.json();
|
||||||
|
|
||||||
|
document.getElementById('convert-label').textContent = 'Convert';
|
||||||
|
document.getElementById('convert-spinner').style.display = 'none';
|
||||||
|
|
||||||
|
const es = new EventSource(`/events/${job_id}`);
|
||||||
|
|
||||||
|
es.addEventListener('status', e => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
document.getElementById('status-line').textContent = d.message;
|
||||||
|
addLog(d.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('meta', e => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
document.getElementById('status-line').textContent = `"${d.title}" by ${d.author}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('chapters', e => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
const ul = document.getElementById('chapter-list');
|
||||||
|
ul.innerHTML = '';
|
||||||
|
d.chapters.forEach((title, i) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'chapter-item';
|
||||||
|
li.id = `ch-${i}`;
|
||||||
|
li.innerHTML = `<span class="dot"></span><span>${esc(title)}</span>`;
|
||||||
|
ul.appendChild(li);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('progress', e => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
document.getElementById('progress-bar').style.width =
|
||||||
|
Math.round((d.current / d.total) * 100) + '%';
|
||||||
|
document.getElementById('status-line').textContent =
|
||||||
|
`Chapter ${d.current} of ${d.total}: ${d.title}`;
|
||||||
|
if (d.current > 1) {
|
||||||
|
const prev = document.getElementById(`ch-${d.current - 2}`);
|
||||||
|
if (prev) prev.className = 'chapter-item done';
|
||||||
|
}
|
||||||
|
const cur = document.getElementById(`ch-${d.current - 1}`);
|
||||||
|
if (cur) { cur.className = 'chapter-item active'; cur.scrollIntoView({ block: 'nearest' }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('warning', e => {
|
||||||
|
addLog(JSON.parse(e.data).message, 'warn');
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('error', e => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
addLog(d.message, 'err');
|
||||||
|
document.getElementById('status-line').textContent = '❌ ' + d.message;
|
||||||
|
document.getElementById('convert-btn').disabled = false;
|
||||||
|
es.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('done', e => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
document.getElementById('progress-bar').style.width = '100%';
|
||||||
|
document.getElementById('status-line').textContent = 'Done ✓';
|
||||||
|
document.querySelectorAll('.chapter-item').forEach(el => el.className = 'chapter-item done');
|
||||||
|
document.getElementById('result-meta').innerHTML =
|
||||||
|
`<strong>${esc(d.title)}</strong><br/>${d.chapters} chapters successfully converted`;
|
||||||
|
document.getElementById('download-btn').onclick = () => {
|
||||||
|
window.location = `/download/${encodeURIComponent(d.filename)}`;
|
||||||
|
};
|
||||||
|
document.getElementById('book-detail-btn').onclick = () => {
|
||||||
|
window.location = `/library/book/${encodeURIComponent(d.filename)}`;
|
||||||
|
};
|
||||||
|
document.getElementById('result-card').classList.add('visible');
|
||||||
|
document.getElementById('convert-btn').disabled = false;
|
||||||
|
es.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLog(msg, cls) {
|
||||||
|
const div = document.getElementById('log-lines');
|
||||||
|
const span = document.createElement('span');
|
||||||
|
if (cls) span.className = cls;
|
||||||
|
span.textContent = msg;
|
||||||
|
span.style.display = 'block';
|
||||||
|
div.appendChild(span);
|
||||||
|
div.scrollTop = div.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '')
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
422
containers/novela/templates/home.html
Normal file
422
containers/novela/templates/home.html
Normal file
@ -0,0 +1,422 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Novela — Home</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<link rel="stylesheet" href="/static/sidebar.css"/>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b;
|
||||||
|
--border: #2e2a24; --accent: #c8783a; --accent2: #e8a063;
|
||||||
|
--text: #e8e2d9; --text-dim: #8a8278; --text-faint: #4a453e;
|
||||||
|
--success: #6baa6b; --warning: #c8a03a; --error: #c85a3a;
|
||||||
|
--radius: 6px; --sidebar: 220px;
|
||||||
|
--mono: 'DM Mono', monospace; --serif: 'Libre Baskerville', Georgia, serif;
|
||||||
|
}
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
|
||||||
|
|
||||||
|
.main { margin-left: var(--sidebar); min-height: 100vh; padding: 2rem 2.5rem 4rem; }
|
||||||
|
|
||||||
|
/* ── Section header ──────────────────────────────────────────────── */
|
||||||
|
.section-block { margin-bottom: 2.5rem; }
|
||||||
|
.section-header {
|
||||||
|
display: flex; align-items: baseline; justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase; color: var(--accent);
|
||||||
|
}
|
||||||
|
.section-sub { color: var(--text-dim); }
|
||||||
|
.section-more {
|
||||||
|
font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim);
|
||||||
|
background: none; border: none; cursor: pointer; letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase; padding: 0;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.section-more:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── Horizontal scroll row ───────────────────────────────────────── */
|
||||||
|
.h-row { display: flex; gap: 1rem; overflow-x: auto; padding-bottom: 0.75rem; }
|
||||||
|
.h-row::-webkit-scrollbar { height: 4px; }
|
||||||
|
.h-row::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||||
|
|
||||||
|
.h-card {
|
||||||
|
flex: 0 0 120px; display: flex; flex-direction: column;
|
||||||
|
text-decoration: none; border-radius: var(--radius); overflow: hidden;
|
||||||
|
border: 1px solid var(--border); background: var(--surface);
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.h-card:hover { border-color: var(--accent); }
|
||||||
|
.h-cover {
|
||||||
|
position: relative; width: 120px; height: 180px;
|
||||||
|
flex-shrink: 0; background: var(--surface2);
|
||||||
|
}
|
||||||
|
.h-cover img, .h-cover canvas { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.h-info { padding: 0.5rem 0.5rem 0.6rem; flex: 1; }
|
||||||
|
.h-title {
|
||||||
|
font-size: 0.72rem; font-weight: 700; color: var(--text); line-height: 1.3;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
.h-author {
|
||||||
|
font-family: var(--mono); font-size: 0.6rem; color: var(--text-dim);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.h-progress-bar { height: 3px; background: rgba(200,120,58,0.2); border-radius: 2px; margin-bottom: 0.25rem; }
|
||||||
|
.h-progress-fill { height: 100%; background: var(--accent); border-radius: 2px; }
|
||||||
|
.h-pct { font-family: var(--mono); font-size: 0.6rem; color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* ── Full grid ───────────────────────────────────────────────────── */
|
||||||
|
.grid-header {
|
||||||
|
display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.75rem;
|
||||||
|
}
|
||||||
|
.grid-title {
|
||||||
|
font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase; color: var(--accent);
|
||||||
|
}
|
||||||
|
.btn-back {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||||
|
font-family: var(--mono); font-size: 0.65rem; letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase; color: var(--text-dim);
|
||||||
|
background: none; border: none; cursor: pointer; padding: 0;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.btn-back:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
.cover-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.book-card { display: flex; flex-direction: column; cursor: default; }
|
||||||
|
.cover-wrap {
|
||||||
|
position: relative; width: 100%; aspect-ratio: 2 / 3;
|
||||||
|
border-radius: var(--radius); overflow: hidden; background: var(--surface2);
|
||||||
|
}
|
||||||
|
.cover-link { display: block; width: 100%; height: 100%; }
|
||||||
|
.cover-img, .cover-canvas { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.progress-mini {
|
||||||
|
position: absolute; bottom: 0; left: 0; right: 0;
|
||||||
|
height: 3px; z-index: 2; pointer-events: none;
|
||||||
|
background: rgba(200,120,58,0.25);
|
||||||
|
}
|
||||||
|
.progress-mini-fill { height: 100%; background: var(--accent); }
|
||||||
|
.book-info { padding: 0.5rem 0.2rem 0; }
|
||||||
|
.book-title {
|
||||||
|
font-size: 0.78rem; font-weight: 700; color: var(--text); line-height: 1.3;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
}
|
||||||
|
.book-author {
|
||||||
|
font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim);
|
||||||
|
margin-top: 0.2rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center; color: var(--text-faint); font-family: var(--mono);
|
||||||
|
font-size: 0.82rem; padding: 4rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main { margin-left: 0; padding: 4rem 1rem 4rem; }
|
||||||
|
.cover-grid { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 1rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{% include "_sidebar.html" %}
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
|
||||||
|
<!-- ── Home view: three horizontal rows ─────────────────────────────── -->
|
||||||
|
<div id="home-view">
|
||||||
|
<div class="section-block" id="cr-section" style="display:none">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-title">Continue Reading</div>
|
||||||
|
<button class="section-more" onclick="switchView('continue-reading')">See all</button>
|
||||||
|
</div>
|
||||||
|
<div class="h-row" id="cr-row"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-block" id="shorts-section" style="display:none">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-title">Shorts <span class="section-sub">· Unread</span></div>
|
||||||
|
<button class="section-more" onclick="switchView('shorts-unread')">See all</button>
|
||||||
|
</div>
|
||||||
|
<div class="h-row" id="shorts-row"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-block" id="novels-section" style="display:none">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-title">Novels <span class="section-sub">· Unread</span></div>
|
||||||
|
<button class="section-more" onclick="switchView('novels-unread')">See all</button>
|
||||||
|
</div>
|
||||||
|
<div class="h-row" id="novels-row"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-block" id="shorts-read-section" style="display:none">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-title">Shorts <span class="section-sub">· Recently Read</span></div>
|
||||||
|
<button class="section-more" onclick="switchView('shorts-read')">See all</button>
|
||||||
|
</div>
|
||||||
|
<div class="h-row" id="shorts-read-row"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-block" id="novels-read-section" style="display:none">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-title">Novels <span class="section-sub">· Recently Read</span></div>
|
||||||
|
<button class="section-more" onclick="switchView('novels-read')">See all</button>
|
||||||
|
</div>
|
||||||
|
<div class="h-row" id="novels-read-row"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty" id="home-empty" style="display:none">Nothing here yet — convert some books to get started.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Grid view: see-all ────────────────────────────────────────────── -->
|
||||||
|
<div id="grid-view" style="display:none">
|
||||||
|
<div class="grid-header">
|
||||||
|
<button class="btn-back" onclick="switchView('home')">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="15 18 9 12 15 6"/></svg>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<div class="grid-title" id="grid-title"></div>
|
||||||
|
</div>
|
||||||
|
<div class="cover-grid" id="grid-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let data = { continue_reading: [], shorts_unread: [], novels_unread: [], shorts_read: [], novels_read: [] };
|
||||||
|
let currentView = 'home';
|
||||||
|
|
||||||
|
// ── Utilities ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function strHash(s) {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
||||||
|
return Math.abs(h);
|
||||||
|
}
|
||||||
|
const PALETTES = [
|
||||||
|
['#1a2a3a','#4a8caa'],['#2a1a1a','#aa4a4a'],['#1a2a1a','#4aaa6a'],['#2a1a2a','#8a4aaa'],
|
||||||
|
['#2a2a1a','#aaa04a'],['#1a2a2a','#4aaa9a'],['#2a1a14','#c8783a'],['#141a2a','#5a78c8'],
|
||||||
|
];
|
||||||
|
function makePlaceholder(canvas, title, author) {
|
||||||
|
const w = canvas.width = canvas.offsetWidth || 150;
|
||||||
|
const h = canvas.height = canvas.offsetHeight || 225;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const [bg, fg] = PALETTES[strHash(title) % PALETTES.length];
|
||||||
|
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||||
|
ctx.fillStyle = fg; ctx.globalAlpha = 0.15; ctx.fillRect(0, 0, w, h * 0.08); ctx.globalAlpha = 1;
|
||||||
|
ctx.fillStyle = fg; ctx.fillRect(w * 0.12, h * 0.12, w * 0.04, h * 0.55);
|
||||||
|
ctx.fillStyle = '#e8e2d9';
|
||||||
|
ctx.font = `bold ${Math.round(w * 0.105)}px 'Libre Baskerville', Georgia, serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
wrapText(ctx, title, w * 0.55, h * 0.28, w * 0.72, Math.round(w * 0.12));
|
||||||
|
ctx.fillStyle = fg; ctx.font = `${Math.round(w * 0.075)}px 'DM Mono', monospace`;
|
||||||
|
ctx.globalAlpha = 0.85; ctx.fillText(trunc(author, 18), w * 0.55, h * 0.86); ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
function wrapText(ctx, text, x, y, maxW, lineH) {
|
||||||
|
const words = text.split(' '); let line = '', lines = [];
|
||||||
|
for (const w of words) {
|
||||||
|
const t = line ? line + ' ' + w : w;
|
||||||
|
if (ctx.measureText(t).width > maxW && line) { lines.push(line); line = w; } else line = t;
|
||||||
|
}
|
||||||
|
if (line) lines.push(line); lines = lines.slice(0, 4);
|
||||||
|
const startY = y - ((lines.length - 1) * lineH) / 2;
|
||||||
|
lines.forEach((l, i) => ctx.fillText(l, x, startY + i * lineH));
|
||||||
|
}
|
||||||
|
function trunc(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
|
||||||
|
function esc(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
function cssId(s) { return s.replace(/[^a-zA-Z0-9]/g, '_'); }
|
||||||
|
function bookTitle(b) { return b.title || (b.filename.replace(/\.epub$/, '').split('-')[2] ?? '').replace(/_/g, ' '); }
|
||||||
|
function bookAuthor(b) {
|
||||||
|
if (b.author) return b.author;
|
||||||
|
return (b.filename.replace(/\.epub$/, '').split('-')[1] ?? '').replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cover helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function attachCover(coverEl, canvasEl, b) {
|
||||||
|
const title = bookTitle(b);
|
||||||
|
const author = bookAuthor(b);
|
||||||
|
if (b.has_cover) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
|
||||||
|
img.src = `/library/cover/${encodeURIComponent(b.filename)}`;
|
||||||
|
img.alt = title;
|
||||||
|
img.onload = () => { canvasEl.style.display = 'none'; };
|
||||||
|
img.onerror = () => requestAnimationFrame(() => makePlaceholder(canvasEl, title, author));
|
||||||
|
coverEl.style.position = 'relative';
|
||||||
|
coverEl.insertBefore(img, coverEl.firstChild);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => makePlaceholder(canvasEl, title, author));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Horizontal row card ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeHCard(b, showProgress) {
|
||||||
|
const title = bookTitle(b);
|
||||||
|
const author = bookAuthor(b);
|
||||||
|
const id = cssId(b.filename);
|
||||||
|
const card = document.createElement('a');
|
||||||
|
card.className = 'h-card';
|
||||||
|
card.href = `/library/book/${encodeURIComponent(b.filename)}`;
|
||||||
|
card.title = title;
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="h-cover" id="hc-${id}">
|
||||||
|
<canvas id="hcv-${id}" style="width:100%;height:100%;display:block"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="h-info">
|
||||||
|
<div class="h-title">${esc(title)}</div>
|
||||||
|
${showProgress
|
||||||
|
? `<div class="h-progress-bar"><div class="h-progress-fill" style="width:${b.progress}%"></div></div>
|
||||||
|
<div class="h-pct">${b.progress}%</div>`
|
||||||
|
: `<div class="h-author">${esc(author)}</div>`}
|
||||||
|
</div>`;
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Grid card (full grid view) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeGridCard(b) {
|
||||||
|
const title = bookTitle(b);
|
||||||
|
const author = bookAuthor(b);
|
||||||
|
const id = cssId(b.filename);
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'book-card';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="cover-wrap">
|
||||||
|
<a class="cover-link" href="/library/book/${encodeURIComponent(b.filename)}">
|
||||||
|
<canvas id="gc-${id}" class="cover-canvas"></canvas>
|
||||||
|
${b.progress > 0
|
||||||
|
? `<div class="progress-mini"><div class="progress-mini-fill" style="width:${b.progress}%"></div></div>`
|
||||||
|
: ''}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="book-info">
|
||||||
|
<div class="book-title">${esc(title)}</div>
|
||||||
|
<div class="book-author">${esc(author)}</div>
|
||||||
|
</div>`;
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render rows ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderRow(rowEl, books, showProgress) {
|
||||||
|
rowEl.innerHTML = '';
|
||||||
|
books.slice(0, 20).forEach(b => {
|
||||||
|
const id = cssId(b.filename);
|
||||||
|
const card = makeHCard(b, showProgress);
|
||||||
|
rowEl.appendChild(card);
|
||||||
|
const coverEl = card.querySelector(`#hc-${id}`);
|
||||||
|
const canvasEl = card.querySelector(`#hcv-${id}`);
|
||||||
|
attachCover(coverEl, canvasEl, b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid(books) {
|
||||||
|
const container = document.getElementById('grid-container');
|
||||||
|
container.innerHTML = '';
|
||||||
|
books.forEach(b => {
|
||||||
|
const id = cssId(b.filename);
|
||||||
|
const card = makeGridCard(b);
|
||||||
|
container.appendChild(card);
|
||||||
|
const canvas = card.querySelector(`#gc-${id}`);
|
||||||
|
if (b.has_cover) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.className = 'cover-img';
|
||||||
|
img.src = `/library/cover/${encodeURIComponent(b.filename)}`;
|
||||||
|
img.alt = bookTitle(b);
|
||||||
|
img.onload = () => { canvas.style.display = 'none'; };
|
||||||
|
img.onerror = () => requestAnimationFrame(() => makePlaceholder(canvas, bookTitle(b), bookAuthor(b)));
|
||||||
|
img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover';
|
||||||
|
canvas.parentElement.insertBefore(img, canvas);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => makePlaceholder(canvas, bookTitle(b), bookAuthor(b)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── View switching ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function switchView(view) {
|
||||||
|
currentView = view;
|
||||||
|
const homeView = document.getElementById('home-view');
|
||||||
|
const gridView = document.getElementById('grid-view');
|
||||||
|
|
||||||
|
if (view === 'home') {
|
||||||
|
homeView.style.display = '';
|
||||||
|
gridView.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleMap = {
|
||||||
|
'continue-reading': 'Continue Reading',
|
||||||
|
'shorts-unread': 'Shorts · Unread',
|
||||||
|
'novels-unread': 'Novels · Unread',
|
||||||
|
'shorts-read': 'Shorts · Recently Read',
|
||||||
|
'novels-read': 'Novels · Recently Read',
|
||||||
|
};
|
||||||
|
document.getElementById('grid-title').textContent = titleMap[view] || '';
|
||||||
|
|
||||||
|
const books =
|
||||||
|
view === 'continue-reading' ? data.continue_reading :
|
||||||
|
view === 'shorts-unread' ? data.shorts_unread :
|
||||||
|
view === 'novels-unread' ? data.novels_unread :
|
||||||
|
view === 'shorts-read' ? data.shorts_read :
|
||||||
|
view === 'novels-read' ? data.novels_read : [];
|
||||||
|
|
||||||
|
homeView.style.display = 'none';
|
||||||
|
gridView.style.display = '';
|
||||||
|
renderGrid(books);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const resp = await fetch('/api/home');
|
||||||
|
data = await resp.json();
|
||||||
|
|
||||||
|
const crSection = document.getElementById('cr-section');
|
||||||
|
const shortsSection = document.getElementById('shorts-section');
|
||||||
|
const novelsSection = document.getElementById('novels-section');
|
||||||
|
const shortsReadSection = document.getElementById('shorts-read-section');
|
||||||
|
const novelsReadSection = document.getElementById('novels-read-section');
|
||||||
|
const homeEmpty = document.getElementById('home-empty');
|
||||||
|
|
||||||
|
if (data.continue_reading.length) {
|
||||||
|
crSection.style.display = '';
|
||||||
|
renderRow(document.getElementById('cr-row'), data.continue_reading, true);
|
||||||
|
}
|
||||||
|
if (data.shorts_unread.length) {
|
||||||
|
shortsSection.style.display = '';
|
||||||
|
renderRow(document.getElementById('shorts-row'), data.shorts_unread, false);
|
||||||
|
}
|
||||||
|
if (data.novels_unread.length) {
|
||||||
|
novelsSection.style.display = '';
|
||||||
|
renderRow(document.getElementById('novels-row'), data.novels_unread, false);
|
||||||
|
}
|
||||||
|
if (data.shorts_read.length) {
|
||||||
|
shortsReadSection.style.display = '';
|
||||||
|
renderRow(document.getElementById('shorts-read-row'), data.shorts_read, false);
|
||||||
|
}
|
||||||
|
if (data.novels_read.length) {
|
||||||
|
novelsReadSection.style.display = '';
|
||||||
|
renderRow(document.getElementById('novels-read-row'), data.novels_read, false);
|
||||||
|
}
|
||||||
|
const hasAny = data.continue_reading.length || data.shorts_unread.length ||
|
||||||
|
data.novels_unread.length || data.shorts_read.length || data.novels_read.length;
|
||||||
|
if (!hasAny) {
|
||||||
|
homeEmpty.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
562
containers/novela/templates/index.html
Normal file
562
containers/novela/templates/index.html
Normal file
@ -0,0 +1,562 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Novela</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<link rel="stylesheet" href="/static/sidebar.css"/>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f0e0c;
|
||||||
|
--surface: #1a1815;
|
||||||
|
--surface2: #221f1b;
|
||||||
|
--border: #2e2a24;
|
||||||
|
--accent: #c8783a;
|
||||||
|
--accent2: #e8a063;
|
||||||
|
--text: #e8e2d9;
|
||||||
|
--text-dim: #8a8278;
|
||||||
|
--text-faint: #4a453e;
|
||||||
|
--success: #6baa6b;
|
||||||
|
--warning: #c8a03a;
|
||||||
|
--error: #c85a3a;
|
||||||
|
--radius: 6px;
|
||||||
|
--sidebar: 220px;
|
||||||
|
--mono: 'DM Mono', monospace;
|
||||||
|
--serif: 'Libre Baskerville', Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html, body { height: 100%; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--serif);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main content ── */
|
||||||
|
.main {
|
||||||
|
margin-left: var(--sidebar);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3rem 1rem 5rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main { margin-left: 0; padding: 4rem 1rem 4rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 2rem;
|
||||||
|
width: 100%; max-width: 620px; margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 0.7rem; font-family: var(--mono);
|
||||||
|
letter-spacing: 0.12em; text-transform: uppercase;
|
||||||
|
color: var(--accent); margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block; font-size: 0.78rem; font-family: var(--mono);
|
||||||
|
color: var(--text-dim); margin-bottom: 0.4rem; letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="url"] {
|
||||||
|
width: 100%; background: var(--bg); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); color: var(--text);
|
||||||
|
font-family: var(--mono); font-size: 0.85rem;
|
||||||
|
padding: 0.65rem 0.85rem; outline: none;
|
||||||
|
transition: border-color 0.15s; margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
input[type="url"]:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
||||||
|
width: 100%; padding: 0.85rem; background: var(--accent); color: #0f0e0c;
|
||||||
|
border: none; border-radius: var(--radius); font-family: var(--mono);
|
||||||
|
font-size: 0.85rem; font-weight: 500; letter-spacing: 0.05em;
|
||||||
|
cursor: pointer; transition: background 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
button:hover { background: var(--accent2); }
|
||||||
|
button:active { transform: scale(0.99); }
|
||||||
|
button:disabled { background: var(--text-faint); cursor: not-allowed; }
|
||||||
|
|
||||||
|
.cred-status {
|
||||||
|
font-family: var(--mono); font-size: 0.75rem;
|
||||||
|
margin-top: -0.6rem; margin-bottom: 1rem;
|
||||||
|
padding: 0.4rem 0.7rem; border-radius: var(--radius); display: none;
|
||||||
|
}
|
||||||
|
.cred-status.found {
|
||||||
|
display: block; color: var(--success);
|
||||||
|
background: rgba(107,170,107,0.08); border: 1px solid rgba(107,170,107,0.2);
|
||||||
|
}
|
||||||
|
.cred-status.missing {
|
||||||
|
display: block; color: var(--text-faint);
|
||||||
|
background: var(--surface2); border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: none; width: 14px; height: 14px;
|
||||||
|
border: 2px solid rgba(15,14,12,0.3); border-top-color: #0f0e0c;
|
||||||
|
border-radius: 50%; animation: spin 0.7s linear infinite; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Metadata preview card */
|
||||||
|
#meta-card { display: none; }
|
||||||
|
#meta-card.visible { display: block; }
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
display: flex; gap: 0.75rem; margin-bottom: 0.5rem;
|
||||||
|
font-family: var(--mono); font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.meta-label { color: var(--text-faint); min-width: 7rem; flex-shrink: 0; }
|
||||||
|
.meta-value { color: var(--text); }
|
||||||
|
|
||||||
|
.description-text {
|
||||||
|
font-size: 0.85rem; color: var(--text-dim); line-height: 1.7;
|
||||||
|
max-height: 160px; overflow-y: auto; margin-bottom: 1rem;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
}
|
||||||
|
.description-text::-webkit-scrollbar { width: 4px; }
|
||||||
|
.description-text::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
border: none; border-top: 1px solid var(--border);
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cover upload */
|
||||||
|
.cover-upload-area {
|
||||||
|
border: 1px dashed var(--border); border-radius: var(--radius);
|
||||||
|
padding: 1.25rem; text-align: center; margin-bottom: 1.25rem;
|
||||||
|
cursor: pointer; transition: border-color 0.15s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.cover-upload-area:hover { border-color: var(--accent); }
|
||||||
|
.cover-upload-area input[type="file"] {
|
||||||
|
position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%;
|
||||||
|
}
|
||||||
|
.cover-upload-label {
|
||||||
|
font-family: var(--mono); font-size: 0.78rem; color: var(--text-dim);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.cover-upload-label span { color: var(--accent); }
|
||||||
|
.cover-preview {
|
||||||
|
display: none; max-height: 180px; max-width: 120px;
|
||||||
|
border-radius: var(--radius); margin: 0 auto 0.6rem;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.cover-preview.visible { display: block; }
|
||||||
|
.cover-filename {
|
||||||
|
font-family: var(--mono); font-size: 0.72rem; color: var(--success);
|
||||||
|
margin-top: 0.4rem; display: none;
|
||||||
|
}
|
||||||
|
.cover-filename.visible { display: block; }
|
||||||
|
|
||||||
|
/* Progress */
|
||||||
|
#progress-card { display: none; }
|
||||||
|
#progress-card.visible { display: block; }
|
||||||
|
|
||||||
|
.status-line {
|
||||||
|
font-family: var(--mono); font-size: 0.8rem;
|
||||||
|
color: var(--text-dim); margin-bottom: 1rem; min-height: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-wrap {
|
||||||
|
background: var(--bg); border: 1px solid var(--border);
|
||||||
|
border-radius: 100px; height: 6px; margin-bottom: 1.5rem; overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%; background: var(--accent); border-radius: 100px;
|
||||||
|
width: 0%; transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-list {
|
||||||
|
list-style: none; max-height: 260px; overflow-y: auto;
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg);
|
||||||
|
}
|
||||||
|
.chapter-list::-webkit-scrollbar { width: 4px; }
|
||||||
|
.chapter-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||||
|
|
||||||
|
.chapter-item {
|
||||||
|
display: flex; align-items: center; gap: 0.6rem;
|
||||||
|
padding: 0.5rem 0.75rem; font-family: var(--mono); font-size: 0.75rem;
|
||||||
|
color: var(--text-faint); border-bottom: 1px solid var(--border); transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.chapter-item:last-child { border-bottom: none; }
|
||||||
|
.chapter-item.done { color: var(--success); }
|
||||||
|
.chapter-item.active { color: var(--accent2); }
|
||||||
|
.chapter-item .dot {
|
||||||
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: currentColor; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chapter-item.active .dot { animation: pulse 1s infinite; }
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||||
|
|
||||||
|
.log-lines {
|
||||||
|
margin-top: 1rem; font-family: var(--mono); font-size: 0.72rem;
|
||||||
|
color: var(--text-faint); line-height: 1.8;
|
||||||
|
max-height: 120px; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.log-lines .warn { color: var(--warning); }
|
||||||
|
.log-lines .err { color: var(--error); }
|
||||||
|
|
||||||
|
/* Result */
|
||||||
|
#result-card { display: none; }
|
||||||
|
#result-card.visible { display: block; }
|
||||||
|
|
||||||
|
.result-meta {
|
||||||
|
font-size: 0.85rem; color: var(--text-dim);
|
||||||
|
margin-bottom: 1.25rem; line-height: 1.8;
|
||||||
|
}
|
||||||
|
.result-meta strong { color: var(--text); font-weight: 400; }
|
||||||
|
|
||||||
|
.download-btn { background: var(--success); }
|
||||||
|
.download-btn:hover { background: #82c082; }
|
||||||
|
.result-actions { display: flex; gap: 0.75rem; }
|
||||||
|
.result-actions button { width: auto; flex: 1; }
|
||||||
|
.btn-outline {
|
||||||
|
background: var(--surface2); color: var(--text-dim);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.btn-outline:hover { background: var(--surface); color: var(--text); border-color: var(--text-faint); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{% include "_sidebar.html" %}
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
|
||||||
|
<!-- Step 1: URL input -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Book URL</div>
|
||||||
|
<label for="url">Story overview page</label>
|
||||||
|
<input type="url" id="url" placeholder="https://..." oninput="checkUrlCredentials()"/>
|
||||||
|
<div class="cred-status" id="cred-status"></div>
|
||||||
|
<button id="load-btn" onclick="loadMeta()">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
|
||||||
|
</svg>
|
||||||
|
<span id="load-label">Load metadata</span>
|
||||||
|
<div class="spinner" id="load-spinner"></div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Metadata preview + cover upload + Convert -->
|
||||||
|
<div class="card" id="meta-card">
|
||||||
|
<div class="card-title">Book info</div>
|
||||||
|
|
||||||
|
<div id="meta-rows"></div>
|
||||||
|
|
||||||
|
<hr class="divider"/>
|
||||||
|
|
||||||
|
<label>Cover image</label>
|
||||||
|
<div class="cover-upload-area" id="cover-upload-area">
|
||||||
|
<input type="file" id="cover-file" accept="image/*" onchange="onCoverSelected()"/>
|
||||||
|
<img class="cover-preview" id="cover-preview" src="" alt="cover preview"/>
|
||||||
|
<div class="cover-upload-label">
|
||||||
|
<span id="cover-upload-text">Click to select a cover image</span>
|
||||||
|
</div>
|
||||||
|
<div class="cover-filename" id="cover-filename"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="convert-btn" onclick="startConvert()">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
<span id="convert-label">Convert</span>
|
||||||
|
<div class="spinner" id="convert-spinner"></div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress -->
|
||||||
|
<div class="card" id="progress-card">
|
||||||
|
<div class="card-title">Progress</div>
|
||||||
|
<div class="status-line" id="status-line">Connecting...</div>
|
||||||
|
<div class="progress-bar-wrap">
|
||||||
|
<div class="progress-bar" id="progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
<ul class="chapter-list" id="chapter-list"></ul>
|
||||||
|
<div class="log-lines" id="log-lines"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Result -->
|
||||||
|
<div class="card" id="result-card">
|
||||||
|
<div class="card-title">Done</div>
|
||||||
|
<div class="result-meta" id="result-meta"></div>
|
||||||
|
<div class="result-actions">
|
||||||
|
<button class="download-btn" id="download-btn">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/>
|
||||||
|
</svg>
|
||||||
|
Download EPUB
|
||||||
|
</button>
|
||||||
|
<button class="btn-outline" id="book-detail-btn">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/>
|
||||||
|
</svg>
|
||||||
|
Go to book
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentUrl = '';
|
||||||
|
let coverB64 = null;
|
||||||
|
|
||||||
|
// --- Credential status ---
|
||||||
|
async function checkUrlCredentials() {
|
||||||
|
const url = document.getElementById('url').value.trim();
|
||||||
|
const el = document.getElementById('cred-status');
|
||||||
|
if (!url) { el.className = 'cred-status'; return; }
|
||||||
|
try {
|
||||||
|
const domain = new URL(url).hostname.replace(/^www\./, '');
|
||||||
|
const r = await fetch('/credentials');
|
||||||
|
const creds = (await r.json())[domain];
|
||||||
|
if (creds !== undefined) {
|
||||||
|
el.className = 'cred-status found';
|
||||||
|
el.textContent = `✓ Credentials available for ${domain}${creds.username ? ' (' + creds.username + ')' : ''}`;
|
||||||
|
} else {
|
||||||
|
el.className = 'cred-status missing';
|
||||||
|
el.textContent = `No credentials configured for ${domain}`;
|
||||||
|
}
|
||||||
|
} catch (e) { el.className = 'cred-status'; }
|
||||||
|
}
|
||||||
|
checkUrlCredentials();
|
||||||
|
|
||||||
|
// --- Step 1: Load metadata ---
|
||||||
|
async function loadMeta() {
|
||||||
|
const url = document.getElementById('url').value.trim();
|
||||||
|
if (!url) { alert('Please enter a URL.'); return; }
|
||||||
|
|
||||||
|
currentUrl = url;
|
||||||
|
coverB64 = null;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
document.getElementById('meta-card').classList.remove('visible');
|
||||||
|
document.getElementById('progress-card').classList.remove('visible');
|
||||||
|
document.getElementById('result-card').classList.remove('visible');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/preload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
});
|
||||||
|
const d = await resp.json();
|
||||||
|
|
||||||
|
if (d.error) {
|
||||||
|
alert('Error loading metadata:\n' + d.error);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMeta(d);
|
||||||
|
document.getElementById('meta-card').classList.add('visible');
|
||||||
|
// Reset cover upload
|
||||||
|
document.getElementById('cover-file').value = '';
|
||||||
|
document.getElementById('cover-preview').classList.remove('visible');
|
||||||
|
document.getElementById('cover-filename').classList.remove('visible');
|
||||||
|
document.getElementById('cover-upload-text').textContent = 'Click to select a cover image';
|
||||||
|
} catch (e) {
|
||||||
|
alert('Failed to load metadata: ' + e);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(on) {
|
||||||
|
document.getElementById('load-btn').disabled = on;
|
||||||
|
document.getElementById('load-label').textContent = on ? 'Loading...' : 'Load metadata';
|
||||||
|
document.getElementById('load-spinner').style.display = on ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function metaRow(label, value) {
|
||||||
|
return `<div class="meta-row">
|
||||||
|
<span class="meta-label">${label}</span>
|
||||||
|
<span class="meta-value">${esc(value)}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMeta(d) {
|
||||||
|
let html = '';
|
||||||
|
html += metaRow('Title', d.title);
|
||||||
|
html += metaRow('Author', d.author);
|
||||||
|
if (d.publisher) html += metaRow('Publisher', d.publisher);
|
||||||
|
if (d.series) html += metaRow('Series', d.series);
|
||||||
|
if (d.series) {
|
||||||
|
html += `<div class="meta-row">
|
||||||
|
<span class="meta-label">Series index</span>
|
||||||
|
<span class="meta-value"><input type="number" id="series-index-input" min="1" value="${d.series_index_next}" style="width:5rem;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-family:var(--mono);font-size:0.82rem;padding:0.25rem 0.5rem;"/></span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if (d.genres && d.genres.length) html += metaRow('Genres', d.genres.join(', '));
|
||||||
|
if (d.subgenres && d.subgenres.length) html += metaRow('Sub-genres', d.subgenres.join(', '));
|
||||||
|
if (d.tags && d.tags.length) html += metaRow('Tags', d.tags.join(', '));
|
||||||
|
html += `<div class="meta-row">
|
||||||
|
<span class="meta-label">Updated</span>
|
||||||
|
<span class="meta-value"><input type="date" id="updated-date-input" value="${d.updated_date || ''}"
|
||||||
|
style="background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);
|
||||||
|
color:var(--text);font-family:var(--mono);font-size:0.82rem;
|
||||||
|
padding:0.25rem 0.5rem;color-scheme:dark;"/></span>
|
||||||
|
</div>`;
|
||||||
|
if (d.publication_status) html += metaRow('Status', d.publication_status);
|
||||||
|
if (d.description) {
|
||||||
|
const paras = d.description.split('\n\n')
|
||||||
|
.map(p => `<p style="margin:.4rem 0">${esc(p.trim())}</p>`).join('');
|
||||||
|
html += `<div class="meta-row"><span class="meta-label">Description</span>
|
||||||
|
<div class="description-text">${paras}</div></div>`;
|
||||||
|
}
|
||||||
|
html += metaRow('Filename', d.filename);
|
||||||
|
document.getElementById('meta-rows').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cover upload ---
|
||||||
|
function onCoverSelected() {
|
||||||
|
const file = document.getElementById('cover-file').files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = e => {
|
||||||
|
const dataUrl = e.target.result;
|
||||||
|
coverB64 = dataUrl.split(',')[1]; // strip "data:image/...;base64,"
|
||||||
|
|
||||||
|
const preview = document.getElementById('cover-preview');
|
||||||
|
preview.src = dataUrl;
|
||||||
|
preview.classList.add('visible');
|
||||||
|
|
||||||
|
document.getElementById('cover-upload-text').textContent = 'Click to replace cover';
|
||||||
|
document.getElementById('cover-filename').textContent = file.name;
|
||||||
|
document.getElementById('cover-filename').classList.add('visible');
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 2: Convert ---
|
||||||
|
async function startConvert() {
|
||||||
|
if (!currentUrl) return;
|
||||||
|
|
||||||
|
document.getElementById('convert-btn').disabled = true;
|
||||||
|
document.getElementById('convert-label').textContent = 'Starting...';
|
||||||
|
document.getElementById('convert-spinner').style.display = 'block';
|
||||||
|
document.getElementById('progress-card').classList.add('visible');
|
||||||
|
document.getElementById('result-card').classList.remove('visible');
|
||||||
|
document.getElementById('chapter-list').innerHTML = '';
|
||||||
|
document.getElementById('log-lines').innerHTML = '';
|
||||||
|
document.getElementById('progress-bar').style.width = '0%';
|
||||||
|
|
||||||
|
const body = { url: currentUrl };
|
||||||
|
if (coverB64) body.cover_b64 = coverB64;
|
||||||
|
const seriesInput = document.getElementById('series-index-input');
|
||||||
|
if (seriesInput) body.series_index = parseInt(seriesInput.value) || 1;
|
||||||
|
const dateInput = document.getElementById('updated-date-input');
|
||||||
|
if (dateInput) body.updated_date = dateInput.value || '';
|
||||||
|
|
||||||
|
const resp = await fetch('/convert', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const { job_id } = await resp.json();
|
||||||
|
|
||||||
|
document.getElementById('convert-label').textContent = 'Convert';
|
||||||
|
document.getElementById('convert-spinner').style.display = 'none';
|
||||||
|
|
||||||
|
const es = new EventSource(`/events/${job_id}`);
|
||||||
|
|
||||||
|
es.addEventListener('status', e => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
document.getElementById('status-line').textContent = d.message;
|
||||||
|
addLog(d.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('meta', e => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
document.getElementById('status-line').textContent = `"${d.title}" by ${d.author}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('chapters', e => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
const ul = document.getElementById('chapter-list');
|
||||||
|
ul.innerHTML = '';
|
||||||
|
d.chapters.forEach((title, i) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'chapter-item';
|
||||||
|
li.id = `ch-${i}`;
|
||||||
|
li.innerHTML = `<span class="dot"></span><span>${esc(title)}</span>`;
|
||||||
|
ul.appendChild(li);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('progress', e => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
document.getElementById('progress-bar').style.width =
|
||||||
|
Math.round((d.current / d.total) * 100) + '%';
|
||||||
|
document.getElementById('status-line').textContent =
|
||||||
|
`Chapter ${d.current} of ${d.total}: ${d.title}`;
|
||||||
|
if (d.current > 1) {
|
||||||
|
const prev = document.getElementById(`ch-${d.current - 2}`);
|
||||||
|
if (prev) prev.className = 'chapter-item done';
|
||||||
|
}
|
||||||
|
const cur = document.getElementById(`ch-${d.current - 1}`);
|
||||||
|
if (cur) { cur.className = 'chapter-item active'; cur.scrollIntoView({ block: 'nearest' }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('warning', e => {
|
||||||
|
addLog(JSON.parse(e.data).message, 'warn');
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('error', e => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
addLog(d.message, 'err');
|
||||||
|
document.getElementById('status-line').textContent = '❌ ' + d.message;
|
||||||
|
document.getElementById('convert-btn').disabled = false;
|
||||||
|
es.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('done', e => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
document.getElementById('progress-bar').style.width = '100%';
|
||||||
|
document.getElementById('status-line').textContent = 'Done ✓';
|
||||||
|
document.querySelectorAll('.chapter-item').forEach(el => el.className = 'chapter-item done');
|
||||||
|
document.getElementById('result-meta').innerHTML =
|
||||||
|
`<strong>${esc(d.title)}</strong><br/>${d.chapters} chapters successfully converted`;
|
||||||
|
document.getElementById('download-btn').onclick = () => {
|
||||||
|
window.location = `/download/${encodeURIComponent(d.filename)}`;
|
||||||
|
};
|
||||||
|
document.getElementById('book-detail-btn').onclick = () => {
|
||||||
|
window.location = `/library/book/${encodeURIComponent(d.filename)}`;
|
||||||
|
};
|
||||||
|
document.getElementById('result-card').classList.add('visible');
|
||||||
|
document.getElementById('convert-btn').disabled = false;
|
||||||
|
es.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLog(msg, cls) {
|
||||||
|
const div = document.getElementById('log-lines');
|
||||||
|
const span = document.createElement('span');
|
||||||
|
if (cls) span.className = cls;
|
||||||
|
span.textContent = msg;
|
||||||
|
span.style.display = 'block';
|
||||||
|
div.appendChild(span);
|
||||||
|
div.scrollTop = div.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '')
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
78
containers/novela/templates/library.html
Normal file
78
containers/novela/templates/library.html
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Novela — Library</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<link rel="stylesheet" href="/static/sidebar.css"/>
|
||||||
|
<link rel="stylesheet" href="/static/library.css"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{% include "_sidebar.html" %}
|
||||||
|
|
||||||
|
<!-- ── Main content ────────────────────────────────────────────────────── -->
|
||||||
|
<main class="main">
|
||||||
|
<div class="main-header">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.75rem">
|
||||||
|
<button class="btn-back" id="back-btn" style="display:none" onclick="goBack()">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="15 18 9 12 15 6"/></svg>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<div class="main-title" id="section-title">All books</div>
|
||||||
|
</div>
|
||||||
|
<div class="search-wrap">
|
||||||
|
<svg class="search-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
|
||||||
|
</svg>
|
||||||
|
<input type="text" id="search-input" class="search-input" placeholder="Search title, author, genre…" autocomplete="off"/>
|
||||||
|
<button id="search-clear" class="search-clear" style="display:none" onclick="clearSearch()" title="Clear search">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="import-dropzone" id="import-dropzone" onclick="openImportPicker()">
|
||||||
|
<input type="file" id="import-file-input" accept=".epub,application/epub+zip" multiple style="display:none" onchange="onImportFilesSelected(this.files)"/>
|
||||||
|
<div class="import-title">Drop EPUB files here</div>
|
||||||
|
<div class="import-sub">or click to choose files</div>
|
||||||
|
</div>
|
||||||
|
<div id="grid-container">
|
||||||
|
<div class="empty">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Delete dialog -->
|
||||||
|
<div class="overlay" id="confirm-overlay">
|
||||||
|
<div class="dialog">
|
||||||
|
<div class="dialog-title del">Delete book</div>
|
||||||
|
<p>Delete <strong id="confirm-filename"></strong>?<br/>This cannot be undone.</p>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="btn btn-cancel" onclick="closeConfirm()">Cancel</button>
|
||||||
|
<button class="btn btn-confirm-del" onclick="confirmDelete()">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add cover dialog -->
|
||||||
|
<div class="overlay" id="cover-overlay">
|
||||||
|
<div class="dialog">
|
||||||
|
<div class="dialog-title cover">Add cover</div>
|
||||||
|
<p><strong id="cover-target-filename"></strong></p>
|
||||||
|
<div class="cover-upload-area" id="cover-upload-area">
|
||||||
|
<input type="file" id="cover-file-input" accept="image/*" onchange="onCoverFileSelected()"/>
|
||||||
|
<img class="cover-preview" id="cover-dialog-preview" src="" alt="preview"/>
|
||||||
|
<div class="cover-upload-label"><span id="cover-upload-prompt">Click to select a cover image</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="btn btn-cancel" onclick="closeCoverDialog()">Cancel</button>
|
||||||
|
<button class="btn btn-confirm-cover" id="cover-upload-btn" onclick="uploadCover()" disabled>
|
||||||
|
<span id="cover-upload-label">Add cover</span>
|
||||||
|
<div class="spinner-inline" id="cover-spinner"></div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/library.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
455
containers/novela/templates/reader.html
Normal file
455
containers/novela/templates/reader.html
Normal file
@ -0,0 +1,455 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Novela — {{ title }}</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b;
|
||||||
|
--border: #2e2a24; --accent: #c8783a; --text: #e8e2d9;
|
||||||
|
--text-dim: #8a8278; --text-faint: #4a453e; --success: #6baa6b;
|
||||||
|
--radius: 6px;
|
||||||
|
--mono: 'DM Mono', monospace; --serif: 'Libre Baskerville', Georgia, serif;
|
||||||
|
--header-h: 50px; --footer-h: 36px;
|
||||||
|
--content-w: 65vw;
|
||||||
|
}
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body { background: var(--bg); color: var(--text); }
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.reader-header {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0;
|
||||||
|
height: var(--header-h);
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex; align-items: center;
|
||||||
|
padding: 0 1rem; gap: 0.75rem;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.btn-hamburger {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 30px; height: 30px; flex-shrink: 0;
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
color: var(--text-dim); transition: color 0.12s;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.btn-hamburger:hover { color: var(--text); }
|
||||||
|
.header-back {
|
||||||
|
font-family: var(--mono); font-size: 0.72rem;
|
||||||
|
color: var(--text-dim); text-decoration: none;
|
||||||
|
display: flex; align-items: center; gap: 0.35rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color 0.12s;
|
||||||
|
}
|
||||||
|
.header-back:hover { color: var(--text); }
|
||||||
|
.header-title {
|
||||||
|
flex: 1;
|
||||||
|
font-family: var(--mono); font-size: 0.72rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.header-title strong { color: var(--text-dim); }
|
||||||
|
.header-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
|
||||||
|
.btn-header {
|
||||||
|
display: flex; align-items: center; gap: 0.35rem;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
background: none; border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-family: var(--mono); font-size: 0.68rem;
|
||||||
|
color: var(--text-dim); cursor: pointer;
|
||||||
|
transition: color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
.btn-header:hover { color: var(--text); border-color: var(--text-faint); }
|
||||||
|
.btn-header-read { color: var(--success); border-color: rgba(107,170,107,0.3); }
|
||||||
|
.btn-header-read:hover { background: rgba(107,170,107,0.08); border-color: var(--success); }
|
||||||
|
|
||||||
|
/* ── Settings drawer ── */
|
||||||
|
.settings-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.45);
|
||||||
|
z-index: 150;
|
||||||
|
}
|
||||||
|
.settings-overlay.open { display: block; }
|
||||||
|
.settings-drawer {
|
||||||
|
position: fixed; top: 0; left: 0; bottom: 0;
|
||||||
|
width: 260px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
z-index: 160;
|
||||||
|
padding: 1.25rem;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.22s ease;
|
||||||
|
}
|
||||||
|
.settings-drawer.open { transform: translateX(0); }
|
||||||
|
.settings-drawer-title {
|
||||||
|
font-family: var(--mono); font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.12em; text-transform: uppercase;
|
||||||
|
color: var(--accent); margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.settings-row {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.settings-label {
|
||||||
|
font-family: var(--mono); font-size: 0.72rem;
|
||||||
|
color: var(--text-dim); margin-bottom: 0.6rem;
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
}
|
||||||
|
.settings-label span {
|
||||||
|
color: var(--text-faint); font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
input[type="range"] {
|
||||||
|
width: 100%; accent-color: var(--accent);
|
||||||
|
background: transparent; cursor: pointer;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Viewer ── */
|
||||||
|
#viewer {
|
||||||
|
margin-top: var(--header-h);
|
||||||
|
margin-bottom: var(--footer-h);
|
||||||
|
min-height: calc(100vh - var(--header-h) - var(--footer-h));
|
||||||
|
padding: 3rem 2rem 4rem;
|
||||||
|
max-width: var(--content-w);
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chapter content */
|
||||||
|
#chapter-content {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.85;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
#chapter-content h1, #chapter-content h2 {
|
||||||
|
font-family: var(--serif); color: var(--text);
|
||||||
|
margin: 2rem 0 1rem; font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
#chapter-content h3, #chapter-content h4 {
|
||||||
|
font-family: var(--serif); color: var(--text-dim);
|
||||||
|
margin: 1.5rem 0 0.75rem; font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
#chapter-content p { margin-bottom: 1rem; color: var(--text); }
|
||||||
|
#chapter-content em, #chapter-content i { font-style: italic; }
|
||||||
|
#chapter-content strong, #chapter-content b { font-weight: 700; }
|
||||||
|
#chapter-content a { color: var(--accent); text-decoration: none; }
|
||||||
|
#chapter-content img { max-width: 100%; height: auto; border-radius: var(--radius); margin: 1rem 0; }
|
||||||
|
#chapter-content .chapter-title {
|
||||||
|
font-size: 1.5rem; font-weight: 700;
|
||||||
|
margin-bottom: 2.5rem; padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chapter nav */
|
||||||
|
.chapter-nav {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
margin-top: 3rem; padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.btn-nav {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
padding: 0.6rem 1.1rem;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-family: var(--mono); font-size: 0.75rem;
|
||||||
|
color: var(--text-dim); cursor: pointer; text-decoration: none;
|
||||||
|
transition: color 0.12s, border-color 0.12s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.btn-nav:hover { color: var(--text); border-color: var(--text-faint); }
|
||||||
|
.btn-nav:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||||
|
.chapter-nav-label {
|
||||||
|
font-family: var(--mono); font-size: 0.68rem; color: var(--text-faint);
|
||||||
|
text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
.reader-footer {
|
||||||
|
position: fixed; bottom: 0; left: 0; right: 0;
|
||||||
|
height: var(--footer-h);
|
||||||
|
background: var(--surface);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex; align-items: center;
|
||||||
|
padding: 0 1rem; gap: 1rem;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.footer-progress-wrap {
|
||||||
|
flex: 1; height: 3px;
|
||||||
|
background: var(--surface2);
|
||||||
|
border-radius: 100px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.footer-progress-fill {
|
||||||
|
height: 100%; background: var(--accent); border-radius: 100px;
|
||||||
|
width: 0%; transition: width 0.15s ease;
|
||||||
|
}
|
||||||
|
.footer-pct {
|
||||||
|
font-family: var(--mono); font-size: 0.65rem;
|
||||||
|
color: var(--text-faint); flex-shrink: 0;
|
||||||
|
min-width: 3rem; text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading overlay ── */
|
||||||
|
#loading {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
font-family: var(--mono); font-size: 0.78rem; color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
width: 20px; height: 20px;
|
||||||
|
border: 2px solid var(--surface2); border-top-color: var(--accent);
|
||||||
|
border-radius: 50%; animation: spin 0.7s linear infinite;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div id="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings overlay + drawer -->
|
||||||
|
<div class="settings-overlay" id="settings-overlay" onclick="closeSettings()"></div>
|
||||||
|
<div class="settings-drawer" id="settings-drawer">
|
||||||
|
<div class="settings-drawer-title">Reading settings</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
Content width
|
||||||
|
<span id="width-value">65%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="width-slider" min="30" max="100" step="1"
|
||||||
|
value="65" oninput="applyWidth(this.value)"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="reader-header">
|
||||||
|
<button class="btn-hamburger" onclick="toggleSettings()" title="Reading settings">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<a class="header-back" href="/library/book/{{ filename | urlencode }}">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<polyline points="15 18 9 12 15 6"/>
|
||||||
|
</svg>
|
||||||
|
{{ title | truncate(30, True) }}
|
||||||
|
</a>
|
||||||
|
<div class="header-title" id="header-title"></div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn-header btn-header-read" onclick="markRead()">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
|
</svg>
|
||||||
|
Mark as read
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Viewer -->
|
||||||
|
<div id="viewer">
|
||||||
|
<div id="chapter-content"></div>
|
||||||
|
<div class="chapter-nav">
|
||||||
|
<button class="btn-nav" id="btn-prev" onclick="navigate(-1)">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<polyline points="15 18 9 12 15 6"/>
|
||||||
|
</svg>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span class="chapter-nav-label" id="chapter-nav-label"></span>
|
||||||
|
<button class="btn-nav" id="btn-next" onclick="navigate(1)">
|
||||||
|
Next
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<polyline points="9 18 15 12 9 6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="reader-footer">
|
||||||
|
<div class="footer-progress-wrap">
|
||||||
|
<div class="footer-progress-fill" id="footer-progress"></div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-pct" id="footer-pct">0%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const filename = {{ filename | tojson }};
|
||||||
|
|
||||||
|
let chapters = [];
|
||||||
|
let currentIndex = 0;
|
||||||
|
let saveTimer = null;
|
||||||
|
let scrollTimer = null;
|
||||||
|
|
||||||
|
// ── Width setting ──────────────────────────────────────────────
|
||||||
|
function applyWidth(pct) {
|
||||||
|
const val = parseInt(pct, 10);
|
||||||
|
document.documentElement.style.setProperty('--content-w', val + 'vw');
|
||||||
|
document.getElementById('width-value').textContent = val + '%';
|
||||||
|
document.getElementById('width-slider').value = val;
|
||||||
|
localStorage.setItem('reader-content-width-pct', val);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadWidth() {
|
||||||
|
const saved = parseInt(localStorage.getItem('reader-content-width-pct') || '65', 10);
|
||||||
|
applyWidth(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings drawer ────────────────────────────────────────────
|
||||||
|
function toggleSettings() {
|
||||||
|
const open = document.getElementById('settings-drawer').classList.toggle('open');
|
||||||
|
document.getElementById('settings-overlay').classList.toggle('open', open);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSettings() {
|
||||||
|
document.getElementById('settings-drawer').classList.remove('open');
|
||||||
|
document.getElementById('settings-overlay').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Progress (chapter + scroll within chapter) ─────────────────
|
||||||
|
function calcProgress() {
|
||||||
|
const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
|
||||||
|
const scrollFrac = maxScroll > 0
|
||||||
|
? Math.min(1, window.scrollY / maxScroll)
|
||||||
|
: 0;
|
||||||
|
// Multi-chapter: (chapterIndex + scrollFrac) / (total - 1) × 100
|
||||||
|
// Single-chapter: use scroll position only so it doesn't start at 100%.
|
||||||
|
const total = chapters.length;
|
||||||
|
const pct = total > 1
|
||||||
|
? Math.round(((currentIndex + scrollFrac) / (total - 1)) * 100)
|
||||||
|
: total === 1
|
||||||
|
? Math.round(scrollFrac * 100)
|
||||||
|
: 0;
|
||||||
|
return { scrollFrac, pct };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFooter() {
|
||||||
|
const { pct } = calcProgress();
|
||||||
|
document.getElementById('footer-progress').style.width = pct + '%';
|
||||||
|
document.getElementById('footer-pct').textContent = pct + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSave() {
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
const { scrollFrac, pct } = calcProgress();
|
||||||
|
fetch(`/library/progress/${encodeURIComponent(filename)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
cfi: `${currentIndex}:${scrollFrac.toFixed(4)}`,
|
||||||
|
progress: pct,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chapter loading ────────────────────────────────────────────
|
||||||
|
async function loadChapter(index, saveProgress, scrollFrac) {
|
||||||
|
if (index < 0 || index >= chapters.length) return;
|
||||||
|
currentIndex = index;
|
||||||
|
|
||||||
|
const resp = await fetch(`/library/chapter/${index}/${encodeURIComponent(filename)}`);
|
||||||
|
const html = await resp.text();
|
||||||
|
document.getElementById('chapter-content').innerHTML = html;
|
||||||
|
|
||||||
|
// Restore scroll position within chapter (after DOM paint)
|
||||||
|
if (scrollFrac && scrollFrac > 0) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
|
||||||
|
window.scrollTo(0, maxScroll * scrollFrac);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update header
|
||||||
|
const ch = chapters[index];
|
||||||
|
document.getElementById('header-title').innerHTML =
|
||||||
|
ch ? `<strong>${esc(ch.title)}</strong>` : '';
|
||||||
|
|
||||||
|
// Update nav
|
||||||
|
document.getElementById('btn-prev').disabled = index === 0;
|
||||||
|
document.getElementById('btn-next').disabled = index === chapters.length - 1;
|
||||||
|
document.getElementById('chapter-nav-label').textContent =
|
||||||
|
`${index + 1} / ${chapters.length}`;
|
||||||
|
|
||||||
|
updateFooter();
|
||||||
|
|
||||||
|
if (saveProgress) scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(delta) {
|
||||||
|
loadChapter(currentIndex + delta, true, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scroll tracking ────────────────────────────────────────────
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
updateFooter();
|
||||||
|
clearTimeout(scrollTimer);
|
||||||
|
scrollTimer = setTimeout(scheduleSave, 300);
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
// ── Keyboard navigation ────────────────────────────────────────
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'ArrowRight' || e.key === 'PageDown') { e.preventDefault(); navigate(1); }
|
||||||
|
if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); navigate(-1); }
|
||||||
|
if (e.key === 'Escape') closeSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Init ───────────────────────────────────────────────────────
|
||||||
|
async function init() {
|
||||||
|
loadWidth();
|
||||||
|
|
||||||
|
const [r1, r2] = await Promise.all([
|
||||||
|
fetch(`/library/chapters/${encodeURIComponent(filename)}`),
|
||||||
|
fetch(`/library/progress/${encodeURIComponent(filename)}`),
|
||||||
|
]);
|
||||||
|
chapters = await r1.json();
|
||||||
|
const prog = await r2.json();
|
||||||
|
|
||||||
|
let startIndex = 0;
|
||||||
|
let startScroll = 0;
|
||||||
|
if (prog.cfi) {
|
||||||
|
const parts = prog.cfi.split(':');
|
||||||
|
const idx = parseInt(parts[0], 10);
|
||||||
|
if (!isNaN(idx) && idx >= 0 && idx < chapters.length) {
|
||||||
|
startIndex = idx;
|
||||||
|
startScroll = parseFloat(parts[1]) || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadChapter(startIndex, false, startScroll);
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markRead() {
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
await fetch(`/library/mark-read/${encodeURIComponent(filename)}`, { method: 'POST' });
|
||||||
|
window.location.href = `/library/book/${encodeURIComponent(filename)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
441
containers/novela/templates/settings.html
Normal file
441
containers/novela/templates/settings.html
Normal file
@ -0,0 +1,441 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Novela — Settings</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<link rel="stylesheet" href="/static/sidebar.css"/>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b;
|
||||||
|
--border: #2e2a24; --accent: #c8783a; --accent2: #e8a063;
|
||||||
|
--text: #e8e2d9; --text-dim: #8a8278; --text-faint: #4a453e;
|
||||||
|
--success: #6baa6b; --warning: #c8a03a; --error: #c85a3a;
|
||||||
|
--radius: 6px; --sidebar: 220px;
|
||||||
|
--mono: 'DM Mono', monospace; --serif: 'Libre Baskerville', Georgia, serif;
|
||||||
|
}
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
|
||||||
|
|
||||||
|
/* ── Main ── */
|
||||||
|
.main {
|
||||||
|
margin-left: var(--sidebar); min-height: 100vh;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
align-items: center; padding: 3rem 1rem 5rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main { margin-left: 0; padding: 4rem 1rem 4rem; }
|
||||||
|
}
|
||||||
|
.main-title {
|
||||||
|
font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase; color: var(--accent);
|
||||||
|
margin-bottom: 1.75rem; width: 100%; max-width: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cards ── */
|
||||||
|
.card {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 2rem;
|
||||||
|
width: 100%; max-width: 620px; margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase; color: var(--accent); margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.card-desc {
|
||||||
|
font-size: 0.85rem; color: var(--text-dim); line-height: 1.7;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.card-desc strong { color: var(--text); }
|
||||||
|
|
||||||
|
/* ── Buttons ── */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||||
|
padding: 0.55rem 1.1rem; border-radius: var(--radius);
|
||||||
|
font-family: var(--mono); font-size: 0.78rem; font-weight: 500;
|
||||||
|
cursor: pointer; border: none; transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: rgba(200,90,58,0.12); color: var(--error);
|
||||||
|
border: 1px solid rgba(200,90,58,0.3);
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: rgba(200,90,58,0.22); }
|
||||||
|
|
||||||
|
/* ── Overlay ── */
|
||||||
|
.overlay {
|
||||||
|
display: none; position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.65); backdrop-filter: blur(2px);
|
||||||
|
align-items: center; justify-content: center; z-index: 100;
|
||||||
|
}
|
||||||
|
.overlay.visible { display: flex; }
|
||||||
|
.dialog {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 2rem; max-width: 420px; width: 90%;
|
||||||
|
}
|
||||||
|
.dialog-title {
|
||||||
|
font-family: var(--mono); font-size: 0.7rem; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em; color: var(--error); margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.dialog p { font-size: 0.88rem; color: var(--text-dim); margin-bottom: 1.25rem; line-height: 1.6; }
|
||||||
|
.dialog p strong { color: var(--text); }
|
||||||
|
.dialog-actions { display: flex; gap: 0.75rem; justify-content: flex-end; }
|
||||||
|
.btn-cancel {
|
||||||
|
background: var(--surface2); color: var(--text-dim);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.btn-cancel:hover { color: var(--text); }
|
||||||
|
.btn-confirm-del {
|
||||||
|
background: rgba(200,90,58,0.15); color: var(--error);
|
||||||
|
border: 1px solid rgba(200,90,58,0.3);
|
||||||
|
}
|
||||||
|
.btn-confirm-del:hover { background: rgba(200,90,58,0.28); }
|
||||||
|
.btn-confirm-del:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.feedback {
|
||||||
|
font-family: var(--mono); font-size: 0.75rem; margin-top: 0.75rem;
|
||||||
|
min-height: 1.2em;
|
||||||
|
}
|
||||||
|
.feedback.ok { color: var(--success); }
|
||||||
|
.feedback.err { color: var(--error); }
|
||||||
|
|
||||||
|
/* ── Break patterns ── */
|
||||||
|
.bp-section { margin-bottom: 1.5rem; }
|
||||||
|
.bp-section:last-of-type { margin-bottom: 0; }
|
||||||
|
.bp-section-title {
|
||||||
|
font-family: var(--mono); font-size: 0.68rem; letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase; color: var(--text-dim); margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
.bp-list { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.6rem; }
|
||||||
|
.bp-row {
|
||||||
|
display: flex; align-items: center; gap: 0.6rem;
|
||||||
|
background: var(--surface2); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 0.35rem 0.6rem;
|
||||||
|
}
|
||||||
|
.bp-row.disabled { opacity: 0.45; }
|
||||||
|
.bp-toggle {
|
||||||
|
width: 14px; height: 14px; flex-shrink: 0;
|
||||||
|
accent-color: var(--accent); cursor: pointer;
|
||||||
|
}
|
||||||
|
.bp-pattern {
|
||||||
|
flex: 1; font-family: var(--mono); font-size: 0.75rem; color: var(--text);
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.bp-default-badge {
|
||||||
|
font-family: var(--mono); font-size: 0.6rem; color: var(--text-faint);
|
||||||
|
border: 1px solid var(--border); border-radius: 3px; padding: 0.1rem 0.3rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.bp-delete {
|
||||||
|
background: none; border: none; cursor: pointer; padding: 0.1rem 0.2rem;
|
||||||
|
color: var(--text-faint); font-size: 1rem; line-height: 1;
|
||||||
|
flex-shrink: 0; transition: color 0.12s;
|
||||||
|
}
|
||||||
|
.bp-delete:hover { color: var(--error); }
|
||||||
|
.bp-add-row { display: flex; gap: 0.5rem; }
|
||||||
|
.bp-input {
|
||||||
|
flex: 1; background: var(--surface2); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 0.4rem 0.6rem;
|
||||||
|
font-family: var(--mono); font-size: 0.78rem; color: var(--text); outline: none;
|
||||||
|
}
|
||||||
|
.bp-input:focus { border-color: var(--accent); }
|
||||||
|
.btn-add {
|
||||||
|
background: none; border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
font-family: var(--mono); font-size: 0.75rem; color: var(--text-dim);
|
||||||
|
padding: 0.4rem 0.8rem; cursor: pointer; transition: color 0.12s, border-color 0.12s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-add:hover { color: var(--text); border-color: var(--accent); }
|
||||||
|
.bp-test-row { display: flex; gap: 0.5rem; }
|
||||||
|
.btn-test {
|
||||||
|
background: none; border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
font-family: var(--mono); font-size: 0.75rem; color: var(--text-dim);
|
||||||
|
padding: 0.4rem 0.8rem; cursor: pointer; transition: color 0.12s, border-color 0.12s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-test:hover { color: var(--text); border-color: var(--text-faint); }
|
||||||
|
.card-divider { border: none; border-top: 1px solid var(--border); margin: 1.5rem 0; }
|
||||||
|
|
||||||
|
.spinner-inline {
|
||||||
|
display: none; width: 12px; height: 12px;
|
||||||
|
border: 2px solid var(--text-faint); border-top-color: var(--error);
|
||||||
|
border-radius: 50%; animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{% include "_sidebar.html" %}
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div class="main-title">Settings</div>
|
||||||
|
|
||||||
|
<!-- Reading history reset -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Reading history</div>
|
||||||
|
<div class="card-desc">
|
||||||
|
Reset all recorded reading sessions. This will permanently delete the entire reading history
|
||||||
|
and reset all counters on library cards and the Statistics page.<br/>
|
||||||
|
<strong>This action cannot be undone.</strong>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-danger" onclick="askReset()">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<polyline points="3 6 5 6 21 6"/>
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||||||
|
<path d="M10 11v6M14 11v6"/>
|
||||||
|
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
|
||||||
|
</svg>
|
||||||
|
Reset reading history
|
||||||
|
</button>
|
||||||
|
<div class="feedback" id="reset-feedback"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Break detection patterns -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Break detection</div>
|
||||||
|
<div class="card-desc">
|
||||||
|
Patronen die herkend worden als scèneovergang tijdens het converteren.
|
||||||
|
Wijzigingen zijn actief bij de eerstvolgende conversie.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bp-section">
|
||||||
|
<div class="bp-section-title">Regex patronen</div>
|
||||||
|
<div class="bp-list" id="bp-regex-list"></div>
|
||||||
|
<div class="bp-add-row" style="margin-bottom:0.4rem">
|
||||||
|
<input class="bp-input" id="bp-text-input" type="text" placeholder="Letterlijke breektekst (bv. *** of ——)…" autocomplete="off"/>
|
||||||
|
<button class="btn btn-add" onclick="addPatternFromText()">Toevoegen</button>
|
||||||
|
</div>
|
||||||
|
<div class="bp-add-row">
|
||||||
|
<input class="bp-input" id="bp-regex-input" type="text" placeholder="Of voer zelf een regex in…" autocomplete="off"/>
|
||||||
|
<button class="btn btn-add" onclick="addPattern('regex')">Regex toevoegen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="card-divider"/>
|
||||||
|
|
||||||
|
<div class="bp-section">
|
||||||
|
<div class="bp-section-title">CSS classes</div>
|
||||||
|
<div class="bp-list" id="bp-css-list"></div>
|
||||||
|
<div class="bp-add-row">
|
||||||
|
<input class="bp-input" id="bp-css-input" type="text" placeholder="CSS class naam…" autocomplete="off"/>
|
||||||
|
<button class="btn btn-add" onclick="addPattern('css_class')">Toevoegen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="card-divider"/>
|
||||||
|
|
||||||
|
<div class="bp-section">
|
||||||
|
<div class="bp-section-title">Test</div>
|
||||||
|
<div class="bp-test-row">
|
||||||
|
<input class="bp-input" id="bp-test-input" type="text" placeholder="Tekst om te testen…" autocomplete="off"/>
|
||||||
|
<button class="btn btn-test" onclick="testBreak()">Test</button>
|
||||||
|
</div>
|
||||||
|
<div class="feedback" id="bp-test-result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feedback" id="bp-feedback"></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Confirmation dialog -->
|
||||||
|
<div class="overlay" id="confirm-overlay">
|
||||||
|
<div class="dialog">
|
||||||
|
<div class="dialog-title">Reset reading history</div>
|
||||||
|
<p>
|
||||||
|
This will permanently delete <strong>all reading sessions</strong> from the database.
|
||||||
|
Statistics will be cleared and all read counts on library cards will reset to zero.<br/><br/>
|
||||||
|
Are you sure you want to continue?
|
||||||
|
</p>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="btn btn-cancel" onclick="closeConfirm()">Cancel</button>
|
||||||
|
<button class="btn btn-confirm-del" id="confirm-btn" onclick="confirmReset()">
|
||||||
|
<span id="confirm-label">Reset everything</span>
|
||||||
|
<div class="spinner-inline" id="confirm-spinner"></div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── Break patterns ─────────────────────────────────────────────────────────
|
||||||
|
let bpPatterns = [];
|
||||||
|
|
||||||
|
async function loadBreakPatterns() {
|
||||||
|
const resp = await fetch('/api/break-patterns');
|
||||||
|
bpPatterns = await resp.json();
|
||||||
|
renderBreakPatterns();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBreakPatterns() {
|
||||||
|
renderBpList('bp-regex-list', bpPatterns.filter(p => p.pattern_type === 'regex'));
|
||||||
|
renderBpList('bp-css-list', bpPatterns.filter(p => p.pattern_type === 'css_class'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBpList(elId, items) {
|
||||||
|
const el = document.getElementById(elId);
|
||||||
|
el.innerHTML = '';
|
||||||
|
if (!items.length) {
|
||||||
|
el.innerHTML = '<div style="font-family:var(--mono);font-size:0.72rem;color:var(--text-faint);padding:0.3rem 0">Geen patronen.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items.forEach(p => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'bp-row' + (p.enabled ? '' : ' disabled');
|
||||||
|
row.dataset.id = p.id;
|
||||||
|
row.innerHTML = `
|
||||||
|
<input type="checkbox" class="bp-toggle" ${p.enabled ? 'checked' : ''} onchange="togglePattern(${p.id}, this.checked)"/>
|
||||||
|
<span class="bp-pattern" title="${esc(p.pattern)}">${esc(p.pattern)}</span>
|
||||||
|
${p.is_default ? '<span class="bp-default-badge">default</span>' : ''}
|
||||||
|
<button class="bp-delete" onclick="deletePattern(${p.id})" title="Verwijder">×</button>
|
||||||
|
`;
|
||||||
|
el.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function textToRegex(text) {
|
||||||
|
// Escape all special regex characters, then wrap with ^\s*...\s*$
|
||||||
|
const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
return '^\\s*' + escaped + '\\s*$';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addPatternFromText() {
|
||||||
|
const input = document.getElementById('bp-text-input');
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
const pattern = textToRegex(text);
|
||||||
|
const fb = document.getElementById('bp-feedback');
|
||||||
|
fb.className = 'feedback';
|
||||||
|
fb.textContent = '';
|
||||||
|
const resp = await fetch('/api/break-patterns', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ pattern_type: 'regex', pattern }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.ok) {
|
||||||
|
input.value = '';
|
||||||
|
await loadBreakPatterns();
|
||||||
|
} else {
|
||||||
|
fb.className = 'feedback err';
|
||||||
|
fb.textContent = data.error || 'Fout bij toevoegen.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addPattern(type) {
|
||||||
|
const inputId = type === 'regex' ? 'bp-regex-input' : 'bp-css-input';
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
const pattern = input.value.trim();
|
||||||
|
if (!pattern) return;
|
||||||
|
const fb = document.getElementById('bp-feedback');
|
||||||
|
fb.className = 'feedback';
|
||||||
|
fb.textContent = '';
|
||||||
|
const resp = await fetch('/api/break-patterns', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ pattern_type: type, pattern }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.ok) {
|
||||||
|
input.value = '';
|
||||||
|
await loadBreakPatterns();
|
||||||
|
} else {
|
||||||
|
fb.className = 'feedback err';
|
||||||
|
fb.textContent = data.error || 'Fout bij toevoegen.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePattern(id, enabled) {
|
||||||
|
await fetch(`/api/break-patterns/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
});
|
||||||
|
const p = bpPatterns.find(p => p.id === id);
|
||||||
|
if (p) p.enabled = enabled;
|
||||||
|
renderBreakPatterns();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePattern(id) {
|
||||||
|
await fetch(`/api/break-patterns/${id}`, { method: 'DELETE' });
|
||||||
|
bpPatterns = bpPatterns.filter(p => p.id !== id);
|
||||||
|
renderBreakPatterns();
|
||||||
|
}
|
||||||
|
|
||||||
|
function testBreak() {
|
||||||
|
const text = document.getElementById('bp-test-input').value;
|
||||||
|
const fb = document.getElementById('bp-test-result');
|
||||||
|
const enabled = bpPatterns.filter(p => p.enabled);
|
||||||
|
|
||||||
|
// Test regex patterns
|
||||||
|
const regexPats = enabled.filter(p => p.pattern_type === 'regex');
|
||||||
|
for (const p of regexPats) {
|
||||||
|
try {
|
||||||
|
if (new RegExp(p.pattern).test(text)) {
|
||||||
|
fb.className = 'feedback ok';
|
||||||
|
fb.textContent = `✓ Break gedetecteerd via regex: ${p.pattern}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fb.className = 'feedback err';
|
||||||
|
fb.textContent = '✗ Niet herkend als break. (CSS classes worden niet getest — die werken op HTML attributen.)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter key in add inputs
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.getElementById('bp-text-input').addEventListener('keydown', e => { if (e.key === 'Enter') addPatternFromText(); });
|
||||||
|
document.getElementById('bp-regex-input').addEventListener('keydown', e => { if (e.key === 'Enter') addPattern('regex'); });
|
||||||
|
document.getElementById('bp-css-input').addEventListener('keydown', e => { if (e.key === 'Enter') addPattern('css_class'); });
|
||||||
|
document.getElementById('bp-test-input').addEventListener('keydown', e => { if (e.key === 'Enter') testBreak(); });
|
||||||
|
loadBreakPatterns();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Reading history ────────────────────────────────────────────────────────
|
||||||
|
function askReset() {
|
||||||
|
document.getElementById('confirm-overlay').classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirm() {
|
||||||
|
document.getElementById('confirm-overlay').classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmReset() {
|
||||||
|
const btn = document.getElementById('confirm-btn');
|
||||||
|
const label = document.getElementById('confirm-label');
|
||||||
|
const spinner = document.getElementById('confirm-spinner');
|
||||||
|
btn.disabled = true;
|
||||||
|
label.textContent = 'Resetting…';
|
||||||
|
spinner.style.display = 'inline-block';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/reading-history', { method: 'DELETE' });
|
||||||
|
const result = await resp.json();
|
||||||
|
closeConfirm();
|
||||||
|
const fb = document.getElementById('reset-feedback');
|
||||||
|
if (result.ok) {
|
||||||
|
fb.className = 'feedback ok';
|
||||||
|
fb.textContent = 'Reading history has been reset.';
|
||||||
|
} else {
|
||||||
|
fb.className = 'feedback err';
|
||||||
|
fb.textContent = 'Something went wrong.';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
closeConfirm();
|
||||||
|
const fb = document.getElementById('reset-feedback');
|
||||||
|
fb.className = 'feedback err';
|
||||||
|
fb.textContent = 'Error: ' + e;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
label.textContent = 'Reset everything';
|
||||||
|
spinner.style.display = 'none';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
320
containers/novela/templates/stats.html
Normal file
320
containers/novela/templates/stats.html
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Novela — Statistics</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<link rel="stylesheet" href="/static/sidebar.css"/>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f0e0c; --surface: #1a1815; --surface2: #221f1b;
|
||||||
|
--border: #2e2a24; --accent: #c8783a; --accent2: #e8a063;
|
||||||
|
--text: #e8e2d9; --text-dim: #8a8278; --text-faint: #4a453e;
|
||||||
|
--success: #6baa6b; --warning: #c8a03a; --error: #c85a3a;
|
||||||
|
--radius: 6px; --sidebar: 220px;
|
||||||
|
--mono: 'DM Mono', monospace; --serif: 'Libre Baskerville', Georgia, serif;
|
||||||
|
}
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--serif); }
|
||||||
|
|
||||||
|
/* ── Main ── */
|
||||||
|
.main { margin-left: var(--sidebar); min-height: 100vh; padding: 2rem 2.5rem 4rem; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main { margin-left: 0; padding: 4rem 1rem 4rem; }
|
||||||
|
}
|
||||||
|
.main-title {
|
||||||
|
font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase; color: var(--accent); margin-bottom: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stat cards ── */
|
||||||
|
.stat-cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 2rem; }
|
||||||
|
.stat-card {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 1.25rem 1.5rem;
|
||||||
|
}
|
||||||
|
.stat-card-label {
|
||||||
|
font-family: var(--mono); font-size: 0.62rem; letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase; color: var(--text-dim); margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.stat-card-value { font-size: 1.75rem; font-weight: 700; color: var(--accent); line-height: 1; }
|
||||||
|
.stat-card-value.text-val { font-size: 1.05rem; }
|
||||||
|
.stat-card-sub {
|
||||||
|
font-family: var(--mono); font-size: 0.7rem; color: var(--text-dim);
|
||||||
|
margin-top: 0.35rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chart cards ── */
|
||||||
|
.charts-row { display: grid; gap: 1.5rem; margin-bottom: 1.5rem; }
|
||||||
|
.charts-row-1 { grid-template-columns: 1fr; }
|
||||||
|
.charts-row-2 { grid-template-columns: 1fr 1fr; }
|
||||||
|
.charts-row-3 { grid-template-columns: 1fr 1fr 1fr; }
|
||||||
|
.chart-card {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.chart-card-title {
|
||||||
|
font-family: var(--mono); font-size: 0.62rem; letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase; color: var(--text-dim); margin-bottom: 1.1rem;
|
||||||
|
}
|
||||||
|
.chart-wrap { position: relative; height: 220px; }
|
||||||
|
.chart-wrap-tall { position: relative; height: 280px; }
|
||||||
|
|
||||||
|
/* ── History ── */
|
||||||
|
.history-card {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 1.5rem; margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
.history-title {
|
||||||
|
font-family: var(--mono); font-size: 0.62rem; letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase; color: var(--text-dim); margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.history-table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 0.75rem; }
|
||||||
|
.history-table th {
|
||||||
|
color: var(--accent); text-align: left; padding: 0.35rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border); letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.history-table td { padding: 0.45rem 0.75rem; border-bottom: 1px solid var(--border); color: var(--text-dim); }
|
||||||
|
.history-table tr:last-child td { border-bottom: none; }
|
||||||
|
.history-table tr:hover td { background: var(--surface2); }
|
||||||
|
.history-table td.title-col { color: var(--text); }
|
||||||
|
|
||||||
|
.tag-pill {
|
||||||
|
display: inline-block; font-size: 0.6rem; padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 3px; background: var(--surface2); color: var(--text-dim);
|
||||||
|
border: 1px solid var(--border); margin: 0.1rem 0.1rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-msg {
|
||||||
|
font-family: var(--mono); font-size: 0.82rem; color: var(--text-dim);
|
||||||
|
text-align: center; padding: 3rem 2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{% include "_sidebar.html" %}
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div class="main-title">Reading Statistics</div>
|
||||||
|
|
||||||
|
<div class="stat-cards">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-card-label">Total reads</div>
|
||||||
|
<div class="stat-card-value" id="s-total">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-card-label">Books read</div>
|
||||||
|
<div class="stat-card-value" id="s-unique">—</div>
|
||||||
|
<div class="stat-card-sub">unique titles</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-card-label">Favourite genre</div>
|
||||||
|
<div class="stat-card-value text-val" id="s-genre">—</div>
|
||||||
|
<div class="stat-card-sub" id="s-genre-sub"></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-card-label">Publisher</div>
|
||||||
|
<div class="stat-card-value text-val" id="s-pub">—</div>
|
||||||
|
<div class="stat-card-sub" id="s-pub-sub"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reads per month -->
|
||||||
|
<div class="charts-row charts-row-1">
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-card-title">Reads per month — last 12 months</div>
|
||||||
|
<div class="chart-wrap"><canvas id="chart-month"></canvas></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Day + hour -->
|
||||||
|
<div class="charts-row charts-row-2">
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-card-title">Day of the week</div>
|
||||||
|
<div class="chart-wrap"><canvas id="chart-dow"></canvas></div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-card-title">Hour of the day</div>
|
||||||
|
<div class="chart-wrap"><canvas id="chart-hour"></canvas></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Genre + top books -->
|
||||||
|
<div class="charts-row charts-row-2">
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-card-title">Genre distribution (library)</div>
|
||||||
|
<div class="chart-wrap-tall"><canvas id="chart-genre"></canvas></div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card">
|
||||||
|
<div class="chart-card-title">Most read books</div>
|
||||||
|
<div class="chart-wrap-tall"><canvas id="chart-top"></canvas></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- History -->
|
||||||
|
<div class="history-card">
|
||||||
|
<div class="history-title">Reading history — last 50 sessions</div>
|
||||||
|
<div id="history-container">
|
||||||
|
<div class="empty-msg">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
Chart.defaults.color = '#8a8278';
|
||||||
|
Chart.defaults.borderColor = '#2e2a24';
|
||||||
|
Chart.defaults.font.family = "'DM Mono', monospace";
|
||||||
|
Chart.defaults.font.size = 11;
|
||||||
|
|
||||||
|
const ACCENT = '#c8783a';
|
||||||
|
const ACCENT_A = 'rgba(200,120,58,0.15)';
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||||
|
+ ' ' + d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHART_OPTIONS_BAR = (indexAxis = 'x') => ({
|
||||||
|
indexAxis,
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
x: { grid: { color: indexAxis === 'y' ? '#2e2a24' : 'transparent' }, beginAtZero: true, ticks: { precision: 0 } },
|
||||||
|
y: { grid: { color: indexAxis === 'x' ? '#2e2a24' : 'transparent' }, ticks: { font: { size: 10 } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
const resp = await fetch('/api/stats');
|
||||||
|
const d = await resp.json();
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
document.getElementById('s-total').textContent = d.total_reads;
|
||||||
|
document.getElementById('s-unique').textContent = d.unique_books_read;
|
||||||
|
if (d.fav_genre) {
|
||||||
|
document.getElementById('s-genre').textContent = d.fav_genre;
|
||||||
|
const gc = d.genre_counts.find(g => g.name === d.fav_genre);
|
||||||
|
if (gc) document.getElementById('s-genre-sub').textContent = gc.count + ' books';
|
||||||
|
}
|
||||||
|
if (d.fav_publisher) {
|
||||||
|
document.getElementById('s-pub').textContent = d.fav_publisher;
|
||||||
|
const pc = d.publisher_counts.find(p => p.name === d.fav_publisher);
|
||||||
|
if (pc) document.getElementById('s-pub-sub').textContent = pc.count + ' books';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads per month
|
||||||
|
new Chart(document.getElementById('chart-month'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: d.reads_by_month.map(r => r.month),
|
||||||
|
datasets: [{
|
||||||
|
data: d.reads_by_month.map(r => r.count),
|
||||||
|
borderColor: ACCENT, backgroundColor: ACCENT_A,
|
||||||
|
fill: true, tension: 0.35,
|
||||||
|
pointBackgroundColor: ACCENT, pointRadius: 4, pointHoverRadius: 6,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true, maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
x: { grid: { color: '#2e2a24' } },
|
||||||
|
y: { grid: { color: '#2e2a24' }, beginAtZero: true, ticks: { precision: 0 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Day of week
|
||||||
|
const DOW = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||||
|
new Chart(document.getElementById('chart-dow'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: DOW,
|
||||||
|
datasets: [{ data: d.reads_by_dow, backgroundColor: ACCENT, borderRadius: 3 }],
|
||||||
|
},
|
||||||
|
options: CHART_OPTIONS_BAR('x'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hour of day
|
||||||
|
const HOURS = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2,'0')}:00`);
|
||||||
|
new Chart(document.getElementById('chart-hour'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: HOURS,
|
||||||
|
datasets: [{ data: d.reads_by_hour, backgroundColor: ACCENT, borderRadius: 2 }],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...CHART_OPTIONS_BAR('x'),
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false }, ticks: { maxRotation: 45, autoSkip: true, maxTicksLimit: 12 } },
|
||||||
|
y: { grid: { color: '#2e2a24' }, beginAtZero: true, ticks: { precision: 0 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Genre distribution (top 12)
|
||||||
|
const genres = d.genre_counts.slice(0, 12);
|
||||||
|
new Chart(document.getElementById('chart-genre'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: genres.map(g => g.name),
|
||||||
|
datasets: [{ data: genres.map(g => g.count), backgroundColor: ACCENT, borderRadius: 3 }],
|
||||||
|
},
|
||||||
|
options: CHART_OPTIONS_BAR('y'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Top books
|
||||||
|
if (d.top_books.length) {
|
||||||
|
new Chart(document.getElementById('chart-top'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: d.top_books.map(b => b.title.length > 24 ? b.title.slice(0, 23) + '…' : b.title),
|
||||||
|
datasets: [{ data: d.top_books.map(b => b.count), backgroundColor: ACCENT, borderRadius: 3 }],
|
||||||
|
},
|
||||||
|
options: CHART_OPTIONS_BAR('y'),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document.getElementById('chart-top').parentElement.innerHTML =
|
||||||
|
'<div class="empty-msg" style="height:100%;display:flex;align-items:center;justify-content:center">No reads recorded yet.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// History table
|
||||||
|
const container = document.getElementById('history-container');
|
||||||
|
if (!d.history.length) {
|
||||||
|
container.innerHTML = '<div class="empty-msg">No reading history yet. Use the book icon on a library card to mark a book as read.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = `
|
||||||
|
<table class="history-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th><th>Author</th><th>Genres</th><th>Publisher</th><th>Read at</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${d.history.map(h => `
|
||||||
|
<tr>
|
||||||
|
<td class="title-col">${esc(h.title)}</td>
|
||||||
|
<td>${esc(h.author)}</td>
|
||||||
|
<td>${(h.genres || []).slice(0,4).map(g => `<span class="tag-pill">${esc(g)}</span>`).join('')}</td>
|
||||||
|
<td>${esc(h.publisher)}</td>
|
||||||
|
<td style="white-space:nowrap;color:var(--text-dim)">${fmtDate(h.read_at)}</td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadStats();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
169
containers/novela/xhtml.py
Normal file
169
containers/novela/xhtml.py
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import re
|
||||||
|
from html import escape as he
|
||||||
|
|
||||||
|
from bs4 import NavigableString, Tag
|
||||||
|
|
||||||
|
BREAK_PATTERNS = [
|
||||||
|
re.compile(r"^\s*[\*\-]{3,}\s*$"), # *** of ---
|
||||||
|
re.compile(r"^\s*[·•◦‣⁃]\s*[·•◦‣⁃]\s*[·•◦‣⁃]\s*$"), # • • •
|
||||||
|
re.compile(r"^\s*~{2,}\s*$"), # ~~
|
||||||
|
re.compile(r"^\s*={3,}\s*$"), # ===
|
||||||
|
re.compile(r"^\s*#{3,}\s*$"), # ###
|
||||||
|
re.compile(r"^\s*[oO0]{1,3}\s*$"), # oOo
|
||||||
|
re.compile(r"^\s*[-–—]\s*[oO0]\s*[-–—]\s*$"), # -o- / —O—
|
||||||
|
re.compile(r"^\s*[<>]+\s*[·•*]\s*[<>]+\s*$"), # <<<<<·>>>>>
|
||||||
|
]
|
||||||
|
|
||||||
|
BREAK_CSS_CLASSES = [
|
||||||
|
"hr", "separator", "section-break", "divider", "break",
|
||||||
|
"chapterbreak", "scene-break", "scenebreak",
|
||||||
|
]
|
||||||
|
# Normalised set (hyphens removed, lowercase) for exact-match checking.
|
||||||
|
# Substring matching caused false positives: e.g. "ipsPageBreak" contains
|
||||||
|
# "break" but is a layout class, not a scene-break marker.
|
||||||
|
_BREAK_CSS_NORM = frozenset(b.replace("-", "") for b in BREAK_CSS_CLASSES)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Runtime-configurable overrides (populated from DB by main.py before scraping)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_active_patterns: list | None = None # None → fall back to BREAK_PATTERNS
|
||||||
|
_active_css_norm: frozenset | None = None # None → fall back to _BREAK_CSS_NORM
|
||||||
|
|
||||||
|
|
||||||
|
def configure_break_patterns(regex_strings: list[str], css_classes: list[str]) -> None:
|
||||||
|
"""Override the active break patterns with values loaded from the database.
|
||||||
|
|
||||||
|
Called by main.py before each scrape so user-edited patterns take effect
|
||||||
|
without requiring a server restart.
|
||||||
|
"""
|
||||||
|
global _active_patterns, _active_css_norm
|
||||||
|
compiled = []
|
||||||
|
for p in regex_strings:
|
||||||
|
try:
|
||||||
|
compiled.append(re.compile(p))
|
||||||
|
except re.error:
|
||||||
|
pass
|
||||||
|
_active_patterns = compiled
|
||||||
|
_active_css_norm = frozenset(c.lower().replace("-", "") for c in css_classes)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_patterns() -> list:
|
||||||
|
return _active_patterns if _active_patterns is not None else BREAK_PATTERNS
|
||||||
|
|
||||||
|
|
||||||
|
def _get_css_norm() -> frozenset:
|
||||||
|
return _active_css_norm if _active_css_norm is not None else _BREAK_CSS_NORM
|
||||||
|
|
||||||
|
|
||||||
|
def is_break_element(el, empty_p_is_spacer: bool = False) -> bool:
|
||||||
|
"""Detect scene breaks based on tag, class, or text pattern."""
|
||||||
|
patterns = _get_patterns()
|
||||||
|
css_norm = _get_css_norm()
|
||||||
|
if isinstance(el, Tag):
|
||||||
|
if el.name == "hr":
|
||||||
|
return True
|
||||||
|
classes = el.get("class", [])
|
||||||
|
for cls in classes:
|
||||||
|
if cls.lower().replace("-", "") in css_norm:
|
||||||
|
return True
|
||||||
|
# Empty paragraph (whitespace or only) counts as a break,
|
||||||
|
# unless the content uses them as spacers between every paragraph.
|
||||||
|
if el.name == "p" and not empty_p_is_spacer:
|
||||||
|
child_tags = [c for c in el.children if isinstance(c, Tag)]
|
||||||
|
if not child_tags and not el.get_text().replace("\xa0", "").strip():
|
||||||
|
return True
|
||||||
|
# Image that represents a break
|
||||||
|
if el.name == "img":
|
||||||
|
src = el.get("src", "").lower()
|
||||||
|
alt = el.get("alt", "").lower()
|
||||||
|
if any(b in src or b in alt for b in ["break", "divider", "separator", "hr"]):
|
||||||
|
return True
|
||||||
|
# Element containing only a single break image
|
||||||
|
children = [c for c in el.children if not (isinstance(c, NavigableString) and not c.strip())]
|
||||||
|
if len(children) == 1 and isinstance(children[0], Tag) and children[0].name == "img":
|
||||||
|
return is_break_element(children[0])
|
||||||
|
# Text pattern
|
||||||
|
text = el.get_text()
|
||||||
|
for pat in patterns:
|
||||||
|
if pat.match(text):
|
||||||
|
return True
|
||||||
|
elif isinstance(el, NavigableString):
|
||||||
|
for pat in patterns:
|
||||||
|
if pat.match(str(el)):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def element_to_xhtml(el, break_img_path: str = "../Images/break.png", empty_p_is_spacer: bool = False) -> str:
|
||||||
|
"""Convert a BeautifulSoup element to an XHTML fragment."""
|
||||||
|
if is_break_element(el, empty_p_is_spacer):
|
||||||
|
result = f'<center><img src="{break_img_path}" style="height:15px;"/></center>'
|
||||||
|
# HTML parsers (notably html.parser) can nest subsequent siblings inside
|
||||||
|
# void elements like <hr>, so a break element may contain actual content
|
||||||
|
# as children. Process those children so no text is silently discarded.
|
||||||
|
if isinstance(el, Tag):
|
||||||
|
trailer = "".join(
|
||||||
|
element_to_xhtml(c, break_img_path, empty_p_is_spacer)
|
||||||
|
for c in el.children
|
||||||
|
)
|
||||||
|
if trailer.strip():
|
||||||
|
result += "\n" + trailer
|
||||||
|
return result
|
||||||
|
|
||||||
|
if isinstance(el, NavigableString):
|
||||||
|
text = str(el)
|
||||||
|
if text.strip():
|
||||||
|
return he(text)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if el.name in ("p", "div"):
|
||||||
|
inner = "".join(element_to_xhtml(c, break_img_path, empty_p_is_spacer) for c in el.children)
|
||||||
|
inner = inner.strip()
|
||||||
|
if not inner:
|
||||||
|
return ""
|
||||||
|
return f"<p>{inner}</p>\n"
|
||||||
|
|
||||||
|
if el.name in ("em", "i"):
|
||||||
|
inner = "".join(element_to_xhtml(c, break_img_path, empty_p_is_spacer) for c in el.children)
|
||||||
|
return f"<em>{inner}</em>"
|
||||||
|
|
||||||
|
if el.name in ("strong", "b"):
|
||||||
|
inner = "".join(element_to_xhtml(c, break_img_path, empty_p_is_spacer) for c in el.children)
|
||||||
|
return f"<strong>{inner}</strong>"
|
||||||
|
|
||||||
|
if el.name in ("h1", "h2", "h3", "h4"):
|
||||||
|
inner = "".join(element_to_xhtml(c, break_img_path, empty_p_is_spacer) for c in el.children)
|
||||||
|
return f"<{el.name}>{inner}</{el.name}>\n"
|
||||||
|
|
||||||
|
if el.name == "br":
|
||||||
|
return "<br />"
|
||||||
|
|
||||||
|
if el.name in ("sup", "sub"):
|
||||||
|
inner = "".join(element_to_xhtml(c, break_img_path, empty_p_is_spacer) for c in el.children)
|
||||||
|
return inner
|
||||||
|
|
||||||
|
if el.name == "a":
|
||||||
|
inner = "".join(element_to_xhtml(c, break_img_path, empty_p_is_spacer) for c in el.children)
|
||||||
|
return inner # strip links, keep text
|
||||||
|
|
||||||
|
if el.name == "img":
|
||||||
|
src = el.get("src", "")
|
||||||
|
alt = he(el.get("alt", ""))
|
||||||
|
if src:
|
||||||
|
return f'<img src="{he(src)}" alt="{alt}"/>\n'
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if el.name == "figure":
|
||||||
|
parts = []
|
||||||
|
for c in el.children:
|
||||||
|
if isinstance(c, Tag) and c.name == "figcaption":
|
||||||
|
continue
|
||||||
|
parts.append(element_to_xhtml(c, break_img_path, empty_p_is_spacer))
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
# Other tags: recurse
|
||||||
|
parts = []
|
||||||
|
for c in el.children:
|
||||||
|
parts.append(element_to_xhtml(c, break_img_path, empty_p_is_spacer))
|
||||||
|
return "".join(parts)
|
||||||
420
docs/BLUEPRINT.md
Normal file
420
docs/BLUEPRINT.md
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
# Novela 2.0 - Blauwdruk
|
||||||
|
|
||||||
|
> Vervangt repository `story-grabber`. Nieuwe repo: **Novela**.
|
||||||
|
> Stack: FastAPI · Jinja2 · plain JS · PostgreSQL 16 · Docker / Portainer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Doelstelling
|
||||||
|
|
||||||
|
Novela 2.0 is een volledig zelfgehoste media-bibliotheek en e-reader voor epub, pdf en cbr/cbz.
|
||||||
|
Het vervangt Kavita (library), Calibre (metadata), en Sigil (epub editor) in een web-applicatie.
|
||||||
|
|
||||||
|
Kernprincipe: **de database is de snelle index, het bestand is de bron van waarheid.**
|
||||||
|
Elke schrijfactie raakt altijd beide: eerst het bestand, dan de database. Lezen gaat altijd via de database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Wat behouden blijft uit v1
|
||||||
|
|
||||||
|
| Module | Bestand | Toelichting |
|
||||||
|
|---|---|---|
|
||||||
|
| EPUB bouw | `epub.py` | `make_epub`, `make_chapter_xhtml`, `add_cover_to_epub` |
|
||||||
|
| EPUB lezen/schrijven | `epub.py` | `read_epub_file`, `write_epub_file` |
|
||||||
|
| XHTML conversie | `xhtml.py` | `element_to_xhtml`, `is_break_element`, `configure_break_patterns` |
|
||||||
|
| Scrapers | `scrapers/` | base, awesomedude, gayauthors, plugin-patroon blijft |
|
||||||
|
| SSE job streaming | `main.py` | `JOBS` dict + `/events/{job_id}` `StreamingResponse` |
|
||||||
|
| Migrations patroon | `migrations.py` | idempotente `CREATE IF NOT EXISTS`, `run_migrations()` bij startup |
|
||||||
|
| Cover cache | DB tabel | `library_cover_cache`, WebP thumbnails 300x450 |
|
||||||
|
| Reading progress | DB tabel | CFI voor epub, paginanummer voor pdf/cbr |
|
||||||
|
| Reading sessions | DB tabel | leesgeschiedenis per boek |
|
||||||
|
| Break patterns | DB tabel | regex + css_class patronen voor scene-breaks |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Projectstructuur
|
||||||
|
|
||||||
|
```text
|
||||||
|
novela/
|
||||||
|
├── containers/
|
||||||
|
│ └── novela/
|
||||||
|
│ ├── main.py
|
||||||
|
│ ├── migrations.py
|
||||||
|
│ ├── db.py
|
||||||
|
│ ├── epub.py
|
||||||
|
│ ├── xhtml.py
|
||||||
|
│ ├── pdf.py
|
||||||
|
│ ├── cbr.py
|
||||||
|
│ ├── routers/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── library.py
|
||||||
|
│ │ ├── reader.py
|
||||||
|
│ │ ├── editor.py
|
||||||
|
│ │ ├── grabber.py
|
||||||
|
│ │ ├── backup.py
|
||||||
|
│ │ └── settings.py
|
||||||
|
│ ├── scrapers/
|
||||||
|
│ ├── static/
|
||||||
|
│ ├── templates/
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ └── Dockerfile
|
||||||
|
├── stack/
|
||||||
|
│ ├── stack.yml
|
||||||
|
│ └── novela.env
|
||||||
|
└── docs/
|
||||||
|
├── BLUEPRINT.md
|
||||||
|
└── TECHNICAL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Bibliotheek op schijf
|
||||||
|
|
||||||
|
`output/` wordt `library/`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
library/
|
||||||
|
├── epub/
|
||||||
|
│ └── {Publisher}/
|
||||||
|
│ └── {Author}/
|
||||||
|
│ ├── Stories/
|
||||||
|
│ │ └── {Titel}.epub
|
||||||
|
│ └── Series/
|
||||||
|
│ └── {Serienaam}/
|
||||||
|
│ └── {001 - Titel}.epub
|
||||||
|
├── pdf/
|
||||||
|
│ └── {Author}/
|
||||||
|
│ └── {Titel}.pdf
|
||||||
|
├── comics/
|
||||||
|
│ └── {Author of Serienaam}/
|
||||||
|
│ └── {001 - Titel}.cbr
|
||||||
|
└── covers/
|
||||||
|
```
|
||||||
|
|
||||||
|
Naamgeving-regels:
|
||||||
|
- Ongeldige tekens weg: `< > : " / \\ | ? *` en control chars
|
||||||
|
- Max 80 tekens per map-segment, 140 voor bestandsnaam
|
||||||
|
- Bij conflict: `Titel (2).epub`, `Titel (3).epub`, enz.
|
||||||
|
|
||||||
|
Hernoemen na metadata-bewerking:
|
||||||
|
- Bestand verplaatsen op schijf
|
||||||
|
- DB-verwijzingen updaten: `library`, `book_tags`, `reading_progress`, `reading_sessions`, `library_cover_cache`
|
||||||
|
- Lege mappen opruimen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Database schema
|
||||||
|
|
||||||
|
### 5.1 `library`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE library (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
filename VARCHAR(600) UNIQUE NOT NULL,
|
||||||
|
media_type VARCHAR(10) NOT NULL DEFAULT 'epub',
|
||||||
|
title VARCHAR(500),
|
||||||
|
author VARCHAR(255),
|
||||||
|
publisher VARCHAR(255),
|
||||||
|
series VARCHAR(500),
|
||||||
|
series_index INTEGER DEFAULT 0,
|
||||||
|
publication_status VARCHAR(100),
|
||||||
|
has_cover BOOLEAN DEFAULT FALSE,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
source_url VARCHAR(1000),
|
||||||
|
publish_date DATE,
|
||||||
|
archived BOOLEAN DEFAULT FALSE,
|
||||||
|
want_to_read BOOLEAN DEFAULT FALSE,
|
||||||
|
needs_review BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 `book_tags`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE book_tags (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
filename VARCHAR(600) NOT NULL REFERENCES library(filename) ON DELETE CASCADE,
|
||||||
|
tag VARCHAR(255) NOT NULL,
|
||||||
|
tag_type VARCHAR(20) NOT NULL,
|
||||||
|
UNIQUE (filename, tag, tag_type)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_book_tags_filename ON book_tags (filename);
|
||||||
|
```
|
||||||
|
|
||||||
|
`tag_type`:
|
||||||
|
- `genre`
|
||||||
|
- `subgenre`
|
||||||
|
- `tag`
|
||||||
|
- `subject`
|
||||||
|
|
||||||
|
### 5.3 `reading_progress`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE reading_progress (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
filename VARCHAR(600) UNIQUE NOT NULL REFERENCES library(filename) ON DELETE CASCADE,
|
||||||
|
cfi TEXT,
|
||||||
|
page INTEGER,
|
||||||
|
progress INTEGER DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 `reading_sessions`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE reading_sessions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
filename VARCHAR(600) NOT NULL REFERENCES library(filename) ON DELETE CASCADE,
|
||||||
|
read_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_reading_sessions_filename ON reading_sessions (filename);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 `library_cover_cache`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE library_cover_cache (
|
||||||
|
filename VARCHAR(600) PRIMARY KEY REFERENCES library(filename) ON DELETE CASCADE,
|
||||||
|
mime_type VARCHAR(100) NOT NULL,
|
||||||
|
thumb_webp BYTEA NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.6 `credentials`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE credentials (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
site VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
username VARCHAR(255) NOT NULL,
|
||||||
|
password VARCHAR(255) NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.7 `break_patterns`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE break_patterns (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
pattern_type VARCHAR(20) NOT NULL,
|
||||||
|
pattern TEXT NOT NULL,
|
||||||
|
enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
is_default BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE (pattern_type, pattern)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.8 `backup_log`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE backup_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
status VARCHAR(20) NOT NULL,
|
||||||
|
files_count INTEGER,
|
||||||
|
size_bytes BIGINT,
|
||||||
|
error_msg TEXT,
|
||||||
|
started_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
finished_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Schrijfprincipe: bestand en database synchroon
|
||||||
|
|
||||||
|
Volgorde per bewerking:
|
||||||
|
1. Bewerk bestand op schijf
|
||||||
|
2. Update database
|
||||||
|
3. Retourneer succes
|
||||||
|
|
||||||
|
Nooit alleen DB updaten zonder bestand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Coverstrategie
|
||||||
|
|
||||||
|
Opslaan:
|
||||||
|
- EPUB cover in bestand (`OEBPS/Images/cover.{ext}`)
|
||||||
|
- Thumbnail als `300x450` WebP in `library_cover_cache`
|
||||||
|
|
||||||
|
Ontbrekende cover:
|
||||||
|
- Als geen cover: voeg tag `Cover Missing` toe
|
||||||
|
- UI upload schrijft cover in EPUB en cache
|
||||||
|
|
||||||
|
Opvragen:
|
||||||
|
- Primair: `/library/cover-cached/{filename}`
|
||||||
|
- Fallback: `/library/cover/{filename}`
|
||||||
|
|
||||||
|
PDF en CBR:
|
||||||
|
- PDF: eerste pagina als thumbnail
|
||||||
|
- CBR/CBZ: eerste afbeelding als thumbnail
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Verwijder-flow
|
||||||
|
|
||||||
|
`DELETE /library/file/{filename}`:
|
||||||
|
1. Verwijder bestand
|
||||||
|
2. Prune lege mappen
|
||||||
|
3. Delete uit `library` (cascade verwijdert gerelateerde tabellen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Router-overzicht
|
||||||
|
|
||||||
|
### 9.1 `routers/library.py`
|
||||||
|
- `GET /library`
|
||||||
|
- `GET /api/library`
|
||||||
|
- `POST /library/rescan`
|
||||||
|
- `POST /library/import`
|
||||||
|
- `DELETE /library/file/{filename}`
|
||||||
|
- `GET /library/cover/{filename}`
|
||||||
|
- `GET /library/cover-cached/{filename}`
|
||||||
|
- `POST /library/cover/{filename}`
|
||||||
|
- `POST /library/want-to-read/{filename}`
|
||||||
|
- `POST /library/archive/{filename}`
|
||||||
|
- `GET /home`
|
||||||
|
- `GET /api/home`
|
||||||
|
- `GET /stats`
|
||||||
|
- `GET /api/stats`
|
||||||
|
|
||||||
|
### 9.2 `routers/reader.py`
|
||||||
|
- `GET /library/read/{filename}`
|
||||||
|
- `GET /library/book/{filename}`
|
||||||
|
- `PATCH /library/book/{filename}`
|
||||||
|
- `GET /library/epub/{filename}`
|
||||||
|
- `GET /library/chapters/{filename}`
|
||||||
|
- `GET /library/chapter/{index}/{filename}`
|
||||||
|
- `GET /library/chapter-img/{path}`
|
||||||
|
- `GET /library/pdf/{filename}`
|
||||||
|
- `GET /library/cbr/{filename}/{page}`
|
||||||
|
- `GET /library/progress/{filename}`
|
||||||
|
- `POST /library/progress/{filename}`
|
||||||
|
- `DELETE /library/progress/{filename}`
|
||||||
|
- `POST /library/mark-read/{filename}`
|
||||||
|
- `GET /api/genres`
|
||||||
|
|
||||||
|
### 9.3 `routers/editor.py`
|
||||||
|
- `GET /library/editor/{filename}`
|
||||||
|
- `GET /api/edit/chapter/{index}/{filename}`
|
||||||
|
- `POST /api/edit/chapter/{index}/{filename}`
|
||||||
|
- `POST /api/edit/chapter/add/{filename}`
|
||||||
|
- `DELETE /api/edit/chapter/{index}/{filename}`
|
||||||
|
|
||||||
|
### 9.4 `routers/grabber.py`
|
||||||
|
- `GET /grabber`
|
||||||
|
- `POST /preload`
|
||||||
|
- `POST /convert`
|
||||||
|
- `GET /events/{job_id}`
|
||||||
|
- `GET /debug`
|
||||||
|
- `POST /debug/run`
|
||||||
|
- `GET /credentials`
|
||||||
|
- `POST /credentials`
|
||||||
|
- `DELETE /credentials/{site}`
|
||||||
|
|
||||||
|
### 9.5 `routers/backup.py`
|
||||||
|
- `GET /backup`
|
||||||
|
- `GET /api/backup/status`
|
||||||
|
- `POST /api/backup/run`
|
||||||
|
- `GET /api/backup/history`
|
||||||
|
|
||||||
|
### 9.6 `routers/settings.py`
|
||||||
|
- `GET /settings`
|
||||||
|
- `GET /api/break-patterns`
|
||||||
|
- `POST /api/break-patterns`
|
||||||
|
- `PATCH /api/break-patterns/{id}`
|
||||||
|
- `DELETE /api/break-patterns/{id}`
|
||||||
|
- `DELETE /api/reading-history`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Nieuwe modules
|
||||||
|
|
||||||
|
### 10.1 `db.py`
|
||||||
|
Gedeelde psycopg2 connection pool (`init_pool`, `get_conn`, `release_conn`).
|
||||||
|
|
||||||
|
### 10.2 `pdf.py`
|
||||||
|
PyMuPDF rendering (`pdf_render_page`), page count en cover thumb.
|
||||||
|
|
||||||
|
### 10.3 `cbr.py`
|
||||||
|
RAR/ZIP paginalijst, page extract en cover thumb.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Cover-flow per mediatype
|
||||||
|
|
||||||
|
| Actie | EPUB | PDF | CBR/CBZ |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Cover import | Uit OPF/Images | Eerste pagina render | Eerste image uit archief |
|
||||||
|
| Thumbnail | Pillow -> WebP | PyMuPDF + Pillow -> WebP | Pillow -> WebP |
|
||||||
|
| Opslag | EPUB + cache | cache | cache |
|
||||||
|
| Cover vervangen | Ja | Nee | Nee |
|
||||||
|
| Geen cover | `Cover Missing` tag | `Cover Missing` tag | `Cover Missing` tag |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Database-opzet
|
||||||
|
|
||||||
|
- Start met schone v2 database
|
||||||
|
- Geen migratiepad vanuit v1 data
|
||||||
|
- `run_migrations()` op startup
|
||||||
|
- `CREATE TABLE IF NOT EXISTS` overal idempotent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Docker stack
|
||||||
|
|
||||||
|
Zie [`stack/stack.yml`](../stack/stack.yml).
|
||||||
|
|
||||||
|
Belangrijk:
|
||||||
|
- App container expose `8099 -> 8000`
|
||||||
|
- PostgreSQL 16
|
||||||
|
- Adminer op `8098`
|
||||||
|
- `NOVELA_MASTER_KEY` in `stack/novela.env` en doorgifte in `stack/stack.yml` voor encrypted credentials
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Requirements
|
||||||
|
|
||||||
|
Zie [`containers/novela/requirements.txt`](../containers/novela/requirements.txt).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Bestanden klaarzetten
|
||||||
|
|
||||||
|
Bron: `/docker/develop/story-grabber/containers/story-grabber`.
|
||||||
|
Doel: `/docker/develop/novela/containers/novela`.
|
||||||
|
|
||||||
|
Overnemen:
|
||||||
|
- `epub.py`
|
||||||
|
- `xhtml.py`
|
||||||
|
- `scrapers/*`
|
||||||
|
- `static/*`
|
||||||
|
- `templates/*`
|
||||||
|
|
||||||
|
Nieuw schrijven:
|
||||||
|
- `main.py`, `db.py`, `pdf.py`, `cbr.py`, `migrations.py`
|
||||||
|
- `routers/*`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Bouw-volgorde
|
||||||
|
|
||||||
|
1. `db.py`
|
||||||
|
2. `migrations.py`
|
||||||
|
3. `main.py`
|
||||||
|
4. `routers/library.py`
|
||||||
|
5. `routers/reader.py`
|
||||||
|
6. `routers/editor.py`
|
||||||
|
7. `routers/grabber.py`
|
||||||
|
8. `routers/settings.py`
|
||||||
|
9. `pdf.py` + reader uitbreiding
|
||||||
|
10. `cbr.py` + reader uitbreiding
|
||||||
|
11. `routers/backup.py`
|
||||||
|
12. `routers/library.py` uitbreiden voor pdf/cbr import
|
||||||
100
docs/TECHNICAL.md
Normal file
100
docs/TECHNICAL.md
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
# Novela 2.0 - Technical Plan
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
Dit document beschrijft de technische uitvoering van de blauwdruk in implementeerbare stappen.
|
||||||
|
|
||||||
|
## Architectural Rules
|
||||||
|
- Bestand is source of truth.
|
||||||
|
- Database is snelle index.
|
||||||
|
- Schrijfacties: eerst bestand, dan DB.
|
||||||
|
- Lezen: primair uit DB, met scan/rescan voor recovery.
|
||||||
|
|
||||||
|
## Data Integrity Rules
|
||||||
|
- Alle child-tabellen refereren `library(filename)` met `ON DELETE CASCADE`.
|
||||||
|
- Verwijderen van een boek is een enkel `DELETE FROM library` na file-delete.
|
||||||
|
- Rename-flow moet `filename` synchroon aanpassen in:
|
||||||
|
- `library`
|
||||||
|
- `book_tags`
|
||||||
|
- `reading_progress`
|
||||||
|
- `reading_sessions`
|
||||||
|
- `library_cover_cache`
|
||||||
|
|
||||||
|
## Runtime Lifecycle
|
||||||
|
- Startup:
|
||||||
|
1. `init_pool()`
|
||||||
|
2. `run_migrations()`
|
||||||
|
3. routers mounten
|
||||||
|
- Shutdown:
|
||||||
|
1. `close_pool()`
|
||||||
|
|
||||||
|
## Module Responsibilities
|
||||||
|
- `db.py`: pool ownership + connection helpers.
|
||||||
|
- `migrations.py`: schema + seeds.
|
||||||
|
- `routers/library.py`: import/scan/delete/cover/home/stats.
|
||||||
|
- `routers/reader.py`: lezen + progress + metadata patch + epub editor endpoints.
|
||||||
|
- `routers/editor.py`: uiteindelijke dedicated editor routes (kan initieel delegaten).
|
||||||
|
- `routers/grabber.py`: scraper orchestration + credentials + SSE.
|
||||||
|
- `routers/backup.py`: Dropbox sync + pg dump + logging.
|
||||||
|
- `routers/settings.py`: break patterns + cleaning endpoints.
|
||||||
|
|
||||||
|
## Endpoint Contract Notes
|
||||||
|
- Alle file routes gebruiken veilige path-resolutie tegen traversal.
|
||||||
|
- Cover endpoint gedrag:
|
||||||
|
- cached eerst
|
||||||
|
- fallback raw extract
|
||||||
|
- anders 404
|
||||||
|
- Progress payload:
|
||||||
|
- EPUB: `{ cfi, progress }`
|
||||||
|
- PDF/CBR: `{ page, progress }`
|
||||||
|
|
||||||
|
## Backup Plan
|
||||||
|
- `POST /api/backup/run`:
|
||||||
|
- insert `running` in `backup_log`
|
||||||
|
- sync files naar Dropbox (incremental op mtime+size)
|
||||||
|
- draai `pg_dump` en upload `.sql`
|
||||||
|
- update `backup_log` naar `success`/`error`
|
||||||
|
- OAuth token opslag via `credentials` (`site='dropbox'`) en encrypted-at-rest (Fernet) in de database.
|
||||||
|
- Beheer via webinterface op `/credentials-manager` (site: `dropbox`, token in password veld).
|
||||||
|
- Legacy plaintext credentials worden automatisch gemigreerd naar encrypted bij uitlezen.
|
||||||
|
|
||||||
|
## Migration Plan from Current State
|
||||||
|
1. Behoud v1 stabiele modules (`epub.py`, `xhtml.py`, scrapers, templates/static).
|
||||||
|
2. Introduceer nieuwe routers zonder bestaande frontend te breken (compat routes waar nodig).
|
||||||
|
3. Schakel library root om naar `library/`.
|
||||||
|
4. Activeer PDF/CBR scan en reader paden.
|
||||||
|
5. Split editor-routes uit reader naar dedicated `editor.py`.
|
||||||
|
6. Volledige scrape->epub flow migreren naar `grabber.py`.
|
||||||
|
7. Backup volledig afronden (Dropbox + pg_dump).
|
||||||
|
|
||||||
|
## Test Matrix
|
||||||
|
- Import:
|
||||||
|
- EPUB met/zonder cover
|
||||||
|
- PDF 1+ pagina
|
||||||
|
- CBR/CBZ met images
|
||||||
|
- Reader:
|
||||||
|
- EPUB CFI save/load
|
||||||
|
- PDF page render + page progress
|
||||||
|
- CBR page render + page progress
|
||||||
|
- Metadata edit:
|
||||||
|
- rename path
|
||||||
|
- db references geupdate
|
||||||
|
- old row cleanup
|
||||||
|
- Delete:
|
||||||
|
- file weg
|
||||||
|
- lege dirs gepruned
|
||||||
|
- cascade records weg
|
||||||
|
- Break patterns:
|
||||||
|
- create/update/delete/enable
|
||||||
|
- Grabber:
|
||||||
|
- preload/debug
|
||||||
|
- convert job events
|
||||||
|
- Backup:
|
||||||
|
- status/history
|
||||||
|
- success/error logging
|
||||||
|
|
||||||
|
## Deployment Notes
|
||||||
|
- Docker image bouwt vanuit `containers/novela`.
|
||||||
|
- Stack uit `stack/stack.yml` met env uit `stack/novela.env`.
|
||||||
|
- `NOVELA_MASTER_KEY` is verplicht voor encrypt/decrypt van credentials in de database en moet stabiel blijven na initiele ingebruikname.
|
||||||
|
- Postgres volume persistent.
|
||||||
|
- Library mount persistent.
|
||||||
7
stack/novela.env
Normal file
7
stack/novela.env
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
POSTGRES_DB=novela
|
||||||
|
POSTGRES_USER=novela
|
||||||
|
POSTGRES_PASSWORD=change-me
|
||||||
|
|
||||||
|
# Required for credential encryption/decryption (Fernet) in DB.
|
||||||
|
# Keep this stable after first use; changing it breaks decrypt of existing credentials.
|
||||||
|
NOVELA_MASTER_KEY=change-me-long-random-secret
|
||||||
48
stack/stack.yml
Normal file
48
stack/stack.yml
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
version: "3.8"
|
||||||
|
services:
|
||||||
|
novela:
|
||||||
|
image: gitea.oskamp.info/ivooskamp/novela:dev
|
||||||
|
container_name: novela
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8099:8000"
|
||||||
|
environment:
|
||||||
|
POSTGRES_HOST: postgres
|
||||||
|
POSTGRES_PORT: 5432
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
NOVELA_MASTER_KEY: ${NOVELA_MASTER_KEY}
|
||||||
|
volumes:
|
||||||
|
- /docker/appdata/novela/library:/app/library
|
||||||
|
- /docker/appdata/novela/config:/app/config
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
networks:
|
||||||
|
- novela-net
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
container_name: novela-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- /docker/appdata/novela/postgres:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- novela-net
|
||||||
|
|
||||||
|
adminer:
|
||||||
|
image: adminer:latest
|
||||||
|
container_name: novela-adminer
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8098:8080"
|
||||||
|
networks:
|
||||||
|
- novela-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
novela-net:
|
||||||
|
driver: bridge
|
||||||
Loading…
Reference in New Issue
Block a user