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:
1 parent
13e02faa8b
commit
c1421665c0
8 files changed
+319
-25
No files matched your search
@@ -5,3 +5,4 @@ uploads/
|
||||
converted/
|
||||
.venv.bak/
|
||||
.sisyphus/
|
||||
bookish.db
|
||||
@@ -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():
|
||||
|
||||
@@ -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})
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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>")
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 %}
|
||||
Reference in new issue
Block a user