Initial commit: Bookish Flask app for ebook conversion and reading
This commit is contained in:
commit
32245f76ac
20 files changed
+2388
No files matched your search
@@ -0,0 +1,6 @@
|
||||
.venv/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
uploads/
|
||||
converted/
|
||||
.venv.bak/
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
import socket
|
||||
from bookish import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
def find_free_port(preferred=5000):
|
||||
"""Trova una porta libera partendo da preferred."""
|
||||
for port in range(preferred, preferred + 100):
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
if s.connect_ex(("127.0.0.1", port)) != 0:
|
||||
return port
|
||||
# fallback: lascia che il sistema assegni una porta
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
preferred = int(sys.argv[1]) if len(sys.argv) > 1 and sys.argv[1].isdigit() else 5000
|
||||
port = find_free_port(preferred)
|
||||
print(f"📚 Bookish avviato su http://0.0.0.0:{port}")
|
||||
app.run(host="0.0.0.0", port=port, debug=True)
|
||||
@@ -0,0 +1,32 @@
|
||||
import os
|
||||
from flask import Flask
|
||||
from config import SECRET_KEY, DEBUG, UPLOAD_FOLDER, CONVERTED_FOLDER
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__,
|
||||
template_folder="templates",
|
||||
static_folder="static",
|
||||
static_url_path="/static")
|
||||
app.secret_key = SECRET_KEY
|
||||
app.debug = DEBUG
|
||||
app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 # 50 MB
|
||||
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
|
||||
app.config["CONVERTED_FOLDER"] = CONVERTED_FOLDER
|
||||
|
||||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||
os.makedirs(CONVERTED_FOLDER, exist_ok=True)
|
||||
|
||||
from bookish.routes.convert import convert_bp
|
||||
from bookish.routes.reader import reader_bp
|
||||
|
||||
app.register_blueprint(convert_bp)
|
||||
app.register_blueprint(reader_bp)
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
from flask import render_template
|
||||
from config import SUPPORTED_FORMATS
|
||||
return render_template("index.html", formats=SUPPORTED_FORMATS)
|
||||
|
||||
return app
|
||||
Whitespace-only changes.
@@ -0,0 +1,136 @@
|
||||
import os
|
||||
import uuid
|
||||
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.converter import run_conversion, get_format_from_ext
|
||||
|
||||
convert_bp = Blueprint("convert", __name__)
|
||||
|
||||
ALLOWED_EXTENSIONS = set(SUPPORTED_FORMATS.keys())
|
||||
|
||||
|
||||
def _allowed_file(filename):
|
||||
ext = os.path.splitext(filename)[1].lower().lstrip(".")
|
||||
return ext in ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
def _get_output_ext(output_format):
|
||||
return f".{output_format}"
|
||||
|
||||
|
||||
def _clean_old_files(folder, hours=1):
|
||||
import time
|
||||
now = time.time()
|
||||
for f in os.listdir(folder):
|
||||
fpath = os.path.join(folder, f)
|
||||
if os.path.isfile(fpath) and now - os.path.getmtime(fpath) > hours * 3600:
|
||||
try:
|
||||
os.remove(fpath)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@convert_bp.route("/upload", methods=["POST"])
|
||||
def upload():
|
||||
if "file" not in request.files:
|
||||
return jsonify({"error": "Nessun file selezionato"}), 400
|
||||
|
||||
file = request.files["file"]
|
||||
if not file.filename:
|
||||
return jsonify({"error": "Nessun file selezionato"}), 400
|
||||
|
||||
if not _allowed_file(file.filename):
|
||||
ext = os.path.splitext(file.filename)[1].lower().lstrip(".")
|
||||
supported = ", ".join(sorted(SUPPORTED_FORMATS.keys()))
|
||||
return jsonify({"error": f"Formato '{ext}' non supportato. Supportati: {supported}"}), 400
|
||||
|
||||
_clean_old_files(current_app.config["UPLOAD_FOLDER"])
|
||||
_clean_old_files(current_app.config["CONVERTED_FOLDER"])
|
||||
|
||||
filename = secure_filename(file.filename)
|
||||
input_ext = get_format_from_ext(filename)
|
||||
unique_name = f"{uuid.uuid4().hex}_{filename}"
|
||||
input_path = os.path.join(current_app.config["UPLOAD_FOLDER"], unique_name)
|
||||
file.save(input_path)
|
||||
|
||||
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
|
||||
|
||||
available = OUTPUT_FORMATS_BY_INPUT.get(input_ext, [])
|
||||
if output_format not in available:
|
||||
return jsonify({
|
||||
"error": f"Conversione da {input_ext} a {output_format} non supportata"
|
||||
}), 400
|
||||
|
||||
output_ext = _get_output_ext(output_format)
|
||||
output_name = f"{uuid.uuid4().hex}{output_ext}"
|
||||
output_path = os.path.join(current_app.config["CONVERTED_FOLDER"], output_name)
|
||||
|
||||
task_id = create_task()
|
||||
|
||||
thread = threading.Thread(
|
||||
target=run_conversion,
|
||||
args=(input_path, output_path, input_ext, output_format, task_id, filename),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return jsonify({
|
||||
"task_id": task_id,
|
||||
"input_format": input_ext,
|
||||
"output_format": output_format,
|
||||
"filename": filename,
|
||||
})
|
||||
|
||||
|
||||
@convert_bp.route("/progress/<task_id>")
|
||||
def progress(task_id):
|
||||
status = get_status(task_id)
|
||||
if not status:
|
||||
return jsonify({"error": "Task not found"}), 404
|
||||
|
||||
return Response(
|
||||
generate_sse_events(task_id),
|
||||
mimetype="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@convert_bp.route("/download/<filename>")
|
||||
def download(filename):
|
||||
safe = os.path.basename(filename)
|
||||
filepath = os.path.join(current_app.config["CONVERTED_FOLDER"], safe)
|
||||
if not os.path.exists(filepath):
|
||||
return jsonify({"error": "File non trovato o già rimosso"}), 404
|
||||
|
||||
ext = os.path.splitext(safe)[1].lower().lstrip(".")
|
||||
mime = SUPPORTED_FORMATS.get(ext, {}).get("mime", "application/octet-stream")
|
||||
download_name = f"converted.{ext}"
|
||||
|
||||
return send_file(filepath, mimetype=mime, as_attachment=True, download_name=download_name)
|
||||
|
||||
|
||||
@convert_bp.route("/formats")
|
||||
def formats():
|
||||
input_fmt = request.args.get("input", "")
|
||||
if not input_fmt:
|
||||
return jsonify({
|
||||
"formats": {k: v["label"] for k, v in SUPPORTED_FORMATS.items()}
|
||||
})
|
||||
available = OUTPUT_FORMATS_BY_INPUT.get(input_fmt, [])
|
||||
return jsonify({
|
||||
"input": input_fmt,
|
||||
"available": available,
|
||||
"labels": {k: SUPPORTED_FORMATS[k]["label"] for k in available if k in SUPPORTED_FORMATS},
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import os
|
||||
from flask import Blueprint, render_template, jsonify, send_file, current_app
|
||||
from config import SUPPORTED_FORMATS
|
||||
|
||||
reader_bp = Blueprint("reader", __name__)
|
||||
|
||||
READABLE_FORMATS = {"epub", "pdf", "html", "md", "txt", "fb2", "docx"}
|
||||
|
||||
|
||||
@reader_bp.route("/reader")
|
||||
def reader():
|
||||
return render_template("reader.html")
|
||||
|
||||
|
||||
@reader_bp.route("/reader/<filename>")
|
||||
def reader_file(filename):
|
||||
safe = os.path.basename(filename)
|
||||
filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], safe)
|
||||
if not os.path.exists(filepath):
|
||||
filepath = os.path.join(current_app.config["CONVERTED_FOLDER"], safe)
|
||||
if not os.path.exists(filepath):
|
||||
return jsonify({"error": "File non trovato"}), 404
|
||||
|
||||
ext = os.path.splitext(safe)[1].lower().lstrip(".")
|
||||
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)
|
||||
|
||||
|
||||
@reader_bp.route("/reader/serve/<filename>")
|
||||
def reader_serve(filename):
|
||||
safe = os.path.basename(filename)
|
||||
filepath = os.path.join(current_app.config["UPLOAD_FOLDER"], safe)
|
||||
if not os.path.exists(filepath):
|
||||
filepath = os.path.join(current_app.config["CONVERTED_FOLDER"], safe)
|
||||
if not os.path.exists(filepath):
|
||||
return jsonify({"error": "File non trovato"}), 404
|
||||
|
||||
return send_file(filepath, mimetype="application/octet-stream")
|
||||
|
||||
|
||||
@reader_bp.route("/reader/formats")
|
||||
def reader_formats():
|
||||
return jsonify({
|
||||
"formats": list(READABLE_FORMATS),
|
||||
"labels": {k: SUPPORTED_FORMATS[k]["label"] for k in READABLE_FORMATS if k in SUPPORTED_FORMATS},
|
||||
})
|
||||
Whitespace-only changes.
@@ -0,0 +1,170 @@
|
||||
import os
|
||||
import pypandoc
|
||||
from config import SUPPORTED_FORMATS
|
||||
from bookish.services.sse_manager import update_progress, mark_complete, mark_error
|
||||
|
||||
|
||||
def get_format_from_ext(filename):
|
||||
ext = os.path.splitext(filename)[1].lower().lstrip(".")
|
||||
if ext in SUPPORTED_FORMATS:
|
||||
return ext
|
||||
# mappa alias comuni
|
||||
alias = {"markdown": "md", "text": "txt", "latex": "latex", "tex": "latex"}
|
||||
return alias.get(ext, ext)
|
||||
|
||||
|
||||
def run_conversion(input_path, output_path, input_format, output_format, task_id, filename):
|
||||
"""
|
||||
Esegue la conversione con Pandoc, aggiornando il progresso via SSE.
|
||||
Per alcuni formati speciali delega a formatter.py.
|
||||
"""
|
||||
try:
|
||||
update_progress(task_id, 10, "analyzing")
|
||||
_validate_pandoc_format(input_format, output_format)
|
||||
|
||||
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:
|
||||
mark_error(task_id, str(e))
|
||||
raise
|
||||
|
||||
|
||||
def _validate_pandoc_format(input_format, output_format):
|
||||
"""Verifica che Pandoc supporti input/output format."""
|
||||
try:
|
||||
readers = pypandoc.get_pandoc_formats()[0]
|
||||
writers = pypandoc.get_pandoc_formats()[1]
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Errore Pandoc: {e}. Installalo con: pip install pypandoc-binary")
|
||||
|
||||
pandoc_input = _to_pandoc_format(input_format)
|
||||
pandoc_output = _to_pandoc_format(output_format)
|
||||
|
||||
if pandoc_input not in readers:
|
||||
raise ValueError(f"Formato input '{input_format}' non supportato da Pandoc")
|
||||
if pandoc_output not in writers:
|
||||
raise ValueError(f"Formato output '{output_format}' non supportato da Pandoc")
|
||||
|
||||
|
||||
def _to_pandoc_format(fmt):
|
||||
mapping = {
|
||||
"md": "markdown",
|
||||
"latex": "latex",
|
||||
"tex": "latex",
|
||||
"html": "html",
|
||||
"epub": "epub",
|
||||
"pdf": "pdf",
|
||||
"docx": "docx",
|
||||
"txt": "plain",
|
||||
"fb2": "fb2",
|
||||
"mobi": "mobi",
|
||||
}
|
||||
return mapping.get(fmt, fmt)
|
||||
|
||||
|
||||
def _convert_with_pandoc(input_path, output_path, input_format, output_format, task_id):
|
||||
extra_args = ["--wrap=preserve"]
|
||||
if output_format == "epub":
|
||||
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)
|
||||
|
||||
def callback(percent):
|
||||
scaled = 30 + int(percent * 0.5)
|
||||
update_progress(task_id, min(scaled, 85))
|
||||
|
||||
pypandoc.convert_file(
|
||||
input_path,
|
||||
pandoc_output,
|
||||
format=pandoc_input,
|
||||
outputfile=output_path,
|
||||
extra_args=extra_args,
|
||||
)
|
||||
|
||||
|
||||
def _convert_to_pdf(input_path, output_path, input_format, task_id):
|
||||
"""Converte in PDF usando Pandoc con WeasyPrint."""
|
||||
from bookish.services.formatter import html_to_pdf
|
||||
|
||||
update_progress(task_id, 40, "converting")
|
||||
html_path = output_path.replace(".pdf", ".html")
|
||||
|
||||
pandoc_input = _to_pandoc_format(input_format)
|
||||
pypandoc.convert_file(
|
||||
input_path,
|
||||
"html",
|
||||
format=pandoc_input,
|
||||
outputfile=html_path,
|
||||
extra_args=["--wrap=preserve", "--self-contained"],
|
||||
)
|
||||
|
||||
update_progress(task_id, 65, "converting")
|
||||
html_to_pdf(html_path, output_path)
|
||||
|
||||
if os.path.exists(html_path):
|
||||
os.remove(html_path)
|
||||
|
||||
|
||||
def _convert_docx_to_pdf(input_path, output_path, task_id):
|
||||
"""DOCX → PDF via Pandoc → HTML → WeasyPrint."""
|
||||
from bookish.services.formatter import html_to_pdf
|
||||
|
||||
update_progress(task_id, 40, "converting")
|
||||
html_path = output_path.replace(".pdf", ".html")
|
||||
|
||||
pypandoc.convert_file(
|
||||
input_path,
|
||||
"html",
|
||||
format="docx",
|
||||
outputfile=html_path,
|
||||
extra_args=["--wrap=preserve"],
|
||||
)
|
||||
|
||||
update_progress(task_id, 65, "converting")
|
||||
html_to_pdf(html_path, output_path)
|
||||
|
||||
if os.path.exists(html_path):
|
||||
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
|
||||
@@ -0,0 +1,55 @@
|
||||
import os
|
||||
from weasyprint import HTML
|
||||
|
||||
|
||||
def html_to_pdf(html_path, pdf_path):
|
||||
"""Converte HTML in PDF con WeasyPrint, preservando layout e immagini."""
|
||||
html_str = _read_html_with_fixes(html_path)
|
||||
|
||||
HTML(string=html_str).write_pdf(pdf_path)
|
||||
|
||||
if not os.path.exists(pdf_path) or os.path.getsize(pdf_path) == 0:
|
||||
raise RuntimeError("WeasyPrint non ha prodotto output PDF valido")
|
||||
|
||||
|
||||
def _read_html_with_fixes(html_path):
|
||||
"""Legge l'HTML e applica fix per una migliore resa PDF."""
|
||||
with open(html_path, "r", encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
|
||||
fix_soup = False
|
||||
if "<table" in html and not _has_pdf_table_styles(html):
|
||||
fix_soup = True
|
||||
|
||||
if fix_soup:
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
|
||||
for table in soup.find_all("table"):
|
||||
table["style"] = (
|
||||
table.get("style", "")
|
||||
+ "; border-collapse: collapse; width: 100%; margin: 1em 0;"
|
||||
)
|
||||
for td in table.find_all(["td", "th"]):
|
||||
existing = td.get("style", "")
|
||||
td["style"] = (
|
||||
existing
|
||||
+ "; border: 1px solid #ccc; padding: 8px; vertical-align: top;"
|
||||
)
|
||||
|
||||
for img in soup.find_all("img"):
|
||||
img["style"] = (
|
||||
img.get("style", "")
|
||||
+ "; max-width: 100%; height: auto; page-break-inside: avoid;"
|
||||
)
|
||||
|
||||
html = str(soup)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return html
|
||||
|
||||
|
||||
def _has_pdf_table_styles(html):
|
||||
return 'border-collapse' in html.lower() or 'border="1"' in html
|
||||
@@ -0,0 +1,75 @@
|
||||
import json
|
||||
import uuid
|
||||
import threading
|
||||
from time import sleep
|
||||
|
||||
_tasks = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def create_task():
|
||||
task_id = str(uuid.uuid4())
|
||||
with _lock:
|
||||
_tasks[task_id] = {"progress": 0, "status": "started", "error": None, "output_file": None}
|
||||
return task_id
|
||||
|
||||
|
||||
def update_progress(task_id, progress, status=None):
|
||||
with _lock:
|
||||
if task_id in _tasks:
|
||||
_tasks[task_id]["progress"] = progress
|
||||
if status:
|
||||
_tasks[task_id]["status"] = status
|
||||
|
||||
|
||||
def mark_complete(task_id, output_file=None):
|
||||
with _lock:
|
||||
if task_id in _tasks:
|
||||
_tasks[task_id]["progress"] = 100
|
||||
_tasks[task_id]["status"] = "completed"
|
||||
_tasks[task_id]["output_file"] = output_file
|
||||
|
||||
|
||||
def mark_error(task_id, message):
|
||||
with _lock:
|
||||
if task_id in _tasks:
|
||||
_tasks[task_id]["status"] = "error"
|
||||
_tasks[task_id]["error"] = message
|
||||
|
||||
|
||||
def get_status(task_id):
|
||||
with _lock:
|
||||
return dict(_tasks.get(task_id, {}))
|
||||
|
||||
|
||||
def cleanup_task(task_id):
|
||||
with _lock:
|
||||
_tasks.pop(task_id, None)
|
||||
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
sleep(0.3)
|
||||
@@ -0,0 +1,605 @@
|
||||
/* === Reset & Base === */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: #f5f5f7;
|
||||
color: #1d1d1f;
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
/* === Navbar === */
|
||||
.navbar {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.nav-inner {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
text-decoration: none;
|
||||
color: #555;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: #f0f0f0;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: #e8e8ed;
|
||||
color: #1d1d1f;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* === Converter Page === */
|
||||
.convert-page h1 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Drop Zone */
|
||||
.drop-zone {
|
||||
border: 2px dashed #c7c7cc;
|
||||
border-radius: 12px;
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.3s, background 0.3s;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.drop-zone:hover {
|
||||
border-color: #007aff;
|
||||
}
|
||||
|
||||
.drop-zone.drag-over {
|
||||
border-color: #007aff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.drop-icon {
|
||||
font-size: 2.5rem;
|
||||
display: block;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.drop-hint {
|
||||
font-size: 0.85rem;
|
||||
color: #999;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* File Info */
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.8rem 1rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.3rem;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
padding: 0.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
color: #ff3b30;
|
||||
}
|
||||
|
||||
/* Format Grid */
|
||||
.format-section {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
display: block;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.format-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.format-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #d0d0d5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.format-btn:hover {
|
||||
border-color: #007aff;
|
||||
color: #007aff;
|
||||
}
|
||||
|
||||
.format-btn.selected {
|
||||
background: #007aff;
|
||||
color: #fff;
|
||||
border-color: #007aff;
|
||||
}
|
||||
|
||||
.format-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Convert Button */
|
||||
.btn-convert {
|
||||
width: 100%;
|
||||
padding: 0.9rem;
|
||||
margin-top: 1.5rem;
|
||||
background: #007aff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-convert:hover:not(:disabled) {
|
||||
background: #0066d6;
|
||||
}
|
||||
|
||||
.btn-convert:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Progress */
|
||||
.progress-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, #007aff, #34c759);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-status {
|
||||
margin-top: 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Result */
|
||||
.result-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.result-success {
|
||||
background: #e8f8ee;
|
||||
border: 1px solid #b8e6c8;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
font-size: 2rem;
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.result-success p {
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-download, .btn-read {
|
||||
display: inline-block;
|
||||
padding: 0.7rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
margin: 0.3rem;
|
||||
}
|
||||
|
||||
.btn-download {
|
||||
background: #007aff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-download:hover {
|
||||
background: #0066d6;
|
||||
}
|
||||
|
||||
.btn-read {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
border: 1px solid #d0d0d5;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn-read:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.error-section {
|
||||
margin-top: 2rem;
|
||||
background: #fef0ef;
|
||||
border: 1px solid #f5c6cb;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
/* === Reader Page === */
|
||||
.reader-page {
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.reader-empty {
|
||||
text-align: center;
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
.reader-empty h2 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.reader-upload {
|
||||
max-width: 500px;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
.reader-drop-zone {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.reader-recent {
|
||||
max-width: 500px;
|
||||
margin: 2rem auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.reader-recent h3 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.8rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
#recentList {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
#recentList li {
|
||||
padding: 0.6rem 0.8rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
#recentList li a {
|
||||
text-decoration: none;
|
||||
color: #007aff;
|
||||
}
|
||||
|
||||
/* Reader UI */
|
||||
.reader-toolbar {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.toolbar-left, .toolbar-center, .toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: #555;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tool-btn:hover {
|
||||
background: #f0f0f0;
|
||||
border-color: #d0d0d5;
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* Reader Layout */
|
||||
.reader-container {
|
||||
display: flex;
|
||||
height: calc(100vh - 100px);
|
||||
}
|
||||
|
||||
.reader-sidebar {
|
||||
width: 280px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
transition: margin-left 0.3s;
|
||||
}
|
||||
|
||||
.reader-sidebar.collapsed {
|
||||
margin-left: -280px;
|
||||
}
|
||||
|
||||
.sidebar-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.sidebar-tab {
|
||||
flex: 1;
|
||||
padding: 0.7rem 0.3rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-tab.active {
|
||||
color: #007aff;
|
||||
border-bottom-color: #007aff;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.tab-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-panel.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#tocList {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
#tocList li {
|
||||
padding: 0.4rem 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: #333;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
#tocList li:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
#tocList li.active {
|
||||
background: #e8f0ff;
|
||||
color: #007aff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
border: 1px solid #d0d0d5;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.search-result-item:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
#bookmarksList {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
#bookmarksList li {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reader-view {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#epubViewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#pdfViewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
#pdfViewer canvas {
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.text-viewer {
|
||||
padding: 2rem 3rem;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.8;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.text-viewer img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.text-viewer table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.text-viewer th, .text-viewer td {
|
||||
border: 1px solid #ccc;
|
||||
padding: 8px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
/* === Responsive === */
|
||||
@media (max-width: 600px) {
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.reader-sidebar {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.reader-sidebar.collapsed {
|
||||
margin-left: -240px;
|
||||
}
|
||||
|
||||
.text-viewer {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.format-grid {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.format-btn {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const dropZone = document.getElementById("dropZone");
|
||||
const fileInput = document.getElementById("fileInput");
|
||||
const fileInfo = document.getElementById("fileInfo");
|
||||
const fileName = document.getElementById("fileName");
|
||||
const removeBtn = document.getElementById("removeFile");
|
||||
const formatSection = document.getElementById("formatSection");
|
||||
const formatGrid = document.getElementById("formatGrid");
|
||||
const convertBtn = document.getElementById("convertBtn");
|
||||
const form = document.getElementById("convert-form");
|
||||
const progressSection = document.getElementById("progressSection");
|
||||
const progressBar = document.getElementById("progressBar");
|
||||
const progressStatus = document.getElementById("progressStatus");
|
||||
const resultSection = document.getElementById("resultSection");
|
||||
const downloadLink = document.getElementById("downloadLink");
|
||||
const readLink = document.getElementById("readLink");
|
||||
const errorSection = document.getElementById("errorSection");
|
||||
const errorMessage = document.getElementById("errorMessage");
|
||||
|
||||
let selectedFile = null;
|
||||
let selectedFormat = null;
|
||||
let currentTaskId = null;
|
||||
let readerFileId = null;
|
||||
|
||||
// --- Drag & Drop ---
|
||||
dropZone.addEventListener("click", () => fileInput.click());
|
||||
|
||||
dropZone.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add("drag-over");
|
||||
});
|
||||
|
||||
dropZone.addEventListener("dragleave", () => {
|
||||
dropZone.classList.remove("drag-over");
|
||||
});
|
||||
|
||||
dropZone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove("drag-over");
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
handleFile(e.dataTransfer.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
if (fileInput.files.length > 0) {
|
||||
handleFile(fileInput.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
removeBtn.addEventListener("click", () => {
|
||||
resetForm();
|
||||
});
|
||||
|
||||
// --- Format Selection ---
|
||||
formatGrid.querySelectorAll(".format-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
formatGrid.querySelectorAll(".format-btn").forEach((b) => b.classList.remove("selected"));
|
||||
btn.classList.add("selected");
|
||||
selectedFormat = btn.dataset.format;
|
||||
convertBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
// --- Form Submit ---
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (!selectedFile || !selectedFormat) return;
|
||||
|
||||
hideAllResults();
|
||||
progressSection.style.display = "block";
|
||||
convertBtn.disabled = true;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", selectedFile);
|
||||
formData.append("output_format", selectedFormat);
|
||||
|
||||
try {
|
||||
const resp = await fetch("/upload", { method: "POST", body: formData });
|
||||
const data = await resp.json();
|
||||
|
||||
if (!resp.ok) {
|
||||
showError(data.error || "Errore durante l'upload");
|
||||
return;
|
||||
}
|
||||
|
||||
currentTaskId = data.task_id;
|
||||
readerFileId = data.filename;
|
||||
|
||||
// Connect SSE for progress
|
||||
connectSSE(currentTaskId);
|
||||
} catch (err) {
|
||||
showError("Errore di connessione al server");
|
||||
}
|
||||
});
|
||||
|
||||
// --- SSE Progress ---
|
||||
function connectSSE(taskId) {
|
||||
const evtSource = new EventSource(`/progress/${taskId}`);
|
||||
|
||||
evtSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
updateProgressUI(data);
|
||||
|
||||
if (data.status === "completed") {
|
||||
evtSource.close();
|
||||
showResult(data);
|
||||
} else if (data.status === "error") {
|
||||
evtSource.close();
|
||||
showError(data.error || "Errore durante la conversione");
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
evtSource.onerror = () => {
|
||||
evtSource.close();
|
||||
if (currentTaskId) {
|
||||
// Try fetching status directly as fallback
|
||||
fetch(`/progress/${currentTaskId}`)
|
||||
.then((r) => r.json())
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function updateProgressUI(data) {
|
||||
progressBar.style.width = `${data.progress}%`;
|
||||
|
||||
const statusTexts = {
|
||||
started: "Avvio conversione...",
|
||||
analyzing: "Analisi del file in corso...",
|
||||
converting: `Conversione in corso (${data.progress}%)...`,
|
||||
finalizing: "Finalizzazione...",
|
||||
completed: "Completato!",
|
||||
error: "Errore",
|
||||
};
|
||||
progressStatus.textContent = statusTexts[data.status] || `Stato: ${data.status}`;
|
||||
}
|
||||
|
||||
function showResult(data) {
|
||||
progressSection.style.display = "none";
|
||||
resultSection.style.display = "block";
|
||||
|
||||
const ext = selectedFormat;
|
||||
downloadLink.href = data.download_url || "#";
|
||||
downloadLink.download = `converted.${ext}`;
|
||||
|
||||
readLink.onclick = () => {
|
||||
if (readerFileId) {
|
||||
const encoded = encodeURIComponent(readerFileId);
|
||||
window.open(`/reader/${encoded}`, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
convertBtn.disabled = false;
|
||||
currentTaskId = null;
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
progressSection.style.display = "none";
|
||||
errorSection.style.display = "block";
|
||||
errorMessage.textContent = msg;
|
||||
convertBtn.disabled = false;
|
||||
currentTaskId = null;
|
||||
}
|
||||
|
||||
function hideAllResults() {
|
||||
progressSection.style.display = "none";
|
||||
resultSection.style.display = "none";
|
||||
errorSection.style.display = "none";
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
function handleFile(file) {
|
||||
selectedFile = file;
|
||||
fileName.textContent = file.name;
|
||||
fileInfo.style.display = "flex";
|
||||
formatSection.style.display = "block";
|
||||
|
||||
resetOutputSelection();
|
||||
updateValidFormats(file.name);
|
||||
|
||||
if (selectedFormat) {
|
||||
convertBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
selectedFile = null;
|
||||
selectedFormat = null;
|
||||
currentTaskId = null;
|
||||
readerFileId = null;
|
||||
fileInput.value = "";
|
||||
fileInfo.style.display = "none";
|
||||
formatSection.style.display = "none";
|
||||
hideAllResults();
|
||||
convertBtn.disabled = true;
|
||||
resetOutputSelection();
|
||||
}
|
||||
|
||||
function resetOutputSelection() {
|
||||
selectedFormat = null;
|
||||
formatGrid.querySelectorAll(".format-btn").forEach((b) => b.classList.remove("selected"));
|
||||
}
|
||||
|
||||
async function updateValidFormats(filename) {
|
||||
const ext = filename.split(".").pop().toLowerCase();
|
||||
try {
|
||||
const resp = await fetch(`/formats?input=${ext}`);
|
||||
const data = await resp.json();
|
||||
const available = data.available || [];
|
||||
|
||||
formatGrid.querySelectorAll(".format-btn").forEach((btn) => {
|
||||
const fmt = btn.dataset.format;
|
||||
btn.disabled = !available.includes(fmt);
|
||||
});
|
||||
} catch (err) {
|
||||
// Allow all on error
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,597 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
if (!BOOKISH_FILE) {
|
||||
initReaderUpload();
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = BOOKISH_EXT;
|
||||
const filename = BOOKISH_FILE;
|
||||
|
||||
if (ext === "epub") {
|
||||
initEpubReader(filename);
|
||||
} else if (ext === "pdf") {
|
||||
initPdfReader(filename);
|
||||
} else if (["md", "txt", "html", "fb2", "docx"].includes(ext)) {
|
||||
initTextViewer(filename, ext);
|
||||
}
|
||||
|
||||
// --- Shared Reader Controls ---
|
||||
initReaderControls();
|
||||
|
||||
// === EPUB Reader (epub.js) ===
|
||||
function initEpubReader(file) {
|
||||
const viewer = document.getElementById("epubViewer");
|
||||
if (!viewer) return;
|
||||
|
||||
const book = ePub(`/reader/serve/${encodeURIComponent(file)}`);
|
||||
const rendition = book.renderTo("epubViewer", {
|
||||
width: "100%",
|
||||
height: window.innerHeight - 100,
|
||||
spread: "auto",
|
||||
flow: "paginated",
|
||||
});
|
||||
|
||||
rendition.display();
|
||||
|
||||
// Navigation
|
||||
let currentToc = [];
|
||||
book.loaded.navigation.then((toc) => {
|
||||
currentToc = toc;
|
||||
renderToc(toc, rendition, book);
|
||||
});
|
||||
|
||||
rendition.on("relocated", (loc) => {
|
||||
updatePageInfo(book, loc);
|
||||
updateActiveToc(loc, currentToc, book);
|
||||
savePosition(book, loc);
|
||||
});
|
||||
|
||||
// Keyboard
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowRight") rendition.next();
|
||||
if (e.key === "ArrowLeft") rendition.prev();
|
||||
if (e.key === "=" || e.key === "+") changeFontSize(rendition, 2);
|
||||
if (e.key === "-") changeFontSize(rendition, -2);
|
||||
});
|
||||
|
||||
// Toolbar controls
|
||||
document.getElementById("zoomIn").addEventListener("click", () => rendition.next());
|
||||
document.getElementById("zoomOut").addEventListener("click", () => rendition.prev());
|
||||
|
||||
// Store ref for theme
|
||||
window._rendition = rendition;
|
||||
window._book = book;
|
||||
|
||||
// Restore position
|
||||
restorePosition(book, rendition, file);
|
||||
}
|
||||
|
||||
// === PDF Reader (pdf.js) ===
|
||||
function initPdfReader(file) {
|
||||
const viewer = document.getElementById("pdfViewer");
|
||||
if (!viewer) return;
|
||||
|
||||
const pdfjsLib = window.pdfjsLib;
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
|
||||
|
||||
let pdfDoc = null;
|
||||
let currentPage = 1;
|
||||
let totalPages = 0;
|
||||
let scale = 1.2;
|
||||
|
||||
function renderPage(num) {
|
||||
viewer.innerHTML = "";
|
||||
pdfDoc.getPage(num).then((page) => {
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
canvas.height = viewport.height;
|
||||
canvas.width = viewport.width;
|
||||
viewer.appendChild(canvas);
|
||||
|
||||
const renderCtx = {
|
||||
canvasContext: ctx,
|
||||
viewport,
|
||||
};
|
||||
return page.render(renderCtx).promise;
|
||||
});
|
||||
}
|
||||
|
||||
// Load pdf
|
||||
pdfjsLib.getDocument(`/reader/serve/${encodeURIComponent(file)}`).promise.then((pdf) => {
|
||||
pdfDoc = pdf;
|
||||
totalPages = pdf.numPages;
|
||||
renderPage(currentPage);
|
||||
updatePageInfoPdf(currentPage, totalPages);
|
||||
|
||||
// Render TOC if available
|
||||
pdf.getOutline().then((outline) => {
|
||||
if (outline && outline.length > 0) {
|
||||
renderPdfToc(outline, pdf);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("zoomIn").addEventListener("click", () => {
|
||||
scale = Math.min(scale + 0.2, 3);
|
||||
if (pdfDoc) renderPage(currentPage);
|
||||
});
|
||||
|
||||
document.getElementById("zoomOut").addEventListener("click", () => {
|
||||
scale = Math.max(scale - 0.2, 0.5);
|
||||
if (pdfDoc) renderPage(currentPage);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (!pdfDoc) return;
|
||||
if (e.key === "ArrowRight" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (currentPage < totalPages) {
|
||||
currentPage++;
|
||||
renderPage(currentPage);
|
||||
updatePageInfoPdf(currentPage, totalPages);
|
||||
}
|
||||
}
|
||||
if (e.key === "ArrowLeft") {
|
||||
if (currentPage > 1) {
|
||||
currentPage--;
|
||||
renderPage(currentPage);
|
||||
updatePageInfoPdf(currentPage, totalPages);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window._pdfViewer = { pdfDoc, renderPage, getCurrentPage: () => currentPage, totalPages };
|
||||
}
|
||||
|
||||
// === Text Viewer (HTML, MD, TXT, FB2, DOCX via Pandoc) ===
|
||||
function initTextViewer(file, ext) {
|
||||
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>`;
|
||||
}
|
||||
});
|
||||
} else if (ext === "html") {
|
||||
// Direct HTML content
|
||||
viewer.innerHTML = content;
|
||||
fixLinks(viewer);
|
||||
} else {
|
||||
viewer.textContent = content;
|
||||
}
|
||||
});
|
||||
|
||||
// No zoom controls needed for text view (CSS handles it)
|
||||
document.getElementById("zoomIn").style.display = "none";
|
||||
document.getElementById("zoomOut").style.display = "none";
|
||||
}
|
||||
|
||||
function simpleMarkdownToHtml(md) {
|
||||
// Simple markdown renderer without dependencies
|
||||
let html = md
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
// Images
|
||||
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%">')
|
||||
// Links
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
|
||||
// Bold
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
// Italic
|
||||
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
||||
// Inline code
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||
// Headings
|
||||
.replace(/^### (.+)$/gm, "<h3>$1</h3>")
|
||||
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
|
||||
.replace(/^# (.+)$/gm, "<h1>$1</h1>")
|
||||
// Horizontal rule
|
||||
.replace(/^---$/gm, "<hr>")
|
||||
// Blockquote
|
||||
.replace(/^> (.+)$/gm, "<blockquote>$1</blockquote>")
|
||||
// Paragraphs (double newline)
|
||||
.replace(/\n\n/g, "</p><p>")
|
||||
// Line breaks
|
||||
.replace(/\n/g, "<br>");
|
||||
|
||||
return `<p>${html}</p>`;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function fixLinks(container) {
|
||||
container.querySelectorAll("a").forEach((a) => a.setAttribute("target", "_blank"));
|
||||
}
|
||||
|
||||
// === Shared Controls ===
|
||||
|
||||
function initReaderControls() {
|
||||
const sidebar = document.getElementById("sidebar");
|
||||
const sidebarToggle = document.getElementById("sidebarToggle");
|
||||
const themeToggle = document.getElementById("themeToggle");
|
||||
const searchToggle = document.getElementById("searchToggle");
|
||||
const searchInput = document.getElementById("searchInput");
|
||||
const searchResults = document.getElementById("searchResults");
|
||||
const fullscreenToggle = document.getElementById("fullscreenToggle");
|
||||
|
||||
// Sidebar toggle
|
||||
if (sidebarToggle && sidebar) {
|
||||
sidebarToggle.addEventListener("click", () => {
|
||||
sidebar.classList.toggle("collapsed");
|
||||
sidebarToggle.textContent = sidebar.classList.contains("collapsed") ? "☰" : "✕";
|
||||
});
|
||||
}
|
||||
|
||||
// Sidebar tabs
|
||||
document.querySelectorAll(".sidebar-tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
document.querySelectorAll(".sidebar-tab").forEach((t) => t.classList.remove("active"));
|
||||
document.querySelectorAll(".tab-panel").forEach((p) => p.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
const panel = document.getElementById(`panel-${tab.dataset.tab}`);
|
||||
if (panel) panel.classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
// Theme toggle
|
||||
if (themeToggle) {
|
||||
let themeIndex = 0;
|
||||
const themes = [
|
||||
{ name: "light", bg: "#fff", fg: "#1d1d1f", label: "☀️" },
|
||||
{ name: "dark", bg: "#1d1d1f", fg: "#eee", label: "🌙" },
|
||||
{ name: "sepia", bg: "#fbf0d9", fg: "#433422", label: "🟤" },
|
||||
];
|
||||
|
||||
themeToggle.addEventListener("click", () => {
|
||||
themeIndex = (themeIndex + 1) % themes.length;
|
||||
const t = themes[themeIndex];
|
||||
themeToggle.textContent = t.label;
|
||||
applyTheme(t);
|
||||
});
|
||||
}
|
||||
|
||||
// Search
|
||||
if (searchInput && searchResults) {
|
||||
searchInput.addEventListener("input", debounce(() => {
|
||||
const q = searchInput.value.trim().toLowerCase();
|
||||
if (!q || q.length < 2) {
|
||||
searchResults.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
searchInBook(q);
|
||||
}, 300));
|
||||
}
|
||||
|
||||
// Fullscreen
|
||||
if (fullscreenToggle) {
|
||||
fullscreenToggle.addEventListener("click", () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().catch(() => {});
|
||||
} else {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load saved theme
|
||||
const savedTheme = localStorage.getItem("bookish_theme");
|
||||
if (savedTheme) {
|
||||
try {
|
||||
const t = JSON.parse(savedTheme);
|
||||
applyTheme(t);
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(t) {
|
||||
document.body.style.background = t.bg;
|
||||
document.body.style.color = t.fg;
|
||||
localStorage.setItem("bookish_theme", JSON.stringify(t));
|
||||
|
||||
// epub.js theme if available
|
||||
if (window._rendition && window._rendition.themes) {
|
||||
const themeName = t.name === "dark" ? "dark" : t.name === "sepia" ? "sepia" : "light";
|
||||
try {
|
||||
window._rendition.themes.select(themeName);
|
||||
} catch (e) {
|
||||
// Theme might not be registered
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function changeFontSize(rendition, delta) {
|
||||
const current = parseFloat(localStorage.getItem("bookish_fontsize") || "100");
|
||||
const newSize = Math.max(60, Math.min(200, current + delta * 10));
|
||||
localStorage.setItem("bookish_fontsize", newSize.toString());
|
||||
rendition.themes.fontSize(`${newSize}%`);
|
||||
}
|
||||
|
||||
function updatePageInfo(book, loc) {
|
||||
const info = document.getElementById("pageInfo");
|
||||
if (info && loc && loc.start) {
|
||||
const current = loc.start.displayed.page || 0;
|
||||
const total = loc.start.displayed.total || 0;
|
||||
info.textContent = `${current} / ${total}`;
|
||||
}
|
||||
}
|
||||
|
||||
function updatePageInfoPdf(current, total) {
|
||||
const info = document.getElementById("pageInfo");
|
||||
if (info) {
|
||||
info.textContent = `${current} / ${total}`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderToc(toc, rendition, book) {
|
||||
const list = document.getElementById("tocList");
|
||||
if (!list) return;
|
||||
|
||||
list.innerHTML = "";
|
||||
|
||||
function appendItems(items, parent) {
|
||||
items.forEach((item) => {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = item.label;
|
||||
li.style.paddingLeft = `${(item.subitems ? 0.5 : 1.5)}rem`;
|
||||
li.addEventListener("click", () => {
|
||||
rendition.display(item.href);
|
||||
document.querySelectorAll("#tocList li").forEach((l) => l.classList.remove("active"));
|
||||
li.classList.add("active");
|
||||
});
|
||||
parent.appendChild(li);
|
||||
|
||||
if (item.subitems && item.subitems.length > 0) {
|
||||
appendItems(item.subitems, parent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
appendItems(toc, list);
|
||||
}
|
||||
|
||||
function renderPdfToc(outline, pdf) {
|
||||
const list = document.getElementById("tocList");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
|
||||
function appendOutline(items, parent) {
|
||||
items.forEach((item) => {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = item.title;
|
||||
li.addEventListener("click", async () => {
|
||||
const dest = await pdf.getDestination(item.dest);
|
||||
if (dest) {
|
||||
const pageIndex = await pdf.getPageIndex(dest[0]);
|
||||
if (window._pdfViewer) {
|
||||
window._pdfViewer.currentPage = pageIndex + 1;
|
||||
window._pdfViewer.renderPage(window._pdfViewer.currentPage);
|
||||
updatePageInfoPdf(window._pdfViewer.currentPage, window._pdfViewer.totalPages);
|
||||
}
|
||||
}
|
||||
});
|
||||
parent.appendChild(li);
|
||||
if (item.items && item.items.length > 0) {
|
||||
appendOutline(item.items, parent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
appendOutline(outline, list);
|
||||
}
|
||||
|
||||
function updateActiveToc(loc, toc, book) {
|
||||
// epub.js provides location.start.cfi
|
||||
document.querySelectorAll("#tocList li").forEach((l) => l.classList.remove("active"));
|
||||
}
|
||||
|
||||
// === Search ===
|
||||
function searchInBook(query) {
|
||||
const results = document.getElementById("searchResults");
|
||||
if (!results) return;
|
||||
|
||||
// For epub.js
|
||||
if (window._book && window._book.search) {
|
||||
window._book.search(query).then((res) => {
|
||||
results.innerHTML = "";
|
||||
if (!res || res.length === 0) {
|
||||
results.innerHTML = "<p style='padding:0.5rem;color:#999'>Nessun risultato</p>";
|
||||
return;
|
||||
}
|
||||
res.forEach((r) => {
|
||||
const div = document.createElement("div");
|
||||
div.className = "search-result-item";
|
||||
div.textContent = r.excerpt || r.text || r.cfi;
|
||||
div.addEventListener("click", () => {
|
||||
if (window._rendition) {
|
||||
window._rendition.display(r.cfi);
|
||||
}
|
||||
});
|
||||
results.appendChild(div);
|
||||
});
|
||||
});
|
||||
} else if (window._pdfViewer && window._pdfViewer.pdfDoc) {
|
||||
pdfSearch(query, results);
|
||||
} else {
|
||||
results.innerHTML = "<p style='padding:0.5rem;color:#999'>Ricerca non disponibile per questo formato</p>";
|
||||
}
|
||||
}
|
||||
|
||||
let pdfSearchTimer = null;
|
||||
|
||||
function pdfSearch(query, resultsEl) {
|
||||
const pdf = window._pdfViewer.pdfDoc;
|
||||
resultsEl.innerHTML = "<p style='padding:0.5rem;color:#999'>Ricerca in corso...</p>";
|
||||
|
||||
if (pdfSearchTimer) clearTimeout(pdfSearchTimer);
|
||||
pdfSearchTimer = setTimeout(async () => {
|
||||
let allResults = [];
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
try {
|
||||
const page = await pdf.getPage(i);
|
||||
const text = await page.getTextContent();
|
||||
const pageText = text.items.map((t) => t.str).join(" ");
|
||||
if (pageText.toLowerCase().includes(query.toLowerCase())) {
|
||||
allResults.push({ page: i, text: pageText.substring(0, 120) });
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
resultsEl.innerHTML = "";
|
||||
if (allResults.length === 0) {
|
||||
resultsEl.innerHTML = "<p style='padding:0.5rem;color:#999'>Nessun risultato</p>";
|
||||
return;
|
||||
}
|
||||
|
||||
allResults.forEach((r) => {
|
||||
const div = document.createElement("div");
|
||||
div.className = "search-result-item";
|
||||
div.textContent = `Pag. ${r.page}: ${r.text}...`;
|
||||
div.addEventListener("click", () => {
|
||||
if (window._pdfViewer) {
|
||||
window._pdfViewer.currentPage = r.page;
|
||||
window._pdfViewer.renderPage(r.page);
|
||||
updatePageInfoPdf(r.page, window._pdfViewer.totalPages);
|
||||
}
|
||||
});
|
||||
resultsEl.appendChild(div);
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// === Position Save/Restore ===
|
||||
function savePosition(book, loc) {
|
||||
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));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// === Reader Upload ===
|
||||
function initReaderUpload() {
|
||||
const dropZone = document.getElementById("readerDropZone");
|
||||
const fileInput = document.getElementById("readerFileInput");
|
||||
const recentReads = document.getElementById("recentReads");
|
||||
const recentList = document.getElementById("recentList");
|
||||
|
||||
if (dropZone && fileInput) {
|
||||
dropZone.addEventListener("click", () => fileInput.click());
|
||||
|
||||
dropZone.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add("drag-over");
|
||||
});
|
||||
|
||||
dropZone.addEventListener("dragleave", () => {
|
||||
dropZone.classList.remove("drag-over");
|
||||
});
|
||||
|
||||
dropZone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove("drag-over");
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
uploadAndRead(e.dataTransfer.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
if (fileInput.files.length > 0) {
|
||||
uploadAndRead(fileInput.files[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Show recent reads
|
||||
showRecentReads();
|
||||
}
|
||||
|
||||
function uploadAndRead(file) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("output_format", "html"); // placeholder, we just upload
|
||||
|
||||
fetch("/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.task_id) {
|
||||
const ext = file.name.split(".").pop().toLowerCase();
|
||||
const encoded = encodeURIComponent(data.filename);
|
||||
// We'll just redirect to reader page after upload completes
|
||||
// For simplicity, redirect immediately
|
||||
window.location.href = `/reader/${encoded}`;
|
||||
} else {
|
||||
alert("Errore upload: " + (data.error || "errore sconosciuto"));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
alert("Errore di connessione");
|
||||
});
|
||||
}
|
||||
|
||||
function showRecentReads() {
|
||||
// Show file from localStorage
|
||||
const recent = JSON.parse(localStorage.getItem("bookish_recent") || "[]");
|
||||
if (recent.length > 0) {
|
||||
const section = document.getElementById("recentReads");
|
||||
const list = document.getElementById("recentList");
|
||||
if (section && list) {
|
||||
section.style.display = "block";
|
||||
list.innerHTML = recent
|
||||
.slice(0, 10)
|
||||
.map(
|
||||
(f) =>
|
||||
`<li><a href="/reader/${encodeURIComponent(f.filename)}">${f.name}</a> <span style="color:#999;font-size:0.8rem">${f.format}</span></li>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Utils ===
|
||||
function debounce(fn, ms) {
|
||||
let timer;
|
||||
return (...args) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Bookish{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="nav-inner">
|
||||
<a href="/" class="nav-brand">📚 Bookish</a>
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link {% if request.path == '/' %}active{% endif %}">Convertitore</a>
|
||||
<a href="/reader" class="nav-link {% if request.path.startswith('/reader') %}active{% endif %}">Reader</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,74 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Bookish — Convertitore{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="convert-page">
|
||||
<h1>Converti ebook</h1>
|
||||
<p class="subtitle">Carica un file e scegli il formato di destinazione</p>
|
||||
|
||||
<form id="convert-form" enctype="multipart/form-data">
|
||||
<!-- Drop zone -->
|
||||
<div class="drop-zone" id="dropZone">
|
||||
<div class="drop-zone-content">
|
||||
<span class="drop-icon">📖</span>
|
||||
<p>Trascina qui il tuo file</p>
|
||||
<p class="drop-hint">oppure clicca per selezionare</p>
|
||||
</div>
|
||||
<input type="file" name="file" id="fileInput" class="file-input" accept=".epub,.pdf,.docx,.md,.txt,.html,.fb2,.mobi,.latex,.tex">
|
||||
</div>
|
||||
|
||||
<!-- File info -->
|
||||
<div class="file-info" id="fileInfo" style="display:none;">
|
||||
<span class="file-name" id="fileName"></span>
|
||||
<button type="button" class="btn-remove" id="removeFile" title="Rimuovi file">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Output format grid -->
|
||||
<div class="format-section" id="formatSection" style="display:none;">
|
||||
<label class="section-label">Converti in:</label>
|
||||
<div class="format-grid" id="formatGrid">
|
||||
{% for key, fmt in formats.items() %}
|
||||
<button type="button" class="format-btn" data-format="{{ key }}">
|
||||
{{ fmt.label }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Convert button -->
|
||||
<button type="submit" class="btn-convert" id="convertBtn" disabled>
|
||||
🚀 Converti
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Progress -->
|
||||
<div class="progress-section" id="progressSection" style="display:none;">
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" id="progressBar"></div>
|
||||
</div>
|
||||
<p class="progress-status" id="progressStatus">Avvio conversione...</p>
|
||||
</div>
|
||||
|
||||
<!-- Result -->
|
||||
<div class="result-section" id="resultSection" style="display:none;">
|
||||
<div class="result-success">
|
||||
<span class="result-icon">✅</span>
|
||||
<p>Conversione completata!</p>
|
||||
<a href="#" class="btn-download" id="downloadLink">⬇ Scarica file</a>
|
||||
<button type="button" class="btn-read" id="readLink">📖 Leggi nel reader</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div class="error-section" id="errorSection" style="display:none;">
|
||||
<p class="error-message" id="errorMessage"></p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
/* epub.js supporta font-size iniettato via CSS, niente extra */
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,96 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Bookish — Reader{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="reader-page">
|
||||
{% if filename %}
|
||||
<div class="reader-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<button class="tool-btn" id="sidebarToggle" title="Indice">☰</button>
|
||||
<span class="chapter-title" id="chapterTitle">Caricamento...</span>
|
||||
</div>
|
||||
<div class="toolbar-center">
|
||||
<span class="page-info" id="pageInfo">-- / --</span>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<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="fullscreenToggle" title="Schermo intero">⛶</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>
|
||||
<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" 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>
|
||||
{% else %}
|
||||
<div class="reader-empty">
|
||||
<h2>Reader</h2>
|
||||
<p class="subtitle">Carica o seleziona un file da leggere</p>
|
||||
|
||||
<div class="reader-upload">
|
||||
<form id="reader-upload-form" enctype="multipart/form-data">
|
||||
<div class="drop-zone reader-drop-zone" id="readerDropZone">
|
||||
<div class="drop-zone-content">
|
||||
<span class="drop-icon">📖</span>
|
||||
<p>Trascina qui un ebook</p>
|
||||
<p class="drop-hint">oppure clicca per selezionare</p>
|
||||
</div>
|
||||
<input type="file" name="file" id="readerFileInput" class="file-input"
|
||||
accept=".epub,.pdf,.md,.txt,.html,.fb2,.docx">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="reader-recent" id="recentReads" style="display:none;">
|
||||
<h3>Letture recenti</h3>
|
||||
<ul id="recentList"></ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if file_ext == 'epub' %}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/epubjs@0.3.93/dist/epub.min.js"></script>
|
||||
{% elif file_ext == 'pdf' %}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
{% endif %}
|
||||
<script>
|
||||
const BOOKISH_FILE = {{ filename | tojson | safe if filename else 'null' }};
|
||||
const BOOKISH_EXT = {{ file_ext | tojson | safe if file_ext else 'null' }};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/reader.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,38 @@
|
||||
import os
|
||||
|
||||
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
SECRET_KEY = os.environ.get("BOOKISH_SECRET", "cambiami-in-produzione")
|
||||
DEBUG = os.environ.get("BOOKISH_DEBUG", "true").lower() == "true"
|
||||
|
||||
UPLOAD_FOLDER = os.path.join(BASE_DIR, "uploads")
|
||||
CONVERTED_FOLDER = os.path.join(BASE_DIR, "converted")
|
||||
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||
CLEANUP_HOURS = 1 # ore prima di cancellare file temporanei
|
||||
|
||||
# Formati supportati
|
||||
SUPPORTED_FORMATS = {
|
||||
"epub": {"label": "EPUB", "mime": "application/epub+zip"},
|
||||
"md": {"label": "Markdown", "mime": "text/markdown"},
|
||||
"docx": {"label": "DOCX", "mime": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
|
||||
"pdf": {"label": "PDF", "mime": "application/pdf"},
|
||||
"txt": {"label": "Testo", "mime": "text/plain"},
|
||||
"html": {"label": "HTML", "mime": "text/html"},
|
||||
"fb2": {"label": "FB2", "mime": "application/x-fictionbook+xml"},
|
||||
"latex": {"label": "LaTeX", "mime": "text/x-tex"},
|
||||
"mobi": {"label": "MOBI", "mime": "application/x-mobipocket-ebook"},
|
||||
}
|
||||
|
||||
# Conversion matrix: quali formati possono convertire in cosa
|
||||
# Pandoc gestisce tutto; qui segnaliamo limitazioni note
|
||||
OUTPUT_FORMATS_BY_INPUT = {
|
||||
"epub": ["epub", "md", "docx", "pdf", "txt", "html", "fb2", "latex"],
|
||||
"pdf": ["epub", "md", "docx", "txt", "html", "latex"],
|
||||
"docx": ["epub", "md", "pdf", "txt", "html", "latex"],
|
||||
"md": ["epub", "docx", "pdf", "txt", "html", "latex"],
|
||||
"txt": ["epub", "md", "docx", "pdf", "html", "latex"],
|
||||
"html": ["epub", "md", "docx", "pdf", "txt", "latex"],
|
||||
"fb2": ["epub", "md", "docx", "pdf", "txt", "html", "latex"],
|
||||
"mobi": ["epub", "md", "docx", "pdf", "txt", "html"],
|
||||
"latex": ["epub", "md", "docx", "pdf", "txt", "html"],
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
# Bookish — Design Document
|
||||
|
||||
**Data**: 2026-07-22
|
||||
**Stato**: Validato
|
||||
|
||||
## Overview
|
||||
|
||||
Bookish è un'app Flask self-hosted per convertire eBook tra formati e visualizzarli in un reader web integrato. Uso personale, girante su server Proxmox.
|
||||
|
||||
## Stack
|
||||
|
||||
| Componente | Tecnologia |
|
||||
|---|---|
|
||||
| Backend | Flask (Python 3) |
|
||||
| Template | Jinja2 |
|
||||
| Conversione | Pandoc + WeasyPrint + python-docx |
|
||||
| Frontend reader | epub.js + pdf.js |
|
||||
| Progresso real-time | SSE (Server-Sent Events) |
|
||||
| DB opzionale | SQLite (cronologia, reading position) |
|
||||
| Storage locale | `uploads/` + `converted/` |
|
||||
|
||||
## Struttura directory
|
||||
|
||||
```
|
||||
bookish/
|
||||
├── app.py
|
||||
├── config.py
|
||||
├── requirements.txt
|
||||
├── run.sh
|
||||
├── uploads/
|
||||
├── converted/
|
||||
└── bookish/
|
||||
├── __init__.py # create_app factory
|
||||
├── routes/
|
||||
│ ├── __init__.py
|
||||
│ ├── convert.py # upload, convert, download, SSE
|
||||
│ └── reader.py # reader page, serve file
|
||||
├── services/
|
||||
│ ├── __init__.py
|
||||
│ ├── converter.py # dispatcher Pandoc/lib specialistiche
|
||||
│ ├── sse_manager.py # eventi progresso in memoria
|
||||
│ └── formatter.py # PDF/DOCX layout avanzato
|
||||
├── templates/
|
||||
│ ├── base.html
|
||||
│ ├── index.html # upload + convertitore
|
||||
│ └── reader.html # reader
|
||||
└── static/
|
||||
├── css/
|
||||
├── js/
|
||||
└── lib/ # epub.js, pdf.js
|
||||
```
|
||||
|
||||
## Formati supportati
|
||||
|
||||
| Formato | Input | Output | Engine |
|
||||
|---|---|---|---|
|
||||
| EPUB | ✅ | ✅ | Pandoc |
|
||||
| Markdown | ✅ | ✅ | Pandoc |
|
||||
| DOCX | ✅ | ✅ | Pandoc + python-docx |
|
||||
| PDF | ✅ | ✅ | Pandoc + WeasyPrint |
|
||||
| TXT | ✅ | ✅ | Pandoc |
|
||||
| HTML | ✅ | ✅ | Pandoc |
|
||||
| FB2 | ✅ | ✅ | Pandoc |
|
||||
| MOBI | ✅ | ❌ | Pandoc |
|
||||
| LaTeX | ✅ | ✅ | Pandoc |
|
||||
|
||||
## Flusso di conversione con SSE
|
||||
|
||||
1. Utente carica file + seleziona formato output
|
||||
2. Flask avvia thread conversione, restituisce `task_id`
|
||||
3. Frontend apre connessione SSE a `/progress/<task_id>`
|
||||
4. Thread conversione emette eventi progresso (20%, 40%, ... 100%)
|
||||
5. A 100%, SSE invia `download_url`
|
||||
6. Frontend mostra link download
|
||||
7. File in `converted/` puliti dopo 1h
|
||||
|
||||
Stati progresso: `started` → `analyzing` → `converting` (con %) → `finalizing` → `completed` / `error`
|
||||
|
||||
## Conversion pipeline
|
||||
|
||||
```
|
||||
convert(input_file, output_format):
|
||||
├─ INPUT non-testuali (EPUB/FB2/MOBI/HTML/LaTeX) → Pandoc per qualsiasi output
|
||||
├─ PDF in → EPUB/MD/DOCX/TXT/HTML → Pandoc
|
||||
├─ DOCX → PDF → Pandoc → HTML intermedio → WeasyPrint
|
||||
├─ MD/TXT → EPUB → Pandoc con template EPUB
|
||||
├─ DOCX → EPUB/MD/HTML → Pandoc
|
||||
├─ MD/TXT/HTML → DOCX → Pandoc + python-docx post-processing
|
||||
└─ Output PDF avanzato → WeasyPrint con template HTML custom
|
||||
```
|
||||
|
||||
## Reader
|
||||
|
||||
Route `/reader/<file_id>` carica il reader con:
|
||||
- **EPUB** → epub.js
|
||||
- **PDF** → pdf.js
|
||||
- **Formati testo** (md, txt, html, docx, fb2) → convertiti in HTML da Pandoc, renderizzati in iframe
|
||||
|
||||
### Funzionalità reader
|
||||
|
||||
| Feature | Come |
|
||||
|---|---|
|
||||
| Zoom | epub.js fontSize / pdf.js zoom / CSS scale |
|
||||
| Temi (giorno/notte/seppia) | epub.js themes / pdf.js CSS filter |
|
||||
| Font (dimensione, famiglia) | epub.js fontSize + CSS injection |
|
||||
| Ricerca full-text | epub.js search API / pdf.js findController |
|
||||
| Sommario | epub.js navigation / pdf.js outline |
|
||||
| Segnalibri | LocalStorage |
|
||||
| Salvataggio posizione | Auto-save su LocalStorage ad ogni cambio pagina |
|
||||
| Vista singola/doppia | epub.js spread mode |
|
||||
| Scorrimento continuo | epub.js continuous manager |
|
||||
| Navigazione tastiera | ← → per pagina, +/- per zoom |
|
||||
| Fullscreen | Fullscreen API |
|
||||
| Annotazioni/highlights | epub.js annotations API |
|
||||
|
||||
### Layout reader
|
||||
|
||||
- Sidebar sinistra collassabile (Indice, Ricerca, Note)
|
||||
- Barra superiore col titolo capitolo
|
||||
- Area lettura centrale
|
||||
- Toolbar inferiore con navigazione, zoom, temi, fullscreen
|
||||
- Toolbar si nasconde dopo 3s inattività
|
||||
|
||||
## Frontend convertitore
|
||||
|
||||
- Drag & drop upload
|
||||
- Griglia formati output con validazione dinamica
|
||||
- Barra progresso animata con SSE
|
||||
- Download diretto a completamento
|
||||
- Tema chiaro iniziale
|
||||
|
||||
## Gestione errori
|
||||
|
||||
- Formato input non supportato → messaggio chiaro
|
||||
- Formato output non valido → bottoni disabilitati
|
||||
- Pandoc non installato → errore all'avvio
|
||||
- File >50MB → bloccato
|
||||
- Conversione fallita → SSE evento error
|
||||
- Pulizia automatica file vecchi >1h
|
||||
|
||||
## Pre-requisiti server
|
||||
|
||||
- Python 3.10+
|
||||
- Pandoc installato via apt
|
||||
- WeasyPrint dipende da librerie di sistema (libpango, libcairo)
|
||||
@@ -0,0 +1,27 @@
|
||||
beautifulsoup4==4.15.0
|
||||
blinker==1.9.0
|
||||
brotli==1.2.0
|
||||
cffi==2.1.0
|
||||
click==8.4.2
|
||||
cssselect2==0.9.0
|
||||
Flask==3.1.3
|
||||
fonttools==4.63.0
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
lxml==6.1.1
|
||||
Markdown==3.10.2
|
||||
MarkupSafe==3.0.3
|
||||
pillow==12.3.0
|
||||
pycparser==3.0
|
||||
pydyf==0.12.1
|
||||
pypandoc_binary==1.17
|
||||
pyphen==0.17.2
|
||||
python-docx==1.2.0
|
||||
soupsieve==2.9.1
|
||||
tinycss2==1.5.1
|
||||
tinyhtml5==2.1.0
|
||||
typing_extensions==4.16.0
|
||||
weasyprint==69.0
|
||||
webencodings==0.5.1
|
||||
Werkzeug==3.1.8
|
||||
zopfli==0.4.3
|
||||
Reference in new issue
Block a user