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
This commit is contained in:
truetype committed 2026-07-23 22:35:04 +02:00
1 parent 13e02faa8b
commit c1421665c0
8 files changed
+319 -25

No files matched your search

+5
View File
@@ -17,11 +17,16 @@ def create_app():
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(CONVERTED_FOLDER, exist_ok=True)
from bookish.services.database import init_db
init_db()
from bookish.routes.convert import convert_bp
from bookish.routes.reader import reader_bp
from bookish.routes.api import api_bp
app.register_blueprint(convert_bp)
app.register_blueprint(reader_bp)
app.register_blueprint(api_bp)
@app.route("/")
def index():
+57
View File
@@ -0,0 +1,57 @@
import time
from flask import Blueprint, jsonify, request, current_app
from bookish.services.database import (
save_bookmark,
get_bookmarks,
delete_bookmark,
save_position,
get_position,
)
api_bp = Blueprint("api", __name__, url_prefix="/api")
# --- Bookmarks ---
@api_bp.route("/bookmarks/<content_hash>", methods=["GET"])
def api_get_bookmarks(content_hash):
bookmarks = get_bookmarks(content_hash)
return jsonify({"bookmarks": bookmarks})
@api_bp.route("/bookmarks", methods=["POST"])
def api_add_bookmark():
data = request.get_json(silent=True)
if not data or not data.get("content_hash") or not data.get("id"):
return jsonify({"error": "Richiesta non valida"}), 400
data["created"] = data.get("created", time.time())
save_bookmark(data)
return jsonify({"ok": True})
@api_bp.route("/bookmarks/<bookmark_id>", methods=["DELETE"])
def api_delete_bookmark(bookmark_id):
delete_bookmark(bookmark_id)
return jsonify({"ok": True})
# --- Reading positions ---
@api_bp.route("/position/<content_hash>", methods=["GET"])
def api_get_position(content_hash):
pos = get_position(content_hash)
if pos:
return jsonify({"position": pos})
return jsonify({"position": None})
@api_bp.route("/position/<content_hash>", methods=["PUT"])
def api_save_position(content_hash):
data = request.get_json(silent=True)
if not data:
return jsonify({"error": "Richiesta non valida"}), 400
data["type"] = data.get("type", "unknown")
save_position(content_hash, data)
return jsonify({"ok": True})
+8
View File
@@ -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,
})
+11 -1
View File
@@ -1,4 +1,5 @@
import os
import hashlib
from flask import Blueprint, render_template, jsonify, send_file, current_app
from config import SUPPORTED_FORMATS
@@ -12,6 +13,14 @@ def reader():
return render_template("reader.html")
def _file_hash(filepath):
h = hashlib.sha256()
with open(filepath, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
@reader_bp.route("/reader/<filename>")
def reader_file(filename):
safe = os.path.basename(filename)
@@ -25,7 +34,8 @@ def reader_file(filename):
if ext not in READABLE_FORMATS:
return jsonify({"error": f"Formato '{ext}' non supportato dal reader"}), 400
return render_template("reader.html", filename=safe, file_ext=ext)
content_hash = _file_hash(filepath)
return render_template("reader.html", filename=safe, file_ext=ext, content_hash=content_hash)
@reader_bp.route("/reader/serve/<filename>")
+122
View File
@@ -0,0 +1,122 @@
import sqlite3
import os
import time
from flask import current_app
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "bookish.db")
def _get_db():
db = sqlite3.connect(DB_PATH)
db.row_factory = sqlite3.Row
db.execute("PRAGMA journal_mode=WAL")
db.execute("PRAGMA foreign_keys=ON")
return db
def init_db():
db = _get_db()
db.executescript("""
CREATE TABLE IF NOT EXISTS bookmarks (
id TEXT PRIMARY KEY,
content_hash TEXT NOT NULL,
type TEXT NOT NULL,
cfi TEXT,
href TEXT,
page INTEGER,
total_pages INTEGER,
scroll_pct INTEGER,
label TEXT NOT NULL,
chapter_title TEXT,
created REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_bookmarks_hash ON bookmarks(content_hash);
CREATE TABLE IF NOT EXISTS reading_positions (
content_hash TEXT PRIMARY KEY,
type TEXT NOT NULL,
cfi TEXT,
href TEXT,
page INTEGER,
scroll_pct INTEGER,
chapter_title TEXT,
updated REAL NOT NULL
);
""")
db.commit()
db.close()
# --- Bookmarks ---
def save_bookmark(data):
db = _get_db()
db.execute(
"""INSERT OR REPLACE INTO bookmarks
(id, content_hash, type, cfi, href, page, total_pages, scroll_pct, label, chapter_title, created)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
data["id"],
data["content_hash"],
data["type"],
data.get("cfi"),
data.get("href"),
data.get("page"),
data.get("total_pages"),
data.get("scroll_pct"),
data["label"],
data.get("chapter_title"),
data.get("created", time.time()),
),
)
db.commit()
db.close()
def get_bookmarks(content_hash):
db = _get_db()
rows = db.execute(
"SELECT * FROM bookmarks WHERE content_hash = ? ORDER BY created ASC",
(content_hash,),
).fetchall()
db.close()
return [dict(r) for r in rows]
def delete_bookmark(bookmark_id):
db = _get_db()
db.execute("DELETE FROM bookmarks WHERE id = ?", (bookmark_id,))
db.commit()
db.close()
# --- Reading positions ---
def save_position(content_hash, data):
db = _get_db()
db.execute(
"""INSERT OR REPLACE INTO reading_positions
(content_hash, type, cfi, href, page, scroll_pct, chapter_title, updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(
content_hash,
data["type"],
data.get("cfi"),
data.get("href"),
data.get("page"),
data.get("scroll_pct"),
data.get("chapter_title"),
time.time(),
),
)
db.commit()
db.close()
def get_position(content_hash):
db = _get_db()
row = db.execute(
"SELECT * FROM reading_positions WHERE content_hash = ?", (content_hash,)
).fetchone()
db.close()
return dict(row) if row else None
+114 -24
View File
@@ -20,6 +20,11 @@
// --- Shared Reader Controls ---
initReaderControls();
// Carica segnalibri e posizione dal server (se disponibile)
if (BOOKISH_HASH) {
loadBookmarksFromServer();
}
// === EPUB Reader (epub.js) ===
function initEpubReader(file) {
const viewer = document.getElementById("epubViewer");
@@ -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) {
+1
View File
@@ -132,6 +132,7 @@
<script>
const BOOKISH_FILE = {{ filename | tojson | safe if filename else 'null' }};
const BOOKISH_EXT = {{ file_ext | tojson | safe if file_ext else 'null' }};
const BOOKISH_HASH = {{ content_hash | tojson | safe if content_hash else 'null' }};
</script>
<script src="{{ url_for('static', filename='js/reader.js') }}"></script>
{% endblock %}