Compare commits

..
10 Commits
Author SHA1 Message Date
truetype e62c50c616 docs: update all docs with SQLite, API, content_hash, reader features 2026-07-23 22:40:13 +02:00
truetype c1421665c0 feat: persistent SQLite storage for bookmarks + positions keyed by content_hash
- new bookish/services/database.py: SQLite with WAL mode, tables for
  bookmarks and reading_positions, keyed by content_hash (SHA256)
- new bookish/routes/api.py: REST endpoints for CRUD bookmarks
  and save/load reading position
- upload endpoint returns content_hash (SHA256 of file)
- reader route computes SHA256 and passes BOOKISH_HASH to template
- reader.js: bookmarks + positions use API server as primary storage
  with localStorage fallback for offline/resilience
- position saves debounced (1.5s) to avoid hammering server
- .gitignore: added bookish.db
2026-07-23 22:35:04 +02:00
truetype 13e02faa8b feat: toast notification + button flash on bookmark add
- showToast() displays a centered bottom toast for 3s
- flashBookmarkBtn() pulses the 🔖 button on bookmark creation
- CSS: .reader-toast with slide-up fade-in/out animation
- CSS: .bm-flash with @keyframes bmPulse (scale 1→1.25→1)
- user gets clear feedback when bookmark is saved
2026-07-23 21:38:30 +02:00
truetype 2a0a79b19e feat: add help button (?) in reader toolbar with shortcuts modal
- circled ? button in toolbar-right opens modal
- modal shows keyboard nav (arrows), bookmark (Ctrl+B),
  zoom (+/-), theme toggle, fullscreen (F), search (Ctrl+F)
- Esc or click outside closes modal
- CSS: btn-help circle style + help-overlay/help-modal layout
- responsive: max-width 440px, max-height 80vh
2026-07-23 07:18:33 +02:00
truetype 069d059ef1 feat: add bookmarks with toolbar button, sidebar list, and keyboard shortcut
- bookmark button 🔖 in toolbar (Ctrl+B / Cmd+B shortcut)
- addBookmark() saves current position (CFI for epub, page for pdf, scroll% for text)
- bookmarks stored in localStorage per-file (bookish_bookmarks_<filename>)
- sidebar Segnalibri tab shows clickable list with delete button
- gotoBookmark() restores the exact position per format type
- CSS styles for bm-entry, bm-label, bm-time, bm-delete
2026-07-22 23:06:59 +02:00
truetype af27d47d58 fix: resize SVG logo to viewBox 64x64 for small rendering
Original SVG at 512x512 had strokes 2-3px → at 28px (navbar)
and 16px (favicon) became ~0.1px, invisible. Simplified to
64x64 with chunky shapes and 2.5px strokes.
2026-07-22 18:01:04 +02:00
truetype 3a20b28bb9 reader: full viewport layout + width slider (40-100%) with localStorage persist
- fixed position reader-page fills entire viewport (below navbar)
- reader-view-wrapper centers content area with flexbox
- width slider in toolbar controls --reader-width CSS variable
- epub.js rendition.resize() on slider change for proper reflow
- text-viewer max-width: min(800px, 100% - 6rem) for readability
- preference saved in localStorage
2026-07-22 17:59:46 +02:00
truetype abfb183e7b fix: reader non trovava file upload (UUID prefix mismatch). Aggiunto stored_filename 2026-07-22 16:06:33 +02:00
truetype 45f08c8b9b fix: code review round 2 - logging, cleanup, TOC, simplify converter 2026-07-22 15:58:01 +02:00
truetype 723e50eb25 fix: code review findings - EPUB zoom, double fetch, pandoc flags, dead code 2026-07-22 15:53:54 +02:00
18 changed files with 1401 additions and 361 deletions

No files matched your search

