Initial commit: Bookish Flask app for ebook conversion and reading

This commit is contained in:
truetype committed 2026-07-22 15:45:07 +02:00
commit 32245f76ac
20 files changed
+2388

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
.venv/
*.pyc
__pycache__/
uploads/
converted/
.venv.bak/
+25
View File
@@ -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)
+32
View File
@@ -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
View File
Whitespace-only changes.
+136
View File
@@ -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},
})
+48
View File
@@ -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},
})
View File
Whitespace-only changes.
+170
View File
@@ -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
+55
View File
@@ -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
+75
View File
@@ -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)
+605
View File
@@ -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;
}
}
+226
View File
@@ -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
}
}
})();
+597
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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);
};
}
})();
+28
View File
@@ -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>
+74
View File
@@ -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 %}
+96
View File
@@ -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 %}
+38
View File
@@ -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"],
}
+145
View File
@@ -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)
+27
View File
@@ -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
Executable
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Avvia Bookish con virtualenv
DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$DIR" || exit 1
.venv/bin/python app.py "${1:-5000}"