/* * FeedItemStore.vala * * CRUD operations for feed items with FTS search support. */ /** * FeedItemStore - Manages feed item persistence */ public class RSSuper.FeedItemStore : Object { private Database db; /** * Signal emitted when an item is added */ public signal void item_added(FeedItem item); /** * Signal emitted when an item is updated */ public signal void item_updated(FeedItem item); /** * Signal emitted when an item is deleted */ public signal void item_deleted(string id); /** * Create a new feed item store */ public FeedItemStore(Database db) { this.db = db; } /** * Add a new feed item */ public FeedItem add(FeedItem item) throws Error { var stmt = db.prepare( "INSERT INTO feed_items (id, subscription_id, title, link, description, content, " + "author, published, updated, categories, enclosure_url, enclosure_type, " + "enclosure_length, guid, is_read, is_starred) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);" ); stmt.bind_text(1, item.id, -1, null); stmt.bind_text(2, item.subscription_title ?? "", -1, null); stmt.bind_text(3, item.title, -1, null); stmt.bind_text(4, item.link ?? "", -1, null); stmt.bind_text(5, item.description ?? "", -1, null); stmt.bind_text(6, item.content ?? "", -1, null); stmt.bind_text(7, item.author ?? "", -1, null); stmt.bind_text(8, item.published ?? "", -1, null); stmt.bind_text(9, item.updated ?? "", -1, null); stmt.bind_text(10, format_categories(item.categories), -1, null); stmt.bind_text(11, item.enclosure_url ?? "", -1, null); stmt.bind_text(12, item.enclosure_type ?? "", -1, null); stmt.bind_text(13, item.enclosure_length ?? "", -1, null); stmt.bind_text(14, item.guid ?? "", -1, null); stmt.bind_int(15, 0); // is_read stmt.bind_int(16, 0); // is_starred stmt.step(); debug("Feed item added: %s", item.id); item_added(item); return item; } /** * Add multiple items in a batch */ public void add_batch(FeedItem[] items) throws Error { db.begin_transaction(); try { foreach (var item in items) { add(item); } db.commit(); debug("Batch insert completed: %d items", items.length); } catch (Error e) { db.rollback(); throw new DBError.FAILED("Transaction failed: %s".printf(e.message)); } } /** * Get an item by ID */ public FeedItem? get_by_id(string id) throws Error { var stmt = db.prepare( "SELECT id, subscription_id, title, link, description, content, author, " + "published, updated, categories, enclosure_url, enclosure_type, " + "enclosure_length, guid, is_read, is_starred " + "FROM feed_items WHERE id = ?;" ); stmt.bind_text(1, id, -1, null); if (stmt.step() == Sqlite.ROW) { return row_to_item(stmt); } return null; } /** * Get items by subscription ID */ public FeedItem[] get_by_subscription(string subscription_id) throws Error { var items = new GLib.List(); var stmt = db.prepare( "SELECT id, subscription_id, title, link, description, content, author, " + "published, updated, categories, enclosure_url, enclosure_type, " + "enclosure_length, guid, is_read, is_starred " + "FROM feed_items WHERE subscription_id = ? " + "ORDER BY published DESC LIMIT 100;" ); stmt.bind_text(1, subscription_id, -1, null); while (stmt.step() == Sqlite.ROW) { var item = row_to_item(stmt); if (item != null) { items.append(item); } } return items_to_array(items); } /** * Get all items */ public FeedItem[] get_all() throws Error { var items = new GLib.List(); var stmt = db.prepare( "SELECT id, subscription_id, title, link, description, content, author, " + "published, updated, categories, enclosure_url, enclosure_type, " + "enclosure_length, guid, is_read, is_starred " + "FROM feed_items ORDER BY published DESC LIMIT 1000;" ); while (stmt.step() == Sqlite.ROW) { var item = row_to_item(stmt); if (item != null) { items.append(item); } } return items_to_array(items); } /** * Search items using FTS */ public FeedItem[] search(string query, int limit = 50) throws Error { var items = new GLib.List(); var stmt = db.prepare( "SELECT f.id, f.subscription_id, f.title, f.link, f.description, f.content, " + "f.author, f.published, f.updated, f.categories, f.enclosure_url, " + "f.enclosure_type, f.enclosure_length, f.guid, f.is_read, f.is_starred " + "FROM feed_items_fts t " + "JOIN feed_items f ON t.rowid = f.rowid " + "WHERE feed_items_fts MATCH ? " + "ORDER BY rank " + "LIMIT ?;" ); stmt.bind_text(1, query, -1, null); stmt.bind_int(2, limit); while (stmt.step() == Sqlite.ROW) { var item = row_to_item(stmt); if (item != null) { items.append(item); } } return items_to_array(items); } /** * Mark an item as read */ public void mark_as_read(string id) throws Error { var stmt = db.prepare("UPDATE feed_items SET is_read = 1 WHERE id = ?;"); stmt.bind_text(1, id, -1, null); stmt.step(); debug("Item marked as read: %s", id); } /** * Mark an item as unread */ public void mark_as_unread(string id) throws Error { var stmt = db.prepare("UPDATE feed_items SET is_read = 0 WHERE id = ?;"); stmt.bind_text(1, id, -1, null); stmt.step(); debug("Item marked as unread: %s", id); } /** * Mark an item as starred */ public void mark_as_starred(string id) throws Error { var stmt = db.prepare("UPDATE feed_items SET is_starred = 1 WHERE id = ?;"); stmt.bind_text(1, id, -1, null); stmt.step(); debug("Item starred: %s", id); } /** * Unmark an item from starred */ public void unmark_starred(string id) throws Error { var stmt = db.prepare("UPDATE feed_items SET is_starred = 0 WHERE id = ?;"); stmt.bind_text(1, id, -1, null); stmt.step(); debug("Item unstarred: %s", id); } /** * Get unread items */ public FeedItem[] get_unread() throws Error { var items = new GLib.List(); var stmt = db.prepare( "SELECT id, subscription_id, title, link, description, content, author, " + "published, updated, categories, enclosure_url, enclosure_type, " + "enclosure_length, guid, is_read, is_starred " + "FROM feed_items WHERE is_read = 0 " + "ORDER BY published DESC LIMIT 100;" ); while (stmt.step() == Sqlite.ROW) { var item = row_to_item(stmt); if (item != null) { items.append(item); } } return items_to_array(items); } /** * Get starred items */ public FeedItem[] get_starred() throws Error { var items = new GLib.List(); var stmt = db.prepare( "SELECT id, subscription_id, title, link, description, content, author, " + "published, updated, categories, enclosure_url, enclosure_type, " + "enclosure_length, guid, is_read, is_starred " + "FROM feed_items WHERE is_starred = 1 " + "ORDER BY published DESC LIMIT 100;" ); while (stmt.step() == Sqlite.ROW) { var item = row_to_item(stmt); if (item != null) { items.append(item); } } return items_to_array(items); } /** * Delete an item by ID */ public void delete(string id) throws Error { var stmt = db.prepare("DELETE FROM feed_items WHERE id = ?;"); stmt.bind_text(1, id, -1, null); stmt.step(); debug("Item deleted: %s", id); item_deleted(id); } /** * Delete items by subscription ID */ public void delete_by_subscription(string subscription_id) throws Error { var stmt = db.prepare("DELETE FROM feed_items WHERE subscription_id = ?;"); stmt.bind_text(1, subscription_id, -1, null); stmt.step(); debug("Items deleted for subscription: %s", subscription_id); } /** * Delete old items (keep last N items per subscription) */ public void cleanup_old_items(int keep_count = 100) throws Error { db.begin_transaction(); try { var stmt = db.prepare( "DELETE FROM feed_items WHERE id NOT IN (" + "SELECT id FROM feed_items " + "ORDER BY published DESC " + "LIMIT -1 OFFSET ?" + ");" ); stmt.bind_int(1, keep_count); stmt.step(); db.commit(); debug("Old items cleaned up, kept %d", keep_count); } catch (Error e) { db.rollback(); throw new DBError.FAILED("Transaction failed: %s".printf(e.message)); } } /** * Convert a database row to a FeedItem */ private FeedItem? row_to_item(Sqlite.Statement stmt) { try { string categories_str = stmt.column_text(9); string[] categories = parse_categories(categories_str); var item = new FeedItem.with_values( stmt.column_text(0), // id stmt.column_text(2), // title stmt.column_text(3), // link stmt.column_text(4), // description stmt.column_text(5), // content stmt.column_text(6), // author stmt.column_text(7), // published stmt.column_text(8), // updated categories, stmt.column_text(10), // enclosure_url stmt.column_text(11), // enclosure_type stmt.column_text(12), // enclosure_length stmt.column_text(13), // guid stmt.column_text(1) // subscription_id (stored as subscription_title) ); return item; } catch (Error e) { warning("Failed to parse item row: %s", e.message); return null; } } /** * Format categories array as JSON string */ private string format_categories(string[] categories) { if (categories.length == 0) { return "[]"; } var sb = new StringBuilder(); sb.append("["); for (var i = 0; i < categories.length; i++) { if (i > 0) sb.append(","); sb.append("\""); sb.append(categories[i]); sb.append("\""); } sb.append("]"); return sb.str; } /** * Parse categories from JSON string */ private string[] parse_categories(string json) { if (json == null || json.length == 0 || json == "[]") { return {}; } try { var parser = new Json.Parser(); if (parser.load_from_data(json)) { var node = parser.get_root(); if (node.get_node_type() == Json.NodeType.ARRAY) { var array = node.get_array(); var categories = new string[array.get_length()]; for (var i = 0; i < array.get_length(); i++) { categories[i] = array.get_string_element(i); } return categories; } } } catch (Error e) { warning("Failed to parse categories: %s", e.message); } return {}; } private FeedItem[] items_to_array(GLib.List list) { FeedItem[] arr = {}; for (unowned var node = list; node != null; node = node.next) { if (node.data != null) arr += node.data; } return arr; } }