commit 32245f76ac7fcaac91f5396ddb89bed66ce2bc0f Author: truetype Date: Wed Jul 22 15:45:07 2026 +0200 Initial commit: Bookish Flask app for ebook conversion and reading diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..63ed8ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +*.pyc +__pycache__/ +uploads/ +converted/ +.venv.bak/ diff --git a/app.py b/app.py new file mode 100644 index 0000000..9563214 --- /dev/null +++ b/app.py @@ -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) diff --git a/bookish/__init__.py b/bookish/__init__.py new file mode 100644 index 0000000..9c5066a --- /dev/null +++ b/bookish/__init__.py @@ -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 diff --git a/bookish/routes/__init__.py b/bookish/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bookish/routes/convert.py b/bookish/routes/convert.py new file mode 100644 index 0000000..d87a7a0 --- /dev/null +++ b/bookish/routes/convert.py @@ -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/") +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/") +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}, + }) diff --git a/bookish/routes/reader.py b/bookish/routes/reader.py new file mode 100644 index 0000000..5387dc0 --- /dev/null +++ b/bookish/routes/reader.py @@ -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/") +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/") +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}, + }) diff --git a/bookish/services/__init__.py b/bookish/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bookish/services/converter.py b/bookish/services/converter.py new file mode 100644 index 0000000..50bbc62 --- /dev/null +++ b/bookish/services/converter.py @@ -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"" not in content: + pass + except Exception: + pass diff --git a/bookish/services/formatter.py b/bookish/services/formatter.py new file mode 100644 index 0000000..1beb1b5 --- /dev/null +++ b/bookish/services/formatter.py @@ -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 " { + "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 + } + } +})(); diff --git a/bookish/static/js/reader.js b/bookish/static/js/reader.js new file mode 100644 index 0000000..e05ae9b --- /dev/null +++ b/bookish/static/js/reader.js @@ -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 = `
${escapeHtml(text)}
`; + } + }); + } 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, ">") + // Images + .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1') + // Links + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + // Bold + .replace(/\*\*([^*]+)\*\*/g, "$1") + // Italic + .replace(/\*([^*]+)\*/g, "$1") + // Inline code + .replace(/`([^`]+)`/g, "$1") + // Headings + .replace(/^### (.+)$/gm, "

$1

") + .replace(/^## (.+)$/gm, "

$1

") + .replace(/^# (.+)$/gm, "

$1

") + // Horizontal rule + .replace(/^---$/gm, "
") + // Blockquote + .replace(/^> (.+)$/gm, "
$1
") + // Paragraphs (double newline) + .replace(/\n\n/g, "

") + // Line breaks + .replace(/\n/g, "
"); + + return `

${html}

`; + } + + function escapeHtml(text) { + return text.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 = "

Nessun risultato

"; + 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 = "

Ricerca non disponibile per questo formato

"; + } + } + + let pdfSearchTimer = null; + + function pdfSearch(query, resultsEl) { + const pdf = window._pdfViewer.pdfDoc; + resultsEl.innerHTML = "

Ricerca in corso...

"; + + 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 = "

Nessun risultato

"; + 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) => + `
  • ${f.name} ${f.format}
  • ` + ) + .join(""); + } + } + } + + // === Utils === + function debounce(fn, ms) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), ms); + }; + } +})(); diff --git a/bookish/templates/base.html b/bookish/templates/base.html new file mode 100644 index 0000000..f114285 --- /dev/null +++ b/bookish/templates/base.html @@ -0,0 +1,28 @@ + + + + + + {% block title %}Bookish{% endblock %} + + {% block head %}{% endblock %} + + + + +
    + {% block content %}{% endblock %} +
    + + + {% block scripts %}{% endblock %} + + diff --git a/bookish/templates/index.html b/bookish/templates/index.html new file mode 100644 index 0000000..631d7ef --- /dev/null +++ b/bookish/templates/index.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} + +{% block title %}Bookish β€” Convertitore{% endblock %} + +{% block content %} +
    +

    Converti ebook

    +

    Carica un file e scegli il formato di destinazione

    + +
    + +
    +
    + πŸ“– +

    Trascina qui il tuo file

    +

    oppure clicca per selezionare

    +
    + +
    + + + + + + + + + +
    + + + + + + + + + +
    +{% endblock %} + +{% block head %} + +{% endblock %} diff --git a/bookish/templates/reader.html b/bookish/templates/reader.html new file mode 100644 index 0000000..b4a3102 --- /dev/null +++ b/bookish/templates/reader.html @@ -0,0 +1,96 @@ +{% extends "base.html" %} + +{% block title %}Bookish β€” Reader{% endblock %} + +{% block content %} +
    + {% if filename %} +
    +
    + + Caricamento... +
    +
    + -- / -- +
    +
    + + + + + +
    +
    + +
    + + +
    + {% if file_ext == 'epub' %} +
    + {% elif file_ext == 'pdf' %} +
    + {% else %} +
    + {% endif %} +
    +
    + {% else %} +
    +

    Reader

    +

    Carica o seleziona un file da leggere

    + +
    +
    +
    +
    + πŸ“– +

    Trascina qui un ebook

    +

    oppure clicca per selezionare

    +
    + +
    +
    +
    + + +
    + {% endif %} +
    +{% endblock %} + +{% block scripts %} +{% if file_ext == 'epub' %} + + +{% elif file_ext == 'pdf' %} + +{% endif %} + + +{% endblock %} diff --git a/config.py b/config.py new file mode 100644 index 0000000..651a74a --- /dev/null +++ b/config.py @@ -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"], +} diff --git a/docs/plans/2026-07-22-bookish-design.md b/docs/plans/2026-07-22-bookish-design.md new file mode 100644 index 0000000..3290e88 --- /dev/null +++ b/docs/plans/2026-07-22-bookish-design.md @@ -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/` +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/` 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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c49370e --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..88edf46 --- /dev/null +++ b/run.sh @@ -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}"