/* * Database.vala * * Core database connection and migration management for RSSuper Linux. * Uses SQLite with FTS5 for full-text search capabilities. */ /** * Database - Manages SQLite database connection and migrations */ public class RSSuper.Database : Object { private Sqlite.Database db; private string db_path; /** * Current database schema version */ public const int CURRENT_VERSION = 4; /** * Signal emitted when database is ready */ public signal void ready(); /** * Signal emitted on error */ public signal void error(string message); /** * Create a new database connection * * @param db_path Path to the SQLite database file */ public Database(string db_path) throws Error { this.db_path = db_path; this.open(); this.migrate(); } /** * Open database connection */ private void open() throws Error { var file = File.new_for_path(db_path); var parent = file.get_parent(); if (parent != null && !parent.query_exists()) { try { parent.make_directory_with_parents(); } catch (Error e) { throw new DBError.FAILED("Failed to create database directory: %s", e.message); } } int result = Sqlite.Database.open(db_path, out db); if (result != Sqlite.OK) { throw new DBError.FAILED("Failed to open database: %s".printf(db.errmsg())); } execute("PRAGMA foreign_keys = ON;"); execute("PRAGMA journal_mode = WAL;"); debug("Database opened: %s", db_path); } /** * Run database migrations */ private void migrate() throws Error { // Create schema_migrations table if not exists execute("CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')));"); // Create feed_subscriptions table execute("CREATE TABLE IF NOT EXISTS feed_subscriptions (id TEXT PRIMARY KEY, url TEXT NOT NULL UNIQUE, title TEXT NOT NULL, category TEXT, enabled INTEGER NOT NULL DEFAULT 1, fetch_interval INTEGER NOT NULL DEFAULT 60, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_fetched_at TEXT, next_fetch_at TEXT, error TEXT, http_auth_username TEXT, http_auth_password TEXT);"); // Create feed_items table execute("CREATE TABLE IF NOT EXISTS feed_items (id TEXT PRIMARY KEY, subscription_id TEXT NOT NULL, title TEXT NOT NULL, link TEXT, description TEXT, content TEXT, author TEXT, published TEXT, updated TEXT, categories TEXT, enclosure_url TEXT, enclosure_type TEXT, enclosure_length TEXT, guid TEXT, is_read INTEGER NOT NULL DEFAULT 0, is_starred INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (subscription_id) REFERENCES feed_subscriptions(id) ON DELETE CASCADE);"); // Create indexes for feed_items execute("CREATE INDEX IF NOT EXISTS idx_feed_items_subscription ON feed_items(subscription_id);"); execute("CREATE INDEX IF NOT EXISTS idx_feed_items_published ON feed_items(published DESC);"); execute("CREATE INDEX IF NOT EXISTS idx_feed_items_read ON feed_items(is_read);"); execute("CREATE INDEX IF NOT EXISTS idx_feed_items_starred ON feed_items(is_starred);"); // Create search_history table execute("CREATE TABLE IF NOT EXISTS search_history (id INTEGER PRIMARY KEY AUTOINCREMENT, query TEXT NOT NULL, filters_json TEXT, sort_option TEXT NOT NULL DEFAULT 'relevance', page INTEGER NOT NULL DEFAULT 1, page_size INTEGER NOT NULL DEFAULT 20, result_count INTEGER, created_at TEXT NOT NULL DEFAULT (datetime('now')));"); execute("CREATE INDEX IF NOT EXISTS idx_search_history_created ON search_history(created_at DESC);"); // Create bookmarks table execute("CREATE TABLE IF NOT EXISTS bookmarks (id TEXT PRIMARY KEY, feed_item_id TEXT NOT NULL, title TEXT NOT NULL, link TEXT, description TEXT, content TEXT, created_at TEXT NOT NULL, tags TEXT, FOREIGN KEY (feed_item_id) REFERENCES feed_items(id) ON DELETE CASCADE);"); execute("CREATE INDEX IF NOT EXISTS idx_bookmarks_feed_item_id ON bookmarks(feed_item_id);"); // Create FTS5 virtual table execute("CREATE VIRTUAL TABLE IF NOT EXISTS feed_items_fts USING fts5(title, description, content, author, content='feed_items', content_rowid='rowid');"); // Create triggers for FTS sync execute("CREATE TRIGGER IF NOT EXISTS feed_items_ai AFTER INSERT ON feed_items BEGIN INSERT INTO feed_items_fts(rowid, title, description, content, author) VALUES (new.rowid, new.title, new.description, new.content, new.author); END;"); execute("CREATE TRIGGER IF NOT EXISTS feed_items_ad AFTER DELETE ON feed_items BEGIN INSERT INTO feed_items_fts(feed_items_fts, rowid, title, description, content, author) VALUES('delete', old.rowid, old.title, old.description, old.content, old.author); END;"); execute("CREATE TRIGGER IF NOT EXISTS feed_items_au AFTER UPDATE ON feed_items BEGIN INSERT INTO feed_items_fts(feed_items_fts, rowid, title, description, content, author) VALUES('delete', old.rowid, old.title, old.description, old.content, old.author); INSERT INTO feed_items_fts(rowid, title, description, content, author) VALUES (new.rowid, new.title, new.description, new.content, new.author); END;"); // Record migration execute("INSERT OR REPLACE INTO schema_migrations (version, applied_at) VALUES (" + CURRENT_VERSION.to_string() + ", datetime('now'));"); debug("Database migrated to version %d", CURRENT_VERSION); } /** * Get current migration version */ private int get_current_version() throws Error { try { Sqlite.Statement stmt; int result = db.prepare_v2("SELECT COALESCE(MAX(version), 0) FROM schema_migrations;", -1, out stmt, null); if (result != Sqlite.OK) { throw new DBError.FAILED("Failed to prepare statement: %s".printf(db.errmsg())); } int version = 0; if (stmt.step() == Sqlite.ROW) { version = stmt.column_int(0); } return version; } catch (Error e) { throw new DBError.FAILED("Failed to get migration version: %s".printf(e.message)); } } /** * Execute a SQL statement */ public void execute(string sql) throws Error { string? errmsg; int result = db.exec(sql, null, out errmsg); if (result != Sqlite.OK) { throw new DBError.FAILED("SQL execution failed: %s\nSQL: %s".printf(errmsg, sql)); } } /** * Prepare a SQL statement */ public Sqlite.Statement prepare(string sql) throws Error { Sqlite.Statement stmt; int result = db.prepare_v2(sql, -1, out stmt, null); if (result != Sqlite.OK) { throw new DBError.FAILED("Failed to prepare statement: %s\nSQL: %s".printf(db.errmsg(), sql)); } return stmt; } /** * Get the database connection handle */ public unowned Sqlite.Database get_handle() { return db; } /** * Close database connection */ public void close() { if (db != null) { db = null; debug("Database closed: %s", db_path); } } /** * Begin a transaction */ public void begin_transaction() throws Error { execute("BEGIN TRANSACTION;"); } /** * Commit a transaction */ public void commit() throws Error { execute("COMMIT;"); } /** * Rollback a transaction */ public void rollback() throws Error { execute("ROLLBACK;"); } /* Helper to convert GLib.List to array */ private T[] toArray(GLib.List list) { T[] arr = {}; for (unowned var node = list; node != null; node = node.next) { arr += node.data; } return arr; } }