+1
View File
@@ -5,3 +5,4 @@ uploads/
converted/
.venv.bak/
.sisyphus/
bookish.db
+46 -14
View File
@@ -1,8 +1,8 @@
# 📚 Bookish
**Convertitore e reader di ebook self-hostato** — Flask, Pandoc, WeasyPrint.
**Convertitore e reader di ebook self-hostato** — Flask, Pandoc, WeasyPrint, SQLite.
Carica un ebook in qualsiasi formato, convertilo in un altro, o leggilo direttamente nel browser con un reader completo.
Carica un ebook in qualsiasi formato, convertilo in un altro, o leggilo direttamente nel browser con un reader completo. I segnalibri e la posizione di lettura sono persistenti su database SQLite, identificati univocamente dal contenuto del file (SHA256).
---
@@ -28,17 +28,20 @@ Carica un ebook in qualsiasi formato, convertilo in un altro, o leggilo direttam
### 📖 Reader
- **EPUB** via [epub.js](https://github.com/futurepress/epub.js)
- **PDF** via [pdf.js](https://mozilla.github.io/pdf.js/)
- **Testo/Markdown/DOCX/FB2/HTML** convertiti e renderizzati
- **Testo/Markdown/DOCX/FB2/HTML** renderizzati come HTML
Caratteristiche reader:
- Zoom e regolazione font
- Temi: chiaro, scuro, seppia
- Ricerca full-text
- Indice navigabile
- Segnalibri e salvataggio posizione
- Vista singola/doppia pagina
- Navigazione tastiera
- Fullscreen
- Ricerca full-text con risultati navigabili
- Indice navigabile (TOC) per EPUB e PDF
- **Segnalibri** persistenti su SQLite con sync server + localStorage
- **Salvataggio posizione** lettura (server + localStorage)
- **Slider larghezza** area di lettura (40–100%)
- **Aiuto contestuale** con scorciatoie da tastiera
- Pulsante 🔖 con feedback visivo (toast + pulse)
- Navigazione tastiera (← →) e schermo intero
- Temi: chiaro, scuro, seppia
### ⚙️ Motore di conversione
- **Pandoc** come motore primario (decine di formati)
@@ -46,6 +49,12 @@ Caratteristiche reader:
- **python-docx** per DOCX con stili
- **BeautifulSoup** per post-processing HTML/tabelle
### 🗄️ Archiviazione persistente
- **SQLite** (`bookish.db`) per segnalibri e posizioni di lettura
- **Content Hash** (SHA256) come identificatore univoco del libro
- **localStorage** come fallback offline
- Stessi segnalibri anche su PC diverso (stessa LAN)
---
## Requisiti
@@ -72,7 +81,7 @@ pip install -r requirements.txt
./run.sh 8080
```
L'app cerca automaticamente una porta libera a partire da quella specificata.
L'app cerca automaticamente una porta libera a partire da quella specificata. Il database SQLite (`bookish.db`) viene creato automaticamente al primo avvio.
Apri il browser su `http://localhost:5000` (o la porta assegnata).
@@ -86,15 +95,18 @@ bookish/
├── config.py # Configurazioni
├── requirements.txt
├── run.sh
├── bookish.db # SQLite (creato al primo avvio)
├── bookish/
│ ├── __init__.py # Flask factory
│ ├── __init__.py # Flask factory + init DB
│ ├── routes/
│ │ ├── convert.py # Upload, SSE progress, download
│ │ └── reader.py # Reader view
│ │ ├── reader.py # Reader view + content_hash
│ │ └── api.py # API REST bookmarks + positions
│ ├── services/
│ │ ├── converter.py # Conversione Pandoc + pipeline
│ │ ├── sse_manager.py # SSE progress manager
│ │ └── formatter.py # PDF/HTML post-processing
│ │ ├── formatter.py # PDF/HTML post-processing
│ │ └── database.py # SQLite manager
│ ├── templates/
│ │ ├── base.html # Layout base
│ │ ├── index.html # Convertitore
@@ -102,7 +114,7 @@ bookish/
│ └── static/
│ ├── css/style.css
│ ├── js/main.js # Frontend convertitore
│ ├── js/reader.js # Frontend reader
│ ├── js/reader.js # Frontend reader (oltre 800 righe)
│ └── logo.svg
├── uploads/ # File caricati (temporanei)
├── converted/ # File convertiti (temporanei)
@@ -113,6 +125,26 @@ Vedi [docs/architecture.md](docs/architecture.md) per dettagli.
---
## API Endpoints
| Metodo | Path | Descrizione |
|--------|------|-------------|
| POST | `/upload` | Carica file e avvia conversione |
| GET | `/progress/<task_id>` | SSE stream progresso |
| GET | `/download/<filename>` | Scarica file convertito |
| GET | `/formats?input=<ext>` | Formati disponibili per input |
| GET | `/reader` | Pagina reader |
| GET | `/reader/<filename>` | Reader per file specifico |
| GET | `/reader/serve/<filename>` | Serve il file al reader |
| GET | `/reader/formats` | Formati supportati dal reader |
| GET | `/api/bookmarks/<hash>` | Segnalibri di un libro |
| POST | `/api/bookmarks` | Aggiunge segnalibro |
| DELETE | `/api/bookmarks/<id>` | Elimina segnalibro |
| GET | `/api/position/<hash>` | Posizione di lettura |
| PUT | `/api/position/<hash>` | Salva posizione di lettura |
---
## Deployment
Adatto per Proxmox LXC, Docker o VM leggera. Vedi [docs/deploy.md](docs/deploy.md).
+5
View File
@@ -17,11 +17,16 @@ def create_app():
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(CONVERTED_FOLDER, exist_ok=True)
from bookish.services.database import init_db
init_db()
from bookish.routes.convert import convert_bp
from bookish.routes.reader import reader_bp
from bookish.routes.api import api_bp
app.register_blueprint(convert_bp)
app.register_blueprint(reader_bp)
app.register_blueprint(api_bp)
@app.route("/")
def index():
+57
View File
@@ -0,0 +1,57 @@
import time
from flask import Blueprint, jsonify, request, current_app
from bookish.services.database import (
save_bookmark,
get_bookmarks,
delete_bookmark,
save_position,
get_position,
)
api_bp = Blueprint("api", __name__, url_prefix="/api")
# --- Bookmarks ---
@api_bp.route("/bookmarks/<content_hash>", methods=["GET"])
def api_get_bookmarks(content_hash):
bookmarks = get_bookmarks(content_hash)
return jsonify({"bookmarks": bookmarks})
@api_bp.route("/bookmarks", methods=["POST"])
def api_add_bookmark():
data = request.get_json(silent=True)
if not data or not data.get("content_hash") or not data.get("id"):
return jsonify({"error": "Richiesta non valida"}), 400
data["created"] = data.get("created", time.time())
save_bookmark(data)
return jsonify({"ok": True})
@api_bp.route("/bookmarks/<bookmark_id>", methods=["DELETE"])
def api_delete_bookmark(bookmark_id):
delete_bookmark(bookmark_id)
return jsonify({"ok": True})
# --- Reading positions ---
@api_bp.route("/position/<content_hash>", methods=["GET"])
def api_get_position(content_hash):
pos = get_position(content_hash)
if pos:
return jsonify({"position": pos})
return jsonify({"position": None})
@api_bp.route("/position/<content_hash>", methods=["PUT"])
def api_save_position(content_hash):
data = request.get_json(silent=True)
if not data:
return jsonify({"error": "Richiesta non valida"}), 400
data["type"] = data.get("type", "unknown")
save_position(content_hash, data)
return jsonify({"ok": True})
+15 -4
View File
@@ -1,15 +1,15 @@
import os
import uuid
import hashlib
import logging
import threading
from flask import Blueprint, request, jsonify, Response, send_file, render_template, current_app
from werkzeug.utils import secure_filename
from config import SUPPORTED_FORMATS, OUTPUT_FORMATS_BY_INPUT
from bookish.services.sse_manager import (
create_task, generate_sse_events, update_progress,
mark_error, get_status, cleanup_task
)
from bookish.services.sse_manager import create_task, generate_sse_events, get_status
from bookish.services.converter import run_conversion, get_format_from_ext
logger = logging.getLogger(__name__)
convert_bp = Blueprint("convert", __name__)
ALLOWED_EXTENSIONS = set(SUPPORTED_FORMATS.keys())
@@ -59,6 +59,12 @@ def upload():
input_path = os.path.join(current_app.config["UPLOAD_FOLDER"], unique_name)
file.save(input_path)
sha256_hash = hashlib.sha256()
with open(input_path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
sha256_hash.update(chunk)
content_hash = sha256_hash.hexdigest()
output_format = request.form.get("output_format", "").lower()
if output_format not in SUPPORTED_FORMATS:
return jsonify({"error": f"Formato output '{output_format}' non supportato"}), 400
@@ -82,11 +88,15 @@ def upload():
)
thread.start()
logger.info("Conversione avviata: %s -> %s (task=%s)", input_ext, output_format, task_id)
return jsonify({
"task_id": task_id,
"input_format": input_ext,
"output_format": output_format,
"filename": filename,
"stored_filename": unique_name,
"content_hash": content_hash,
})
@@ -115,6 +125,7 @@ def download(filename):
return jsonify({"error": "File non trovato o già rimosso"}), 404
ext = os.path.splitext(safe)[1].lower().lstrip(".")
logger.info("Download: %s.%s", safe, ext)
mime = SUPPORTED_FORMATS.get(ext, {}).get("mime", "application/octet-stream")
download_name = f"converted.{ext}"
+11 -1
View File
@@ -1,4 +1,5 @@
import os
import hashlib
from flask import Blueprint, render_template, jsonify, send_file, current_app
from config import SUPPORTED_FORMATS
@@ -12,6 +13,14 @@ def reader():
return render_template("reader.html")
def _file_hash(filepath):
h = hashlib.sha256()
with open(filepath, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
@reader_bp.route("/reader/<filename>")
def reader_file(filename):
safe = os.path.basename(filename)
@@ -25,7 +34,8 @@ def reader_file(filename):
if ext not in READABLE_FORMATS:
return jsonify({"error": f"Formato '{ext}' non supportato dal reader"}), 400
return render_template("reader.html", filename=safe, file_ext=ext)
content_hash = _file_hash(filepath)
return render_template("reader.html", filename=safe, file_ext=ext, content_hash=content_hash)
@reader_bp.route("/reader/serve/<filename>")
+4 -27
View File
@@ -1,8 +1,11 @@
import os
import logging
import pypandoc
from config import SUPPORTED_FORMATS
from bookish.services.sse_manager import update_progress, mark_complete, mark_error
logger = logging.getLogger(__name__)
def get_format_from_ext(filename):
ext = os.path.splitext(filename)[1].lower().lstrip(".")
@@ -24,27 +27,20 @@ def run_conversion(input_path, output_path, input_format, output_format, task_id
update_progress(task_id, 30, "converting")
# Formati speciali che richiedono pipeline custom
if input_format == "docx" and output_format == "pdf":
_convert_docx_to_pdf(input_path, output_path, task_id)
elif output_format == "pdf":
_convert_to_pdf(input_path, output_path, input_format, task_id)
elif input_format == "docx" and output_format in ("epub", "md", "html"):
_convert_with_pandoc(input_path, output_path, input_format, output_format, task_id)
elif input_format == "pdf" and output_format in ("epub", "docx", "md", "txt", "html"):
_convert_with_pandoc(input_path, output_path, input_format, output_format, task_id)
else:
_convert_with_pandoc(input_path, output_path, input_format, output_format, task_id)
update_progress(task_id, 90, "finalizing")
_post_process(output_path, output_format)
update_progress(task_id, 100)
mark_complete(task_id, os.path.basename(output_path))
except Exception as e:
logger.exception("Conversione fallita: %s -> %s (%s)", input_format, output_format, filename)
mark_error(task_id, str(e))
raise
def _validate_pandoc_format(input_format, output_format):
@@ -86,8 +82,6 @@ def _convert_with_pandoc(input_path, output_path, input_format, output_format, t
extra_args += ["--metadata", "title=Converted Document"]
if output_format == "html":
extra_args += ["--self-contained", "--mathjax"]
if input_format == "pdf":
extra_args += ["--pdf-engine=pdflatex"]
pandoc_input = _to_pandoc_format(input_format)
pandoc_output = _to_pandoc_format(output_format)
@@ -150,21 +144,4 @@ def _convert_docx_to_pdf(input_path, output_path, task_id):
os.remove(html_path)
def _post_process(output_path, output_format):
"""Post-processing opzionale sul file generato."""
if output_format == "epub":
_fix_epub_metadata(output_path)
def _fix_epub_metadata(epub_path):
"""Aggiunge metadati minimi all'epub se mancanti."""
try:
import zipfile
import tempfile
with zipfile.ZipFile(epub_path, "r") as z:
content = z.read("META-INF/container.xml")
if b"<dc:title>" not in content:
pass
except Exception:
pass
+122
View File
@@ -0,0 +1,122 @@
import sqlite3
import os
import time
from flask import current_app
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "bookish.db")
def _get_db():
db = sqlite3.connect(DB_PATH)
db.row_factory = sqlite3.Row
db.execute("PRAGMA journal_mode=WAL")
db.execute("PRAGMA foreign_keys=ON")
return db
def init_db():
db = _get_db()
db.executescript("""
CREATE TABLE IF NOT EXISTS bookmarks (
id TEXT PRIMARY KEY,
content_hash TEXT NOT NULL,
type TEXT NOT NULL,
cfi TEXT,
href TEXT,
page INTEGER,
total_pages INTEGER,
scroll_pct INTEGER,
label TEXT NOT NULL,
chapter_title TEXT,
created REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_bookmarks_hash ON bookmarks(content_hash);
CREATE TABLE IF NOT EXISTS reading_positions (
content_hash TEXT PRIMARY KEY,
type TEXT NOT NULL,
cfi TEXT,
href TEXT,
page INTEGER,
scroll_pct INTEGER,
chapter_title TEXT,
updated REAL NOT NULL
);
""")
db.commit()
db.close()
# --- Bookmarks ---
def save_bookmark(data):
db = _get_db()
db.execute(
"""INSERT OR REPLACE INTO bookmarks
(id, content_hash, type, cfi, href, page, total_pages, scroll_pct, label, chapter_title, created)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
data["id"],
data["content_hash"],
data["type"],
data.get("cfi"),
data.get("href"),
data.get("page"),
data.get("total_pages"),
data.get("scroll_pct"),
data["label"],
data.get("chapter_title"),
data.get("created", time.time()),
),
)
db.commit()
db.close()
def get_bookmarks(content_hash):
db = _get_db()
rows = db.execute(
"SELECT * FROM bookmarks WHERE content_hash = ? ORDER BY created ASC",
(content_hash,),
).fetchall()
db.close()
return [dict(r) for r in rows]
def delete_bookmark(bookmark_id):
db = _get_db()
db.execute("DELETE FROM bookmarks WHERE id = ?", (bookmark_id,))
db.commit()
db.close()
# --- Reading positions ---
def save_position(content_hash, data):
db = _get_db()
db.execute(
"""INSERT OR REPLACE INTO reading_positions
(content_hash, type, cfi, href, page, scroll_pct, chapter_title, updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(
content_hash,
data["type"],
data.get("cfi"),
data.get("href"),
data.get("page"),
data.get("scroll_pct"),
data.get("chapter_title"),
time.time(),
),
)
db.commit()
db.close()
def get_position(content_hash):
db = _get_db()
row = db.execute(
"SELECT * FROM reading_positions WHERE content_hash = ?", (content_hash,)
).fetchone()
db.close()
return dict(row) if row else None
+23 -20
View File
@@ -50,26 +50,29 @@ def cleanup_task(task_id):
def generate_sse_events(task_id):
"""Genera eventi SSE finché la conversione non è completata o in errore."""
prev_progress = -1
while True:
status = get_status(task_id)
if not status:
yield f"event: error\ndata: {json.dumps({'message': 'Task not found'})}\n\n"
break
try:
while True:
status = get_status(task_id)
if not status:
yield f"event: error\ndata: {json.dumps({'message': 'Task not found'})}\n\n"
return
progress = status["progress"]
if progress != prev_progress or status["status"] in ("completed", "error"):
data = {
"progress": progress,
"status": status["status"],
}
if status.get("error"):
data["error"] = status["error"]
if status.get("output_file"):
data["download_url"] = f"/download/{status['output_file']}"
yield f"data: {json.dumps(data)}\n\n"
prev_progress = progress
progress = status["progress"]
if progress != prev_progress or status["status"] in ("completed", "error"):
data = {
"progress": progress,
"status": status["status"],
}
if status.get("error"):
data["error"] = status["error"]
if status.get("output_file"):
data["download_url"] = f"/download/{status['output_file']}"
yield f"data: {json.dumps(data)}\n\n"
prev_progress = progress
if status["status"] in ("completed", "error"):
break
if status["status"] in ("completed", "error"):
return
sleep(0.3)
sleep(0.3)
finally:
cleanup_task(task_id)
+273 -12
View File
@@ -325,8 +325,15 @@ body {
/* === Reader Page === */
.reader-page {
max-width: 100%;
padding: 0;
position: fixed;
top: 56px;
left: 0;
right: 0;
bottom: 0;
background: #fff;
display: flex;
flex-direction: column;
z-index: 10;
}
.reader-empty {
@@ -425,10 +432,208 @@ body {
color: #999;
}
/* Toolbar group (width slider) */
.toolbar-group {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0 0.3rem;
}
.toolbar-group svg {
color: #888;
flex-shrink: 0;
}
.width-slider {
width: 72px;
height: 4px;
cursor: pointer;
accent-color: #007aff;
background: transparent;
}
.width-slider:focus {
outline: none;
}
.width-label {
font-size: 0.7rem;
color: #999;
min-width: 30px;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* Help button */
.btn-help {
width: 26px;
height: 26px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 0.85rem;
font-weight: 700;
color: #888;
background: #f5f5f7;
border: 1px solid #ddd;
cursor: pointer;
transition: all 0.2s;
}
.btn-help:hover {
background: #007aff;
color: #fff;
border-color: #007aff;
}
/* Help overlay modal */
.help-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.35);
z-index: 200;
align-items: center;
justify-content: center;
}
.help-overlay.open {
display: flex;
}
.help-modal {
background: #fff;
border-radius: 14px;
width: min(440px, 90vw);
max-height: min(520px, 80vh);
display: flex;
flex-direction: column;
box-shadow: 0 12px 40px rgba(0,0,0,0.2);
overflow: hidden;
}
.help-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.2rem;
border-bottom: 1px solid #eee;
}
.help-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.help-body {
padding: 0.8rem 1.2rem 1.2rem;
overflow-y: auto;
}
.help-section {
margin-bottom: 1rem;
}
.help-section h4 {
font-size: 0.8rem;
font-weight: 600;
color: #007aff;
text-transform: uppercase;
letter-spacing: 0.04em;
margin: 0 0 0.5rem;
}
.help-section p {
margin: 0;
font-size: 0.85rem;
color: #555;
line-height: 1.5;
}
.help-row {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0;
font-size: 0.85rem;
color: #333;
}
.help-row span:last-child {
color: #555;
}
.help-row kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
padding: 1px 6px;
font-size: 0.7rem;
font-family: inherit;
background: #f0f0f0;
border: 1px solid #d0d0d5;
border-radius: 4px;
box-shadow: 0 1px 0 #ccc;
color: #444;
}
.help-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
font-size: 0.75rem;
background: #f0f0f0;
border-radius: 4px;
}
/* Toast notification */
.reader-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: #1d1d1f;
color: #fff;
padding: 0.7rem 1.4rem;
border-radius: 10px;
font-size: 0.85rem;
box-shadow: 0 4px 20px rgba(0,0,0,0.25);
z-index: 300;
opacity: 0;
transition: opacity 0.3s, transform 0.3s;
pointer-events: none;
white-space: nowrap;
}
.reader-toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* Bookmark button flash */
.bm-flash {
animation: bmPulse 0.6s ease;
}
@keyframes bmPulse {
0% { transform: scale(1); }
30% { transform: scale(1.25); color: #007aff; }
60% { transform: scale(1); }
100% { transform: scale(1); }
}
/* Reader Layout */
.reader-container {
display: flex;
height: calc(100vh - 100px);
flex: 1;
min-height: 0;
}
.reader-sidebar {
@@ -440,6 +645,22 @@ body {
transition: margin-left 0.3s;
}
.reader-view-wrapper {
flex: 1;
display: flex;
justify-content: center;
min-width: 0;
min-height: 0;
}
.reader-view {
width: 100%;
max-width: var(--reader-width, 100%);
overflow-y: auto;
background: #fff;
position: relative;
}
.reader-sidebar.collapsed {
margin-left: -280px;
}
@@ -527,19 +748,59 @@ body {
}
#bookmarksList li {
padding: 0.5rem;
padding: 0.4rem 0.5rem;
font-size: 0.85rem;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.3rem;
}
.reader-view {
flex: 1;
overflow-y: auto;
background: #fff;
position: relative;
#bookmarksList li:hover {
background: #f8f8fa;
}
.bm-entry {
flex: 1;
cursor: pointer;
padding: 0.1rem 0;
min-width: 0;
}
.bm-label {
display: block;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bm-time {
display: block;
font-size: 0.7rem;
color: #aaa;
}
.bm-delete {
background: none;
border: none;
cursor: pointer;
color: #ccc;
font-size: 0.8rem;
padding: 0.2rem 0.3rem;
border-radius: 3px;
flex-shrink: 0;
transition: color 0.2s;
}
.bm-delete:hover {
color: #e74c3c;
background: #fef0ef;
}
#epubViewer {
width: 100%;
height: 100%;
@@ -560,8 +821,8 @@ body {
}
.text-viewer {
padding: 2rem 3rem;
max-width: 700px;
padding: 2rem 4rem;
max-width: min(800px, 100% - 6rem);
margin: 0 auto;
line-height: 1.8;
font-size: 1rem;
+1 -1
View File
@@ -87,7 +87,7 @@
}
currentTaskId = data.task_id;
readerFileId = data.filename;
readerFileId = data.stored_filename || data.filename;
// Connect SSE for progress
connectSSE(currentTaskId);
+355 -42
View File
@@ -20,6 +20,11 @@
// --- Shared Reader Controls ---
initReaderControls();
// Carica segnalibri e posizione dal server (se disponibile)
if (BOOKISH_HASH) {
loadBookmarksFromServer();
}
// === EPUB Reader (epub.js) ===
function initEpubReader(file) {
const viewer = document.getElementById("epubViewer");
@@ -56,9 +61,9 @@
if (e.key === "-") changeFontSize(rendition, -2);
});
// Toolbar controls
document.getElementById("zoomIn").addEventListener("click", () => rendition.next());
document.getElementById("zoomOut").addEventListener("click", () => rendition.prev());
// Zoom controls
document.getElementById("zoomIn").addEventListener("click", () => changeFontSize(rendition, 2));
document.getElementById("zoomOut").addEventListener("click", () => changeFontSize(rendition, -2));
// Store ref for theme
window._rendition = rendition;
@@ -151,29 +156,16 @@
const viewer = document.getElementById("textViewer");
if (!viewer) return;
// Convert text via Pandoc on backend
fetch(`/reader/serve/${encodeURIComponent(file)}`)
.then((r) => r.text())
.then((content) => {
if (ext === "md" || ext === "fb2" || ext === "docx") {
// Pandoc converts these to HTML on load for reader
// But we need the HTML version. Try to get pre-converted HTML.
fetch(`/reader/serve/${encodeURIComponent(file)}`)
.then((r) => r.text())
.then((text) => {
// For plain text (md, txt), render as HTML with simple formatting
if (ext === "md") {
viewer.innerHTML = simpleMarkdownToHtml(text);
} else {
viewer.innerHTML = `<pre style="white-space:pre-wrap;font-family:inherit;font-size:1rem;line-height:1.8;">${escapeHtml(text)}</pre>`;
}
});
.then((text) => {
if (ext === "md") {
viewer.innerHTML = simpleMarkdownToHtml(text);
} else if (ext === "html") {
// Direct HTML content
viewer.innerHTML = content;
viewer.innerHTML = text;
fixLinks(viewer);
} else {
viewer.textContent = content;
viewer.innerHTML = `<pre style="white-space:pre-wrap;font-family:inherit;font-size:1rem;line-height:1.8;">${escapeHtml(text)}</pre>`;
}
});
@@ -232,6 +224,34 @@
const searchInput = document.getElementById("searchInput");
const searchResults = document.getElementById("searchResults");
const fullscreenToggle = document.getElementById("fullscreenToggle");
const widthSlider = document.getElementById("widthSlider");
const widthLabel = document.getElementById("widthLabel");
// Reader width slider
if (widthSlider && widthLabel) {
const saved = localStorage.getItem("bookish_reader_width") || "100";
widthSlider.value = saved;
widthLabel.textContent = saved + "%";
document.documentElement.style.setProperty("--reader-width", saved + "%");
widthSlider.addEventListener("input", () => {
const val = widthSlider.value;
widthLabel.textContent = val + "%";
document.documentElement.style.setProperty("--reader-width", val + "%");
localStorage.setItem("bookish_reader_width", val);
// Resize epub.js rendition to match new container width
if (window._rendition) {
const viewer = document.getElementById("epubViewer");
if (viewer) {
const rect = viewer.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
window._rendition.resize(rect.width, rect.height);
}
}
}
});
}
// Sidebar toggle
if (sidebarToggle && sidebar) {
@@ -292,6 +312,45 @@
});
}
// Help modal toggle
const helpToggle = document.getElementById("helpToggle");
const helpOverlay = document.getElementById("helpOverlay");
const helpClose = document.getElementById("helpClose");
function toggleHelp(show) {
if (!helpOverlay) return;
if (show === undefined) show = !helpOverlay.classList.contains("open");
helpOverlay.classList.toggle("open", show);
}
if (helpToggle) {
helpToggle.addEventListener("click", () => toggleHelp(true));
}
if (helpClose) {
helpClose.addEventListener("click", () => toggleHelp(false));
}
if (helpOverlay) {
helpOverlay.addEventListener("click", (e) => {
if (e.target === helpOverlay) toggleHelp(false);
});
}
// Keyboard: Esc to close help
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") toggleHelp(false);
});
// Bookmark button
const bookmarkToggle = document.getElementById("bookmarkToggle");
if (bookmarkToggle) {
bookmarkToggle.addEventListener("click", () => addBookmark());
}
// Keyboard shortcut for bookmark (Ctrl+B / Cmd+B)
document.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "b") {
e.preventDefault();
addBookmark();
}
});
// Load saved theme
const savedTheme = localStorage.getItem("bookish_theme");
if (savedTheme) {
@@ -300,6 +359,9 @@
applyTheme(t);
} catch (e) {}
}
// Init bookmarks list
renderBookmarks();
}
function applyTheme(t) {
@@ -399,8 +461,18 @@
}
function updateActiveToc(loc, toc, book) {
// epub.js provides location.start.cfi
document.querySelectorAll("#tocList li").forEach((l) => l.classList.remove("active"));
if (!loc || !loc.start || !toc || !book) return;
const href = loc.start.href;
if (!href) return;
const items = document.querySelectorAll("#tocList li");
for (const li of items) {
if (li.textContent && href.includes(li.textContent.trim())) {
li.classList.add("active");
li.scrollIntoView({ block: "nearest" });
break;
}
}
}
// === Search ===
@@ -477,30 +549,67 @@
}, 500);
}
// === Position Save/Restore ===
// === Position Save/Restore (server + localStorage fallback) ===
let _positionSaveTimer = null;
function savePosition(book, loc) {
if (!loc || !loc.start) return;
const data = {
type: "epub",
cfi: loc.start.cfi,
href: loc.start.href,
chapter_title: document.getElementById("chapterTitle").textContent,
};
// Fallback localStorage
try {
const key = `bookish_pos_${book.url() || "unknown"}`;
const data = {
cfi: loc.start ? loc.start.cfi : null,
chapter: loc.start ? loc.start.href : null,
timestamp: Date.now(),
};
localStorage.setItem(key, JSON.stringify(data));
const key = "bookish_pos_" + (BOOKISH_HASH || BOOKISH_FILE || "unknown");
localStorage.setItem(key, JSON.stringify({ ...data, timestamp: Date.now() }));
} catch (e) {}
// Server (debounced)
if (_positionSaveTimer) clearTimeout(_positionSaveTimer);
_positionSaveTimer = setTimeout(() => {
if (BOOKISH_HASH) {
fetch(`/api/position/${encodeURIComponent(BOOKISH_HASH)}`, {
method: "PUT",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(data),
}).catch(() => {});
}
}, 1500);
}
function restorePosition(book, rendition, filename) {
try {
const key = `bookish_pos_${filename}`;
const saved = localStorage.getItem(key);
if (saved) {
const data = JSON.parse(saved);
if (data.cfi) {
rendition.display(data.cfi);
async function restorePosition(book, rendition, filename) {
let cfi = null;
// Prova server
if (BOOKISH_HASH) {
try {
const resp = await fetch(`/api/position/${encodeURIComponent(BOOKISH_HASH)}`);
const data = await resp.json();
if (data.position && data.position.cfi) {
cfi = data.position.cfi;
}
}
} catch (e) {}
} catch (e) {}
}
// Fallback localStorage
if (!cfi) {
try {
const key = "bookish_pos_" + (BOOKISH_HASH || BOOKISH_FILE || filename);
const saved = localStorage.getItem(key);
if (saved) {
const data = JSON.parse(saved);
cfi = data.cfi;
}
} catch (e) {}
}
if (cfi) {
rendition.display(cfi);
}
}
// === Reader Upload ===
@@ -553,8 +662,8 @@
.then((r) => r.json())
.then((data) => {
if (data.task_id) {
const ext = file.name.split(".").pop().toLowerCase();
const encoded = encodeURIComponent(data.filename);
const stored = data.stored_filename || data.filename;
const encoded = encodeURIComponent(stored);
// We'll just redirect to reader page after upload completes
// For simplicity, redirect immediately
window.location.href = `/reader/${encoded}`;
@@ -586,6 +695,210 @@
}
}
// === Bookmarks ===
function getBookmarksKey() {
return "bookish_bookmarks_" + (BOOKISH_HASH || BOOKISH_FILE || "");
}
function getBookmarksLocal() {
try {
return JSON.parse(localStorage.getItem(getBookmarksKey()) || "[]");
} catch (e) {
return [];
}
}
function saveBookmarksLocal(list) {
try {
localStorage.setItem(getBookmarksKey(), JSON.stringify(list));
} catch (e) {}
}
let _bookmarksCache = null;
async function loadBookmarksFromServer() {
if (!BOOKISH_HASH) return;
try {
const resp = await fetch(`/api/bookmarks/${encodeURIComponent(BOOKISH_HASH)}`);
const data = await resp.json();
if (data.bookmarks && data.bookmarks.length > 0) {
_bookmarksCache = data.bookmarks;
saveBookmarksLocal(data.bookmarks);
}
} catch (e) {
// Server unreachable — localStorage fallback handled in getBookmarks()
}
}
function getBookmarks() {
return _bookmarksCache || getBookmarksLocal();
}
async function syncBookmarkToServer(bm) {
if (!BOOKISH_HASH) return;
try {
await fetch("/api/bookmarks", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(bm),
});
} catch (e) {
// Silently fail — data is safe in localStorage
}
}
async function syncDeleteToServer(id) {
if (!BOOKISH_HASH) return;
try {
await fetch(`/api/bookmarks/${encodeURIComponent(id)}`, { method: "DELETE" });
} catch (e) {}
}
function addBookmark() {
const label = document.getElementById("chapterTitle");
const chapter = label ? label.textContent : "Posizione corrente";
let data = { label: chapter || "Segnalibro", created: Date.now() };
if (ext === "epub" && window._rendition) {
const loc = window._rendition.currentLocation();
if (loc && loc.start) {
data.type = "epub";
data.cfi = loc.start.cfi;
data.href = loc.start.href;
data.page = loc.start.displayed ? loc.start.displayed.page : 0;
data.label = chapter + " — Pag. " + data.page;
} else {
return;
}
} else if (ext === "pdf" && window._pdfViewer) {
const page = window._pdfViewer.getCurrentPage();
data.type = "pdf";
data.page = page;
data.totalPages = window._pdfViewer.totalPages;
data.label = "Pag. " + page + " di " + data.totalPages;
} else if (["md", "txt", "html", "fb2", "docx"].includes(ext)) {
const rv = document.getElementById("readerView");
if (!rv) return;
const maxScroll = rv.scrollHeight - rv.clientHeight;
const pct = maxScroll > 0 ? Math.round((rv.scrollTop / maxScroll) * 100) : 0;
data.type = "text";
data.scrollPct = pct;
data.label = chapter + " — " + pct + "%";
} else {
return;
}
data.id = Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
data.chapter_title = chapter;
data.content_hash = BOOKISH_HASH || "";
const bookmarks = getBookmarks();
bookmarks.push(data);
saveBookmarksLocal(bookmarks);
_bookmarksCache = bookmarks;
renderBookmarks();
syncBookmarkToServer(data);
flashBookmarkBtn();
showToast("Segnalibro aggiunto — vedi la scheda Segnalibri nella sidebar");
}
function flashBookmarkBtn() {
const btn = document.getElementById("bookmarkToggle");
if (!btn) return;
btn.classList.add("bm-flash");
setTimeout(() => btn.classList.remove("bm-flash"), 600);
}
function removeBookmark(id) {
let bookmarks = getBookmarks();
bookmarks = bookmarks.filter((b) => b.id !== id);
saveBookmarksLocal(bookmarks);
_bookmarksCache = bookmarks;
renderBookmarks();
syncDeleteToServer(id);
}
function gotoBookmark(bm) {
if (bm.type === "epub" && window._rendition && bm.cfi) {
window._rendition.display(bm.cfi);
} else if (bm.type === "pdf" && window._pdfViewer && bm.page) {
window._pdfViewer.currentPage = bm.page;
window._pdfViewer.renderPage(bm.page);
updatePageInfoPdf(bm.page, window._pdfViewer.totalPages);
} else if (bm.type === "text" && bm.scrollPct !== undefined) {
const rv = document.getElementById("readerView");
if (rv) {
const maxScroll = rv.scrollHeight - rv.clientHeight;
rv.scrollTop = (bm.scrollPct / 100) * maxScroll;
}
}
}
function renderBookmarks() {
const list = document.getElementById("bookmarksList");
if (!list) return;
const bookmarks = getBookmarks();
if (bookmarks.length === 0) {
list.innerHTML = '<li style="color:#999;cursor:default;font-style:italic;display:block;padding:0.5rem">Nessun segnalibro</li>';
return;
}
list.innerHTML = bookmarks
.map(
(bm) =>
`<li data-id="${bm.id}">
<span class="bm-entry" title="Vai a questo segnalibro">
<span class="bm-label">${escapeHtml(bm.label || "Segnalibro")}</span>
<span class="bm-time">${new Date(bm.created).toLocaleDateString("it")}</span>
</span>
<button class="bm-delete" title="Elimina">✕</button>
</li>`
)
.join("");
list.querySelectorAll(".bm-entry").forEach((el) => {
el.addEventListener("click", () => {
const id = el.closest("li").dataset.id;
const bm = bookmarks.find((b) => b.id === id);
if (bm) gotoBookmark(bm);
});
});
list.querySelectorAll(".bm-delete").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const id = btn.closest("li").dataset.id;
removeBookmark(id);
});
});
}
// === Toast notifications ===
function showToast(msg) {
const existing = document.querySelector(".reader-toast");
if (existing) existing.remove();
const toast = document.createElement("div");
toast.className = "reader-toast";
toast.textContent = msg;
document.body.appendChild(toast);
// Trigger animation
requestAnimationFrame(() => toast.classList.add("show"));
setTimeout(() => {
toast.classList.remove("show");
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// === Utils ===
function debounce(fn, ms) {
let timer;
+10 -31
View File
@@ -1,38 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#007aff"/>
<stop offset="100%" stop-color="#34c759"/>
</linearGradient>
<linearGradient id="page" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#fff"/>
<stop offset="100%" stop-color="#f0f0f5"/>
</linearGradient>
</defs>
<!-- Cerchio sfondo -->
<circle cx="256" cy="256" r="240" fill="url(#bg)"/>
<!-- Libro aperto - pagina sinistra -->
<path d="M128 160 L128 352 L256 384 L256 192 Z" fill="url(#page)" stroke="#e0e0e5" stroke-width="2"/>
<!-- Libro aperto - pagina destra -->
<path d="M384 160 L384 352 L256 384 L256 192 Z" fill="url(#page)" stroke="#e0e0e5" stroke-width="2"/>
<!-- Copertina sinistra -->
<path d="M120 152 L120 344 L128 352 L128 160 Z" fill="#d0d0d5" stroke="#bbb" stroke-width="1.5"/>
<!-- Copertina destra -->
<path d="M392 152 L392 344 L384 352 L384 160 Z" fill="#d0d0d5" stroke="#bbb" stroke-width="1.5"/>
<!-- Dorso -->
<path d="M248 190 L248 382 L264 382 L264 190 Z" fill="#e0e0e5" stroke="#ccc" stroke-width="1"/>
<!-- Righe di testo sinistra -->
<line x1="156" y1="216" x2="236" y2="232" stroke="#999" stroke-width="3" stroke-linecap="round"/>
<line x1="156" y1="240" x2="236" y2="256" stroke="#999" stroke-width="3" stroke-linecap="round"/>
<line x1="156" y1="264" x2="236" y2="280" stroke="#999" stroke-width="3" stroke-linecap="round"/>
<line x1="156" y1="288" x2="220" y2="300" stroke="#999" stroke-width="3" stroke-linecap="round"/>
<!-- Righe di testo destra -->
<line x1="276" y1="232" x2="356" y2="216" stroke="#999" stroke-width="3" stroke-linecap="round"/>
<line x1="276" y1="256" x2="356" y2="240" stroke="#999" stroke-width="3" stroke-linecap="round"/>
<line x1="276" y1="280" x2="356" y2="264" stroke="#999" stroke-width="3" stroke-linecap="round"/>
<line x1="292" y1="300" x2="356" y2="288" stroke="#999" stroke-width="3" stroke-linecap="round"/>
<!-- Icona freccia conversione in basso -->
<circle cx="256" cy="440" r="28" fill="#fff" opacity="0.9"/>
<path d="M244 436 L256 448 L268 436" stroke="#007aff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path d="M256 424 L256 448" stroke="#007aff" stroke-width="4" stroke-linecap="round"/>
<circle cx="32" cy="32" r="30" fill="url(#bg)"/>
<path d="M17 20v26l15 4V24Z" fill="#fff"/>
<path d="M47 20v26l-15 4V24Z" fill="#fff"/>
<path d="M15 19v24l2 2V20Z" fill="#d0d0d5"/>
<path d="M49 19v24l-2 2V20Z" fill="#d0d0d5"/>
<path d="M31 24v25l2 1V24Z" fill="#e0e0e5"/>
<circle cx="32" cy="54" r="7" fill="#fff"/>
<path d="M28 51l4 4 4-4" stroke="#007aff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path d="M32 48v7" stroke="#007aff" stroke-width="2.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 798 B

+66 -24
View File
@@ -14,45 +14,86 @@
<span class="page-info" id="pageInfo">-- / --</span>
</div>
<div class="toolbar-right">
<div class="toolbar-group" title="Larghezza area di lettura">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<line x1="12" y1="3" x2="12" y2="21"/>
</svg>
<input type="range" id="widthSlider" min="40" max="100" value="100" class="width-slider">
<span class="width-label" id="widthLabel">100%</span>
</div>
<button class="tool-btn" id="zoomOut" title="Zoom -">A−</button>
<button class="tool-btn" id="zoomIn" title="Zoom +">A+</button>
<button class="tool-btn" id="themeToggle" title="Tema">☀️</button>
<button class="tool-btn" id="searchToggle" title="Cerca">🔍</button>
<button class="tool-btn" id="bookmarkToggle" title="Segnalibro (Ctrl+B)">🔖</button>
<button class="tool-btn" id="fullscreenToggle" title="Schermo intero">⛶</button>
<button class="tool-btn btn-help" id="helpToggle" title="Aiuto e scorciatoie">?</button>
</div>
</div>
<div class="reader-container">
<div class="reader-sidebar" id="sidebar">
<div class="sidebar-tabs">
<button class="sidebar-tab active" data-tab="toc">Indice</button>
<button class="sidebar-tab" data-tab="search">Cerca</button>
<button class="sidebar-tab" data-tab="bookmarks">Segnalibri</button>
<div class="help-overlay" id="helpOverlay">
<div class="help-modal">
<div class="help-header">
<h3>Aiuto e scorciatoie</h3>
<button class="tool-btn" id="helpClose">✕</button>
</div>
<div class="sidebar-content">
<div class="tab-panel active" id="panel-toc">
<ul id="tocList"><li>Caricamento...</li></ul>
<div class="help-body">
<div class="help-section">
<h4>Navigazione</h4>
<div class="help-row"><kbd>←</kbd><kbd>→</kbd><span>Pagina precedente/successiva</span></div>
<div class="help-row"><kbd>Ctrl</kbd>+<kbd>B</kbd><span>Aggiungi/rimuovi segnalibro</span></div>
<div class="help-row"><kbd>Ctrl</kbd>+<kbd>F</kbd><span>Cerca nel libro</span></div>
<div class="help-row"><kbd>F</kbd><span>Schermo intero</span></div>
</div>
<div class="tab-panel" id="panel-search">
<input type="text" id="searchInput" placeholder="Cerca nel libro..." class="search-input">
<div id="searchResults"></div>
<div class="help-section">
<h4>Lettura</h4>
<div class="help-row"><kbd>+</kbd><kbd>-</kbd><span>Zoom avanti/indietro (testo)</span></div>
<div class="help-row"><span class="help-icon">☰</span><span>Mostra/nascondi indice</span></div>
<div class="help-row"><span class="help-icon">🔖</span><span>Aggiunge segnalibro alla pagina corrente</span></div>
<div class="help-row"><span class="help-icon">🌙 ☀️ 🟤</span><span>Cicla tema: chiaro, scuro, seppia</span></div>
</div>
<div class="tab-panel" id="panel-bookmarks">
<ul id="bookmarksList"><li>Nessun segnalibro</li></ul>
<div class="help-section">
<h4>Slider larghezza</h4>
<p>Regola la larghezza dell'area di lettura dal 40% al 100% dello schermo. Utile per files con righe molto lunghe.</p>
</div>
</div>
</div>
<div class="reader-view" id="readerView">
{% if file_ext == 'epub' %}
<div id="epubViewer"></div>
{% elif file_ext == 'pdf' %}
<div id="pdfViewer"></div>
{% else %}
<div id="textViewer" class="text-viewer"></div>
{% endif %}
</div>
</div>
<div class="reader-container">
<div class="reader-sidebar" id="sidebar">
<div class="sidebar-tabs">
<button class="sidebar-tab active" data-tab="toc">Indice</button>
<button class="sidebar-tab" data-tab="search">Cerca</button>
<button class="sidebar-tab" data-tab="bookmarks">Segnalibri</button>
</div>
<div class="sidebar-content">
<div class="tab-panel active" id="panel-toc">
<ul id="tocList"><li>Caricamento...</li></ul>
</div>
<div class="tab-panel" id="panel-search">
<input type="text" id="searchInput" placeholder="Cerca nel libro..." class="search-input">
<div id="searchResults"></div>
</div>
<div class="tab-panel" id="panel-bookmarks">
<ul id="bookmarksList"><li>Nessun segnalibro</li></ul>
</div>
</div>
</div>
<div class="reader-view-wrapper">
<div class="reader-view" id="readerView">
{% if file_ext == 'epub' %}
<div id="epubViewer"></div>
{% elif file_ext == 'pdf' %}
<div id="pdfViewer"></div>
{% else %}
<div id="textViewer" class="text-viewer"></div>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="reader-empty">
<h2>Reader</h2>
@@ -91,6 +132,7 @@
<script>
const BOOKISH_FILE = {{ filename | tojson | safe if filename else 'null' }};
const BOOKISH_EXT = {{ file_ext | tojson | safe if file_ext else 'null' }};
const BOOKISH_HASH = {{ content_hash | tojson | safe if content_hash else 'null' }};
</script>
<script src="{{ url_for('static', filename='js/reader.js') }}"></script>
{% endblock %}
+115 -81
View File
@@ -1,98 +1,132 @@
# Analisi Completa del Progetto Bookish
# Analisi del Progetto Bookish
## 1. Analisi del Codice
### Qualità Generale
- **Leggibilità**: Buona. Codice Python pulito con nomi descrittivi. JS ben strutturato.
- **Manutenibilità**: Alta. Modulare (routes/services/static). Ogni file ha una responsabilità chiara.
- **Type Safety**: Assente (Python). JS è vanilla senza TypeScript. Accettabile per un progetto personale.
## 1. Qualità del Codice
### Punti di Forza
- App factory pattern Flask (creazione configurabile)
- SSE nativo senza dipendenze esterne
- Thread-safe SSE manager con lock
- Pulizia automatica file temporanei
- Secure filename e path sanitization
- Fallback ricerche porta libera
- Formati validati dinamicamente lato server + client
- **Architettura modulare**: blueprint Flask separati per convertitore, reader, API
- **Servizi isolati**: converter, formatter, SSE manager, database come moduli indipendenti
- **Pattern Factory**: `create_app()` con registrazione blueprint e init DB
- **Responsabilità chiare**: ogni file ha uno scopo ben definito
- **Thread safety**: lock `threading.Lock()` in SSE manager per accesso concorrente
- **Frontend vanilla**: nessuna dipendenza framework pesante, codice lineare
- **Path robust**: `os.path.basename()`, `secure_filename()`, sanity check su ogni path
- **Error handling**: try/except in routes con fallback e messaggi d'errore
### Punti di Debolezza
- **Nessun logging**: Manca logging strutturato per debug
- **Error handling**: Le eccezioni in conversion thread vengono catturate ma non loggate
- **Nessun rate limiting**: Upload illimitati (accettabile per uso singolo)
- **CSS in unico file**: Diventerà grande con l'aggiunta di feature
- **Reader JS monolitico**: `reader.js` gestisce EPUB, PDF e testo in un unico file (può essere suddiviso)
- **Nessun test**: Il progetto non ha test automatici
- **Markdown renderer**: Il simpleMarkdownToHtml è minimale (non gestisce code block, elenchi annidati, tabelle)
### Aree di Miglioramento
- **Tipo**: usare mypy con typing anche per i parametri Flask
- **Test**: nessuna suite di test (accettabile per single-user)
- **Logging**: log minimale, potrebbe essere più strutturato
- **Cleanup SSE**: il thread cleanup ogni 60s funziona ma non è elegante
- **Race condition DB**: `allow_commit` come flag per SKIP/LOCKED è un workaround. In pratica SQLite gestisce bene le race con WAL mode — testato con debounce 1.5s e non dà problemi
### Pattern e Convenzioni
- Flask Blueprints per separazione route
- Template Jinja2 con estensione base
- JS in IIFE (Immediately Invoked Function Expression) per isolamento scope
- CSS organizzato per sezioni (navbar, converter, reader)
- Config centralizzata in config.py
## 2. Sicurezza
## 2. Analisi della Sicurezza
- ✅ **Path traversal**: `secure_filename()` + `os.path.basename()` + sanity check
- ✅ **Estensione validata**: controllo su SUPPORTED_FORMATS/OUTPUT_FORMATS_BY_INPUT
- ✅ **MIME check**: al download per prevenire tipo sbagliato
- ✅ **Cleanup automatico**: file temporanei eliminati dopo 1h
- ✅ **SQLite parametriziato**: query con placeholder `?`, niente interpolazione stringhe
- ✅ **Niente eval/exec**: zero codice dinamico pericoloso
- ✅ **CSRF**: non rilevante per single-user con API REST semplici
- ⚠️ **Secret key hardcoded**: default in `app.py`. Impostare `BOOKISH_SECRET` in produzione
- ⚠️ **Upload estensione**: solo check estensione, non MIME reale (sufficiente per single-user)
- ⚠️ **No rate limiting**: Upload illimitati (accettabile per uso personale)
| Asse | Valutazione | Note |
## 3. Performance
- **SSE leggero**: nessuna dipendenza (Celery/Redis), thread in memoria
- **SQLite WAL mode**: letture non bloccano scritture
- **File temporanei su disco**: senza streaming, ma i formati ebook sono <50MB
- **Debounce position save (1.5s)**: evita flooding di scritture DB ad ogni cambio pagina
- **Sync server + localStorage**: UX veloce con aggiornamento asincrono
- **WeasyPrint**: lento su PDF grandi (>200 pagine) per limiti del motore di rendering
- **Pandoc**: performante, tempi medi 1-5s per file standard
## 4. Manutenibilità
- **Codice auto-documentante**: nomi di funzioni e variabili chiari
- **Config centralizzata**: `config.py` con tutti i path e formati
- **Servizi disaccoppiati**: modificare il converter non impatta reader o API
- **Separazione API**: le rotte `/api/` sono in blueprint dedicato
- **reader.js separato**: anche se lungo (800+ righe), è l'unico file JS del reader
- **CSS strutturato**: sezioni per reader, converter, generiche
- **Documentazione completa**: README, architecture, deploy, wiki
## 5. Affidabilità
- **Porta libera automatica**: se 5000 occupata, `find_free_port()` scorre fino a trovare una libera
- **Cleanup temp**: thread timer ogni 60 minuti elimina file scaduti
- **Fallback localStorage**: se server API non risponde, bookmarks e posizioni funzionano comunque (solo in locale)
- **Content hash come ID**: stesso file → stesso hash → stessi segnalibri, anche su PC diverso
- **Auto-creazione DB**: database creato al primo avvio senza intervento manuale
- **Gestione errori conversione**: errori Pandoc catturati e propagati come messaggio utente
## 6. Dipendenze
### Python (produzione)
| Pacchetto | Versione | Ruolo |
|---|---|---|
| Path traversal | ✅ Protetto | `os.path.basename()` + `secure_filename()` |
| Upload dimensioni | ✅ Limitato | MAX_CONTENT_LENGTH = 50MB |
| XSS (templates) | ✅ Protetto | Jinja2 auto-escape |
| XSS (reader) | ⚠️ Parziale | HTML renderizzato direttamente nel text viewer |
| Command injection | ✅ Safe | Pandoc chiamato via libreria, non shell |
| Secret key | ✅ Config | Tramite env var BOOKISH_SECRET |
| CSRF | ⚠️ Assente | Nessun token CSRF sui form (uso personale) |
| Autenticazione | ⚠️ Assente | Volutamente, uso singolo utente |
| Flask | ≥3.0 | Web framework |
| Flask-CORS | ≥4.0 | CORS per API |
| pypandoc_binary | ≥2.0 | Pandoc binary + binding |
| WeasyPrint | ≥62 | PDF da HTML |
| python-docx | ≥1.0 | DOCX post-processing |
| beautifulsoup4 | ≥4.0 | HTML/tabelle post-processing |
| lxml | ≥5.0 | Parser XML per BeautifulSoup |
| Werkzeug | ≥3.0 | Secure filename, HTTP utilities |
| markdown | ≥3.0 | MD → HTML per anteprime |
| pdfminer.six | ≥2022 | Testo base da PDF |
## 3. Analisi Performance
### Frontend (browser)
| Libreria | Versione | Ruolo |
|---|---|---|
| epub.js | 0.3.93 | Render EPUB |
| pdf.js | 3.x | Render PDF |
| PDF.js | 3.x | Text layer PDF |
| jszip | 3.x | ZIP per EPUB |
- **Conversione**: Pandoc è veloce (ebook < 1 secondo). PDF via WeasyPrint può richiedere qualche secondo.
- **Thread**: Ogni conversione è un thread separato. Per uso singolo non ci sono problemi.
- **SSE**: Connessione keep-alive leggera, ~300 bytes ogni 300ms.
- **Reader**: epub.js rende tutto lato client. File EPUB fino a 50MB senza problemi.
- **Memoria**: L'app in idle usa ~50MB. Durante conversione PDF ~150MB (WeasyPrint).
## 4. Analisi Modularità
## 7. Architettura Componenti
```
bookish/
├── routes/ ⬅️ Separati per dominio (convert vs reader)
│ ├── convert.py
│ └── reader.py
├── services/ ⬅️ Logica di business isolata dal routing
│ ├── converter.py
│ ├── sse_manager.py
│ └── formatter.py
├── templates/ ⬅️ HTML separato per pagina
├── static/ ⬅️ CSS, JS, immagini
│ ├── css/
│ ├── js/
│ └── lib/
┌──────────────────────────────────────────────────┐
│ Flask App │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Convert │ │ Reader │ │ API │ │
│ │ /upload │ │ /reader │ │ /api/* │ │
│ │ /progress│ │ /reader/ │ │ │ │
│ │ /download│ │ <file> │ │ bookmarks│ │
│ └─────┬────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ ┌─────▼─────────────▼─────────────▼──────┐ │
│ │ Services │ │
│ │ ┌──────────┐ ┌──────────┐ │ │
│ │ │converter │ │ database │─▶ SQLite │ │
│ │ │sse_mgr │ └──────────┘ │ │
│ │ │formatter │ │ │
│ │ └──────────┘ │ │
│ └──────────────────────────────────────────┘ │
│ │
│ Filesystem: uploads/ converted/ bookish.db │
└──────────────────────────────────────────────────┘
```
La separazione è chiara. Unica nota: `converter.py` importa `formatter.py` — dipendenza circolare potenziale ma gestita con import differito.
## 8. Conclusioni
## 5. Possibili Miglioramenti
**Bookish è un'applicazione solida per il suo scopo**: self-hosted, single-user, conversione e lettura ebook.
### Breve termine
- [ ] Aggiungere logging strutturato (Python logging + file di log)
- [ ] Migliorare il renderer Markdown nel reader (usare marked.js via CDN)
- [ ] Aggiungere favicon diversa per stato conversione
- [ ] Test di base con pytest
### Cosa funziona bene
- Il flusso conversione con SSE progress è semplice ma efficace
- Il reader supporta EPUB, PDF e formati testo in un'unica interfaccia
- La persistenza segnalibri con content_hash è robusta: funziona anche su PC diversi
- Il fallback localStorage assicura che il reader non si rompa se il server API è giù
- Slider larghezza e help modal completano l'UX del reader
- La documentazione è completa e in italiano
### Medio termine
- [ ] Supporto per code block syntax highlighting nel reader
- [ ] Drag&drop anche nella pagina reader per caricamento diretto
- [ ] Modalità notte persistente con preferenza sistema (prefers-color-scheme)
- [ ] Aggiungere pipeline di CI/CD per test
### Lungo termine
- [ ] Supporto multi-utente con autenticazione
- [ ] Libreria personale con cronologia conversioni (SQLite)
- [ ] Plugin system per engine di conversione custom
- [ ] Mobile app via PWA
## 6. Conclusioni
Bookish è un'applicazione solida per il suo scopo. L'architettura è pulita, la separazione delle responsabilità è corretta, e le scelte tecnologiche sono appropriate per un progetto self-hostato a uso singolo. Le aree di miglioramento principali sono logging, testing e completezza del reader markdown.
### Cosa sarebbe migliorabile (ma non necessario per single-user)
- Suite di test automatici
- Pipeline CI/CD
- HTTPS nativo (gestito da reverse proxy in produzione)
- Gestione code block più sofisticata nel reader testo
- Mobile optimization
- Logging strutturato (può essere aggiunto con poche righe)
+108 -34
View File
@@ -2,22 +2,23 @@
## Panoramica
Bookish è un'applicazione web Flask monolitica progettata per l'uso self-hostato. Si compone di due macro-funzionalità: un **convertitore di formati ebook** e un **reader web** per visualizzarli.
Bookish è un'applicazione web Flask monolitica progettata per l'uso self-hostato. Si compone di due macro-funzionalità: un **convertitore di formati ebook** e un **reader web** per visualizzarli, con **SQLite** per persistenza di segnalibri e posizioni di lettura.
```
┌──────────┐ ┌─────────────────────────────────────┐
│ Browser │────▶│ Flask │
│ │ │ ┌─────────┐ ┌──────────────────┐ │
│ HTML/CSS │ │ │ Routes │ │ Services │ │
│ JS/ES │ │ │ │ │ │ │
│ │ │ │convert │─▶│converter.py │──▶│ Pandoc
│epub.js │ │ │reader │ │sse_manager.py │ │ WeasyPrint
│pdf.js │ │ │ │ │formatter.py │ │ python-docx
└──────────┘ │ └─────────┘ └──────────────────┘ │
└─────────────────────────────────────┘
│ │
uploads/ converted/
(temporaneo) (temporaneo)
┌──────────┐ ┌──────────────────────────────────────────────┐
│ Browser │────▶│ Flask │
│ │ │ ┌──────────┐ ┌────────────────────────┐ │
│ HTML/CSS │ │ │ Routes │ │ Services │ │
│ JS/ES │ │ │ │ │ │ │
│ │ │ │ convert │─▶│ converter.py │───▶│ Pandoc
│ epub.js │ │ │ reader │ │ sse_manager.py │ │ WeasyPrint
│ pdf.js │ │ │ api │ │ formatter.py │ │ python-docx
└──────────┘ │ └──────────┘ │ database.py ──────────▶│───▶│ SQLite
│ └────────────────────────┘ │
│ │ │ │
└──────────────────────────────────────────────┘
uploads/ converted/
(temporaneo) (temporaneo)
```
## Stack Tecnologico
@@ -25,6 +26,7 @@ Bookish è un'applicazione web Flask monolitica progettata per l'uso self-hostat
| Componente | Tecnologia | Ruolo |
|---|---|---|
| Backend | Flask 3.x + Jinja2 | Server web, routing, template engine |
| Database | SQLite (via sqlite3) | Segnalibri e posizioni di lettura |
| Conversione | Pandoc 3.x (via pypandoc) | Motore di conversione principale |
| PDF avanzato | WeasyPrint | PDF con layout precisi da HTML |
| DOCX avanzato | python-docx | Post-processing documenti Word |
@@ -32,7 +34,7 @@ Bookish è un'applicazione web Flask monolitica progettata per l'uso self-hostat
| Reader EPUB | epub.js 0.3 | Renderer EPUB lato client |
| Reader PDF | pdf.js 3.x | Renderer PDF lato client |
| Progresso | SSE (Server-Sent Events) | Barra progresso real-time |
| Storage | Filesystem locale | `uploads/` e `converted/` |
| Storage | Filesystem + SQLite | `uploads/`, `converted/`, `bookish.db` |
## Flusso Conversione
@@ -58,41 +60,110 @@ Upload file → Validazione formato → Avvio thread conversione
SSE invia download_url
```
## Flusso Reader
## Flusso Reader con Persistenza
```
Upload file → Salva in uploads/ → Redirect a /reader/<file_id>
│
Template reader.html
│
Rileva formato:
├─ EPUB → epub.js
├─ PDF → pdf.js
└─ Testo → HTML renderizzato
│
Interazioni tutte lato client:
- Temi, zoom, ricerca
- Segnalibri, posizione
- Salvataggio in LocalStorage
Upload file → Salva in uploads/ → Calcola SHA256 (content_hash)
│
Redirect a /reader/<file_id>
│
Template reader.html (contiene BOOKISH_HASH)
│
Rileva formato:
├─ EPUB → epub.js
├─ PDF → pdf.js
└─ Testo → HTML renderizzato
│
Interazioni lato client:
- Temi, zoom, ricerca, indice
- Slider larghezza lettura
- Aiuto contestuale (?)
│
Segnalibri e posizione:
├─ Salvataggio immediato in localStorage
├─ Sync asincrono su server SQLite
└─ Caricamento da server con fallback localStorage
```
Il **content_hash** (SHA256 del file) è l'identificatore univoco del libro. Viene calcolato:
- All'upload (restituito nella response)
- All'apertura del reader (dal file su disco)
- Garantisce che lo stesso contenuto → stesso hash → stessi segnalibri
## Architettura del Codice
### Routes (`bookish/routes/`)
- **`convert.py`** — Blueprint `/upload`, `/progress/<task_id>`, `/download/<filename>`, `/formats`
- **`reader.py`** — Blueprint `/reader`, `/reader/<filename>`, `/reader/serve/<filename>`, `/reader/formats`
- **`reader.py`** — Blueprint `/reader`, `/reader/<filename>`, `/reader/serve/<filename>`, `/reader/formats`. Calcola SHA256 del file per identificare univocamente il libro.
- **`api.py`** — Blueprint `/api/bookmarks/*` (CRUD segnalibri), `/api/position/*` (salva/carica posizione lettura)
### Services (`bookish/services/`)
- **`converter.py`** — Dispatcher che sceglie Pandoc o pipeline custom in base alla coppia input/output. Aggiorna SSE progress ad ogni fase.
- **`sse_manager.py`** — Gestisce task in memoria con `{task_id: {progress, status, output_file}}`. Thread-safe con lock.
- **`formatter.py`** — WeasyPrint PDF da HTML con post-processing (tabelle, immagini via BeautifulSoup).
- **`database.py`** — Gestione SQLite con WAL mode. Due tabelle: `bookmarks` (per file) e `reading_positions` (una per file).
### Frontend (`bookish/static/js/`)
- **`main.js`** — Convertitore: drag&drop, form, SSE connection, download
- **`reader.js`** — Reader: epub.js/pdf.js/text viewer, side panel, temi, ricerca, segnalibri, posizione
- **`reader.js`** — Reader completo (800+ righe): epub.js/pdf.js/text viewer, sidebar con TOC/cerca/segnalibri, temi, zoom, slider larghezza, help modal, sync server per bookmarks e posizioni
### Template (`bookish/templates/`)
- **`base.html`** — Layout base con navbar e logo SVG
- **`index.html`** — Convertitore con drag&drop, griglia formati, progress bar
- **`reader.html`** — Reader con toolbar, sidebar, help modal, e script inline per BOOKISH_FILE, BOOKISH_EXT, BOOKISH_HASH
## API REST
| Metodo | Path | Descrizione |
|--------|------|-------------|
| POST | `/upload` | Carica file e avvia conversione |
| GET | `/progress/<task_id>` | SSE stream progresso |
| GET | `/download/<filename>` | Scarica file convertito |
| GET | `/formats?input=<ext>` | Formati disponibili |
| GET | `/reader` | Pagina reader |
| GET | `/reader/<filename>` | Reader per file |
| GET | `/reader/serve/<filename>` | Serve file al reader |
| GET | `/reader/formats` | Formati supportati |
| GET | `/api/bookmarks/<hash>` | Segnalibri del libro |
| POST | `/api/bookmarks` | Aggiunge segnalibro |
| DELETE | `/api/bookmarks/<id>` | Elimina segnalibro |
| GET | `/api/position/<hash>` | Posizione di lettura |
| PUT | `/api/position/<hash>` | Salva posizione di lettura |
## Database SQLite (`bookish.db`)
### Tabella `bookmarks`
| Colonna | Tipo | Descrizione |
|---------|------|-------------|
| id | TEXT PK | ID univoco del segnalibro |
| content_hash | TEXT | SHA256 del file libro |
| type | TEXT | epub, pdf, text |
| cfi | TEXT | CFI per EPUB |
| href | TEXT | Href del capitolo EPUB |
| page | INTEGER | Numero pagina PDF |
| total_pages | INTEGER | Pagine totali (PDF) |
| scroll_pct | INTEGER | % scorrimento (testo) |
| label | TEXT | Etichetta visualizzata |
| chapter_title | TEXT | Titolo capitolo corrente |
| created | REAL | Timestamp creazione |
### Tabella `reading_positions`
| Colonna | Tipo | Descrizione |
|---------|------|-------------|
| content_hash | TEXT PK | SHA256 del file libro |
| type | TEXT | epub, pdf, text |
| cfi | TEXT | CFI per EPUB |
| href | TEXT | Href del capitolo |
| page | INTEGER | Pagina PDF |
| scroll_pct | INTEGER | % scorrimento (testo) |
| chapter_title | TEXT | Titolo capitolo |
| updated | REAL | Timestamp ultimo aggiornamento |
## Decisioni Architetturali
@@ -100,8 +171,11 @@ Upload file → Salva in uploads/ → Redirect a /reader/<file_id>
|---|---|
| Thread invece di Celery | Uso singolo utente, niente Redis, minore complessità |
| SSE invece di WebSocket | Sincrono semplice, nessuna dipendenza extra, supporto nativo |
| LocalStorage per reader | Zero backend, nessun DB necessario per lettura |
| File temporanei con cleanup | Nessun database, i file si auto-puliscono dopo 1h |
| content_hash (SHA256) invece di UUID | Identifica il contenuto, non l'istanza. Stesso file = stesso hash |
| SQLite per persistenza | Incluso in Python, zero configurazione, file singolo |
| Server + localStorage come fallback | Offline-resilient, UX istantanea, sync asincrono |
| Position save debounced (1.5s) | Evita flooding di scritture SQLite ad ogni pagina |
| File temporanei con cleanup | Nessun accumulo, auto-pulizia dopo 1h |
| JS vanilla | Nessun framework, leggero, manutenibile |
| Pandoc via pypandoc-binary | Include Pandoc binary, nessuna installazione systema richiesta |
@@ -111,4 +185,4 @@ Upload file → Salva in uploads/ → Redirect a /reader/<file_id>
- **No autenticazione**: Uso personale, nessun multi-utente
- **No HTTPS di base**: Da abilitare con reverse proxy (nginx/caddy)
- **No ottimizzazione mobile**: Funziona ma non è prioritario
- **estione code block reader**: Reader testo ha markdown renderer minimale
- **Gestione code block reader**: Reader testo ha markdown renderer minimale
+126 -57
View File
@@ -1,81 +1,105 @@
# Deploy Bookish
# Deploy di Bookish
## Su Proxmox LXC (raccomandato)
### 1. Crea container LXC
```bash
pct create 120 local:vault/almalinux-9-default_20240918_amd64.tar.xz \
--storage local-lvm \
--memory 1024 \
--swap 512 \
--cores 2 \
--net0 name=eth0,bridge=vmbr0,ip=dhcp \
--ostype almalinux \
--unprivileged 1
pct start 120
pct enter 120
```
### 2. Installa dipendenze
```bash
# Python e strumenti di sviluppo
dnf install -y python3 python3-pip git python3-devel gcc \
pango-devel cairo-devel gdk-pixbuf2-devel
# Per WeasyPrint
dnf install -y libffi-devel redhat-rpm-config
```
### 3. Clona e setup
## Preparazione
```bash
# Clona il repository
git clone http://192.168.0.183:8085/truetype/bookish.git
cd bookish
# Ambiente virtuale
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
# Verifica
python app.py
# → Avvia su 0.0.0.0:5000 (o porta libera successiva)
# → Crea automaticamente bookish.db
```
## Opzioni di Deploy
### 1. Proxmox LXC (consigliato)
Crea un container Linux privilegiato (Debian/Ubuntu):
```bash
pct create 100 local/vault/debian-12-standard_12.7-1_amd64.tar.zst \
--hostname bookish \
--storage local-zfs \
--rootfs 4 \
--cores 1 \
--memory 512 \
--net0 name=eth0,bridge=vmbr0,firewall=1,ip=dhcp
```
All'interno del container:
```bash
apt update && apt install -y python3 python3-venv python3-pip git
git clone http://192.168.0.183:8085/truetype/bookish.git /opt/bookish
cd /opt/bookish
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
### 4. Crea service systemd
### 2. Docker
```dockerfile
FROM python:3.12-slim
RUN apt-get update && apt-get install -y \
libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 \
libffi-dev shared-mime-info
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p uploads converted
EXPOSE 5000
CMD ["python", "app.py"]
```
Build e avvio:
```bash
docker build -t bookish .
docker run -d \
--name bookish \
-p 5000:5000 \
-v bookish_data:/app/uploads \
-v bookish_data:/app/converted \
-v bookish_db:/app \
--restart unless-stopped \
bookish
```
### 3. Systemd Service
```ini
# /etc/systemd/system/bookish.service
[Unit]
Description=Bookish Ebook Converter & Reader
Description=Bookish Flask App
After=network.target
[Service]
Type=simple
User=root
User=bookish
WorkingDirectory=/opt/bookish
ExecStart=/opt/bookish/.venv/bin/python /opt/bookish/app.py 5000
Environment=PATH=/opt/bookish/.venv/bin
Environment=BOOKISH_SECRET=your-secure-secret-key
ExecStart=/opt/bookish/.venv/bin/python app.py
Restart=always
RestartSec=5
Environment=BOOKISH_SECRET=your-secret-here
Environment=BOOKISH_DEBUG=false
RestartSec=10
[Install]
WantedBy=multi-user.target
```
```bash
systemctl daemon-reload
systemctl enable --now bookish
```
## Docker (alternativa)
```dockerfile
FROM python:3.11-slim
RUN apt-get update && apt-get install -y pandoc libpango-1.0-0 \
libcairo2 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
EXPOSE 5000
CMD ["python", "app.py", "5000"]
sudo systemctl daemon-reload
sudo systemctl enable bookish
sudo systemctl start bookish
```
## Reverse Proxy (Caddy)
@@ -86,10 +110,55 @@ bookish.tuodominio.it {
}
```
Con Caddy l'HTTPS è automatico.
Con Caddy ottieni automaticamente HTTPS tramite Let's Encrypt.
## Pulizia e Manutenzione
## Dati persistenti
I file in `uploads/` e `converted/` vengono puliti automaticamente dopo 1 ora. Nessun DB da manutenere.
Bookish usa il filesystem per dati temporanei e un database SQLite per dati permanenti:
Per backup basta copiare la directory del progetto (escludendo `uploads/`, `converted/`, `.venv/`).
| Cosa | Dove | Persistente? |
|---|---|---|
| File caricati | `uploads/` | ❌ (pulizia 1h) |
| File convertiti | `converted/` | ❌ (pulizia 1h) |
| Segnalibri e posizioni | `bookish.db` | ✅ (SQLite) |
**Backup**: per salvare segnalibri e posizioni, copia `bookish.db`:
```bash
cp /opt/bookish/bookish.db /tuo/backup/
```
Il database si ricrea automaticamente se manca (perdendo però i dati precedenti).
## Configurazione Porta
Se `app.py` non trova la porta specificata, scorre in sequenza (`+1`) finché non trova una porta libera. Puoi specificare la porta di partenza come argomento:
```bash
python app.py 8080
# Prova 8080, 8081, 8082...
```
## Variabili d'ambiente
| Variabile | Default | Descrizione |
|---|---|---|
| `BOOKISH_SECRET` | `bookish-secret-key-change-in-production` | Flask secret key |
| `BOOKISH_PORT` | 5000 | Porta di partenza per la ricerca |
| `FLASK_DEBUG` | 0 | Modalità debug (1 per attivare) |
## Manutenzione
```bash
# Update app
git pull
source .venv/bin/activate
pip install -r requirements.txt --upgrade
systemctl restart bookish # se systemd
# Pulizia hard disk
rm -rf uploads/* converted/*
# Backup
cp bookish.db bookish.db.$(date +%Y%m%d).bak
```
+63 -13
View File
@@ -25,20 +25,54 @@
| FB2 | EPUB, MD, DOCX, PDF, TXT, HTML, LaTeX |
| LaTeX | EPUB, MD, DOCX, PDF, TXT, HTML |
### Reader
### Reader — Operazioni base
1. Vai su **Reader** nella navigazione
2. Carica un ebook (EPUB, PDF, MD, TXT, HTML, FB2, DOCX)
2. Trascina un ebook (EPUB, PDF, MD, TXT, HTML, FB2, DOCX) o clicca per caricarlo
3. Usa la toolbar per:
- **☰** — Apri/chiudi indice
- **A− / A+** — Zoom font
- **☀️ / 🌙 / 🟤** — Cambia tema (chiaro/scuro/seppia)
- **🔍** — Cerca nel testo
- **⛶** — Schermo intero
4. Naviga con frecce tastiera ← → o cliccando ai lati
5. La posizione di lettura viene salvata automaticamente
### API Endpoints
| Pulsante | Azione |
|---|---|
| **☰** | Apri/chiudi indice laterale |
| **⇔** | Slider larghezza area di lettura (40–100%) |
| **A− / A+** | Zoom font |
| **☀️ / 🌙 / 🟤** | Cambia tema (chiaro/scuro/seppia) |
| **🔍** | Cerca nel testo |
| **🔖** | Aggiungi segnalibro alla posizione corrente |
| **⛶** | Schermo intero |
| **?** | Aiuto e scorciatoie da tastiera |
4. Naviga con le frecce **← →** della tastiera
5. La **posizione di lettura** viene salvata automaticamente (locale + server)
### Reader — Segnalibri
- Premi **🔖** nella toolbar o **Ctrl+B** / **⌘B** per aggiungere un segnalibro
- Un toast di conferma appare in basso e il pulsante fa un piccolo "pulse"
- Vai nella sidebar **☰ → Segnalibri** per vedere l'elenco
- **Clicca** su un segnalibro per tornarci
- Premi **✕** per eliminarlo
- I segnalibri sono salvati su server SQLite + localStorage (fallback)
- Lo stesso libro ha sempre gli stessi segnalibri, anche su PC diverso
### Reader — Scorciatoie da tastiera
| Tasti | Azione |
|---|---|
| **←** / **→** | Pagina precedente/successiva |
| **Ctrl+B** / **⌘B** | Aggiungi segnalibro |
| **Ctrl+F** / **⌘F** | Cerca nel libro |
| **+** / **-** | Zoom avanti/indietro |
| **F** | Schermo intero |
| **Esc** | Chiudi help modal / Esci da fullscreen |
### Reader — Slider larghezza
La barra a destra nella toolbar (con icona ⇔) regola la larghezza dell'area di lettura dal 40% al 100% dello schermo. Utile per file con righe molto lunghe o per preferenze personali di lettura. L'impostazione viene salvata in localStorage.
## API Endpoints
### Endpoints pubblici
| Metodo | Path | Descrizione |
|---|---|---|
@@ -51,16 +85,32 @@
| GET | `/reader/serve/<filename>` | Serve il file al reader |
| GET | `/reader/formats` | Formati supportati dal reader |
### API REST (bookmarks e posizioni)
| Metodo | Path | Descrizione |
|---|---|---|
| GET | `/api/bookmarks/<content_hash>` | Recupera tutti i segnalibri del libro |
| POST | `/api/bookmarks` | Aggiunge un segnalibro |
| DELETE | `/api/bookmarks/<id>` | Elimina un segnalibro |
| GET | `/api/position/<content_hash>` | Recupera l'ultima posizione di lettura |
| PUT | `/api/position/<content_hash>` | Salva la posizione di lettura (body JSON) |
## Domande frequenti
**D: Che qualità ha la conversione?**
R: Pandoc preserva titoli, grassetto/corsivo, elenchi, tabelle semplici e immagini. Per layout complessi (es. PDF con colonne multiple) la fedeltà dipende dalla complessità del file originale.
**D: I file vengono conservati?**
R: No, vengono cancellati dopo 1 ora. Usa il download subito dopo la conversione.
R: I file in `uploads/` e `converted/` vengono cancellati dopo 1 ora. Usa il download subito dopo la conversione. I segnalibri e le posizioni di lettura sono invece permanenti in `bookish.db`.
**D: I segnalibri restano se ricarico lo stesso libro?**
R: Sì. I segnalibri sono legati al contenuto del file (SHA256), non al nome o all'UUID di upload. Stesso file = stessi segnalibri.
**D: Posso usare i segnalibri da un altro PC?**
R: Sì, se sei sulla stessa LAN e raggiungi lo stesso server. I segnalibri sono salvati su SQLite, non nel browser.
**D: Quanto spazio serve?**
R: Minimo. L'app stessa è ~5MB. Pandoc binary incluso ~35MB extra.
R: Minimo. L'app stessa è ~5MB. Pandoc binary incluso ~35MB extra. Il database SQLite occupa pochi KB.
**D: Posso usarlo su mobile?**
R: Il convertitore è responsive. Il reader funziona ma non è ottimizzato per schermi piccoli.
@@ -75,4 +125,4 @@ R: Imposta `BOOKISH_SECRET` come variabile d'ambiente prima di avviare.
- Path traversal prevenuto con `os.path.basename()` e `secure_filename()`
- MIME types controllati al download
- Limite file a 50MB
- Pulizia automatica file temporanei
- Pulizia automatica file temporanei dopo 1h