Files
RSSuper/linux/src/database/database.vala
Michael Freno 14efe072fa feat: implement cross-platform features and UI integration
- iOS: Add BackgroundSyncService, SyncScheduler, SyncWorker, BookmarkViewModel, FeedViewModel
- iOS: Add BackgroundSyncService, SyncScheduler, SyncWorker services
- Linux: Add settings-store.vala, State.vala signals, view widgets (FeedList, FeedDetail, AddFeed, Search, Settings, Bookmark)
- Linux: Add bookmark-store.vala, bookmark vala model, search-service.vala
- Android: Add NotificationService, NotificationManager, NotificationPreferencesStore
- Android: Add BookmarkDao, BookmarkRepository, SettingsStore
- Add unit tests for iOS, Android, Linux
- Add integration tests
- Add performance benchmarks
- Update tasks and documentation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 23:06:12 -04:00

205 lines
8.2 KiB
Vala

/*
* 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<T>(GLib.List<T> list) {
T[] arr = {};
for (unowned var node = list; node != null; node = node.next) {
arr += node.data;
}
return arr;
}
}