From c1421665c03043982ae5d47bb00ab5f73f1db00c Mon Sep 17 00:00:00 2001 From: truetype Date: Thu, 23 Jul 2026 22:35:04 +0200 Subject: [PATCH] feat: persistent SQLite storage for bookmarks + positions keyed by content_hash - new bookish/services/database.py: SQLite with WAL mode, tables for bookmarks and reading_positions, keyed by content_hash (SHA256) - new bookish/routes/api.py: REST endpoints for CRUD bookmarks and save/load reading position - upload endpoint returns content_hash (SHA256 of file) - reader route computes SHA256 and passes BOOKISH_HASH to template - reader.js: bookmarks + positions use API server as primary storage with localStorage fallback for offline/resilience - position saves debounced (1.5s) to avoid hammering server - .gitignore: added bookish.db --- .gitignore | 1 + bookish/__init__.py | 5 ++ bookish/routes/api.py | 57 ++++++++++++++ bookish/routes/convert.py | 8 ++ bookish/routes/reader.py | 12 ++- bookish/services/database.py | 122 ++++++++++++++++++++++++++++++ bookish/static/js/reader.js | 138 ++++++++++++++++++++++++++++------ bookish/templates/reader.html | 1 + 8 files changed, 319 insertions(+), 25 deletions(-) create mode 100644 bookish/routes/api.py create mode 100644 bookish/services/database.py diff --git a/.gitignore b/.gitignore index b587a59..b9e62f6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ uploads/ converted/ .venv.bak/ .sisyphus/ +bookish.db diff --git a/bookish/__init__.py b/bookish/__init__.py index 9c5066a..1473f10 100644 --- a/bookish/__init__.py +++ b/bookish/__init__.py @@ -17,11 +17,16 @@ def create_app(): os.makedirs(UPLOAD_FOLDER, exist_ok=True) os.makedirs(CONVERTED_FOLDER, exist_ok=True) + from bookish.services.database import init_db + init_db() + from bookish.routes.convert import convert_bp from bookish.routes.reader import reader_bp + from bookish.routes.api import api_bp app.register_blueprint(convert_bp) app.register_blueprint(reader_bp) + app.register_blueprint(api_bp) @app.route("/") def index(): diff --git a/bookish/routes/api.py b/bookish/routes/api.py new file mode 100644 index 0000000..74a613c --- /dev/null +++ b/bookish/routes/api.py @@ -0,0 +1,57 @@ +import time +from flask import Blueprint, jsonify, request, current_app +from bookish.services.database import ( + save_bookmark, + get_bookmarks, + delete_bookmark, + save_position, + get_position, +) + +api_bp = Blueprint("api", __name__, url_prefix="/api") + + +# --- Bookmarks --- + +@api_bp.route("/bookmarks/", methods=["GET"]) +def api_get_bookmarks(content_hash): + bookmarks = get_bookmarks(content_hash) + return jsonify({"bookmarks": bookmarks}) + + +@api_bp.route("/bookmarks", methods=["POST"]) +def api_add_bookmark(): + data = request.get_json(silent=True) + if not data or not data.get("content_hash") or not data.get("id"): + return jsonify({"error": "Richiesta non valida"}), 400 + + data["created"] = data.get("created", time.time()) + save_bookmark(data) + return jsonify({"ok": True}) + + +@api_bp.route("/bookmarks/", methods=["DELETE"]) +def api_delete_bookmark(bookmark_id): + delete_bookmark(bookmark_id) + return jsonify({"ok": True}) + + +# --- Reading positions --- + +@api_bp.route("/position/", methods=["GET"]) +def api_get_position(content_hash): + pos = get_position(content_hash) + if pos: + return jsonify({"position": pos}) + return jsonify({"position": None}) + + +@api_bp.route("/position/", methods=["PUT"]) +def api_save_position(content_hash): + data = request.get_json(silent=True) + if not data: + return jsonify({"error": "Richiesta non valida"}), 400 + + data["type"] = data.get("type", "unknown") + save_position(content_hash, data) + return jsonify({"ok": True}) diff --git a/bookish/routes/convert.py b/bookish/routes/convert.py index c55f70e..6ac15ae 100644 --- a/bookish/routes/convert.py +++ b/bookish/routes/convert.py @@ -1,5 +1,6 @@ import os import uuid +import hashlib import logging import threading from flask import Blueprint, request, jsonify, Response, send_file, render_template, current_app @@ -58,6 +59,12 @@ def upload(): input_path = os.path.join(current_app.config["UPLOAD_FOLDER"], unique_name) file.save(input_path) + sha256_hash = hashlib.sha256() + with open(input_path, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + sha256_hash.update(chunk) + content_hash = sha256_hash.hexdigest() + output_format = request.form.get("output_format", "").lower() if output_format not in SUPPORTED_FORMATS: return jsonify({"error": f"Formato output '{output_format}' non supportato"}), 400 @@ -89,6 +96,7 @@ def upload(): "output_format": output_format, "filename": filename, "stored_filename": unique_name, + "content_hash": content_hash, }) diff --git a/bookish/routes/reader.py b/bookish/routes/reader.py index 5387dc0..541c3be 100644 --- a/bookish/routes/reader.py +++ b/bookish/routes/reader.py @@ -1,4 +1,5 @@ import os +import hashlib from flask import Blueprint, render_template, jsonify, send_file, current_app from config import SUPPORTED_FORMATS @@ -12,6 +13,14 @@ def reader(): return render_template("reader.html") +def _file_hash(filepath): + h = hashlib.sha256() + with open(filepath, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + @reader_bp.route("/reader/") def reader_file(filename): safe = os.path.basename(filename) @@ -25,7 +34,8 @@ def reader_file(filename): if ext not in READABLE_FORMATS: return jsonify({"error": f"Formato '{ext}' non supportato dal reader"}), 400 - return render_template("reader.html", filename=safe, file_ext=ext) + content_hash = _file_hash(filepath) + return render_template("reader.html", filename=safe, file_ext=ext, content_hash=content_hash) @reader_bp.route("/reader/serve/") diff --git a/bookish/services/database.py b/bookish/services/database.py new file mode 100644 index 0000000..d7c105c --- /dev/null +++ b/bookish/services/database.py @@ -0,0 +1,122 @@ +import sqlite3 +import os +import time +from flask import current_app + +DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "bookish.db") + + +def _get_db(): + db = sqlite3.connect(DB_PATH) + db.row_factory = sqlite3.Row + db.execute("PRAGMA journal_mode=WAL") + db.execute("PRAGMA foreign_keys=ON") + return db + + +def init_db(): + db = _get_db() + db.executescript(""" + CREATE TABLE IF NOT EXISTS bookmarks ( + id TEXT PRIMARY KEY, + content_hash TEXT NOT NULL, + type TEXT NOT NULL, + cfi TEXT, + href TEXT, + page INTEGER, + total_pages INTEGER, + scroll_pct INTEGER, + label TEXT NOT NULL, + chapter_title TEXT, + created REAL NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_bookmarks_hash ON bookmarks(content_hash); + + CREATE TABLE IF NOT EXISTS reading_positions ( + content_hash TEXT PRIMARY KEY, + type TEXT NOT NULL, + cfi TEXT, + href TEXT, + page INTEGER, + scroll_pct INTEGER, + chapter_title TEXT, + updated REAL NOT NULL + ); + """) + db.commit() + db.close() + + +# --- Bookmarks --- + +def save_bookmark(data): + db = _get_db() + db.execute( + """INSERT OR REPLACE INTO bookmarks + (id, content_hash, type, cfi, href, page, total_pages, scroll_pct, label, chapter_title, created) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + data["id"], + data["content_hash"], + data["type"], + data.get("cfi"), + data.get("href"), + data.get("page"), + data.get("total_pages"), + data.get("scroll_pct"), + data["label"], + data.get("chapter_title"), + data.get("created", time.time()), + ), + ) + db.commit() + db.close() + + +def get_bookmarks(content_hash): + db = _get_db() + rows = db.execute( + "SELECT * FROM bookmarks WHERE content_hash = ? ORDER BY created ASC", + (content_hash,), + ).fetchall() + db.close() + return [dict(r) for r in rows] + + +def delete_bookmark(bookmark_id): + db = _get_db() + db.execute("DELETE FROM bookmarks WHERE id = ?", (bookmark_id,)) + db.commit() + db.close() + + +# --- Reading positions --- + +def save_position(content_hash, data): + db = _get_db() + db.execute( + """INSERT OR REPLACE INTO reading_positions + (content_hash, type, cfi, href, page, scroll_pct, chapter_title, updated) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + ( + content_hash, + data["type"], + data.get("cfi"), + data.get("href"), + data.get("page"), + data.get("scroll_pct"), + data.get("chapter_title"), + time.time(), + ), + ) + db.commit() + db.close() + + +def get_position(content_hash): + db = _get_db() + row = db.execute( + "SELECT * FROM reading_positions WHERE content_hash = ?", (content_hash,) + ).fetchone() + db.close() + return dict(row) if row else None diff --git a/bookish/static/js/reader.js b/bookish/static/js/reader.js index 7b7a3c0..2aecf1f 100644 --- a/bookish/static/js/reader.js +++ b/bookish/static/js/reader.js @@ -20,6 +20,11 @@ // --- Shared Reader Controls --- initReaderControls(); + // Carica segnalibri e posizione dal server (se disponibile) + if (BOOKISH_HASH) { + loadBookmarksFromServer(); + } + // === EPUB Reader (epub.js) === function initEpubReader(file) { const viewer = document.getElementById("epubViewer"); @@ -544,30 +549,67 @@ }, 500); } - // === Position Save/Restore === + // === Position Save/Restore (server + localStorage fallback) === + + let _positionSaveTimer = null; + function savePosition(book, loc) { + if (!loc || !loc.start) return; + const data = { + type: "epub", + cfi: loc.start.cfi, + href: loc.start.href, + chapter_title: document.getElementById("chapterTitle").textContent, + }; + + // Fallback localStorage try { - const key = `bookish_pos_${book.url() || "unknown"}`; - const data = { - cfi: loc.start ? loc.start.cfi : null, - chapter: loc.start ? loc.start.href : null, - timestamp: Date.now(), - }; - localStorage.setItem(key, JSON.stringify(data)); + const key = "bookish_pos_" + (BOOKISH_HASH || BOOKISH_FILE || "unknown"); + localStorage.setItem(key, JSON.stringify({ ...data, timestamp: Date.now() })); } catch (e) {} + + // Server (debounced) + if (_positionSaveTimer) clearTimeout(_positionSaveTimer); + _positionSaveTimer = setTimeout(() => { + if (BOOKISH_HASH) { + fetch(`/api/position/${encodeURIComponent(BOOKISH_HASH)}`, { + method: "PUT", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(data), + }).catch(() => {}); + } + }, 1500); } - function restorePosition(book, rendition, filename) { - try { - const key = `bookish_pos_${filename}`; - const saved = localStorage.getItem(key); - if (saved) { - const data = JSON.parse(saved); - if (data.cfi) { - rendition.display(data.cfi); + async function restorePosition(book, rendition, filename) { + let cfi = null; + + // Prova server + if (BOOKISH_HASH) { + try { + const resp = await fetch(`/api/position/${encodeURIComponent(BOOKISH_HASH)}`); + const data = await resp.json(); + if (data.position && data.position.cfi) { + cfi = data.position.cfi; } - } - } catch (e) {} + } catch (e) {} + } + + // Fallback localStorage + if (!cfi) { + try { + const key = "bookish_pos_" + (BOOKISH_HASH || BOOKISH_FILE || filename); + const saved = localStorage.getItem(key); + if (saved) { + const data = JSON.parse(saved); + cfi = data.cfi; + } + } catch (e) {} + } + + if (cfi) { + rendition.display(cfi); + } } // === Reader Upload === @@ -656,10 +698,10 @@ // === Bookmarks === function getBookmarksKey() { - return "bookish_bookmarks_" + (BOOKISH_FILE || ""); + return "bookish_bookmarks_" + (BOOKISH_HASH || BOOKISH_FILE || ""); } - function getBookmarks() { + function getBookmarksLocal() { try { return JSON.parse(localStorage.getItem(getBookmarksKey()) || "[]"); } catch (e) { @@ -667,12 +709,52 @@ } } - function saveBookmarks(list) { + function saveBookmarksLocal(list) { try { localStorage.setItem(getBookmarksKey(), JSON.stringify(list)); } catch (e) {} } + let _bookmarksCache = null; + + async function loadBookmarksFromServer() { + if (!BOOKISH_HASH) return; + try { + const resp = await fetch(`/api/bookmarks/${encodeURIComponent(BOOKISH_HASH)}`); + const data = await resp.json(); + if (data.bookmarks && data.bookmarks.length > 0) { + _bookmarksCache = data.bookmarks; + saveBookmarksLocal(data.bookmarks); + } + } catch (e) { + // Server unreachable — localStorage fallback handled in getBookmarks() + } + } + + function getBookmarks() { + return _bookmarksCache || getBookmarksLocal(); + } + + async function syncBookmarkToServer(bm) { + if (!BOOKISH_HASH) return; + try { + await fetch("/api/bookmarks", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(bm), + }); + } catch (e) { + // Silently fail — data is safe in localStorage + } + } + + async function syncDeleteToServer(id) { + if (!BOOKISH_HASH) return; + try { + await fetch(`/api/bookmarks/${encodeURIComponent(id)}`, { method: "DELETE" }); + } catch (e) {} + } + function addBookmark() { const label = document.getElementById("chapterTitle"); const chapter = label ? label.textContent : "Posizione corrente"; @@ -709,12 +791,17 @@ } data.id = Date.now().toString(36) + Math.random().toString(36).slice(2, 5); + data.chapter_title = chapter; + data.content_hash = BOOKISH_HASH || ""; + const bookmarks = getBookmarks(); bookmarks.push(data); - saveBookmarks(bookmarks); + saveBookmarksLocal(bookmarks); + _bookmarksCache = bookmarks; renderBookmarks(); - // Feedback visivo + syncBookmarkToServer(data); + flashBookmarkBtn(); showToast("Segnalibro aggiunto — vedi la scheda Segnalibri nella sidebar"); } @@ -729,8 +816,11 @@ function removeBookmark(id) { let bookmarks = getBookmarks(); bookmarks = bookmarks.filter((b) => b.id !== id); - saveBookmarks(bookmarks); + saveBookmarksLocal(bookmarks); + _bookmarksCache = bookmarks; renderBookmarks(); + + syncDeleteToServer(id); } function gotoBookmark(bm) { diff --git a/bookish/templates/reader.html b/bookish/templates/reader.html index c0cd470..fb7fcb8 100644 --- a/bookish/templates/reader.html +++ b/bookish/templates/reader.html @@ -132,6 +132,7 @@ {% endblock %}