grundle
Some checks failed
CI - Multi-Platform Native / Build iOS (RSSuper) (push) Has been cancelled
CI - Multi-Platform Native / Build macOS (push) Has been cancelled
CI - Multi-Platform Native / Build Android (push) Has been cancelled
CI - Multi-Platform Native / Build Linux (push) Has been cancelled
CI - Multi-Platform Native / Build Summary (push) Has been cancelled

This commit is contained in:
2026-03-29 23:04:47 -04:00
parent 473457df2f
commit dc17a71be4
25 changed files with 2199 additions and 6 deletions

View File

@@ -0,0 +1,416 @@
/*
* 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.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<FeedItem?>();
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.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<FeedItem?>();
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.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<FeedItem?>();
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.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<FeedItem?>();
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.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<FeedItem?>();
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.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.Stmt 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<FeedItem?> list) {
FeedItem[] arr = {};
for (unowned var node = list; node != null; node = node.next) {
if (node.data != null) arr += node.data;
}
return arr;
}
}