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 Summary (push) Has been cancelled
CI - Multi-Platform Native / Build Linux (push) Has been cancelled
417 lines
13 KiB
Vala
417 lines
13 KiB
Vala
/*
|
|
* 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<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.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.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.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.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.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<FeedItem?> list) {
|
|
FeedItem[] arr = {};
|
|
for (unowned var node = list; node != null; node = node.next) {
|
|
if (node.data != null) arr += node.data;
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
}
|
|
|