"""PostgreSQL (Neon) persistence layer for books and reading progress.""" import json import os import uuid import psycopg2 import psycopg2.extras import settings def get_db(): conn = psycopg2.connect(settings.DATABASE_URL) return conn def _row_to_dict(cursor) -> dict | None: """Fetch one row as a dict.""" row = cursor.fetchone() if not row: return None cols = [desc[0] for desc in cursor.description] return dict(zip(cols, row)) def _rows_to_dicts(cursor) -> list[dict]: """Fetch all rows as dicts.""" rows = cursor.fetchall() cols = [desc[0] for desc in cursor.description] return [dict(zip(cols, row)) for row in rows] def init_db(): """Ensure upload dirs exist. Tables should be created via SQL migration.""" os.makedirs(settings.UPLOAD_DIR, exist_ok=True) def save_book(book_id: str, title: str, filepath: str, cover: str, language: str, pages: list[str], folder_id: str | None = None, segments: list[dict] | None = None, chapters: list[dict] | None = None): conn = get_db() cur = conn.cursor() cur.execute( "INSERT INTO books (id, title, filepath, cover, language, pages, total_pages, folder_id, segments, chapters) " "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) " "ON CONFLICT (id) DO UPDATE SET title=EXCLUDED.title, filepath=EXCLUDED.filepath, " "cover=EXCLUDED.cover, language=EXCLUDED.language, pages=EXCLUDED.pages, " "total_pages=EXCLUDED.total_pages, folder_id=EXCLUDED.folder_id, " "segments=EXCLUDED.segments, chapters=EXCLUDED.chapters", (book_id, title, filepath, cover, language, json.dumps(pages), len(pages), folder_id, json.dumps(segments or []), json.dumps(chapters or [])) ) cur.execute( "INSERT INTO reading_progress (book_id) VALUES (%s) ON CONFLICT (book_id) DO NOTHING", (book_id,) ) conn.commit() cur.close() conn.close() def get_all_books(folder_id: str | None = None) -> list[dict]: conn = get_db() cur = conn.cursor() if folder_id: cur.execute( "SELECT b.id, b.title, b.filepath, b.cover, b.language, b.total_pages, b.folder_id, " "COALESCE(rp.current_page, 0) as current_page, " "COALESCE(rp.percent_read, 0.0) as percent_read " "FROM books b LEFT JOIN reading_progress rp ON b.id = rp.book_id " "WHERE b.folder_id = %s ORDER BY rp.last_read_at DESC", (folder_id,) ) else: cur.execute( "SELECT b.id, b.title, b.filepath, b.cover, b.language, b.total_pages, b.folder_id, " "COALESCE(rp.current_page, 0) as current_page, " "COALESCE(rp.percent_read, 0.0) as percent_read " "FROM books b LEFT JOIN reading_progress rp ON b.id = rp.book_id " "ORDER BY rp.last_read_at DESC" ) result = _rows_to_dicts(cur) cur.close() conn.close() return result def get_book(book_id: str) -> dict | None: conn = get_db() cur = conn.cursor() cur.execute( "SELECT b.*, COALESCE(rp.current_page, 0) as current_page, " "COALESCE(rp.percent_read, 0.0) as percent_read " "FROM books b LEFT JOIN reading_progress rp ON b.id = rp.book_id " "WHERE b.id = %s", (book_id,) ) book = _row_to_dict(cur) cur.close() conn.close() if not book: return None book["pages"] = json.loads(book["pages"]) return book def get_book_page(book_id: str, page_num: int) -> dict | None: conn = get_db() cur = conn.cursor() cur.execute("SELECT pages, total_pages FROM books WHERE id = %s", (book_id,)) row = _row_to_dict(cur) cur.close() conn.close() if not row: return None pages = json.loads(row["pages"]) if page_num < 0 or page_num >= len(pages): return None return {"text": pages[page_num], "total_pages": row["total_pages"]} def update_progress(book_id: str, current_page: int): conn = get_db() cur = conn.cursor() cur.execute("SELECT total_pages FROM books WHERE id = %s", (book_id,)) row = _row_to_dict(cur) if not row: cur.close() conn.close() return total = row["total_pages"] percent = round(((current_page + 1) / total) * 100, 1) if total > 0 else 0.0 cur.execute( "UPDATE reading_progress SET current_page = %s, percent_read = %s, " "last_read_at = NOW() WHERE book_id = %s", (current_page, percent, book_id) ) conn.commit() cur.close() conn.close() def update_progress_direct(book_id: str, current_page: int, percent: float): conn = get_db() cur = conn.cursor() cur.execute( "UPDATE reading_progress SET current_page = %s, percent_read = %s, " "last_read_at = NOW() WHERE book_id = %s", (current_page, round(percent, 1), book_id) ) conn.commit() cur.close() conn.close() def rename_book(book_id: str, new_title: str): conn = get_db() cur = conn.cursor() cur.execute("UPDATE books SET title = %s WHERE id = %s", (new_title, book_id)) conn.commit() cur.close() conn.close() def update_book_cover(book_id: str, cover_path: str): conn = get_db() cur = conn.cursor() cur.execute("UPDATE books SET cover = %s WHERE id = %s", (cover_path, book_id)) conn.commit() cur.close() conn.close() def delete_book(book_id: str) -> dict | None: conn = get_db() cur = conn.cursor() cur.execute("SELECT filepath, cover FROM books WHERE id = %s", (book_id,)) row = _row_to_dict(cur) if not row: cur.close() conn.close() return None cur.execute("DELETE FROM reading_progress WHERE book_id = %s", (book_id,)) cur.execute("DELETE FROM bookmarks WHERE book_id = %s", (book_id,)) cur.execute("DELETE FROM books WHERE id = %s", (book_id,)) conn.commit() cur.close() conn.close() return row def get_progress(book_id: str) -> dict | None: conn = get_db() cur = conn.cursor() cur.execute( "SELECT current_page, percent_read, last_read_at FROM reading_progress WHERE book_id = %s", (book_id,) ) row = _row_to_dict(cur) cur.close() conn.close() return row def get_book_segments(book_id: str) -> dict | None: conn = get_db() cur = conn.cursor() cur.execute("SELECT segments, chapters, language, title FROM books WHERE id = %s", (book_id,)) row = _row_to_dict(cur) cur.close() conn.close() if not row: return None return { "segments": json.loads(row["segments"] or "[]"), "chapters": json.loads(row["chapters"] or "[]"), "language": row["language"], "title": row["title"], } # ==================== FOLDERS ==================== def create_folder(name: str) -> str: folder_id = uuid.uuid4().hex[:10] conn = get_db() cur = conn.cursor() cur.execute("INSERT INTO folders (id, name) VALUES (%s, %s)", (folder_id, name)) conn.commit() cur.close() conn.close() return folder_id def get_all_folders() -> list[dict]: conn = get_db() cur = conn.cursor() cur.execute( "SELECT f.id, f.name, COUNT(b.id) as book_count " "FROM folders f LEFT JOIN books b ON f.id = b.folder_id " "GROUP BY f.id, f.name ORDER BY f.name" ) result = _rows_to_dicts(cur) cur.close() conn.close() return result def rename_folder(folder_id: str, new_name: str): conn = get_db() cur = conn.cursor() cur.execute("UPDATE folders SET name = %s WHERE id = %s", (new_name, folder_id)) conn.commit() cur.close() conn.close() def delete_folder(folder_id: str): conn = get_db() cur = conn.cursor() cur.execute("UPDATE books SET folder_id = NULL WHERE folder_id = %s", (folder_id,)) cur.execute("DELETE FROM folders WHERE id = %s", (folder_id,)) conn.commit() cur.close() conn.close() def move_book_to_folder(book_id: str, folder_id: str | None): conn = get_db() cur = conn.cursor() cur.execute("UPDATE books SET folder_id = %s WHERE id = %s", (folder_id, book_id)) conn.commit() cur.close() conn.close() # ==================== BOOKMARKS ==================== def get_bookmarks(book_id: str) -> list[dict]: conn = get_db() cur = conn.cursor() cur.execute( "SELECT id, name, segment_index, created_at FROM bookmarks " "WHERE book_id = %s ORDER BY segment_index", (book_id,) ) result = _rows_to_dicts(cur) cur.close() conn.close() return result def add_bookmark(book_id: str, name: str, segment_index: int) -> int: conn = get_db() cur = conn.cursor() cur.execute( "INSERT INTO bookmarks (book_id, name, segment_index) VALUES (%s, %s, %s) RETURNING id", (book_id, name, segment_index) ) bm_id = cur.fetchone()[0] conn.commit() cur.close() conn.close() return bm_id def rename_bookmark(bookmark_id: int, new_name: str): conn = get_db() cur = conn.cursor() cur.execute("UPDATE bookmarks SET name = %s WHERE id = %s", (new_name, bookmark_id)) conn.commit() cur.close() conn.close() def delete_bookmark(bookmark_id: int): conn = get_db() cur = conn.cursor() cur.execute("DELETE FROM bookmarks WHERE id = %s", (bookmark_id,)) conn.commit() cur.close() conn.close() # ==================== LAST READ ==================== def get_last_read_book() -> dict | None: conn = get_db() cur = conn.cursor() cur.execute( "SELECT b.id, rp.percent_read FROM books b " "JOIN reading_progress rp ON b.id = rp.book_id " "WHERE rp.percent_read > 0 " "ORDER BY rp.last_read_at DESC LIMIT 1" ) row = _row_to_dict(cur) cur.close() conn.close() return row