restructure
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-30 16:39:18 -04:00
parent a8e07d52f0
commit c2e1622bd8
252 changed files with 4803 additions and 17165 deletions

1
linux/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
build

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<gsettings schema="org.rssuper.notification.preferences">
<prefix>rssuper</prefix>
<binding>
<property name="newArticles" type="boolean"/>
</binding>
<binding>
<property name="episodeReleases" type="boolean"/>
</binding>
<binding>
<property name="customAlerts" type="boolean"/>
</binding>
<binding>
<property name="badgeCount" type="boolean"/>
</binding>
<binding>
<property name="sound" type="boolean"/>
</binding>
<binding>
<property name="vibration" type="boolean"/>
</binding>
<binding>
<property name="preferences" type="json"/>
</binding>
<keyvalue>
<key name="newArticles">New Article Notifications</key>
<default>true</default>
<description>Enable notifications for new articles</description>
</keyvalue>
<keyvalue>
<key name="episodeReleases">Episode Release Notifications</key>
<default>true</default>
<description>Enable notifications for episode releases</description>
</keyvalue>
<keyvalue>
<key name="customAlerts">Custom Alert Notifications</key>
<default>true</default>
<description>Enable notifications for custom alerts</description>
</keyvalue>
<keyvalue>
<key name="badgeCount">Badge Count</key>
<default>true</default>
<description>Show badge count in app header</description>
</keyvalue>
<keyvalue>
<key name="sound">Sound</key>
<default>true</default>
<description>Play sound on notification</description>
</keyvalue>
<keyvalue>
<key name="vibration">Vibration</key>
<default>true</default>
<description>Vibrate device on notification</description>
</keyvalue>
<keyvalue>
<key name="preferences">All Preferences</key>
<default>{
"newArticles": true,
"episodeReleases": true,
"customAlerts": true,
"badgeCount": true,
"sound": true,
"vibration": true
}</default>
<description>All notification preferences as JSON</description>
</keyvalue>
</gsettings>

119
linux/meson.build Normal file
View File

@@ -0,0 +1,119 @@
project('rssuper-linux', 'vala', 'c',
version: '0.1.0',
default_options: [
'c_std=c11',
'warning_level=3',
'werror=false',
]
)
vala = find_program('valac')
meson_version_check = run_command(vala, '--version', check: true)
# Dependencies
glib_dep = dependency('glib-2.0', version: '>= 2.58')
gio_dep = dependency('gio-2.0', version: '>= 2.58')
json_dep = dependency('json-glib-1.0', version: '>= 1.4')
sqlite_dep = dependency('sqlite3', version: '>= 3.0')
gobject_dep = dependency('gobject-2.0', version: '>= 2.58')
xml_dep = dependency('libxml-2.0', version: '>= 2.0')
soup_dep = dependency('libsoup-3.0', version: '>= 3.0')
# Source files
models = files(
'src/models/feed-item.vala',
'src/models/feed.vala',
'src/models/feed-subscription.vala',
'src/models/search-result.vala',
'src/models/search-filters.vala',
'src/models/notification-preferences.vala',
'src/models/reading-preferences.vala',
)
# Database files
database = files(
'src/database/db-error.vala',
'src/database/database.vala',
'src/database/subscription-store.vala',
'src/database/feed-item-store.vala',
'src/database/search-history-store.vala',
)
# Parser files
parser = files(
'src/parser/feed-type.vala',
'src/parser/parse-result.vala',
'src/parser/rss-parser.vala',
'src/parser/atom-parser.vala',
'src/parser/feed-parser.vala',
)
# Network files
network = files(
'src/network/network-error.vala',
'src/network/http-auth-credentials.vala',
'src/network/fetch-result.vala',
'src/network/feed-fetcher.vala',
)
# Main library
models_lib = library('rssuper-models', models,
dependencies: [glib_dep, gio_dep, json_dep],
install: false
)
# Database library
database_lib = library('rssuper-database', database,
dependencies: [glib_dep, gio_dep, json_dep, sqlite_dep, gobject_dep],
link_with: [models_lib],
install: false,
vala_args: ['--vapidir', 'src/database', '--pkg', 'sqlite3']
)
# Parser library
parser_lib = library('rssuper-parser', parser,
dependencies: [glib_dep, gio_dep, json_dep, xml_dep],
link_with: [models_lib],
install: false,
vala_args: ['--vapidir', 'src/parser', '--pkg', 'libxml-2.0']
)
# Network library
network_lib = library('rssuper-network', network,
dependencies: [glib_dep, gio_dep, json_dep, soup_dep],
link_with: [models_lib],
install: false,
vala_args: ['--vapidir', 'src/network', '--pkg', 'libsoup-3.0']
)
# Test executable
test_exe = executable('database-tests',
'src/tests/database-tests.vala',
dependencies: [glib_dep, gio_dep, json_dep, sqlite_dep, gobject_dep, xml_dep],
link_with: [models_lib, database_lib, parser_lib],
vala_args: ['--vapidir', '.', '--pkg', 'sqlite3', '--pkg', 'libxml-2.0'],
install: false
)
# Parser test executable
parser_test_exe = executable('parser-tests',
'src/tests/parser-tests.vala',
dependencies: [glib_dep, gio_dep, json_dep, xml_dep],
link_with: [models_lib, parser_lib],
vala_args: ['--vapidir', '.', '--pkg', 'libxml-2.0'],
install: false
)
# Feed fetcher test executable
fetcher_test_exe = executable('feed-fetcher-tests',
'src/tests/feed-fetcher-tests.vala',
dependencies: [glib_dep, gio_dep, json_dep, xml_dep, soup_dep],
link_with: [models_lib, parser_lib, network_lib],
vala_args: ['--vapidir', '.', '--pkg', 'libxml-2.0', '--pkg', 'libsoup-3.0'],
install: false
)
# Test definitions
test('database tests', test_exe)
test('parser tests', parser_test_exe)
test('feed fetcher tests', fetcher_test_exe)

View File

@@ -0,0 +1,69 @@
/*
* RSSuper Database vapi - exports SQLite bindings for use by dependent modules
*/
[CCode (cheader_filename = "sqlite3.h")]
namespace SQLite {
[CCode (cname = "sqlite3", free_function = "sqlite3_close")]
public class DB {
[CCode (cname = "sqlite3_open")]
public static int open(string filename, out DB db);
[CCode (cname = "sqlite3_close")]
public int close();
[CCode (cname = "sqlite3_exec")]
public int exec(string sql, DBCallback? callback = null, void* arg = null, [CCode (array_length = false)] out string? errmsg = null);
[CCode (cname = "sqlite3_errmsg")]
public unowned string errmsg();
[CCode (cname = "sqlite3_prepare_v2")]
public int prepare_v2(string zSql, int nByte, out Stmt stmt, void* pzTail = null);
}
[CCode (cname = "sqlite3_stmt", free_function = "sqlite3_finalize")]
public class Stmt {
[CCode (cname = "sqlite3_step")]
public int step();
[CCode (cname = "sqlite3_column_count")]
public int column_count();
[CCode (cname = "sqlite3_column_text")]
public unowned string column_text(int i);
[CCode (cname = "sqlite3_column_int")]
public int column_int(int i);
[CCode (cname = "sqlite3_column_double")]
public double column_double(int i);
[CCode (cname = "sqlite3_bind_text")]
public int bind_text(int i, string z, int n, void* x);
[CCode (cname = "sqlite3_bind_int")]
public int bind_int(int i, int val);
[CCode (cname = "sqlite3_bind_double")]
public int bind_double(int i, double val);
[CCode (cname = "sqlite3_bind_null")]
public int bind_null(int i);
[CCode (cname = "sqlite3_finalize")]
public int finalize();
}
[CCode (cname = "SQLITE_OK")]
public const int SQLITE_OK;
[CCode (cname = "SQLITE_ROW")]
public const int SQLITE_ROW;
[CCode (cname = "SQLITE_DONE")]
public const int SQLITE_DONE;
[CCode (cname = "SQLITE_ERROR")]
public const int SQLITE_ERROR;
[CCode (simple_type = true)]
public delegate int DBCallback(void* arg, int argc, string[] argv, string[] col_names);
}

View File

@@ -0,0 +1,200 @@
/*
* 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 = 1;
/**
* 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 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;
}
}

View File

@@ -0,0 +1,24 @@
/*
* DBError.vala
*
* Database error domain definition.
*/
namespace RSSuper {
/**
* DBError - Database error domain
*/
public errordomain DBError {
FAILED, /** Generic database operation failed */
NOT_FOUND, /** Record not found */
DUPLICATE, /** Duplicate key or record */
CORRUPTED, /** Database is corrupted */
TIMEOUT, /** Operation timed out */
INVALID_STATE, /** Invalid database state */
MIGRATION_FAILED, /** Migration failed */
CONSTRAINT_FAILED, /** Constraint violation */
FOREIGN_KEY_FAILED, /** Foreign key constraint failed */
UNIQUE_FAILED, /** Unique constraint failed */
CHECK_FAILED, /** Check constraint failed */
}
}

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.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;
}
}

View File

@@ -0,0 +1,103 @@
-- RSSuper Database Schema
-- SQLite with FTS5 for full-text search
-- Enable foreign keys
PRAGMA foreign_keys = ON;
-- Migration tracking table
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Feed subscriptions table
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
);
-- Feed items table
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, -- JSON array as 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 index for feed items
CREATE INDEX IF NOT EXISTS idx_feed_items_subscription ON feed_items(subscription_id);
CREATE INDEX IF NOT EXISTS idx_feed_items_published ON feed_items(published DESC);
CREATE INDEX IF NOT EXISTS idx_feed_items_read ON feed_items(is_read);
CREATE INDEX IF NOT EXISTS idx_feed_items_starred ON feed_items(is_starred);
-- Search history table
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'))
);
CREATE INDEX IF NOT EXISTS idx_search_history_created ON search_history(created_at DESC);
-- FTS5 virtual table for full-text search on feed items
CREATE VIRTUAL TABLE IF NOT EXISTS feed_items_fts USING fts5(
title,
description,
content,
author,
content='feed_items',
content_rowid='rowid'
);
-- Trigger to keep FTS table in sync on INSERT
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;
-- Trigger to keep FTS table in sync on DELETE
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;
-- Trigger to keep FTS table in sync on UPDATE
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;
-- Initial migration record
INSERT OR IGNORE INTO schema_migrations (version) VALUES (1);

View File

@@ -0,0 +1,171 @@
/*
* SearchHistoryStore.vala
*
* CRUD operations for search history.
*/
/**
* SearchHistoryStore - Manages search history persistence
*/
public class RSSuper.SearchHistoryStore : Object {
private Database db;
/**
* Maximum number of history entries to keep
*/
public int max_entries { get; set; default = 100; }
/**
* Signal emitted when a search is recorded
*/
public signal void search_recorded(SearchQuery query, int result_count);
/**
* Signal emitted when history is cleared
*/
public signal void history_cleared();
/**
* Create a new search history store
*/
public SearchHistoryStore(Database db) {
this.db = db;
}
/**
* Record a search query
*/
public int record_search(SearchQuery query, int result_count = 0) throws Error {
var stmt = db.prepare(
"INSERT INTO search_history (query, filters_json, sort_option, page, page_size, result_count) " +
"VALUES (?, ?, ?, ?, ?, ?);"
);
stmt.bind_text(1, query.query, -1, null);
stmt.bind_text(2, query.filters_json ?? "", -1, null);
stmt.bind_text(3, SearchFilters.sort_option_to_string(query.sort), -1, null);
stmt.bind_int(4, query.page);
stmt.bind_int(5, query.page_size);
stmt.bind_int(6, result_count);
stmt.step();
debug("Search recorded: %s (%d results)", query.query, result_count);
search_recorded(query, result_count);
// Clean up old entries if needed
cleanup_old_entries();
return 0; // Returns the last inserted row ID in SQLite
}
/**
* Get search history
*/
public SearchQuery[] get_history(int limit = 50) throws Error {
var queries = new GLib.List<SearchQuery?>();
var stmt = db.prepare(
"SELECT query, filters_json, sort_option, page, page_size, result_count, created_at " +
"FROM search_history " +
"ORDER BY created_at DESC " +
"LIMIT ?;"
);
stmt.bind_int(1, limit);
while (stmt.step() == Sqlite.ROW) {
var query = row_to_query(stmt);
queries.append(query);
}
return queries_to_array(queries);
}
/**
* Get recent searches (last 24 hours)
*/
public SearchQuery[] get_recent() throws Error {
var queries = new GLib.List<SearchQuery?>();
var now = new DateTime.now_local();
var yesterday = now.add_days(-1);
var threshold = yesterday.format("%Y-%m-%dT%H:%M:%S");
var stmt = db.prepare(
"SELECT query, filters_json, sort_option, page, page_size, result_count, created_at " +
"FROM search_history " +
"WHERE created_at >= ? " +
"ORDER BY created_at DESC " +
"LIMIT 20;"
);
stmt.bind_text(1, threshold, -1, null);
while (stmt.step() == Sqlite.ROW) {
var query = row_to_query(stmt);
queries.append(query);
}
return queries_to_array(queries);
}
/**
* Delete a search history entry by ID
*/
public void delete(int id) throws Error {
var stmt = db.prepare("DELETE FROM search_history WHERE id = ?;");
stmt.bind_int(1, id);
stmt.step();
debug("Search history entry deleted: %d", id);
}
/**
* Clear all search history
*/
public void clear() throws Error {
var stmt = db.prepare("DELETE FROM search_history;");
stmt.step();
debug("Search history cleared");
history_cleared();
}
/**
* Clear old search history entries
*/
private void cleanup_old_entries() throws Error {
var stmt = db.prepare(
"DELETE FROM search_history WHERE id NOT IN (" +
"SELECT id FROM search_history ORDER BY created_at DESC LIMIT ?" +
");"
);
stmt.bind_int(1, max_entries);
stmt.step();
}
/**
* Convert a database row to a SearchQuery
*/
private SearchQuery row_to_query(Sqlite.Statement stmt) {
string query_str = stmt.column_text(0);
string? filters_json = stmt.column_text(1);
string sort_str = stmt.column_text(2);
int page = stmt.column_int(3);
int page_size = stmt.column_int(4);
return SearchQuery(query_str, page, page_size, filters_json,
SearchFilters.sort_option_from_string(sort_str));
}
private SearchQuery[] queries_to_array(GLib.List<SearchQuery?> list) {
SearchQuery[] arr = {};
for (unowned var node = list; node != null; node = node.next) {
arr += node.data;
}
return arr;
}
}

View File

@@ -0,0 +1,69 @@
/*
* SQLite3 C API bindings for Vala
*/
[CCode (cheader_filename = "sqlite3.h")]
namespace SQLite {
[CCode (cname = "sqlite3", free_function = "sqlite3_close")]
public class DB {
[CCode (cname = "sqlite3_open")]
public static int open(string filename, out DB db);
[CCode (cname = "sqlite3_close")]
public int close();
[CCode (cname = "sqlite3_exec")]
public int exec(string sql, DBCallback? callback = null, void* arg = null, [CCode (array_length = false)] out string? errmsg = null);
[CCode (cname = "sqlite3_errmsg")]
public unowned string errmsg();
[CCode (cname = "sqlite3_prepare_v2")]
public int prepare_v2(string zSql, int nByte, out Stmt stmt, void* pzTail = null);
}
[CCode (cname = "sqlite3_stmt", free_function = "sqlite3_finalize")]
public class Stmt {
[CCode (cname = "sqlite3_step")]
public int step();
[CCode (cname = "sqlite3_column_count")]
public int column_count();
[CCode (cname = "sqlite3_column_text")]
public unowned string column_text(int i);
[CCode (cname = "sqlite3_column_int")]
public int column_int(int i);
[CCode (cname = "sqlite3_column_double")]
public double column_double(int i);
[CCode (cname = "sqlite3_bind_text")]
public int bind_text(int i, string z, int n, void* x);
[CCode (cname = "sqlite3_bind_int")]
public int bind_int(int i, int val);
[CCode (cname = "sqlite3_bind_double")]
public int bind_double(int i, double val);
[CCode (cname = "sqlite3_bind_null")]
public int bind_null(int i);
[CCode (cname = "sqlite3_finalize")]
public int finalize();
}
[CCode (cname = "SQLITE_OK")]
public const int SQLITE_OK;
[CCode (cname = "SQLITE_ROW")]
public const int SQLITE_ROW;
[CCode (cname = "SQLITE_DONE")]
public const int SQLITE_DONE;
[CCode (cname = "SQLITE_ERROR")]
public const int SQLITE_ERROR;
[CCode (simple_type = true)]
public delegate int DBCallback(void* arg, int argc, string[] argv, string[] col_names);
}

View File

@@ -0,0 +1,244 @@
/*
* SubscriptionStore.vala
*
* CRUD operations for feed subscriptions.
*/
/**
* SubscriptionStore - Manages feed subscription persistence
*/
public class RSSuper.SubscriptionStore : Object {
private Database db;
/**
* Signal emitted when a subscription is added
*/
public signal void subscription_added(FeedSubscription subscription);
/**
* Signal emitted when a subscription is updated
*/
public signal void subscription_updated(FeedSubscription subscription);
/**
* Signal emitted when a subscription is deleted
*/
public signal void subscription_deleted(string id);
/**
* Create a new subscription store
*/
public SubscriptionStore(Database db) {
this.db = db;
}
/**
* Add a new subscription
*/
public FeedSubscription add(FeedSubscription subscription) throws Error {
var stmt = db.prepare(
"INSERT INTO feed_subscriptions (id, url, title, category, enabled, fetch_interval, " +
"created_at, updated_at, last_fetched_at, next_fetch_at, error, http_auth_username, http_auth_password) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"
);
stmt.bind_text(1, subscription.id, -1, null);
stmt.bind_text(2, subscription.url, -1, null);
stmt.bind_text(3, subscription.title, -1, null);
stmt.bind_text(4, subscription.category ?? "", -1, null);
stmt.bind_int(5, subscription.enabled ? 1 : 0);
stmt.bind_int(6, subscription.fetch_interval);
stmt.bind_text(7, subscription.created_at, -1, null);
stmt.bind_text(8, subscription.updated_at, -1, null);
stmt.bind_text(9, subscription.last_fetched_at ?? "", -1, null);
stmt.bind_text(10, subscription.next_fetch_at ?? "", -1, null);
stmt.bind_text(11, subscription.error ?? "", -1, null);
stmt.bind_text(12, subscription.http_auth_username ?? "", -1, null);
stmt.bind_text(13, subscription.http_auth_password ?? "", -1, null);
stmt.step();
debug("Subscription added: %s", subscription.id);
subscription_added(subscription);
return subscription;
}
/**
* Get a subscription by ID
*/
public FeedSubscription? get_by_id(string id) throws Error {
var stmt = db.prepare(
"SELECT id, url, title, category, enabled, fetch_interval, created_at, updated_at, " +
"last_fetched_at, next_fetch_at, error, http_auth_username, http_auth_password " +
"FROM feed_subscriptions WHERE id = ?;"
);
stmt.bind_text(1, id, -1, null);
if (stmt.step() == Sqlite.ROW) {
return row_to_subscription(stmt);
}
return null;
}
/**
* Get all subscriptions
*/
public FeedSubscription[] get_all() throws Error {
var subscriptions = new GLib.List<FeedSubscription?>();
var stmt = db.prepare(
"SELECT id, url, title, category, enabled, fetch_interval, created_at, updated_at, " +
"last_fetched_at, next_fetch_at, error, http_auth_username, http_auth_password " +
"FROM feed_subscriptions ORDER BY title;"
);
while (stmt.step() == Sqlite.ROW) {
var subscription = row_to_subscription(stmt);
if (subscription != null) {
subscriptions.append(subscription);
}
}
return list_to_array(subscriptions);
}
/**
* Update a subscription
*/
public void update(FeedSubscription subscription) throws Error {
var stmt = db.prepare(
"UPDATE feed_subscriptions SET url = ?, title = ?, category = ?, enabled = ?, " +
"fetch_interval = ?, updated_at = ?, last_fetched_at = ?, next_fetch_at = ?, " +
"error = ?, http_auth_username = ?, http_auth_password = ? " +
"WHERE id = ?;"
);
stmt.bind_text(1, subscription.url, -1, null);
stmt.bind_text(2, subscription.title, -1, null);
stmt.bind_text(3, subscription.category ?? "", -1, null);
stmt.bind_int(4, subscription.enabled ? 1 : 0);
stmt.bind_int(5, subscription.fetch_interval);
stmt.bind_text(6, subscription.updated_at, -1, null);
stmt.bind_text(7, subscription.last_fetched_at ?? "", -1, null);
stmt.bind_text(8, subscription.next_fetch_at ?? "", -1, null);
stmt.bind_text(9, subscription.error ?? "", -1, null);
stmt.bind_text(10, subscription.http_auth_username ?? "", -1, null);
stmt.bind_text(11, subscription.http_auth_password ?? "", -1, null);
stmt.bind_text(12, subscription.id, -1, null);
stmt.step();
debug("Subscription updated: %s", subscription.id);
subscription_updated(subscription);
}
/**
* Delete a subscription
*/
public void remove_subscription(string id) throws Error {
var stmt = db.prepare("DELETE FROM feed_subscriptions WHERE id = ?;");
stmt.bind_text(1, id, -1, null);
stmt.step();
debug("Subscription deleted: %s", id);
subscription_deleted(id);
}
/**
* Delete a subscription by object
*/
public void delete_subscription(FeedSubscription subscription) throws Error {
remove_subscription(subscription.id);
}
/**
* Get enabled subscriptions
*/
public FeedSubscription[] get_enabled() throws Error {
var subscriptions = new GLib.List<FeedSubscription?>();
var stmt = db.prepare(
"SELECT id, url, title, category, enabled, fetch_interval, created_at, updated_at, " +
"last_fetched_at, next_fetch_at, error, http_auth_username, http_auth_password " +
"FROM feed_subscriptions WHERE enabled = 1 ORDER BY title;"
);
while (stmt.step() == Sqlite.ROW) {
var subscription = row_to_subscription(stmt);
if (subscription != null) {
subscriptions.append(subscription);
}
}
return list_to_array(subscriptions);
}
/**
* Get subscriptions that need fetching
*/
public FeedSubscription[] get_due_for_fetch() throws Error {
var subscriptions = new GLib.List<FeedSubscription?>();
var now = new DateTime.now_local();
var now_str = now.format("%Y-%m-%dT%H:%M:%S");
var stmt = db.prepare(
"SELECT id, url, title, category, enabled, fetch_interval, created_at, updated_at, " +
"last_fetched_at, next_fetch_at, error, http_auth_username, http_auth_password " +
"FROM feed_subscriptions WHERE enabled = 1 AND " +
"(next_fetch_at IS NULL OR next_fetch_at <= ?) " +
"ORDER BY next_fetch_at ASC;"
);
stmt.bind_text(1, now_str, -1, null);
while (stmt.step() == Sqlite.ROW) {
var subscription = row_to_subscription(stmt);
if (subscription != null) {
subscriptions.append(subscription);
}
}
return list_to_array(subscriptions);
}
/**
* Convert a database row to a FeedSubscription
*/
private FeedSubscription? row_to_subscription(Sqlite.Statement stmt) {
try {
var subscription = new FeedSubscription.with_values(
stmt.column_text(0), // id
stmt.column_text(1), // url
stmt.column_text(2), // title
stmt.column_int(5), // fetch_interval
stmt.column_text(3), // category
stmt.column_int(4) == 1, // enabled
stmt.column_text(6), // created_at
stmt.column_text(7), // updated_at
stmt.column_text(8), // last_fetched_at
stmt.column_text(9), // next_fetch_at
stmt.column_text(10), // error
stmt.column_text(11), // http_auth_username
stmt.column_text(12) // http_auth_password
);
return subscription;
} catch (Error e) {
warning("Failed to parse subscription row: %s", e.message);
return null;
}
}
private FeedSubscription[] list_to_array(GLib.List<FeedSubscription?> list) {
FeedSubscription[] arr = {};
for (unowned var node = list; node != null; node = node.next) {
if (node.data != null) arr += node.data;
}
return arr;
}
}

View File

@@ -0,0 +1,313 @@
/*
* FeedItem.vala
*
* Represents a single RSS/Atom feed item (article, episode, etc.)
* Following GNOME HIG naming conventions and Vala/GObject patterns.
*/
/**
* Enclosure metadata for media attachments (podcasts, videos, etc.)
*/
public struct RSSuper.Enclosure {
public string url { get; set; }
public string item_type { get; set; }
public string? length { get; set; }
public Enclosure(string url, string type, string? length = null) {
this.url = url;
this.item_type = type;
this.length = length;
}
}
/**
* FeedItem - Represents a single RSS/Atom entry
*/
public class RSSuper.FeedItem : Object {
public string id { get; set; }
public string title { get; set; }
public string? link { get; set; }
public string? description { get; set; }
public string? content { get; set; }
public string? author { get; set; }
public string? published { get; set; }
public string? updated { get; set; }
public string[] categories { get; set; }
public string? enclosure_url { get; set; }
public string? enclosure_type { get; set; }
public string? enclosure_length { get; set; }
public string? guid { get; set; }
public string? subscription_title { get; set; }
/**
* Default constructor
*/
public FeedItem() {
this.id = "";
this.title = "";
this.categories = {};
}
/**
* Constructor with initial values
*/
public FeedItem.with_values(string id, string title, string? link = null,
string? description = null, string? content = null,
string? author = null, string? published = null,
string? updated = null, string[]? categories = null,
string? enclosure_url = null, string? enclosure_type = null,
string? enclosure_length = null, string? guid = null,
string? subscription_title = null) {
this.id = id;
this.title = title;
this.link = link;
this.description = description;
this.content = content;
this.author = author;
this.published = published;
this.updated = updated;
this.categories = categories;
this.enclosure_url = enclosure_url;
this.enclosure_type = enclosure_type;
this.enclosure_length = enclosure_length;
this.guid = guid;
this.subscription_title = subscription_title;
}
/**
* Get enclosure as struct
*/
public Enclosure? get_enclosure() {
if (this.enclosure_url == null) {
return null;
}
return Enclosure(this.enclosure_url, this.enclosure_type ?? "", this.enclosure_length);
}
/**
* Set enclosure from struct
*/
public void set_enclosure(Enclosure? enclosure) {
if (enclosure == null) {
this.enclosure_url = null;
this.enclosure_type = null;
this.enclosure_length = null;
} else {
this.enclosure_url = enclosure.url;
this.enclosure_type = enclosure_type;
this.enclosure_length = enclosure.length;
}
}
/**
* Serialize to JSON string
*/
public string to_json_string() {
var sb = new StringBuilder();
sb.append("{");
sb.append("\"id\":\"");
sb.append(this.id);
sb.append("\",\"title\":\"");
sb.append(this.title);
sb.append("\"");
if (this.link != null) {
sb.append(",\"link\":\"");
sb.append(this.link);
sb.append("\"");
}
if (this.description != null) {
sb.append(",\"description\":\"");
sb.append(this.description);
sb.append("\"");
}
if (this.content != null) {
sb.append(",\"content\":\"");
sb.append(this.content);
sb.append("\"");
}
if (this.author != null) {
sb.append(",\"author\":\"");
sb.append(this.author);
sb.append("\"");
}
if (this.published != null) {
sb.append(",\"published\":\"");
sb.append(this.published);
sb.append("\"");
}
if (this.updated != null) {
sb.append(",\"updated\":\"");
sb.append(this.updated);
sb.append("\"");
}
if (this.categories.length > 0) {
sb.append(",\"categories\":[");
for (var i = 0; i < this.categories.length; i++) {
if (i > 0) sb.append(",");
sb.append("\"");
sb.append(this.categories[i]);
sb.append("\"");
}
sb.append("]");
}
if (this.enclosure_url != null) {
sb.append(",\"enclosure\":{\"url\":\"");
sb.append(this.enclosure_url);
sb.append("\"");
if (this.enclosure_type != null) {
sb.append(",\"type\":\"");
sb.append(this.enclosure_type);
sb.append("\"");
}
if (this.enclosure_length != null) {
sb.append(",\"length\":\"");
sb.append(this.enclosure_length);
sb.append("\"");
}
sb.append("}");
}
if (this.guid != null) {
sb.append(",\"guid\":\"");
sb.append(this.guid);
sb.append("\"");
}
if (this.subscription_title != null) {
sb.append(",\"subscription_title\":\"");
sb.append(this.subscription_title);
sb.append("\"");
}
sb.append("}");
return sb.str;
}
/**
* Deserialize from JSON string (simple parser)
*/
public static FeedItem? from_json_string(string json_string) {
var parser = new Json.Parser();
try {
if (!parser.load_from_data(json_string)) {
return null;
}
} catch (Error e) {
warning("Failed to parse JSON: %s", e.message);
return null;
}
return from_json_node(parser.get_root());
}
/**
* Deserialize from Json.Node
*/
public static FeedItem? from_json_node(Json.Node node) {
if (node.get_node_type() != Json.NodeType.OBJECT) {
return null;
}
var obj = node.get_object();
if (!obj.has_member("id") || !obj.has_member("title")) {
return null;
}
var item = new FeedItem();
item.id = obj.get_string_member("id");
item.title = obj.get_string_member("title");
if (obj.has_member("link")) {
item.link = obj.get_string_member("link");
}
if (obj.has_member("description")) {
item.description = obj.get_string_member("description");
}
if (obj.has_member("content")) {
item.content = obj.get_string_member("content");
}
if (obj.has_member("author")) {
item.author = obj.get_string_member("author");
}
if (obj.has_member("published")) {
item.published = obj.get_string_member("published");
}
if (obj.has_member("updated")) {
item.updated = obj.get_string_member("updated");
}
if (obj.has_member("categories")) {
var categories_array = obj.get_array_member("categories");
var categories = new string[categories_array.get_length()];
for (var i = 0; i < categories_array.get_length(); i++) {
categories[i] = categories_array.get_string_element(i);
}
item.categories = categories;
}
if (obj.has_member("enclosure")) {
var enclosure_obj = obj.get_object_member("enclosure");
item.enclosure_url = enclosure_obj.get_string_member("url");
if (enclosure_obj.has_member("type")) {
item.enclosure_type = enclosure_obj.get_string_member("type");
}
if (enclosure_obj.has_member("length")) {
item.enclosure_length = enclosure_obj.get_string_member("length");
}
}
if (obj.has_member("guid")) {
item.guid = obj.get_string_member("guid");
}
if (obj.has_member("subscription_title")) {
item.subscription_title = obj.get_string_member("subscription_title");
}
return item;
}
/**
* Equality comparison
*/
public bool equals(FeedItem? other) {
if (other == null) {
return false;
}
return this.id == other.id &&
this.title == other.title &&
this.link == other.link &&
this.description == other.description &&
this.content == other.content &&
this.author == other.author &&
this.published == other.published &&
this.updated == other.updated &&
this.categories_equal(other.categories) &&
this.enclosure_url == other.enclosure_url &&
this.enclosure_type == other.enclosure_type &&
this.enclosure_length == other.enclosure_length &&
this.guid == other.guid &&
this.subscription_title == other.subscription_title;
}
/**
* Helper for category array comparison
*/
private bool categories_equal(string[] other) {
if (this.categories.length != other.length) {
return false;
}
for (var i = 0; i < this.categories.length; i++) {
if (this.categories[i] != other[i]) {
return false;
}
}
return true;
}
/**
* Get a human-readable summary
*/
public string get_summary() {
return "%s by %s".printf(this.title, this.author ?? "Unknown");
}
}

View File

@@ -0,0 +1,259 @@
/*
* FeedSubscription.vala
*
* Represents a user's subscription to a feed with sync settings.
* Following GNOME HIG naming conventions and Vala/GObject patterns.
*/
/**
* HTTP Authentication credentials
*/
public struct RSSuper.HttpAuth {
public string username { get; set; }
public string password { get; set; }
public HttpAuth(string username, string password) {
this.username = username;
this.password = password;
}
}
/**
* FeedSubscription - Represents a user's subscription to a feed
*/
public class RSSuper.FeedSubscription : Object {
public string id { get; set; }
public string url { get; set; }
public string title { get; set; }
public string? category { get; set; }
public bool enabled { get; set; }
public int fetch_interval { get; set; }
public string created_at { get; set; }
public string updated_at { get; set; }
public string? last_fetched_at { get; set; }
public string? next_fetch_at { get; set; }
public string? error { get; set; }
public string? http_auth_username { get; set; }
public string? http_auth_password { get; set; }
/**
* Default constructor
*/
public FeedSubscription() {
this.id = "";
this.url = "";
this.title = "";
this.enabled = true;
this.fetch_interval = 60;
this.created_at = "";
this.updated_at = "";
}
/**
* Constructor with initial values
*/
public FeedSubscription.with_values(string id, string url, string title,
int fetch_interval = 60,
string? category = null, bool enabled = true,
string? created_at = null, string? updated_at = null,
string? last_fetched_at = null,
string? next_fetch_at = null,
string? error = null,
string? http_auth_username = null,
string? http_auth_password = null) {
this.id = id;
this.url = url;
this.title = title;
this.category = category;
this.enabled = enabled;
this.fetch_interval = fetch_interval;
this.created_at = created_at ?? "";
this.updated_at = updated_at ?? "";
this.last_fetched_at = last_fetched_at;
this.next_fetch_at = next_fetch_at;
this.error = error;
this.http_auth_username = http_auth_username;
this.http_auth_password = http_auth_password;
}
/**
* Get HTTP auth as struct
*/
public HttpAuth? get_http_auth() {
if (this.http_auth_username == null) {
return null;
}
return HttpAuth(this.http_auth_username, this.http_auth_password ?? "");
}
/**
* Set HTTP auth from struct
*/
public void set_http_auth(HttpAuth? auth) {
if (auth == null) {
this.http_auth_username = null;
this.http_auth_password = null;
} else {
this.http_auth_username = auth.username;
this.http_auth_password = auth.password;
}
}
/**
* Check if subscription has an error
*/
public bool has_error() {
return this.error != null && this.error.length > 0;
}
/**
* Serialize to JSON string
*/
public string to_json_string() {
var sb = new StringBuilder();
sb.append("{");
sb.append("\"id\":\"");
sb.append(this.id);
sb.append("\",\"url\":\"");
sb.append(this.url);
sb.append("\",\"title\":\"");
sb.append(this.title);
sb.append("\",\"enabled\":");
sb.append(this.enabled ? "true" : "false");
sb.append(",\"fetchInterval\":%d".printf(this.fetch_interval));
sb.append(",\"createdAt\":\"");
sb.append(this.created_at);
sb.append("\",\"updatedAt\":\"");
sb.append(this.updated_at);
sb.append("\"");
if (this.category != null) {
sb.append(",\"category\":\"");
sb.append(this.category);
sb.append("\"");
}
if (this.last_fetched_at != null) {
sb.append(",\"lastFetchedAt\":\"");
sb.append(this.last_fetched_at);
sb.append("\"");
}
if (this.next_fetch_at != null) {
sb.append(",\"nextFetchAt\":\"");
sb.append(this.next_fetch_at);
sb.append("\"");
}
if (this.error != null) {
sb.append(",\"error\":\"");
sb.append(this.error);
sb.append("\"");
}
if (this.http_auth_username != null) {
sb.append(",\"httpAuth\":{\"username\":\"");
sb.append(this.http_auth_username);
sb.append("\"");
if (this.http_auth_password != null) {
sb.append(",\"password\":\"");
sb.append(this.http_auth_password);
sb.append("\"");
}
sb.append("}");
}
sb.append("}");
return sb.str;
}
/**
* Deserialize from JSON string
*/
public static FeedSubscription? from_json_string(string json_string) {
var parser = new Json.Parser();
try {
if (!parser.load_from_data(json_string)) {
return null;
}
} catch (Error e) {
warning("Failed to parse JSON: %s", e.message);
return null;
}
return from_json_node(parser.get_root());
}
/**
* Deserialize from Json.Node
*/
public static FeedSubscription? from_json_node(Json.Node node) {
if (node.get_node_type() != Json.NodeType.OBJECT) {
return null;
}
var obj = node.get_object();
if (!obj.has_member("id") || !obj.has_member("url") ||
!obj.has_member("title") || !obj.has_member("createdAt") ||
!obj.has_member("updatedAt")) {
return null;
}
var subscription = new FeedSubscription();
subscription.id = obj.get_string_member("id");
subscription.url = obj.get_string_member("url");
subscription.title = obj.get_string_member("title");
if (obj.has_member("category")) {
subscription.category = obj.get_string_member("category");
}
if (obj.has_member("enabled")) {
subscription.enabled = obj.get_boolean_member("enabled");
}
if (obj.has_member("fetchInterval")) {
subscription.fetch_interval = (int)obj.get_int_member("fetchInterval");
}
subscription.created_at = obj.get_string_member("createdAt");
subscription.updated_at = obj.get_string_member("updatedAt");
if (obj.has_member("lastFetchedAt")) {
subscription.last_fetched_at = obj.get_string_member("lastFetchedAt");
}
if (obj.has_member("nextFetchAt")) {
subscription.next_fetch_at = obj.get_string_member("nextFetchAt");
}
if (obj.has_member("error")) {
subscription.error = obj.get_string_member("error");
}
if (obj.has_member("httpAuth")) {
var auth_obj = obj.get_object_member("httpAuth");
subscription.http_auth_username = auth_obj.get_string_member("username");
if (auth_obj.has_member("password")) {
subscription.http_auth_password = auth_obj.get_string_member("password");
}
}
return subscription;
}
/**
* Equality comparison
*/
public bool equals(FeedSubscription? other) {
if (other == null) {
return false;
}
return this.id == other.id &&
this.url == other.url &&
this.title == other.title &&
this.category == other.category &&
this.enabled == other.enabled &&
this.fetch_interval == other.fetch_interval &&
this.created_at == other.created_at &&
this.updated_at == other.updated_at &&
this.last_fetched_at == other.last_fetched_at &&
this.next_fetch_at == other.next_fetch_at &&
this.error == other.error &&
this.http_auth_username == other.http_auth_username &&
this.http_auth_password == other.http_auth_password;
}
}

282
linux/src/models/feed.vala Normal file
View File

@@ -0,0 +1,282 @@
/*
* Feed.vala
*
* Represents an RSS/Atom feed with its metadata and items.
* Following GNOME HIG naming conventions and Vala/GObject patterns.
*/
/**
* Feed - Represents an RSS/Atom feed
*/
public class RSSuper.Feed : Object {
public string id { get; set; }
public string title { get; set; }
public string? link { get; set; }
public string? description { get; set; }
public string? subtitle { get; set; }
public string? language { get; set; }
public string? last_build_date { get; set; }
public string? updated { get; set; }
public string? generator { get; set; }
public int ttl { get; set; }
public string raw_url { get; set; }
public string? last_fetched_at { get; set; }
public string? next_fetch_at { get; set; }
public FeedItem[] items { get; set; }
/**
* Default constructor
*/
public Feed() {
this.id = "";
this.title = "";
this.raw_url = "";
this.ttl = 60;
this.items = {};
}
/**
* Constructor with initial values
*/
public Feed.with_values(string id, string title, string raw_url,
string? link = null, string? description = null,
string? subtitle = null, string? language = null,
string? last_build_date = null, string? updated = null,
string? generator = null, int ttl = 60,
FeedItem[]? items = null, string? last_fetched_at = null,
string? next_fetch_at = null) {
this.id = id;
this.title = title;
this.link = link;
this.description = description;
this.subtitle = subtitle;
this.language = language;
this.last_build_date = last_build_date;
this.updated = updated;
this.generator = generator;
this.ttl = ttl;
this.items = items;
this.raw_url = raw_url;
this.last_fetched_at = last_fetched_at;
this.next_fetch_at = next_fetch_at;
}
/**
* Add an item to the feed
*/
public void add_item(FeedItem item) {
var new_items = new FeedItem[this.items.length + 1];
for (var i = 0; i < this.items.length; i++) {
new_items[i] = this.items[i];
}
new_items[this.items.length] = item;
this.items = new_items;
}
/**
* Get item count
*/
public int get_item_count() {
return this.items.length;
}
/**
* Serialize to JSON string
*/
public string to_json_string() {
var sb = new StringBuilder();
sb.append("{");
sb.append("\"id\":\"");
sb.append(this.id);
sb.append("\",\"title\":\"");
sb.append(this.title);
sb.append("\",\"raw_url\":\"");
sb.append(this.raw_url);
sb.append("\"");
if (this.link != null) {
sb.append(",\"link\":\"");
sb.append(this.link);
sb.append("\"");
}
if (this.description != null) {
sb.append(",\"description\":\"");
sb.append(this.description);
sb.append("\"");
}
if (this.subtitle != null) {
sb.append(",\"subtitle\":\"");
sb.append(this.subtitle);
sb.append("\"");
}
if (this.language != null) {
sb.append(",\"language\":\"");
sb.append(this.language);
sb.append("\"");
}
if (this.last_build_date != null) {
sb.append(",\"lastBuildDate\":\"");
sb.append(this.last_build_date);
sb.append("\"");
}
if (this.updated != null) {
sb.append(",\"updated\":\"");
sb.append(this.updated);
sb.append("\"");
}
if (this.generator != null) {
sb.append(",\"generator\":\"");
sb.append(this.generator);
sb.append("\"");
}
if (this.ttl != 60) {
sb.append(",\"ttl\":%d".printf(this.ttl));
}
if (this.items.length > 0) {
sb.append(",\"items\":[");
for (var i = 0; i < this.items.length; i++) {
if (i > 0) sb.append(",");
sb.append(this.items[i].to_json_string());
}
sb.append("]");
}
if (this.last_fetched_at != null) {
sb.append(",\"lastFetchedAt\":\"");
sb.append(this.last_fetched_at);
sb.append("\"");
}
if (this.next_fetch_at != null) {
sb.append(",\"nextFetchAt\":\"");
sb.append(this.next_fetch_at);
sb.append("\"");
}
sb.append("}");
return sb.str;
}
/**
* Deserialize from JSON string
*/
public static Feed? from_json_string(string json_string) {
var parser = new Json.Parser();
try {
if (!parser.load_from_data(json_string)) {
return null;
}
} catch (Error e) {
warning("Failed to parse JSON: %s", e.message);
return null;
}
return from_json_node(parser.get_root());
}
/**
* Deserialize from Json.Node
*/
public static Feed? from_json_node(Json.Node node) {
if (node.get_node_type() != Json.NodeType.OBJECT) {
return null;
}
var obj = node.get_object();
if (!obj.has_member("id") || !obj.has_member("title") || !obj.has_member("raw_url")) {
return null;
}
var feed = new Feed();
feed.id = obj.get_string_member("id");
feed.title = obj.get_string_member("title");
feed.raw_url = obj.get_string_member("raw_url");
if (obj.has_member("link")) {
feed.link = obj.get_string_member("link");
}
if (obj.has_member("description")) {
feed.description = obj.get_string_member("description");
}
if (obj.has_member("subtitle")) {
feed.subtitle = obj.get_string_member("subtitle");
}
if (obj.has_member("language")) {
feed.language = obj.get_string_member("language");
}
if (obj.has_member("lastBuildDate")) {
feed.last_build_date = obj.get_string_member("lastBuildDate");
}
if (obj.has_member("updated")) {
feed.updated = obj.get_string_member("updated");
}
if (obj.has_member("generator")) {
feed.generator = obj.get_string_member("generator");
}
if (obj.has_member("ttl")) {
feed.ttl = (int)obj.get_int_member("ttl");
}
if (obj.has_member("lastFetchedAt")) {
feed.last_fetched_at = obj.get_string_member("lastFetchedAt");
}
if (obj.has_member("nextFetchAt")) {
feed.next_fetch_at = obj.get_string_member("nextFetchAt");
}
// Deserialize items
if (obj.has_member("items")) {
var items_array = obj.get_array_member("items");
var items = new FeedItem[items_array.get_length()];
for (var i = 0; i < items_array.get_length(); i++) {
var item_node = items_array.get_element(i);
var item = FeedItem.from_json_node(item_node);
if (item != null) {
items[i] = item;
}
}
feed.items = items;
}
return feed;
}
/**
* Equality comparison
*/
public bool equals(Feed? other) {
if (other == null) {
return false;
}
return this.id == other.id &&
this.title == other.title &&
this.link == other.link &&
this.description == other.description &&
this.subtitle == other.subtitle &&
this.language == other.language &&
this.last_build_date == other.last_build_date &&
this.updated == other.updated &&
this.generator == other.generator &&
this.ttl == other.ttl &&
this.raw_url == other.raw_url &&
this.last_fetched_at == other.last_fetched_at &&
this.next_fetch_at == other.next_fetch_at &&
this.items_equal(other.items);
}
/**
* Helper for item array comparison
*/
private bool items_equal(FeedItem[] other) {
if (this.items.length != other.length) {
return false;
}
for (var i = 0; i < this.items.length; i++) {
if (!this.items[i].equals(other[i])) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,5 @@
/*
* Namespace definition for RSSuper Linux models
*/
public namespace RSSuper {
}

View File

@@ -0,0 +1,190 @@
/*
* NotificationPreferences.vala
*
* Represents user notification preferences.
* Following GNOME HIG naming conventions and Vala/GObject patterns.
*/
/**
* NotificationPreferences - User notification settings
*/
public class RSSuper.NotificationPreferences : Object {
public bool new_articles { get; set; }
public bool episode_releases { get; set; }
public bool custom_alerts { get; set; }
public bool badge_count { get; set; }
public bool sound { get; set; }
public bool vibration { get; set; }
/**
* Default constructor (all enabled by default)
*/
public NotificationPreferences() {
this.new_articles = true;
this.episode_releases = true;
this.custom_alerts = true;
this.badge_count = true;
this.sound = true;
this.vibration = true;
}
/**
* Constructor with initial values
*/
public NotificationPreferences.with_values(bool new_articles = true,
bool episode_releases = true,
bool custom_alerts = true,
bool badge_count = true,
bool sound = true,
bool vibration = true) {
this.new_articles = new_articles;
this.episode_releases = episode_releases;
this.custom_alerts = custom_alerts;
this.badge_count = badge_count;
this.sound = sound;
this.vibration = vibration;
}
/**
* Enable all notifications
*/
public void enable_all() {
this.new_articles = true;
this.episode_releases = true;
this.custom_alerts = true;
this.badge_count = true;
this.sound = true;
this.vibration = true;
}
/**
* Disable all notifications
*/
public void disable_all() {
this.new_articles = false;
this.episode_releases = false;
this.custom_alerts = false;
this.badge_count = false;
this.sound = false;
this.vibration = false;
}
/**
* Check if any notifications are enabled
*/
public bool has_any_enabled() {
return this.new_articles ||
this.episode_releases ||
this.custom_alerts ||
this.badge_count ||
this.sound ||
this.vibration;
}
/**
* Check if content notifications are enabled
*/
public bool has_content_notifications() {
return this.new_articles || this.episode_releases || this.custom_alerts;
}
/**
* Serialize to JSON string
*/
public string to_json_string() {
var sb = new StringBuilder();
sb.append("{");
sb.append("\"newArticles\":");
sb.append(this.new_articles ? "true" : "false");
sb.append(",\"episodeReleases\":");
sb.append(this.episode_releases ? "true" : "false");
sb.append(",\"customAlerts\":");
sb.append(this.custom_alerts ? "true" : "false");
sb.append(",\"badgeCount\":");
sb.append(this.badge_count ? "true" : "false");
sb.append(",\"sound\":");
sb.append(this.sound ? "true" : "false");
sb.append(",\"vibration\":");
sb.append(this.vibration ? "true" : "false");
sb.append("}");
return sb.str;
}
/**
* Deserialize from JSON string
*/
public static NotificationPreferences? from_json_string(string json_string) {
var parser = new Json.Parser();
try {
if (!parser.load_from_data(json_string)) {
return null;
}
} catch (Error e) {
warning("Failed to parse JSON: %s", e.message);
return null;
}
return from_json_node(parser.get_root());
}
/**
* Deserialize from Json.Node
*/
public static NotificationPreferences? from_json_node(Json.Node node) {
if (node.get_node_type() != Json.NodeType.OBJECT) {
return null;
}
var obj = node.get_object();
var prefs = new NotificationPreferences();
if (obj.has_member("newArticles")) {
prefs.new_articles = obj.get_boolean_member("newArticles");
}
if (obj.has_member("episodeReleases")) {
prefs.episode_releases = obj.get_boolean_member("episodeReleases");
}
if (obj.has_member("customAlerts")) {
prefs.custom_alerts = obj.get_boolean_member("customAlerts");
}
if (obj.has_member("badgeCount")) {
prefs.badge_count = obj.get_boolean_member("badgeCount");
}
if (obj.has_member("sound")) {
prefs.sound = obj.get_boolean_member("sound");
}
if (obj.has_member("vibration")) {
prefs.vibration = obj.get_boolean_member("vibration");
}
return prefs;
}
/**
* Equality comparison
*/
public bool equals(NotificationPreferences? other) {
if (other == null) {
return false;
}
return this.new_articles == other.new_articles &&
this.episode_releases == other.episode_releases &&
this.custom_alerts == other.custom_alerts &&
this.badge_count == other.badge_count &&
this.sound == other.sound &&
this.vibration == other.vibration;
}
/**
* Copy preferences from another instance
*/
public void copy_from(NotificationPreferences other) {
this.new_articles = other.new_articles;
this.episode_releases = other.episode_releases;
this.custom_alerts = other.custom_alerts;
this.badge_count = other.badge_count;
this.sound = other.sound;
this.vibration = other.vibration;
}
}

View File

@@ -0,0 +1,168 @@
/*
* ReadingPreferences.vala
*
* Represents user reading/display preferences.
* Following GNOME HIG naming conventions and Vala/GObject patterns.
*/
/**
* FontSize - Available font size options
*/
public enum RSSuper.FontSize {
SMALL,
MEDIUM,
LARGE,
XLARGE
}
/**
* LineHeight - Available line height options
*/
public enum RSSuper.LineHeight {
NORMAL,
RELAXED,
LOOSE
}
/**
* ReadingPreferences - User reading/display settings
*/
public class RSSuper.ReadingPreferences : Object {
public FontSize font_size { get; set; }
public LineHeight line_height { get; set; }
public bool show_table_of_contents { get; set; }
public bool show_reading_time { get; set; }
public bool show_author { get; set; }
public bool show_date { get; set; }
public ReadingPreferences() {
this.font_size = FontSize.MEDIUM;
this.line_height = LineHeight.NORMAL;
this.show_table_of_contents = true;
this.show_reading_time = true;
this.show_author = true;
this.show_date = true;
}
public ReadingPreferences.with_values(FontSize font_size = FontSize.MEDIUM,
LineHeight line_height = LineHeight.NORMAL,
bool show_table_of_contents = true,
bool show_reading_time = true,
bool show_author = true,
bool show_date = true) {
this.font_size = font_size;
this.line_height = line_height;
this.show_table_of_contents = show_table_of_contents;
this.show_reading_time = show_reading_time;
this.show_author = show_author;
this.show_date = show_date;
}
public string get_font_size_string() {
switch (this.font_size) {
case FontSize.SMALL: return "small";
case FontSize.MEDIUM: return "medium";
case FontSize.LARGE: return "large";
case FontSize.XLARGE: return "xlarge";
default: return "medium";
}
}
public static FontSize font_size_from_string(string str) {
switch (str) {
case "small": return FontSize.SMALL;
case "medium": return FontSize.MEDIUM;
case "large": return FontSize.LARGE;
case "xlarge": return FontSize.XLARGE;
default: return FontSize.MEDIUM;
}
}
public string get_line_height_string() {
switch (this.line_height) {
case LineHeight.NORMAL: return "normal";
case LineHeight.RELAXED: return "relaxed";
case LineHeight.LOOSE: return "loose";
default: return "normal";
}
}
public static LineHeight line_height_from_string(string str) {
switch (str) {
case "normal": return LineHeight.NORMAL;
case "relaxed": return LineHeight.RELAXED;
case "loose": return LineHeight.LOOSE;
default: return LineHeight.NORMAL;
}
}
public void reset_to_defaults() {
this.font_size = FontSize.MEDIUM;
this.line_height = LineHeight.NORMAL;
this.show_table_of_contents = true;
this.show_reading_time = true;
this.show_author = true;
this.show_date = true;
}
public string to_json_string() {
var sb = new StringBuilder();
sb.append("{\"fontSize\":\"");
sb.append(this.get_font_size_string());
sb.append("\",\"lineHeight\":\"");
sb.append(this.get_line_height_string());
sb.append("\",\"showTableOfContents\":");
sb.append(this.show_table_of_contents ? "true" : "false");
sb.append(",\"showReadingTime\":");
sb.append(this.show_reading_time ? "true" : "false");
sb.append(",\"showAuthor\":");
sb.append(this.show_author ? "true" : "false");
sb.append(",\"showDate\":");
sb.append(this.show_date ? "true" : "false");
sb.append("}");
return sb.str;
}
public static ReadingPreferences? from_json_string(string json_string) {
var parser = new Json.Parser();
try {
if (!parser.load_from_data(json_string)) return null;
} catch (Error e) {
warning("Failed to parse JSON: %s", e.message);
return null;
}
return from_json_node(parser.get_root());
}
public static ReadingPreferences? from_json_node(Json.Node node) {
if (node.get_node_type() != Json.NodeType.OBJECT) return null;
var obj = node.get_object();
var prefs = new ReadingPreferences();
if (obj.has_member("fontSize")) prefs.font_size = font_size_from_string(obj.get_string_member("fontSize"));
if (obj.has_member("lineHeight")) prefs.line_height = line_height_from_string(obj.get_string_member("lineHeight"));
if (obj.has_member("showTableOfContents")) prefs.show_table_of_contents = obj.get_boolean_member("showTableOfContents");
if (obj.has_member("showReadingTime")) prefs.show_reading_time = obj.get_boolean_member("showReadingTime");
if (obj.has_member("showAuthor")) prefs.show_author = obj.get_boolean_member("showAuthor");
if (obj.has_member("showDate")) prefs.show_date = obj.get_boolean_member("showDate");
return prefs;
}
public bool equals(ReadingPreferences? other) {
if (other == null) return false;
return this.font_size == other.font_size &&
this.line_height == other.line_height &&
this.show_table_of_contents == other.show_table_of_contents &&
this.show_reading_time == other.show_reading_time &&
this.show_author == other.show_author &&
this.show_date == other.show_date;
}
public void copy_from(ReadingPreferences other) {
this.font_size = other.font_size;
this.line_height = other.line_height;
this.show_table_of_contents = other.show_table_of_contents;
this.show_reading_time = other.show_reading_time;
this.show_author = other.show_author;
this.show_date = other.show_date;
}
}

View File

@@ -0,0 +1,435 @@
/*
* SearchFilters.vala
*
* Represents search query parameters and filters.
* Following GNOME HIG naming conventions and Vala/GObject patterns.
*/
/**
* SearchContentType - Type of content to search for
*/
public enum RSSuper.SearchContentType {
ARTICLE,
AUDIO,
VIDEO
}
/**
* SearchSortOption - Sorting options for search results
*/
public enum RSSuper.SearchSortOption {
RELEVANCE,
DATE_DESC,
DATE_ASC,
TITLE_ASC,
TITLE_DESC,
FEED_ASC,
FEED_DESC
}
/**
* SearchFilters - Represents search filters and query parameters
*/
public struct RSSuper.SearchFilters {
public string? date_from { get; set; }
public string? date_to { get; set; }
public string[] feed_ids { get; set; }
public string[] authors { get; set; }
public SearchContentType? content_type { get; set; }
/**
* Default constructor
*/
public SearchFilters(string? date_from = null, string? date_to = null,
string[]? feed_ids = null, string[]? authors = null,
SearchContentType? content_type = null) {
this.date_from = date_from;
this.date_to = date_to;
this.feed_ids = feed_ids;
this.authors = authors;
this.content_type = content_type;
}
/**
* Get content type as string
*/
public string? get_content_type_string() {
if (this.content_type == null) {
return null;
}
switch (this.content_type) {
case SearchContentType.ARTICLE:
return "article";
case SearchContentType.AUDIO:
return "audio";
case SearchContentType.VIDEO:
return "video";
default:
return null;
}
}
/**
* Parse content type from string
*/
public static SearchContentType? content_type_from_string(string? str) {
if (str == null) {
return null;
}
switch (str) {
case "article":
return SearchContentType.ARTICLE;
case "audio":
return SearchContentType.AUDIO;
case "video":
return SearchContentType.VIDEO;
default:
return null;
}
}
/**
* Get sort option as string
*/
public static string sort_option_to_string(SearchSortOption option) {
switch (option) {
case SearchSortOption.RELEVANCE:
return "relevance";
case SearchSortOption.DATE_DESC:
return "date_desc";
case SearchSortOption.DATE_ASC:
return "date_asc";
case SearchSortOption.TITLE_ASC:
return "title_asc";
case SearchSortOption.TITLE_DESC:
return "title_desc";
case SearchSortOption.FEED_ASC:
return "feed_asc";
case SearchSortOption.FEED_DESC:
return "feed_desc";
default:
return "relevance";
}
}
/**
* Parse sort option from string
*/
public static SearchSortOption sort_option_from_string(string str) {
switch (str) {
case "relevance":
return SearchSortOption.RELEVANCE;
case "date_desc":
return SearchSortOption.DATE_DESC;
case "date_asc":
return SearchSortOption.DATE_ASC;
case "title_asc":
return SearchSortOption.TITLE_ASC;
case "title_desc":
return SearchSortOption.TITLE_DESC;
case "feed_asc":
return SearchSortOption.FEED_ASC;
case "feed_desc":
return SearchSortOption.FEED_DESC;
default:
return SearchSortOption.RELEVANCE;
}
}
/**
* Check if any filters are set
*/
public bool has_filters() {
return this.date_from != null ||
this.date_to != null ||
(this.feed_ids != null && this.feed_ids.length > 0) ||
(this.authors != null && this.authors.length > 0) ||
this.content_type != null;
}
/**
* Serialize to JSON string
*/
public string to_json_string() {
var sb = new StringBuilder();
sb.append("{");
var first = true;
if (this.date_from != null) {
sb.append("\"dateFrom\":\"");
sb.append(this.date_from);
sb.append("\"");
first = false;
}
if (this.date_to != null) {
if (!first) sb.append(",");
sb.append("\"dateTo\":\"");
sb.append(this.date_to);
sb.append("\"");
first = false;
}
if (this.feed_ids != null && this.feed_ids.length > 0) {
if (!first) sb.append(",");
sb.append("\"feedIds\":[");
for (var i = 0; i < this.feed_ids.length; i++) {
if (i > 0) sb.append(",");
sb.append("\"");
sb.append(this.feed_ids[i]);
sb.append("\"");
}
sb.append("]");
first = false;
}
if (this.authors != null && this.authors.length > 0) {
if (!first) sb.append(",");
sb.append("\"authors\":[");
for (var i = 0; i < this.authors.length; i++) {
if (i > 0) sb.append(",");
sb.append("\"");
sb.append(this.authors[i]);
sb.append("\"");
}
sb.append("]");
first = false;
}
if (this.content_type != null) {
if (!first) sb.append(",");
sb.append("\"contentType\":\"");
sb.append(this.get_content_type_string());
sb.append("\"");
}
sb.append("}");
return sb.str;
}
/**
* Deserialize from JSON string
*/
public static SearchFilters? from_json_string(string json_string) {
var parser = new Json.Parser();
try {
if (!parser.load_from_data(json_string)) {
return null;
}
} catch (Error e) {
warning("Failed to parse JSON: %s", e.message);
return null;
}
return from_json_node(parser.get_root());
}
/**
* Deserialize from Json.Node
*/
public static SearchFilters? from_json_node(Json.Node node) {
if (node.get_node_type() != Json.NodeType.OBJECT) {
return null;
}
var obj = node.get_object();
var filters = SearchFilters();
if (obj.has_member("dateFrom")) {
filters.date_from = obj.get_string_member("dateFrom");
}
if (obj.has_member("dateTo")) {
filters.date_to = obj.get_string_member("dateTo");
}
if (obj.has_member("feedIds")) {
var array = obj.get_array_member("feedIds");
var feed_ids = new string[array.get_length()];
for (var i = 0; i < array.get_length(); i++) {
feed_ids[i] = array.get_string_element(i);
}
filters.feed_ids = feed_ids;
}
if (obj.has_member("authors")) {
var array = obj.get_array_member("authors");
var authors = new string[array.get_length()];
for (var i = 0; i < array.get_length(); i++) {
authors[i] = array.get_string_element(i);
}
filters.authors = authors;
}
if (obj.has_member("contentType")) {
filters.content_type = content_type_from_string(obj.get_string_member("contentType"));
}
return filters;
}
/**
* Equality comparison
*/
public bool equals(SearchFilters other) {
return this.date_from == other.date_from &&
this.date_to == other.date_to &&
this.feeds_equal(other.feed_ids) &&
this.authors_equal(other.authors) &&
this.content_type == other.content_type;
}
/**
* Helper for feed_ids comparison
*/
private bool feeds_equal(string[]? other) {
if (this.feed_ids == null && other == null) return true;
if (this.feed_ids == null || other == null) return false;
if (this.feed_ids.length != other.length) {
return false;
}
for (var i = 0; i < this.feed_ids.length; i++) {
if (this.feed_ids[i] != other[i]) {
return false;
}
}
return true;
}
/**
* Helper for authors comparison
*/
private bool authors_equal(string[]? other) {
if (this.authors == null && other == null) return true;
if (this.authors == null || other == null) return false;
if (this.authors.length != other.length) {
return false;
}
for (var i = 0; i < this.authors.length; i++) {
if (this.authors[i] != other[i]) {
return false;
}
}
return true;
}
}
/**
* SearchQuery - Represents a complete search query
*/
public struct RSSuper.SearchQuery {
public string query { get; set; }
public int page { get; set; }
public int page_size { get; set; }
public string filters_json { get; set; }
public SearchSortOption sort { get; set; }
/**
* Default constructor
*/
public SearchQuery(string query, int page = 1, int page_size = 20,
string? filters_json = null, SearchSortOption sort = SearchSortOption.RELEVANCE) {
this.query = query;
this.page = page;
this.page_size = page_size;
this.filters_json = filters_json;
this.sort = sort;
}
/**
* Get filters as struct
*/
public SearchFilters? get_filters() {
if (this.filters_json == null || this.filters_json.length == 0) {
return null;
}
return SearchFilters.from_json_string(this.filters_json);
}
/**
* Set filters from struct
*/
public void set_filters(SearchFilters? filters) {
if (filters == null) {
this.filters_json = "";
} else {
this.filters_json = filters.to_json_string();
}
}
/**
* Serialize to JSON string
*/
public string to_json_string() {
var sb = new StringBuilder();
sb.append("{");
sb.append("\"query\":\"");
sb.append(this.query);
sb.append("\"");
sb.append(",\"page\":%d".printf(this.page));
sb.append(",\"pageSize\":%d".printf(this.page_size));
if (this.filters_json != null && this.filters_json.length > 0) {
sb.append(",\"filters\":");
sb.append(this.filters_json);
}
sb.append(",\"sort\":\"");
sb.append(SearchFilters.sort_option_to_string(this.sort));
sb.append("\"");
sb.append("}");
return sb.str;
}
/**
* Deserialize from JSON string
*/
public static SearchQuery? from_json_string(string json_string) {
var parser = new Json.Parser();
try {
if (!parser.load_from_data(json_string)) {
return null;
}
} catch (Error e) {
warning("Failed to parse JSON: %s", e.message);
return null;
}
return from_json_node(parser.get_root());
}
/**
* Deserialize from Json.Node
*/
public static SearchQuery? from_json_node(Json.Node node) {
if (node.get_node_type() != Json.NodeType.OBJECT) {
return null;
}
var obj = node.get_object();
if (!obj.has_member("query")) {
return null;
}
var query = SearchQuery(obj.get_string_member("query"));
if (obj.has_member("page")) {
query.page = (int)obj.get_int_member("page");
}
if (obj.has_member("pageSize")) {
query.page_size = (int)obj.get_int_member("pageSize");
}
if (obj.has_member("filters")) {
var generator = new Json.Generator();
generator.set_root(obj.get_member("filters"));
query.filters_json = generator.to_data(null);
}
if (obj.has_member("sort")) {
query.sort = SearchFilters.sort_option_from_string(obj.get_string_member("sort"));
}
return query;
}
/**
* Equality comparison
*/
public bool equals(SearchQuery other) {
return this.query == other.query &&
this.page == other.page &&
this.page_size == other.page_size &&
this.filters_json == other.filters_json &&
this.sort == other.sort;
}
}

View File

@@ -0,0 +1,208 @@
/*
* SearchResult.vala
*
* Represents a search result item from the feed database.
* Following GNOME HIG naming conventions and Vala/GObject patterns.
*/
/**
* SearchResultType - Type of search result
*/
public enum RSSuper.SearchResultType {
ARTICLE,
FEED
}
/**
* SearchResult - Represents a single search result
*/
public class RSSuper.SearchResult : Object {
public string id { get; set; }
public SearchResultType result_type { get; set; }
public string title { get; set; }
public string? snippet { get; set; }
public string? link { get; set; }
public string? feed_title { get; set; }
public string? published { get; set; }
public double score { get; set; }
/**
* Default constructor
*/
public SearchResult() {
this.id = "";
this.result_type = SearchResultType.ARTICLE;
this.title = "";
this.score = 0.0;
}
/**
* Constructor with initial values
*/
public SearchResult.with_values(string id, SearchResultType type, string title,
string? snippet = null, string? link = null,
string? feed_title = null, string? published = null,
double score = 0.0) {
this.id = id;
this.result_type = type;
this.title = title;
this.snippet = snippet;
this.link = link;
this.feed_title = feed_title;
this.published = published;
this.score = score;
}
/**
* Get type as string
*/
public string get_type_string() {
switch (this.result_type) {
case SearchResultType.ARTICLE:
return "article";
case SearchResultType.FEED:
return "feed";
default:
return "unknown";
}
}
/**
* Parse type from string
*/
public static SearchResultType type_from_string(string str) {
switch (str) {
case "article":
return SearchResultType.ARTICLE;
case "feed":
return SearchResultType.FEED;
default:
return SearchResultType.ARTICLE;
}
}
/**
* Serialize to JSON string
*/
public string to_json_string() {
var sb = new StringBuilder();
sb.append("{");
sb.append("\"id\":\"");
sb.append(this.id);
sb.append("\",\"type\":\"");
sb.append(this.get_type_string());
sb.append("\",\"title\":\"");
sb.append(this.title);
sb.append("\"");
if (this.snippet != null) {
sb.append(",\"snippet\":\"");
sb.append(this.snippet);
sb.append("\"");
}
if (this.link != null) {
sb.append(",\"link\":\"");
sb.append(this.link);
sb.append("\"");
}
if (this.feed_title != null) {
sb.append(",\"feedTitle\":\"");
sb.append(this.feed_title);
sb.append("\"");
}
if (this.published != null) {
sb.append(",\"published\":\"");
sb.append(this.published);
sb.append("\"");
}
if (this.score != 0.0) {
sb.append(",\"score\":%f".printf(this.score));
}
sb.append("}");
return sb.str;
}
/**
* Deserialize from JSON string
*/
public static SearchResult? from_json_string(string json_string) {
var parser = new Json.Parser();
try {
if (!parser.load_from_data(json_string)) {
return null;
}
} catch (Error e) {
warning("Failed to parse JSON: %s", e.message);
return null;
}
return from_json_node(parser.get_root());
}
/**
* Deserialize from Json.Node
*/
public static SearchResult? from_json_node(Json.Node node) {
if (node.get_node_type() != Json.NodeType.OBJECT) {
return null;
}
var obj = node.get_object();
if (!obj.has_member("id") || !obj.has_member("type") || !obj.has_member("title")) {
return null;
}
var result = new SearchResult();
result.id = obj.get_string_member("id");
result.result_type = SearchResult.type_from_string(obj.get_string_member("type"));
result.title = obj.get_string_member("title");
if (obj.has_member("snippet")) {
result.snippet = obj.get_string_member("snippet");
}
if (obj.has_member("link")) {
result.link = obj.get_string_member("link");
}
if (obj.has_member("feedTitle")) {
result.feed_title = obj.get_string_member("feedTitle");
}
if (obj.has_member("published")) {
result.published = obj.get_string_member("published");
}
if (obj.has_member("score")) {
result.score = obj.get_double_member("score");
}
return result;
}
/**
* Equality comparison
*/
public bool equals(SearchResult? other) {
if (other == null) {
return false;
}
return this.id == other.id &&
this.result_type == other.result_type &&
this.title == other.title &&
this.snippet == other.snippet &&
this.link == other.link &&
this.feed_title == other.feed_title &&
this.published == other.published &&
this.score == other.score;
}
/**
* Get a human-readable summary
*/
public string get_summary() {
if (this.feed_title != null) {
return "[%s] %s - %s".printf(this.get_type_string(), this.feed_title, this.title);
}
return "[%s] %s".printf(this.get_type_string(), this.title);
}
}

View File

@@ -0,0 +1,503 @@
/*
* FeedFetcher.vala
*
* Feed fetching service using libsoup-3.0
* Supports HTTP auth, caching, timeouts, and retry with exponential backoff.
*/
using Soup;
using GLib;
/**
* FeedFetcher - Service for fetching RSS/Atom feeds
*/
public class RSSuper.FeedFetcher : Object {
private Session session;
private int timeout_seconds;
private int max_retries;
private int base_retry_delay_ms;
private int max_content_size;
/**
* Cache for fetched feeds
* Key: feed URL, Value: cached response data
*/
private HashTable<string, CacheEntry> cache;
/**
* Default timeout in seconds
*/
public const int DEFAULT_TIMEOUT = 15;
/**
* Default maximum retries
*/
public const int DEFAULT_MAX_RETRIES = 3;
/**
* Default base retry delay in milliseconds
*/
public const int DEFAULT_BASE_RETRY_DELAY_MS = 1000;
/**
* Maximum content size (10 MB)
*/
public const int DEFAULT_MAX_CONTENT_SIZE = 10 * 1024 * 1024;
/**
* Valid content types for feeds
*/
private static string[] VALID_CONTENT_TYPES = {
"application/rss+xml",
"application/atom+xml",
"text/xml",
"text/html",
"application/xml"
};
/**
* Signal emitted when a feed is fetched
*/
public signal void feed_fetched(string url, bool success, int? error_code = null);
/**
* Signal emitted when a retry is about to happen
*/
public signal void retrying(string url, int attempt, int delay_ms);
/**
* Create a new feed fetcher
*/
public FeedFetcher(int timeout_seconds = DEFAULT_TIMEOUT,
int max_retries = DEFAULT_MAX_RETRIES,
int base_retry_delay_ms = DEFAULT_BASE_RETRY_DELAY_MS,
int max_content_size = DEFAULT_MAX_CONTENT_SIZE) {
this.timeout_seconds = timeout_seconds;
this.max_retries = max_retries;
this.base_retry_delay_ms = base_retry_delay_ms;
this.max_content_size = max_content_size;
this.cache = new HashTable<string, CacheEntry>(str_hash, str_equal);
this.session = new Session();
this.configure_session();
}
/**
* Configure the Soup session
*/
private void configure_session() {
// Set timeout
this.session.set_property("timeout", this.timeout_seconds * 1000); // Convert to ms
// Set HTTP/2
this.session.set_property("http-version", "2.0");
// Set user agent
this.session.set_property("user-agent", "RSSuper/1.0");
// Disable cookies (not needed for feed fetching)
var cookie_jar = new CookieJar();
this.session.set_property("cookie-jar", cookie_jar);
// Set TCP keepalive
this.session.set_property("tcp-keepalive", true);
this.session.set_property("tcp-keepalive-interval", 60);
}
/**
* Fetch a feed from the given URL
*
* @param url The feed URL to fetch
* @param credentials Optional HTTP auth credentials
* @return FetchResult containing the feed content or error
*/
public FetchResult fetch(string url, HttpAuthCredentials? credentials = null) throws Error {
// Validate URL
if (!is_valid_url(url)) {
return FetchResult.err("Invalid URL", 400);
}
// Check cache first
var cached_entry = this.cache.lookup(url);
if (cached_entry != null && !cached_entry.is_expired()) {
debug("Cache hit for: %s", url);
return FetchResult.ok(cached_entry.content, 200,
cached_entry.content_type,
cached_entry.etag,
cached_entry.last_modified,
true);
}
// Perform fetch with retry logic
var request = new Message(Method.GET, url);
// Add cache validation headers if we have cached data
if (cached_entry != null) {
if (cached_entry.etag != null) {
request.headers.append("If-None-Match", cached_entry.etag);
}
if (cached_entry.last_modified != null) {
request.headers.append("If-Modified-Since", cached_entry.last_modified);
}
}
// Set up HTTP auth if credentials provided
if (credentials != null && credentials.has_credentials()) {
setup_http_auth(request, credentials);
}
int attempt = 0;
int delay_ms = this.base_retry_delay_ms;
while (attempt <= this.max_retries) {
try {
if (attempt > 0) {
this.retrying.emit(url, attempt, delay_ms);
GLib.usleep((uint)(delay_ms * 1000));
}
// Send request
this.session.send_and_read(request);
// Check status code
var status = request.status_code;
if (status == 304) {
// 304 Not Modified - return cached content
debug("304 Not Modified for: %s", url);
if (cached_entry != null) {
return FetchResult.ok(cached_entry.content, 304,
cached_entry.content_type,
cached_entry.etag,
cached_entry.last_modified,
true);
}
return FetchResult.err("No cached content for 304 response", 304);
}
if (status != 200) {
return handle_http_error(status, request);
}
// Read response body
var body = request.get_response_body();
if (body == null || body.length == 0) {
return FetchResult.err("Empty response", status);
}
// Check content size
if (body.length > this.max_content_size) {
return FetchResult.err("Content too large", status);
}
// Get content type
var content_type = request.get_response_content_type();
if (!is_valid_content_type(content_type)) {
warning("Unexpected content type: %s", content_type);
}
// Convert body to string
string content;
try {
content = body.get_data_as_text();
} catch (Error e) {
warning("Failed to decode response: %s", e.message);
return FetchResult.err("Failed to decode response", status);
}
// Extract cache headers
string? etag = null;
string? last_modified = null;
try {
etag = request.headers.get_one("ETag");
last_modified = request.headers.get_one("Last-Modified");
} catch (Error e) {
warning("Failed to get cache headers: %s", e.message);
}
// Cache the response
cache_response(url, content, content_type, etag, last_modified, request);
return FetchResult.ok(content, status,
content_type,
etag,
last_modified,
false);
} catch (Error e) {
warning("Fetch error (attempt %d): %s", attempt + 1, e.message);
// Check if retryable
if (!is_retryable_error(e)) {
return FetchResult.from_error(e);
}
attempt++;
if (attempt <= this.max_retries) {
// Exponential backoff
delay_ms = this.base_retry_delay_ms * (1 << attempt);
if (delay_ms > 30000) delay_ms = 30000; // Max 30 seconds
} else {
return FetchResult.from_error(e);
}
}
}
return FetchResult.err("Max retries exceeded", 0);
}
/**
* Fetch multiple feeds concurrently
*/
public FetchResult[] fetch_many(string[] urls, HttpAuthCredentials[]? credentials = null) throws Error {
var results = new FetchResult[urls.length];
for (int i = 0; i < urls.length; i++) {
var cred = (credentials != null && i < credentials.length) ? credentials[i] : null;
results[i] = this.fetch(urls[i], cred);
}
return results;
}
/**
* Set up HTTP authentication on a request
*/
private void setup_http_auth(Message request, HttpAuthCredentials credentials) {
if (credentials.username == null || credentials.username.length == 0) {
return;
}
// Create auth header
string auth_value;
if (credentials.password != null) {
auth_value = "%s:%s".printf(credentials.username, credentials.password);
} else {
auth_value = credentials.username;
}
var encoded = Base64.encode((uint8[])auth_value);
request.headers.append("Authorization", "Basic %s".printf((string)encoded));
}
/**
* Handle HTTP error status codes
*/
private FetchResult handle_http_error(int status, Message request) {
switch (status) {
case 404:
return FetchResult.err("Feed not found", 404);
case 403:
return FetchResult.err("Access forbidden", 403);
case 401:
return FetchResult.err("Unauthorized", 401);
case 400:
return FetchResult.err("Bad request", 400);
case 500:
case 502:
case 503:
case 504:
return FetchResult.err("Server error", status);
default:
if (status >= 400) {
return FetchResult.err("Client error", status);
}
return FetchResult.err("Request failed", status);
}
}
/**
* Cache a response
*/
private void cache_response(string url, string content, string? content_type,
string? etag, string? last_modified, Message request) {
// Parse Cache-Control header
string? cache_control = null;
try {
cache_control = request.headers.get_one("Cache-Control");
} catch (Error e) {
warning("Failed to get Cache-Control header: %s", e.message);
}
int max_age = 60; // Default 60 seconds
if (cache_control != null) {
max_age = parse_cache_control(cache_control);
}
var entry = new CacheEntry();
entry.content = content;
entry.content_type = content_type;
entry.etag = etag;
entry.last_modified = last_modified;
entry.fetched_at = DateTime.new_now_local();
entry.max_age_seconds = max_age;
this.cache.insert(url, entry);
// Limit cache size
if (this.cache.get_size() > 100) {
// Remove oldest entry
var oldest_key = find_oldest_cache_entry();
if (oldest_key != null) {
this.cache.remove(oldest_key);
}
}
}
/**
* Parse Cache-Control header for max-age
*/
private int parse_cache_control(string cache_control) {
var parts = cache_control.split(",");
foreach (var part in parts) {
var trimmed = part.strip();
if (trimmed.has_prefix("max-age=")) {
var value_str = trimmed.substring(8).strip();
int? max_age = int.try_parse(value_str);
if (max_age.HasValue && max_age.Value > 0) {
return min(max_age.Value, 3600); // Cap at 1 hour
}
}
}
return 60; // Default
}
/**
* Find the oldest cache entry key
*/
private string? find_oldest_cache_entry() {
string? oldest_key = null;
DateTime? oldest_time = null;
foreach (var key in this.cache.get_keys()) {
var entry = this.cache.lookup(key);
if (entry != null) {
if (oldest_time == null || entry.fetched_at.compare(oldest_time) < 0) {
oldest_time = entry.fetched_at;
oldest_key = key;
}
}
}
return oldest_key;
}
/**
* Check if a URL is valid
*/
private bool is_valid_url(string url) {
try {
var uri = new Soup.Uri(url);
var scheme = uri.get_scheme();
return scheme == "http" || scheme == "https";
} catch (Error e) {
return false;
}
}
/**
* Check if content type is valid for feeds
*/
private bool is_valid_content_type(string? content_type) {
if (content_type == null) {
return true; // Allow unknown content types
}
foreach (var valid_type in VALID_CONTENT_TYPES) {
if (content_type.contains(valid_type)) {
return true;
}
}
return true; // Be permissive
}
/**
* Check if an error is retryable
*/
private bool is_retryable_error(Error error) {
if (error is NetworkError) {
var net_error = error as NetworkError;
switch ((int)net_error) {
case (int)NetworkError.TIMEOUT:
case (int)NetworkError.CONNECTION_FAILED:
case (int)NetworkError.SERVER_ERROR:
case (int)NetworkError.EMPTY_RESPONSE:
return true;
default:
return false;
}
}
return false;
}
/**
* Clear the cache
*/
public void clear_cache() {
this.cache.remove_all();
}
/**
* Get cache statistics
*/
public int get_cache_size() {
return this.cache.get_size();
}
/**
* Set timeout
*/
public void set_timeout(int seconds) {
this.timeout_seconds = seconds;
this.session.set_property("timeout", seconds * 1000);
}
/**
* Get timeout
*/
public int get_timeout() {
return this.timeout_seconds;
}
/**
* Set maximum retries
*/
public void set_max_retries(int retries) {
this.max_retries = retries;
}
/**
* Get maximum retries
*/
public int get_max_retries() {
return this.max_retries;
}
}
/**
* CacheEntry - Cached feed response
*/
private class CacheEntry : Object {
public string content { get; set; }
public string? content_type { get; set; }
public string? etag { get; set; }
public string? last_modified { get; set; }
public DateTime fetched_at { get; set; }
public int max_age_seconds { get; set; }
public CacheEntry() {
this.content = "";
this.max_age_seconds = 60;
}
/**
* Check if cache entry is expired
*/
public bool is_expired() {
var now = DateTime.new_now_local();
var elapsed = now.unix_timestamp() - this.fetched_at.unix_timestamp();
return elapsed > this.max_age_seconds;
}
}

View File

@@ -0,0 +1,137 @@
/*
* FetchResult.vala
*
* Result type for feed fetch operations.
*/
/**
* FetchResult - Result of a feed fetch operation
*/
public class RSSuper.FetchResult : Object {
private bool is_success;
private string? content;
private string? error_message;
private int http_status_code;
private string? content_type;
private string? etag;
private string? last_modified;
private bool from_cache;
/**
* Check if the fetch was successful
*/
public bool successful {
get { return this.is_success; }
}
/**
* Get the fetched content
*/
public string? fetched_content {
get { return this.content; }
}
/**
* Get the error message if fetch failed
*/
public string? error {
get { return this.error_message; }
}
/**
* Get the HTTP status code
*/
public int status_code {
get { return this.http_status_code; }
}
/**
* Get the content type
*/
public string? response_content_type {
get { return this.content_type; }
}
/**
* Get the ETag header value
*/
public string? response_etag {
get { return this.etag; }
}
/**
* Get the Last-Modified header value
*/
public string? response_last_modified {
get { return this.last_modified; }
}
/**
* Check if response was from cache
*/
public bool is_from_cache {
get { return this.from_cache; }
}
/**
* Create a successful fetch result
*/
public static FetchResult ok(string content, int status_code = 200,
string? content_type = null, string? etag = null,
string? last_modified = null, bool from_cache = false) {
var result = new FetchResult();
result.is_success = true;
result.content = content;
result.http_status_code = status_code;
result.content_type = content_type;
result.etag = etag;
result.last_modified = last_modified;
result.from_cache = from_cache;
return result;
}
/**
* Create a failed fetch result
*/
public static FetchResult err(string error_message, int status_code = 0) {
var result = new FetchResult();
result.is_success = false;
result.error_message = error_message;
result.http_status_code = status_code;
return result;
}
/**
* Create a failed fetch result from NetworkError
*/
public static FetchResult from_error(Error error) {
if (error is NetworkError) {
var net_error = error as NetworkError;
return FetchResult.err(net_error.message, get_status_code_from_error(net_error));
}
return FetchResult.err(error.message);
}
/**
* Helper to get HTTP status code from error
*/
private static int get_status_code_from_error(NetworkError error) {
switch ((int)error) {
case (int)NetworkError.NOT_FOUND:
return 404;
case (int)NetworkError.FORBIDDEN:
return 403;
case (int)NetworkError.UNAUTHORIZED:
return 401;
case (int)NetworkError.BAD_REQUEST:
return 400;
case (int)NetworkError.SERVER_ERROR:
return 500;
case (int)NetworkError.PROTOCOL_ERROR:
case (int)NetworkError.SSL_ERROR:
return 502;
default:
return 0;
}
}
}

View File

@@ -0,0 +1,63 @@
/*
* HttpAuthCredentials.vala
*
* HTTP authentication credentials for feed subscriptions.
*/
/**
* HttpAuthCredentials - HTTP authentication credentials
*/
public class RSSuper.HttpAuthCredentials : Object {
/**
* Username for HTTP authentication
*/
public string? username { get; set; }
/**
* Password for HTTP authentication
*/
public string? password { get; set; }
/**
* Default constructor
*/
public HttpAuthCredentials() {
this.username = null;
this.password = null;
}
/**
* Constructor with credentials
*/
public HttpAuthCredentials.with_credentials(string? username = null, string? password = null) {
this.username = username;
this.password = password;
}
/**
* Check if credentials are set
*/
public bool has_credentials() {
return this.username != null && this.username.length > 0;
}
/**
* Clear credentials
*/
public void clear() {
this.username = null;
this.password = null;
}
/**
* Equality comparison
*/
public bool equals(HttpAuthCredentials? other) {
if (other == null) {
return false;
}
return this.username == other.username &&
this.password == other.password;
}
}

View File

@@ -0,0 +1,29 @@
/*
* NetworkError.vala
*
* Network error domain for feed fetcher service.
*/
namespace RSSuper {
/**
* NetworkError - Error domain for network operations
*/
public errordomain NetworkError {
TIMEOUT, /** Request timed out */
NOT_FOUND, /** Resource not found (404) */
FORBIDDEN, /** Access forbidden (403) */
UNAUTHORIZED, /** Unauthorized (401) */
BAD_REQUEST, /** Bad request (400) */
SERVER_ERROR, /** Server error (5xx) */
CLIENT_ERROR, /** Client error (4xx, generic) */
DNS_FAILED, /** DNS resolution failed */
CONNECTION_FAILED, /** Connection failed */
PROTOCOL_ERROR, /** Protocol error */
SSL_ERROR, /** SSL/TLS error */
CANCELLED, /** Request was cancelled */
EMPTY_RESPONSE, /** Empty response received */
INVALID_URL, /** Invalid URL */
CONTENT_TOO_LARGE, /** Content exceeds size limit */
INVALID_CONTENT_TYPE, /** Invalid content type */
}
}

View File

@@ -0,0 +1,373 @@
/*
* notification-manager.vala
*
* Notification manager for RSSuper on Linux.
* Coordinates notifications, badge management, and tray integration.
*/
using Gio;
using GLib;
using Gtk;
namespace RSSuper {
/**
* NotificationManager - Manager for coordinating notifications
*/
public class NotificationManager : Object {
// Singleton instance
private static NotificationManager? _instance;
// Notification service
private NotificationService? _notification_service;
// Badge reference
private Gtk.Badge? _badge;
// Tray icon reference
private Gtk.TrayIcon? _tray_icon;
// App reference
private Gtk.App? _app;
// Current unread count
private int _unread_count = 0;
// Badge visibility
private bool _badge_visible = true;
/**
* Get singleton instance
*/
public static NotificationManager? get_instance() {
if (_instance == null) {
_instance = new NotificationManager();
}
return _instance;
}
/**
* Get the instance
*/
private NotificationManager() {
_notification_service = NotificationService.get_instance();
_app = Gtk.App.get_active();
}
/**
* Initialize the notification manager
*/
public void initialize() {
// Set up badge
_badge = Gtk.Badge.new();
_badge.set_visible(_badge_visible);
_badge.set_halign(Gtk.Align.START);
// Connect badge changed signal
_badge.changed.connect(_on_badge_changed);
// Set up tray icon
_tray_icon = Gtk.TrayIcon.new();
_tray_icon.set_icon_name("rssuper");
_tray_icon.set_tooltip_text("RSSuper - Press for notifications");
// Connect tray icon clicked signal
_tray_icon.clicked.connect(_on_tray_clicked);
// Set up tray icon popup menu
var popup = new PopupMenu();
popup.add_item(new Gtk.Label("Notifications: " + _unread_count.toString()));
popup.add_item(new Gtk.Separator());
popup.add_item(new Gtk.Label("Mark all as read"));
popup.add_item(new Gtk.Separator());
popup.add_item(new Gtk.Label("Settings"));
popup.add_item(new Gtk.Label("Exit"));
popup.connect_closed(_on_tray_closed);
_tray_icon.set_popup(popup);
// Connect tray icon popup menu signal
popup.menu_closed.connect(_on_tray_popup_closed);
// Set up tray icon popup handler
_tray_icon.set_popup_handler(_on_tray_popup);
// Set up tray icon tooltip
_tray_icon.set_tooltip_text("RSSuper - Press for notifications");
}
/**
* Set up the badge in the app header
*/
public void set_up_badge() {
_badge.set_visible(_badge_visible);
_badge.set_halign(Gtk.Align.START);
// Set up badge changed signal
_badge.changed.connect(_on_badge_changed);
}
/**
* Set up the tray icon
*/
public void set_up_tray_icon() {
_tray_icon.set_icon_name("rssuper");
_tray_icon.set_tooltip_text("RSSuper - Press for notifications");
// Connect tray icon clicked signal
_tray_icon.clicked.connect(_on_tray_clicked);
// Set up tray icon popup menu
var popup = new PopupMenu();
popup.add_item(new Gtk.Label("Notifications: " + _unread_count.toString()));
popup.add_item(new Gtk.Separator());
popup.add_item(new Gtk.Label("Mark all as read"));
popup.add_item(new Gtk.Separator());
popup.add_item(new Gtk.Label("Settings"));
popup.add_item(new Gtk.Label("Exit"));
popup.connect_closed(_on_tray_closed);
_tray_icon.set_popup(popup);
// Connect tray icon popup menu signal
popup.menu_closed.connect(_on_tray_popup_closed);
// Set up tray icon popup handler
_tray_icon.set_popup_handler(_on_tray_popup);
// Set up tray icon tooltip
_tray_icon.set_tooltip_text("RSSuper - Press for notifications");
}
/**
* Show badge
*/
public void show_badge() {
_badge.set_visible(_badge_visible);
}
/**
* Hide badge
*/
public void hide_badge() {
_badge.set_visible(false);
}
/**
* Show badge with count
*/
public void show_badge_with_count(int count) {
_badge.set_visible(_badge_visible);
_badge.set_label(count.toString());
}
/**
* Set unread count
*/
public void set_unread_count(int count) {
_unread_count = count;
// Update badge
if (_badge != null) {
_badge.set_label(count.toString());
}
// Update tray icon popup
if (_tray_icon != null) {
var popup = _tray_icon.get_popup();
if (popup != null) {
popup.set_label("Notifications: " + count.toString());
}
}
// Show badge if count > 0
if (count > 0) {
show_badge();
}
}
/**
* Clear unread count
*/
public void clear_unread_count() {
_unread_count = 0;
hide_badge();
// Update tray icon popup
if (_tray_icon != null) {
var popup = _tray_icon.get_popup();
if (popup != null) {
popup.set_label("Notifications: 0");
}
}
}
/**
* Get unread count
*/
public int get_unread_count() {
return _unread_count;
}
/**
* Get badge reference
*/
public Gtk.Badge? get_badge() {
return _badge;
}
/**
* Get tray icon reference
*/
public Gtk.TrayIcon? get_tray_icon() {
return _tray_icon;
}
/**
* Get app reference
*/
public Gtk.App? get_app() {
return _app;
}
/**
* Check if badge should be visible
*/
public bool should_show_badge() {
return _unread_count > 0 && _badge_visible;
}
/**
* Set badge visibility
*/
public void set_badge_visibility(bool visible) {
_badge_visible = visible;
if (_badge != null) {
_badge.set_visible(visible);
}
}
/**
* Show notification with badge
*/
public void show_with_badge(string title, string body,
string icon = null,
Urgency urgency = Urgency.NORMAL) {
var notification = _notification_service.create(title, body, icon, urgency);
notification.show_with_timeout(5000);
// Show badge
if (_unread_count == 0) {
show_badge_with_count(1);
}
}
/**
* Show notification without badge
*/
public void show_without_badge(string title, string body,
string icon = null,
Urgency urgency = Urgency.NORMAL) {
var notification = _notification_service.create(title, body, icon, urgency);
notification.show_with_timeout(5000);
}
/**
* Show critical notification
*/
public void show_critical(string title, string body,
string icon = null) {
show_with_badge(title, body, icon, Urgency.CRITICAL);
}
/**
* Show low priority notification
*/
public void show_low(string title, string body,
string icon = null) {
show_with_badge(title, body, icon, Urgency.LOW);
}
/**
* Show normal notification
*/
public void show_normal(string title, string body,
string icon = null) {
show_with_badge(title, body, icon, Urgency.NORMAL);
}
/**
* Handle badge changed signal
*/
private void _on_badge_changed(Gtk.Badge badge) {
var count = badge.get_label();
if (!string.IsNullOrEmpty(count)) {
_unread_count = int.Parse(count);
}
}
/**
* Handle tray icon clicked signal
*/
private void _on_tray_clicked(Gtk.TrayIcon tray) {
show_notifications_panel();
}
/**
* Handle tray icon popup closed signal
*/
private void _on_tray_popup_closed(Gtk.Popup popup) {
// Popup closed, hide icon
if (_tray_icon != null) {
_tray_icon.hide();
}
}
/**
* Handle tray icon popup open signal
*/
private void _on_tray_popup(Gtk.TrayIcon tray, Gtk.MenuItem menu) {
// Show icon when popup is opened
if (_tray_icon != null) {
_tray_icon.show();
}
}
/**
* Handle tray icon closed signal
*/
private void _on_tray_closed(Gtk.App app) {
// App closed, hide tray icon
if (_tray_icon != null) {
_tray_icon.hide();
}
}
/**
* Show notifications panel
*/
private void show_notifications_panel() {
// TODO: Show notifications panel
print("Notifications panel requested");
}
/**
* Get notification service
*/
public NotificationService? get_notification_service() {
return _notification_service;
}
/**
* Check if notification manager is available
*/
public bool is_available() {
return _notification_service != null && _notification_service.is_available();
}
}
}

View File

@@ -0,0 +1,285 @@
/*
* notification-preferences-store.vala
*
* Store for notification preferences.
* Provides persistent storage for user notification settings.
*/
using GLib;
namespace RSSuper {
/**
* NotificationPreferencesStore - Persistent storage for notification preferences
*
* Uses GSettings for persistent storage following freedesktop.org conventions.
*/
public class NotificationPreferencesStore : Object {
// Singleton instance
private static NotificationPreferencesStore? _instance;
// GSettings schema key
private const string SCHEMA_KEY = "org.rssuper.notification.preferences";
// GSettings schema description
private const string SCHEMA_DESCRIPTION = "RSSuper notification preferences";
// GSettings schema source URI
private const string SCHEMA_SOURCE = "file:///app/gsettings/org.rssuper.notification.preferences.gschema.xml";
// Preferences schema
private GSettings? _settings;
// Preferences object
private NotificationPreferences? _preferences;
/**
* Get singleton instance
*/
public static NotificationPreferencesStore? get_instance() {
if (_instance == null) {
_instance = new NotificationPreferencesStore();
}
return _instance;
}
/**
* Get the instance
*/
private NotificationPreferencesStore() {
_settings = GSettings.new(SCHEMA_KEY, SCHEMA_DESCRIPTION);
// Load initial preferences
_preferences = NotificationPreferences.from_json_string(_settings.get_string("preferences"));
if (_preferences == null) {
// Set default preferences if none exist
_preferences = new NotificationPreferences();
_settings.set_string("preferences", _preferences.to_json_string());
}
// Listen for settings changes
_settings.changed.connect(_on_settings_changed);
}
/**
* Get notification preferences
*/
public NotificationPreferences? get_preferences() {
return _preferences;
}
/**
* Set notification preferences
*/
public void set_preferences(NotificationPreferences prefs) {
_preferences = prefs;
// Save to GSettings
_settings.set_string("preferences", prefs.to_json_string());
}
/**
* Get new articles preference
*/
public bool get_new_articles() {
return _preferences != null ? _preferences.new_articles : true;
}
/**
* Set new articles preference
*/
public void set_new_articles(bool enabled) {
_preferences = _preferences ?? new NotificationPreferences();
_preferences.new_articles = enabled;
_settings.set_boolean("newArticles", enabled);
}
/**
* Get episode releases preference
*/
public bool get_episode_releases() {
return _preferences != null ? _preferences.episode_releases : true;
}
/**
* Set episode releases preference
*/
public void set_episode_releases(bool enabled) {
_preferences = _preferences ?? new NotificationPreferences();
_preferences.episode_releases = enabled;
_settings.set_boolean("episodeReleases", enabled);
}
/**
* Get custom alerts preference
*/
public bool get_custom_alerts() {
return _preferences != null ? _preferences.custom_alerts : true;
}
/**
* Set custom alerts preference
*/
public void set_custom_alerts(bool enabled) {
_preferences = _preferences ?? new NotificationPreferences();
_preferences.custom_alerts = enabled;
_settings.set_boolean("customAlerts", enabled);
}
/**
* Get badge count preference
*/
public bool get_badge_count() {
return _preferences != null ? _preferences.badge_count : true;
}
/**
* Set badge count preference
*/
public void set_badge_count(bool enabled) {
_preferences = _preferences ?? new NotificationPreferences();
_preferences.badge_count = enabled;
_settings.set_boolean("badgeCount", enabled);
}
/**
* Get sound preference
*/
public bool get_sound() {
return _preferences != null ? _preferences.sound : true;
}
/**
* Set sound preference
*/
public void set_sound(bool enabled) {
_preferences = _preferences ?? new NotificationPreferences();
_preferences.sound = enabled;
_settings.set_boolean("sound", enabled);
}
/**
* Get vibration preference
*/
public bool get_vibration() {
return _preferences != null ? _preferences.vibration : true;
}
/**
* Set vibration preference
*/
public void set_vibration(bool enabled) {
_preferences = _preferences ?? new NotificationPreferences();
_preferences.vibration = enabled;
_settings.set_boolean("vibration", enabled);
}
/**
* Enable all notifications
*/
public void enable_all() {
_preferences = _preferences ?? new NotificationPreferences();
_preferences.enable_all();
// Save to GSettings
_settings.set_string("preferences", _preferences.to_json_string());
}
/**
* Disable all notifications
*/
public void disable_all() {
_preferences = _preferences ?? new NotificationPreferences();
_preferences.disable_all();
// Save to GSettings
_settings.set_string("preferences", _preferences.to_json_string());
}
/**
* Get all preferences as dictionary
*/
public Dictionary<string, object> get_all_preferences() {
if (_preferences == null) {
return new Dictionary<string, object>();
}
var prefs = new Dictionary<string, object>();
prefs["new_articles"] = _preferences.new_articles;
prefs["episode_releases"] = _preferences.episode_releases;
prefs["custom_alerts"] = _preferences.custom_alerts;
prefs["badge_count"] = _preferences.badge_count;
prefs["sound"] = _preferences.sound;
prefs["vibration"] = _preferences.vibration;
return prefs;
}
/**
* Set all preferences from dictionary
*/
public void set_all_preferences(Dictionary<string, object> prefs) {
_preferences = new NotificationPreferences();
if (prefs.containsKey("new_articles")) {
_preferences.new_articles = prefs["new_articles"] as bool;
}
if (prefs.containsKey("episode_releases")) {
_preferences.episode_releases = prefs["episode_releases"] as bool;
}
if (prefs.containsKey("custom_alerts")) {
_preferences.custom_alerts = prefs["custom_alerts"] as bool;
}
if (prefs.containsKey("badge_count")) {
_preferences.badge_count = prefs["badge_count"] as bool;
}
if (prefs.containsKey("sound")) {
_preferences.sound = prefs["sound"] as bool;
}
if (prefs.containsKey("vibration")) {
_preferences.vibration = prefs["vibration"] as bool;
}
// Save to GSettings
_settings.set_string("preferences", _preferences.to_json_string());
}
/**
* Get schema key
*/
public string get_schema_key() {
return SCHEMA_KEY;
}
/**
* Get schema description
*/
public string get_schema_description() {
return SCHEMA_DESCRIPTION;
}
/**
* Get schema source
*/
public string get_schema_source() {
return SCHEMA_SOURCE;
}
/**
* Handle settings changed signal
*/
private void _on_settings_changed(GSettings settings) {
// Settings changed, reload preferences
_preferences = NotificationPreferences.from_json_string(settings.get_string("preferences"));
if (_preferences == null) {
// Set defaults on error
_preferences = new NotificationPreferences();
settings.set_string("preferences", _preferences.to_json_string());
}
}
}
}

View File

@@ -0,0 +1,232 @@
/*
* notification-service.vala
*
* Main notification service for RSSuper on Linux.
* Implements Gio.Notification API following freedesktop.org spec.
*/
using Gio;
using GLib;
namespace RSSuper {
/**
* NotificationService - Main notification service for Linux
*
* Handles desktop notifications using Gio.Notification.
* Follows freedesktop.org notify-send specification.
*/
public class NotificationService : Object {
// Singleton instance
private static NotificationService? _instance;
// Gio.Notification instance
private Gio.Notification? _notification;
// Tray icon reference
private Gtk.App? _app;
// Default title
private string _default_title = "RSSuper";
// Default urgency
private Urgency _default_urgency = Urgency.NORMAL;
/**
* Get singleton instance
*/
public static NotificationService? get_instance() {
if (_instance == null) {
_instance = new NotificationService();
}
return _instance;
}
/**
* Get the instance (for singleton pattern)
*/
private NotificationService() {
_app = Gtk.App.get_active();
_default_title = _app != null ? _app.get_name() : "RSSuper";
_default_urgency = Urgency.NORMAL;
}
/**
* Check if notification service is available
*/
public bool is_available() {
return Gio.Notification.is_available();
}
/**
* Create a new notification
*
* @param title The notification title
* @param body The notification body
* @param urgency Urgency level (NORMAL, CRITICAL, LOW)
* @param timestamp Optional timestamp (defaults to now)
*/
public Notification create(string title, string body,
Urgency urgency = Urgency.NORMAL,
DateTime timestamp = null) {
_notification = Gio.Notification.new(_default_title);
_notification.set_body(body);
_notification.set_urgency(urgency);
if (timestamp == null) {
_notification.set_time_now();
} else {
_notification.set_time(timestamp);
}
return _notification;
}
/**
* Create a notification with summary and icon
*/
public Notification create(string title, string body, string icon,
Urgency urgency = Urgency.NORMAL,
DateTime timestamp = null) {
_notification = Gio.Notification.new(title);
_notification.set_body(body);
_notification.set_urgency(urgency);
if (timestamp == null) {
_notification.set_time_now();
} else {
_notification.set_time(timestamp);
}
// Set icon
try {
_notification.set_icon(icon);
} catch (Error e) {
warning("Failed to set icon: %s", e.message);
}
return _notification;
}
/**
* Create a notification with summary, body, and icon
*/
public Notification create(string summary, string body, string icon,
Urgency urgency = Urgency.NORMAL,
DateTime timestamp = null) {
_notification = Gio.Notification.new(summary);
_notification.set_body(body);
_notification.set_urgency(urgency);
if (timestamp == null) {
_notification.set_time_now();
} else {
_notification.set_time(timestamp);
}
// Set icon
try {
_notification.set_icon(icon);
} catch (Error e) {
warning("Failed to set icon: %s", e.message);
}
return _notification;
}
/**
* Show the notification
*/
public void show() {
if (_notification == null) {
warning("Cannot show null notification");
return;
}
try {
_notification.show();
} catch (Error e) {
warning("Failed to show notification: %s", e.message);
}
}
/**
* Show the notification with timeout
*
* @param timeout_seconds Timeout in seconds (default: 5)
*/
public void show_with_timeout(int timeout_seconds = 5) {
if (_notification == null) {
warning("Cannot show null notification");
return;
}
try {
_notification.show_with_timeout(timeout_seconds * 1000);
} catch (Error e) {
warning("Failed to show notification with timeout: %s", e.message);
}
}
/**
* Get the notification instance
*/
public Gio.Notification? get_notification() {
return _notification;
}
/**
* Set the default title
*/
public void set_default_title(string title) {
_default_title = title;
}
/**
* Set the default urgency
*/
public void set_default_urgency(Urgency urgency) {
_default_urgency = urgency;
}
/**
* Get the default title
*/
public string get_default_title() {
return _default_title;
}
/**
* Get the default urgency
*/
public Urgency get_default_urgency() {
return _default_urgency;
}
/**
* Get the app reference
*/
public Gtk.App? get_app() {
return _app;
}
/**
* Check if the notification can be shown
*/
public bool can_show() {
return _notification != null && _notification.can_show();
}
/**
* Get available urgency levels
*/
public static List<Urgency> get_available_urgencies() {
return Urgency.get_available();
}
}
}

View File

@@ -0,0 +1,245 @@
/*
* AtomParser.vala
*
* Atom 1.0 feed parser
*/
public class RSSuper.AtomParser : Object {
private string feed_url;
private Feed? current_feed;
private FeedItem? current_item;
private string[] current_categories;
private bool in_feed;
private bool in_entry;
public AtomParser() {}
public ParseResult parse(string xml_content, string url) {
this.feed_url = url;
Xml.Doc* doc = Xml.Parser.parse_doc(xml_content);
if (doc == null) {
return ParseResult.error("Failed to parse XML document");
}
Xml.Node* root = doc->get_root_element();
if (root == null) {
delete doc;
return ParseResult.error("No root element found");
}
string name = root->name;
if (name == null || name != "feed") {
delete doc;
return ParseResult.error("Not an Atom feed: root element is '%s'".printf(name ?? "unknown"));
}
Xml.Ns* ns = root->ns;
if (ns != null && ns->href != null && ns->href != "http://www.w3.org/2005/Atom") {
delete doc;
return ParseResult.error("Not an Atom 1.0 feed");
}
parse_element(root);
delete doc;
if (current_feed == null) {
return ParseResult.error("No feed element found");
}
current_feed.raw_url = url;
return ParseResult.success(current_feed);
}
private void parse_element(Xml.Node* node) {
string? name = node->name;
if (name == null) {
return;
}
switch (name) {
case "feed":
in_feed = true;
current_feed = new Feed();
current_categories = {};
iterate_children(node);
in_feed = false;
break;
case "entry":
in_entry = true;
current_item = new FeedItem();
current_categories = {};
iterate_children(node);
if (current_item != null && current_item.title != "") {
if (current_item.id == "") {
current_item.id = current_item.guid ?? current_item.link ?? current_item.title;
}
if (current_feed != null) {
current_feed.add_item(current_item);
}
}
in_entry = false;
break;
case "title":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (in_entry && current_item != null && text != null) {
current_item.title = text;
} else if (in_feed && current_feed != null && text != null) {
current_feed.title = text;
}
break;
case "subtitle":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (current_feed != null && text != null) {
current_feed.subtitle = text;
}
break;
case "link":
var href = node->get_prop("href");
var rel = node->get_prop("rel");
if (in_feed && href != null) {
if (current_feed != null && (rel == null || rel == "alternate")) {
if (current_feed.link == null) {
current_feed.link = href;
}
}
} else if (in_entry && href != null) {
if (current_item != null && (rel == null || rel == "alternate")) {
if (current_item.link == null) {
current_item.link = href;
}
} else if (rel == "enclosure") {
var type = node->get_prop("type");
var length = node->get_prop("length");
if (current_item != null) {
current_item.enclosure_url = href;
current_item.enclosure_type = type;
current_item.enclosure_length = length;
}
}
}
break;
case "summary":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (in_entry && current_item != null) {
if (current_item.description == null && text != null) {
current_item.description = text;
}
}
break;
case "content":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (in_entry && current_item != null) {
if (current_item.content == null && text != null) {
current_item.content = text;
}
if (current_item.description == null && text != null) {
current_item.description = text;
}
}
break;
case "id":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (in_entry && current_item != null && current_item.guid == null && text != null) {
current_item.guid = text;
}
break;
case "updated":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (in_feed && current_feed != null && text != null) {
current_feed.updated = text;
} else if (in_entry && current_item != null && text != null) {
current_item.updated = text;
}
break;
case "published":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (in_entry && current_item != null && text != null) {
current_item.published = text;
}
break;
case "author":
if (in_entry && current_item != null) {
Xml.Node* child = node->first_element_child();
while (child != null) {
string? child_name = child->name;
if (child_name == "name") {
var text = child->get_content();
if (text != null) {
text = text.strip();
if (current_item.author == null && text != null) {
current_item.author = text;
}
}
}
child = child->next_element_sibling();
}
}
break;
case "generator":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (current_feed != null && text != null) {
current_feed.generator = text;
}
break;
case "category":
var term = node->get_prop("term");
if (current_item != null && term != null) {
var new_categories = new string[current_categories.length + 1];
for (var i = 0; i < current_categories.length; i++) {
new_categories[i] = current_categories[i];
}
new_categories[current_categories.length] = term;
current_categories = new_categories;
current_item.categories = current_categories;
}
break;
}
}
private void iterate_children(Xml.Node* node) {
Xml.Node* child = node->first_element_child();
while (child != null) {
parse_element(child);
child = child->next_element_sibling();
}
}
}

View File

@@ -0,0 +1,88 @@
/*
* FeedParser.vala
*
* Main feed parser that detects and handles both RSS and Atom feeds
*/
public class RSSuper.FeedParser : Object {
private RSSParser rss_parser;
private AtomParser atom_parser;
public FeedParser() {
this.rss_parser = new RSSParser();
this.atom_parser = new AtomParser();
}
public ParseResult parse(string xml_content, string url) {
var type = detect_feed_type(xml_content);
switch (type) {
case FeedType.ATOM:
return atom_parser.parse(xml_content, url);
case FeedType.RSS_1_0:
case FeedType.RSS_2_0:
default:
return rss_parser.parse(xml_content, url);
}
}
public FeedType detect_feed_type(string xml_content) {
Xml.Doc* doc = Xml.Parser.parse_doc(xml_content);
if (doc == null) {
return FeedType.UNKNOWN;
}
Xml.Node* root = doc->get_root_element();
if (root == null) {
delete doc;
return FeedType.UNKNOWN;
}
string? name = root->name;
if (name == "feed") {
Xml.Ns* ns = root->ns;
if (ns == null || ns->href == null || ns->href == "http://www.w3.org/2005/Atom") {
delete doc;
return FeedType.ATOM;
}
}
if (name == "rss") {
string? version = root->get_prop("version");
delete doc;
if (version == "2.0") {
return FeedType.RSS_2_0;
}
if (version == "0.91" || version == "0.92") {
return FeedType.RSS_2_0;
}
if (version == "1.0") {
return FeedType.RSS_1_0;
}
return FeedType.RSS_2_0;
}
delete doc;
if (name == "RDF") {
return FeedType.RSS_1_0;
}
return FeedType.UNKNOWN;
}
public ParseResult parse_from_content_type(string xml_content, string url, string? content_type = null) {
if (content_type != null) {
var type = FeedType.from_string(content_type);
if (type == FeedType.ATOM) {
return atom_parser.parse(xml_content, url);
}
if (type == FeedType.RSS_1_0 || type == FeedType.RSS_2_0) {
return rss_parser.parse(xml_content, url);
}
}
return parse(xml_content, url);
}
}

View File

@@ -0,0 +1,41 @@
/*
* FeedType.vala
*
* Enum for RSS/Atom feed types
*/
public enum RSSuper.FeedType {
UNKNOWN,
RSS_1_0,
RSS_2_0,
ATOM;
public static FeedType from_string(string type) {
switch (type.down()) {
case "rss":
case "application/rss+xml":
return RSS_2_0;
case "atom":
case "application/atom+xml":
return ATOM;
case "rdf":
case "application/rdf+xml":
return RSS_1_0;
default:
return UNKNOWN;
}
}
public string to_string() {
switch (this) {
case RSS_1_0:
return "RSS 1.0";
case RSS_2_0:
return "RSS 2.0";
case ATOM:
return "Atom";
default:
return "Unknown";
}
}
}

View File

@@ -0,0 +1,61 @@
/*
* ParseResult.vala
*
* Result type for feed parsing operations
*/
public class RSSuper.ParseError : Object {
public string message { get; private set; }
public int code { get; private set; }
public ParseError(string message, int code = 0) {
this.message = message;
this.code = code;
}
}
public class RSSuper.ParseResult : Object {
private Object? _value;
private ParseError? _error;
public bool ok { get; private set; }
private Type _value_type;
private ParseResult() {}
public static ParseResult success(Object value) {
var result = new ParseResult();
result.ok = true;
result._value = value;
result._value_type = value.get_type();
return result;
}
public static ParseResult error(string message, int code = 0) {
var result = new ParseResult();
result.ok = false;
result._error = new ParseError(message, code);
return result;
}
public Object? get_value() {
return this._value;
}
public T? get_value_as<T>() {
if (!ok) {
return null;
}
if (_value is T) {
return (T)_value;
}
return null;
}
public ParseError? get_error() {
return this._error;
}
public bool is_type<T>() {
return ok && _value_type == typeof(T);
}
}

View File

@@ -0,0 +1,348 @@
/*
* RSSParser.vala
*
* RSS 2.0 feed parser
*/
public class RSSuper.RSSParser : Object {
private string feed_url;
private Feed? current_feed;
private FeedItem? current_item;
private string[] current_categories;
private bool in_item;
private bool in_channel;
private bool in_image;
private bool in_entry;
public RSSParser() {}
public ParseResult parse(string xml_content, string url) {
this.feed_url = url;
Xml.Doc* doc = Xml.Parser.parse_doc(xml_content);
if (doc == null) {
return ParseResult.error("Failed to parse XML document");
}
Xml.Node* root = doc->get_root_element();
if (root == null) {
delete doc;
return ParseResult.error("No root element found");
}
string name = root->name;
if (name == null || name != "rss") {
delete doc;
return ParseResult.error("Not an RSS feed: root element is '%s'".printf(name ?? "unknown"));
}
string? version = root->get_prop("version");
if (version != null && version != "2.0" && version != "0.91" && version != "0.92") {
delete doc;
return ParseResult.error("Unsupported RSS version: %s".printf(version));
}
iterate_children(root);
delete doc;
if (current_feed == null) {
return ParseResult.error("No channel element found");
}
current_feed.raw_url = url;
return ParseResult.success(current_feed);
}
private void parse_element(Xml.Node* node) {
string? name = node->name;
if (name == null) {
return;
}
switch (name) {
case "channel":
in_channel = true;
current_feed = new Feed();
current_categories = {};
iterate_children(node);
in_channel = false;
break;
case "item":
in_item = true;
current_item = new FeedItem();
current_categories = {};
iterate_children(node);
if (current_item != null && current_item.title != "") {
if (current_item.id == "") {
current_item.id = current_item.guid ?? current_item.link ?? current_item.title;
}
if (current_feed != null) {
current_feed.add_item(current_item);
}
}
in_item = false;
break;
case "entry":
in_entry = true;
current_item = new FeedItem();
current_categories = {};
iterate_children(node);
if (current_item != null && current_item.title != "") {
if (current_item.id == "") {
current_item.id = current_item.guid ?? current_item.link ?? current_item.title;
}
if (current_feed != null) {
current_feed.add_item(current_item);
}
}
in_entry = false;
break;
case "image":
in_image = true;
iterate_children(node);
in_image = false;
break;
case "title":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (in_item || in_entry) {
if (current_item != null && text != null) {
current_item.title = text;
}
} else if (in_channel || in_image) {
if (current_feed != null && text != null) {
current_feed.title = text;
}
}
break;
case "link":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (in_channel) {
if (current_feed != null && current_feed.link == null && text != null) {
current_feed.link = text;
}
} else if (in_item || in_entry) {
if (current_item != null && current_item.link == null && text != null) {
current_item.link = text;
}
}
break;
case "description":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (in_item || in_entry) {
if (current_item != null && current_item.description == null && text != null) {
current_item.description = text;
}
} else if (in_channel) {
if (current_feed != null && text != null) {
current_feed.description = text;
}
}
break;
case "subtitle":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (current_feed != null && text != null) {
current_feed.subtitle = text;
}
break;
case "language":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (current_feed != null && text != null) {
current_feed.language = text;
}
break;
case "lastBuildDate":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (current_feed != null && text != null) {
current_feed.last_build_date = text;
}
break;
case "updated":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (current_feed != null && text != null) {
current_feed.updated = text;
} else if (current_item != null && text != null) {
current_item.updated = text;
}
break;
case "generator":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (current_feed != null && text != null) {
current_feed.generator = text;
}
break;
case "ttl":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (current_feed != null && text != null) {
current_feed.ttl = int.parse(text);
}
break;
case "author":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (current_item != null && text != null) {
current_item.author = text;
}
break;
case "dc:creator":
case "creator":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (current_item != null && current_item.author == null && text != null) {
current_item.author = text;
}
break;
case "pubDate":
case "published":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (current_item != null && text != null) {
current_item.published = text;
}
break;
case "guid":
case "id":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (current_item != null && current_item.guid == null && text != null) {
current_item.guid = text;
}
break;
case "category":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (current_item != null && text != null) {
var new_categories = new string[current_categories.length + 1];
for (var i = 0; i < current_categories.length; i++) {
new_categories[i] = current_categories[i];
}
new_categories[current_categories.length] = text;
current_categories = new_categories;
current_item.categories = current_categories;
}
break;
case "enclosure":
var url = node->get_prop("url");
var type = node->get_prop("type");
var length = node->get_prop("length");
if (current_item != null && url != null) {
current_item.enclosure_url = url;
current_item.enclosure_type = type;
current_item.enclosure_length = length;
}
break;
case "content:encoded":
case "content":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (current_item != null && text != null) {
current_item.content = text;
}
break;
case "itunes:author":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (current_item != null && current_item.author == null && text != null) {
current_item.author = text;
}
break;
case "itunes:summary":
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (current_item != null) {
if (current_item.description == null && text != null) {
current_item.description = text;
}
}
break;
case "url":
if (in_image && current_feed != null) {
var text = node->get_content();
if (text != null) {
text = text.strip();
}
if (current_feed.link == null && text != null) {
current_feed.link = text;
}
}
break;
default:
iterate_children(node);
break;
}
}
private void iterate_children(Xml.Node* node) {
Xml.Node* child = node->first_element_child();
while (child != null) {
parse_element(child);
child = child->next_element_sibling();
}
}
}

View File

@@ -0,0 +1,41 @@
/*
* Repositories.vala
*
* Repository interfaces for Linux state management
*/
namespace RSSuper {
/**
* FeedRepository - Interface for feed repository operations
*/
public interface FeedRepository : Object {
public abstract void get_feed_items(string? subscription_id, State<FeedItem[]> callback);
public abstract FeedItem? get_feed_item_by_id(string id) throws Error;
public abstract void insert_feed_item(FeedItem item) throws Error;
public abstract void insert_feed_items(FeedItem[] items) throws Error;
public abstract void update_feed_item(FeedItem item) throws Error;
public abstract void mark_as_read(string id, bool is_read) throws Error;
public abstract void mark_as_starred(string id, bool is_starred) throws Error;
public abstract void delete_feed_item(string id) throws Error;
public abstract int get_unread_count(string? subscription_id) throws Error;
}
/**
* SubscriptionRepository - Interface for subscription repository operations
*/
public interface SubscriptionRepository : Object {
public abstract void get_all_subscriptions(State<FeedSubscription[]> callback);
public abstract void get_enabled_subscriptions(State<FeedSubscription[]> callback);
public abstract void get_subscriptions_by_category(string category, State<FeedSubscription[]> callback);
public abstract FeedSubscription? get_subscription_by_id(string id) throws Error;
public abstract FeedSubscription? get_subscription_by_url(string url) throws Error;
public abstract void insert_subscription(FeedSubscription subscription) throws Error;
public abstract void update_subscription(FeedSubscription subscription) throws Error;
public abstract void delete_subscription(string id) throws Error;
public abstract void set_enabled(string id, bool enabled) throws Error;
public abstract void set_error(string id, string? error) throws Error;
public abstract void update_last_fetched_at(string id, ulong last_fetched_at) throws Error;
public abstract void update_next_fetch_at(string id, ulong next_fetch_at) throws Error;
}
}

View File

@@ -0,0 +1,136 @@
/*
* RepositoriesImpl.vala
*
* Repository implementations for Linux state management
*/
namespace RSSuper {
/**
* FeedRepositoryImpl - Implementation of FeedRepository
*/
public class FeedRepositoryImpl : Object, FeedRepository {
private Database db;
public FeedRepositoryImpl(Database db) {
this.db = db;
}
public override void get_feed_items(string? subscription_id, State<FeedItem[]> callback) {
try {
var feedItems = db.getFeedItems(subscription_id);
callback.set_success(feedItems);
} catch (Error e) {
callback.set_error("Failed to get feed items", e);
}
}
public override FeedItem? get_feed_item_by_id(string id) throws Error {
return db.getFeedItemById(id);
}
public override void insert_feed_item(FeedItem item) throws Error {
db.insertFeedItem(item);
}
public override void insert_feed_items(FeedItem[] items) throws Error {
foreach (var item in items) {
db.insertFeedItem(item);
}
}
public override void update_feed_item(FeedItem item) throws Error {
db.updateFeedItem(item);
}
public override void mark_as_read(string id, bool is_read) throws Error {
db.markFeedItemAsRead(id, is_read);
}
public override void mark_as_starred(string id, bool is_starred) throws Error {
db.markFeedItemAsStarred(id, is_starred);
}
public override void delete_feed_item(string id) throws Error {
db.deleteFeedItem(id);
}
public override int get_unread_count(string? subscription_id) throws Error {
return db.getUnreadCount(subscription_id);
}
}
/**
* SubscriptionRepositoryImpl - Implementation of SubscriptionRepository
*/
public class SubscriptionRepositoryImpl : Object, SubscriptionRepository {
private Database db;
public SubscriptionRepositoryImpl(Database db) {
this.db = db;
}
public override void get_all_subscriptions(State<FeedSubscription[]> callback) {
try {
var subscriptions = db.getAllSubscriptions();
callback.set_success(subscriptions);
} catch (Error e) {
callback.set_error("Failed to get subscriptions", e);
}
}
public override void get_enabled_subscriptions(State<FeedSubscription[]> callback) {
try {
var subscriptions = db.getEnabledSubscriptions();
callback.set_success(subscriptions);
} catch (Error e) {
callback.set_error("Failed to get enabled subscriptions", e);
}
}
public override void get_subscriptions_by_category(string category, State<FeedSubscription[]> callback) {
try {
var subscriptions = db.getSubscriptionsByCategory(category);
callback.set_success(subscriptions);
} catch (Error e) {
callback.set_error("Failed to get subscriptions by category", e);
}
}
public override FeedSubscription? get_subscription_by_id(string id) throws Error {
return db.getSubscriptionById(id);
}
public override FeedSubscription? get_subscription_by_url(string url) throws Error {
return db.getSubscriptionByUrl(url);
}
public override void insert_subscription(FeedSubscription subscription) throws Error {
db.insertSubscription(subscription);
}
public override void update_subscription(FeedSubscription subscription) throws Error {
db.updateSubscription(subscription);
}
public override void delete_subscription(string id) throws Error {
db.deleteSubscription(id);
}
public override void set_enabled(string id, bool enabled) throws Error {
db.setSubscriptionEnabled(id, enabled);
}
public override void set_error(string id, string? error) throws Error {
db.setSubscriptionError(id, error);
}
public override void update_last_fetched_at(string id, ulong last_fetched_at) throws Error {
db.setSubscriptionLastFetchedAt(id, last_fetched_at);
}
public override void update_next_fetch_at(string id, ulong next_fetch_at) throws Error {
db.setSubscriptionNextFetchAt(id, next_fetch_at);
}
}
}

View File

@@ -0,0 +1,34 @@
/*
* ErrorType.vala
*
* Error types for state management
*/
namespace RSSuper {
/**
* ErrorType - Category of errors
*/
public enum ErrorType {
NETWORK,
DATABASE,
PARSING,
AUTH,
UNKNOWN
}
/**
* ErrorDetails - Detailed error information
*/
public class ErrorDetails : Object {
public ErrorType type { get; set; }
public string message { get; set; }
public bool retryable { get; set; }
public ErrorDetails(ErrorType type, string message, bool retryable = false) {
this.type = type;
this.message = message;
this.retryable = retryable;
}
}
}

110
linux/src/state/State.vala Normal file
View File

@@ -0,0 +1,110 @@
/*
* State.vala
*
* Reactive state management using GObject signals
*/
namespace RSSuper {
/**
* State - Enumerated state for reactive state management
*/
public enum State {
IDLE,
LOADING,
SUCCESS,
ERROR
}
/**
* State<T> - Generic state container with signals
*/
public class State<T> : Object {
private State _state;
private T? _data;
private string? _message;
private Error? _error;
public State() {
_state = State.IDLE;
}
public State.idle() {
_state = State.IDLE;
}
public State.loading() {
_state = State.LOADING;
}
public State.success(T data) {
_state = State.SUCCESS;
_data = data;
}
public State.error(string message, Error? error = null) {
_state = State.ERROR;
_message = message;
_error = error;
}
public State get_state() {
return _state;
}
public T? get_data() {
return _data;
}
public string? get_message() {
return _message;
}
public Error? get_error() {
return _error;
}
public bool is_idle() {
return _state == State.IDLE;
}
public bool is_loading() {
return _state == State.LOADING;
}
public bool is_success() {
return _state == State.SUCCESS;
}
public bool is_error() {
return _state == State.ERROR;
}
public void set_idle() {
_state = State.IDLE;
_data = null;
_message = null;
_error = null;
}
public void set_loading() {
_state = State.LOADING;
_data = null;
_message = null;
_error = null;
}
public void set_success(T data) {
_state = State.SUCCESS;
_data = data;
_message = null;
_error = null;
}
public void set_error(string message, Error? error = null) {
_state = State.ERROR;
_message = message;
_error = error;
}
}
}

View File

@@ -0,0 +1,423 @@
/*
* DatabaseTests.vala
*
* Unit tests for database layer.
*/
public class RSSuper.DatabaseTests {
private Database? db;
private string test_db_path;
public void run_subscription_crud() {
try {
test_db_path = "/tmp/rssuper_test_%d.db".printf((int)new DateTime.now_local().to_unix());
db = new Database(test_db_path);
} catch (DBError e) {
warning("Failed to create test database: %s", e.message);
return;
}
try {
var store = new SubscriptionStore(db);
// Create test subscription
var subscription = new FeedSubscription.with_values(
"sub_1",
"https://example.com/feed.xml",
"Example Feed",
60,
"Technology",
true,
"2024-01-01T00:00:00Z",
"2024-01-01T00:00:00Z"
);
// Test add
store.add(subscription);
var retrieved = store.get_by_id("sub_1");
if (retrieved == null) {
printerr("FAIL: Expected subscription to exist after add\n");
return;
}
// Test get
if (retrieved.title != "Example Feed") {
printerr("FAIL: Expected title 'Example Feed', got '%s'\n", retrieved.title);
return;
}
if (retrieved.url != "https://example.com/feed.xml") {
printerr("FAIL: Expected url 'https://example.com/feed.xml', got '%s'\n", retrieved.url);
return;
}
// Test update
retrieved.title = "Updated Feed";
store.update(retrieved);
var updated = store.get_by_id("sub_1");
if (updated.title != "Updated Feed") {
printerr("FAIL: Expected title 'Updated Feed', got '%s'\n", updated.title);
return;
}
// Test delete
store.remove_subscription("sub_1");
var deleted = store.get_by_id("sub_1");
if (deleted != null) {
printerr("FAIL: Expected subscription to be deleted\n");
return;
}
print("PASS: test_subscription_crud\n");
} finally {
cleanup();
}
}
public void run_subscription_list() {
try {
test_db_path = "/tmp/rssuper_test_%d.db".printf((int)new DateTime.now_local().to_unix());
db = new Database(test_db_path);
} catch (DBError e) {
warning("Failed to create test database: %s", e.message);
return;
}
try {
var store = new SubscriptionStore(db);
// Add multiple subscriptions
var sub1 = new FeedSubscription.with_values("sub_1", "https://feed1.com", "Feed 1");
var sub2 = new FeedSubscription.with_values("sub_2", "https://feed2.com", "Feed 2");
var sub3 = new FeedSubscription.with_values("sub_3", "https://feed3.com", "Feed 3", 60, null, false);
store.add(sub1);
store.add(sub2);
store.add(sub3);
// Test get_all
var all = store.get_all();
if (all.length != 3) {
printerr("FAIL: Expected 3 subscriptions, got %d\n", all.length);
return;
}
// Test get_enabled
var enabled = store.get_enabled();
if (enabled.length != 2) {
printerr("FAIL: Expected 2 enabled subscriptions, got %d\n", enabled.length);
return;
}
print("PASS: test_subscription_list\n");
} finally {
cleanup();
}
}
public void run_feed_item_crud() {
try {
test_db_path = "/tmp/rssuper_test_%d.db".printf((int)new DateTime.now_local().to_unix());
db = new Database(test_db_path);
} catch (DBError e) {
warning("Failed to create test database: %s", e.message);
return;
}
try {
var sub_store = new SubscriptionStore(db);
var item_store = new FeedItemStore(db);
// Create subscription first
var subscription = new FeedSubscription.with_values(
"sub_1", "https://example.com/feed.xml", "Example Feed"
);
sub_store.add(subscription);
// Create test item
var item = new FeedItem.with_values(
"item_1",
"Test Article",
"https://example.com/article",
"This is a test description",
"Full content of the article",
"John Doe",
"2024-01-01T12:00:00Z",
"2024-01-01T12:00:00Z",
{"Technology", "News"},
null, null, null, null,
"sub_1" // subscription_id (stored as subscription_title in DB)
);
// Test add
item_store.add(item);
var retrieved = item_store.get_by_id("item_1");
if (retrieved == null) {
printerr("FAIL: Expected item to exist after add\n");
return;
}
if (retrieved.title != "Test Article") {
printerr("FAIL: Expected title 'Test Article', got '%s'\n", retrieved.title);
return;
}
// Test get by subscription
var items = item_store.get_by_subscription("sub_1");
if (items.length != 1) {
printerr("FAIL: Expected 1 item, got %d\n", items.length);
return;
}
// Test mark as read
item_store.mark_as_read("item_1");
// Test delete
item_store.delete("item_1");
var deleted = item_store.get_by_id("item_1");
if (deleted != null) {
printerr("FAIL: Expected item to be deleted\n");
return;
}
print("PASS: test_feed_item_crud\n");
} finally {
cleanup();
}
}
public void run_feed_item_batch() {
try {
test_db_path = "/tmp/rssuper_test_%d.db".printf((int)new DateTime.now_local().to_unix());
db = new Database(test_db_path);
} catch (DBError e) {
warning("Failed to create test database: %s", e.message);
return;
}
try {
var sub_store = new SubscriptionStore(db);
var item_store = new FeedItemStore(db);
// Create subscription
var subscription = new FeedSubscription.with_values(
"sub_1", "https://example.com/feed.xml", "Example Feed"
);
sub_store.add(subscription);
// Create multiple items
var items = new FeedItem[5];
for (var i = 0; i < 5; i++) {
items[i] = new FeedItem.with_values(
"item_%d".printf(i),
"Article %d".printf(i),
"https://example.com/article%d".printf(i),
"Description %d".printf(i),
null,
"Author %d".printf(i),
"2024-01-%02dT12:00:00Z".printf(i + 1),
null,
null,
null, null, null, null,
"sub_1" // subscription_id
);
}
// Test batch insert
item_store.add_batch(items);
var all = item_store.get_by_subscription("sub_1");
if (all.length != 5) {
printerr("FAIL: Expected 5 items, got %d\n", all.length);
return;
}
print("PASS: test_feed_item_batch\n");
} finally {
cleanup();
}
}
public void run_search_history() {
try {
test_db_path = "/tmp/rssuper_test_%d.db".printf((int)new DateTime.now_local().to_unix());
db = new Database(test_db_path);
} catch (DBError e) {
warning("Failed to create test database: %s", e.message);
return;
}
try {
var store = new SearchHistoryStore(db);
// Create test queries
var query1 = SearchQuery("test query", 1, 20, null, SearchSortOption.RELEVANCE);
var query2 = SearchQuery("another search", 1, 10, null, SearchSortOption.DATE_DESC);
// Test record
store.record_search(query1, 15);
store.record_search(query2, 8);
// Test get_history
var history = store.get_history();
if (history.length != 2) {
printerr("FAIL: Expected 2 history entries, got %d\n", history.length);
return;
}
// Check that both queries are in history (order may vary due to timing)
bool found_test_query = false;
bool found_another_search = false;
foreach (var q in history) {
if (q.query == "test query") found_test_query = true;
if (q.query == "another search") found_another_search = true;
}
if (!found_test_query || !found_another_search) {
printerr("FAIL: Expected both queries in history\n");
return;
}
// Test get_recent
var recent = store.get_recent();
if (recent.length != 2) {
printerr("FAIL: Expected 2 recent entries, got %d\n", recent.length);
return;
}
print("PASS: test_search_history\n");
} finally {
cleanup();
}
}
public void run_fts_search() {
try {
test_db_path = "/tmp/rssuper_test_%d.db".printf((int)new DateTime.now_local().to_unix());
db = new Database(test_db_path);
} catch (DBError e) {
warning("Failed to create test database: %s", e.message);
return;
}
try {
var sub_store = new SubscriptionStore(db);
var item_store = new FeedItemStore(db);
// Create subscription
var subscription = new FeedSubscription.with_values(
"sub_1", "https://example.com/feed.xml", "Example Feed"
);
sub_store.add(subscription);
// Add items with searchable content
var item1 = new FeedItem.with_values(
"item_1",
"Swift Programming Guide",
"https://example.com/swift",
"Learn Swift programming language basics",
"A comprehensive guide to Swift",
"Apple Developer",
"2024-01-01T12:00:00Z",
null,
null,
null, null, null, null,
"sub_1" // subscription_id
);
var item2 = new FeedItem.with_values(
"item_2",
"Python for Data Science",
"https://example.com/python",
"Data analysis with Python and pandas",
"Complete Python data science tutorial",
"Data Team",
"2024-01-02T12:00:00Z",
null,
null,
null, null, null, null,
"sub_1" // subscription_id
);
item_store.add(item1);
item_store.add(item2);
// Test FTS search
var results = item_store.search("swift");
if (results.length != 1) {
printerr("FAIL: Expected 1 result for 'swift', got %d\n", results.length);
return;
}
if (results[0].title != "Swift Programming Guide") {
printerr("FAIL: Expected 'Swift Programming Guide', got '%s'\n", results[0].title);
return;
}
results = item_store.search("python");
if (results.length != 1) {
printerr("FAIL: Expected 1 result for 'python', got %d\n", results.length);
return;
}
if (results[0].title != "Python for Data Science") {
printerr("FAIL: Expected 'Python for Data Science', got '%s'\n", results[0].title);
return;
}
print("PASS: test_fts_search\n");
} finally {
cleanup();
}
}
private void cleanup() {
if (db != null) {
db.close();
db = null;
}
// Clean up test database
if (test_db_path != null && test_db_path.length > 0) {
var file = File.new_for_path(test_db_path);
if (file.query_exists()) {
try {
file.delete();
} catch (Error e) {
warning("Failed to delete test database: %s", e.message);
}
}
// Clean up WAL file
var wal_file = File.new_for_path(test_db_path + "-wal");
if (wal_file.query_exists()) {
try {
wal_file.delete();
} catch (Error e) {
warning("Failed to delete WAL file: %s", e.message);
}
}
}
}
public static int main(string[] args) {
print("Running database tests...\n");
var tests = new DatabaseTests();
print("\n=== Running subscription CRUD tests ===");
tests.run_subscription_crud();
print("\n=== Running subscription list tests ===");
tests.run_subscription_list();
print("\n=== Running feed item CRUD tests ===");
tests.run_feed_item_crud();
print("\n=== Running feed item batch tests ===");
tests.run_feed_item_batch();
print("\n=== Running search history tests ===");
tests.run_search_history();
print("\n=== Running FTS search tests ===");
tests.run_fts_search();
print("\nAll tests completed!\n");
return 0;
}
}

View File

@@ -0,0 +1,302 @@
/*
* FeedFetcherTests.vala
*
* Unit and integration tests for the feed fetcher service.
*/
using Soup;
using GLib;
/**
* FeedFetcherTests - Tests for FeedFetcher
*/
public class RSSuper.FeedFetcherTests {
public static int main(string[] args) {
var tests = new FeedFetcherTests();
// Unit tests
tests.test_session_configuration();
tests.test_http_auth_credentials();
tests.test_fetch_result_success();
tests.test_fetch_result_failure();
tests.test_cache_entry_expiration();
tests.test_url_validation();
tests.test_content_type_validation();
tests.test_error_handling();
// Integration tests (require network)
tests.test_fetch_real_feed();
tests.test_fetch_with_timeout();
tests.test_fetch_404();
tests.test_fetch_invalid_url();
print("All feed fetcher tests passed!\n");
return 0;
}
/**
* Test Soup session configuration
*/
public void test_session_configuration() {
var fetcher = new FeedFetcher(timeout_seconds: 10, max_retries: 5);
// Test default values
var default_fetcher = new FeedFetcher();
assert(default_fetcher.get_timeout() == FeedFetcher.DEFAULT_TIMEOUT);
assert(default_fetcher.get_max_retries() == FeedFetcher.DEFAULT_MAX_RETRIES);
// Test custom values
assert(fetcher.get_timeout() == 10);
assert(fetcher.get_max_retries() == 5);
// Test setting timeout
fetcher.set_timeout(20);
assert(fetcher.get_timeout() == 20);
print("PASS: test_session_configuration\n");
}
/**
* Test HTTP auth credentials
*/
public void test_http_auth_credentials() {
// Test default constructor
var creds1 = new HttpAuthCredentials();
assert(!creds1.has_credentials());
assert(creds1.username == null);
assert(creds1.password == null);
// Test with credentials
var creds2 = new HttpAuthCredentials.with_credentials("user", "pass");
assert(creds2.has_credentials());
assert(creds2.username == "user");
assert(creds2.password == "pass");
// Test with only username
var creds3 = new HttpAuthCredentials.with_credentials("user", null);
assert(creds3.has_credentials());
assert(creds3.username == "user");
// Test clear
creds2.clear();
assert(!creds2.has_credentials());
// Test equality
var creds4 = new HttpAuthCredentials.with_credentials("user", "pass");
var creds5 = new HttpAuthCredentials.with_credentials("user", "pass");
var creds6 = new HttpAuthCredentials.with_credentials("other", "pass");
assert(creds4.equals(creds5));
assert(!creds4.equals(creds6));
assert(!creds4.equals(null));
print("PASS: test_http_auth_credentials\n");
}
/**
* Test FetchResult success case
*/
public void test_fetch_result_success() {
var result = FetchResult.ok("feed content", 200, "application/rss+xml", "etag123", "Mon, 01 Jan 2024 00:00:00 GMT", false);
assert(result.successful);
assert(result.fetched_content == "feed content");
assert(result.status_code == 200);
assert(result.response_content_type == "application/rss+xml");
assert(result.response_etag == "etag123");
assert(result.response_last_modified == "Mon, 01 Jan 2024 00:00:00 GMT");
assert(!result.is_from_cache);
assert(result.error == null);
// Test cached success
var cached_result = FetchResult.ok("cached content", 304, null, null, null, true);
assert(cached_result.successful);
assert(cached_result.status_code == 304);
assert(cached_result.is_from_cache);
print("PASS: test_fetch_result_success\n");
}
/**
* Test FetchResult failure case
*/
public void test_fetch_result_failure() {
var result = FetchResult.err("Not found", 404);
assert(!result.successful);
assert(result.error == "Not found");
assert(result.status_code == 404);
assert(result.fetched_content == null);
// Test from error
try {
throw new NetworkError.NOT_FOUND("Resource not found");
} catch (Error e) {
var error_result = FetchResult.from_error(e);
assert(!error_result.successful);
assert(error_result.status_code == 404);
}
print("PASS: test_fetch_result_failure\n");
}
/**
* Test cache entry expiration
*/
public void test_cache_entry_expiration() {
// This tests the CacheEntry class indirectly through FeedFetcher
var fetcher = new FeedFetcher();
// Test cache operations
assert(fetcher.get_cache_size() == 0);
// Clear cache (should work even when empty)
fetcher.clear_cache();
assert(fetcher.get_cache_size() == 0);
// Test HashTable operations directly
var hash_table = new HashTable<string, string>(str_hash, str_equal);
hash_table.insert("key1", "value1");
assert(hash_table.lookup("key1") == "value1");
assert(hash_table.get_size() == 1);
hash_table.remove("key1");
assert(hash_table.lookup("key1") == null);
print("PASS: test_cache_entry_expiration\n");
}
/**
* Test URL validation
*/
public void test_url_validation() {
var fetcher = new FeedFetcher();
// Test invalid URLs
var result1 = fetcher.fetch("not a url");
assert(!result1.successful);
var result2 = fetcher.fetch("ftp://example.com/feed.xml");
assert(!result2.successful);
var result3 = fetcher.fetch("");
assert(!result3.successful);
print("PASS: test_url_validation\n");
}
/**
* Test content type validation
*/
public void test_content_type_validation() {
// Content type validation is done during fetch
// This test verifies the fetcher accepts various content types
var fetcher = new FeedFetcher();
// We can't easily test this without a mock server
// But we can verify the fetcher is created correctly
assert(fetcher != null);
print("PASS: test_content_type_validation\n");
}
/**
* Test error handling
*/
public void test_error_handling() {
var fetcher = new FeedFetcher(timeout_seconds: 1, max_retries: 1);
// Test timeout error (using a slow/unreachable host)
var result = fetcher.fetch("http://10.255.255.1/feed.xml");
assert(!result.successful);
print("PASS: test_error_handling\n");
}
/**
* Integration test: fetch a real feed
*/
public void test_fetch_real_feed() {
var fetcher = new FeedFetcher(timeout_seconds: 15);
// Use a reliable public feed
var test_url = "https://feeds.feedburner.com/OrangePressReleases";
print("Fetching test feed from: %s\n", test_url);
try {
var result = fetcher.fetch(test_url);
if (!result.successful) {
printerr("Feed fetch failed: %s (status: %d)\n",
result.error,
result.status_code);
// Don't fail the test for network issues
print("WARNING: Skipping real feed test due to network issue\n");
return;
}
var content = result.fetched_content;
assert(content != null);
assert(content.length() > 0);
// Verify it looks like XML/RSS/Atom
assert(content.contains("<") || content.contains("<?xml"));
print("Fetched %d bytes from %s\n", content.length(), test_url);
print("PASS: test_fetch_real_feed\n");
} catch (Error e) {
printerr("Feed fetch error: %s\n", e.message);
print("WARNING: Skipping real feed test due to error\n");
}
}
/**
* Integration test: fetch with timeout
*/
public void test_fetch_with_timeout() {
var fetcher = new FeedFetcher(timeout_seconds: 2, max_retries: 0);
// Try to fetch from a slow host
var result = fetcher.fetch("http://10.255.255.1/feed.xml");
assert(!result.successful);
// Should timeout or connection fail
print("PASS: test_fetch_with_timeout\n");
}
/**
* Integration test: fetch 404
*/
public void test_fetch_404() {
var fetcher = new FeedFetcher(timeout_seconds: 10);
// Try to fetch a non-existent feed from a reliable host
var result = fetcher.fetch("https://httpbin.org/status/404");
if (result.successful) {
// httpbin might return 200 with 404 content
// Just verify we got a response
print("Note: httpbin returned success, checking content...\n");
} else {
assert(result.status_code == 404 || result.status_code == 0);
}
print("PASS: test_fetch_404\n");
}
/**
* Integration test: fetch invalid URL
*/
public void test_fetch_invalid_url() {
var fetcher = new FeedFetcher();
var result = fetcher.fetch("invalid-url");
assert(!result.successful);
assert(result.error != null);
print("PASS: test_fetch_invalid_url\n");
}
}

View File

@@ -0,0 +1,347 @@
/*
* ParserTests.vala
*
* Unit tests for RSS/Atom feed parser.
*/
public class RSSuper.ParserTests {
public static int main(string[] args) {
var tests = new ParserTests();
tests.test_rss_parsing();
tests.test_atom_parsing();
tests.test_feed_type_detection();
tests.test_malformed_xml();
tests.test_itunes_namespace();
tests.test_enclosures();
print("All parser tests passed!\n");
return 0;
}
public void test_rss_parsing() {
var rss_content = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Test Feed</title>
<link>https://example.com</link>
<description>A test RSS feed</description>
<language>en</language>
<lastBuildDate>Mon, 01 Jan 2024 12:00:00 GMT</lastBuildDate>
<ttl>60</ttl>
<item>
<title>First Post</title>
<link>https://example.com/post1</link>
<description>This is the first post</description>
<pubDate>Mon, 01 Jan 2024 12:00:00 GMT</pubDate>
<guid>post-1</guid>
</item>
<item>
<title>Second Post</title>
<link>https://example.com/post2</link>
<description>This is the second post</description>
<pubDate>Tue, 02 Jan 2024 12:00:00 GMT</pubDate>
<guid>post-2</guid>
</item>
</channel>
</rss>""";
var parser = new FeedParser();
var result = parser.parse(rss_content, "https://example.com/feed.xml");
print("RSS parsing result ok: %s\n", result.ok ? "true" : "false");
if (!result.ok) {
printerr("FAIL: RSS parsing failed: %s\n", result.get_error().message);
return;
}
var feed = result.get_value() as Feed;
if (feed == null) {
printerr("FAIL: Expected Feed object\n");
return;
}
print("Feed title: '%s'\n", feed.title);
print("Feed link: '%s'\n", feed.link);
print("Feed description: '%s'\n", feed.description);
print("Items length: %d\n", feed.items.length);
if (feed.items.length > 0) {
print("First item title: '%s'\n", feed.items[0].title);
}
if (feed.items.length > 1) {
print("Second item title: '%s'\n", feed.items[1].title);
}
if (feed.title != "Test Feed") {
printerr("FAIL: Expected title 'Test Feed', got '%s'\n", feed.title);
return;
}
if (feed.link != "https://example.com") {
printerr("FAIL: Expected link 'https://example.com', got '%s'\n", feed.link);
return;
}
if (feed.description != "A test RSS feed") {
printerr("FAIL: Expected description 'A test RSS feed', got '%s'\n", feed.description);
return;
}
if (feed.items.length != 2) {
printerr("FAIL: Expected 2 items, got %d\n", feed.items.length);
return;
}
if (feed.items[0].title != "First Post") {
printerr("FAIL: Expected first item title 'First Post', got '%s'\n", feed.items[0].title);
return;
}
if (feed.items[1].title != "Second Post") {
printerr("FAIL: Expected second item title 'Second Post', got '%s'\n", feed.items[1].title);
return;
}
print("PASS: test_rss_parsing\n");
}
public void test_atom_parsing() {
var atom_content = """<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Test Atom Feed</title>
<subtitle>A test Atom feed</subtitle>
<link href="https://example.com" rel="alternate"/>
<link href="https://example.com/feed.xml" rel="self"/>
<updated>2024-01-01T12:00:00Z</updated>
<id>urn:uuid:feed-123</id>
<entry>
<title>First Entry</title>
<link href="https://example.com/entry1" rel="alternate"/>
<summary>This is the first entry</summary>
<updated>2024-01-01T12:00:00Z</updated>
<published>2024-01-01T12:00:00Z</published>
<id>urn:uuid:entry-1</id>
<author>
<name>Test Author</name>
</author>
</entry>
<entry>
<title>Second Entry</title>
<link href="https://example.com/entry2" rel="alternate"/>
<summary>This is the second entry</summary>
<updated>2024-01-02T12:00:00Z</updated>
<published>2024-01-02T12:00:00Z</published>
<id>urn:uuid:entry-2</id>
</entry>
</feed>""";
var parser = new FeedParser();
var result = parser.parse(atom_content, "https://example.com/feed.xml");
if (!result.ok) {
printerr("FAIL: Atom parsing failed: %s\n", result.get_error().message);
return;
}
var feed = result.get_value() as Feed;
if (feed == null) {
printerr("FAIL: Expected Feed object\n");
return;
}
if (feed.title != "Test Atom Feed") {
printerr("FAIL: Expected title 'Test Atom Feed', got '%s'\n", feed.title);
return;
}
if (feed.link != "https://example.com") {
printerr("FAIL: Expected link 'https://example.com', got '%s'\n", feed.link);
return;
}
if (feed.subtitle != "A test Atom feed") {
printerr("FAIL: Expected subtitle 'A test Atom feed', got '%s'\n", feed.subtitle);
return;
}
if (feed.items.length != 2) {
printerr("FAIL: Expected 2 items, got %d\n", feed.items.length);
return;
}
if (feed.items[0].title != "First Entry") {
printerr("FAIL: Expected first item title 'First Entry', got '%s'\n", feed.items[0].title);
return;
}
if (feed.items[0].author != "Test Author") {
printerr("FAIL: Expected first item author 'Test Author', got '%s'\n", feed.items[0].author);
return;
}
if (feed.items[0].description != "This is the first entry") {
printerr("FAIL: Expected first item description 'This is the first entry', got '%s'\n", feed.items[0].description);
return;
}
print("PASS: test_atom_parsing\n");
}
public void test_feed_type_detection() {
var parser = new FeedParser();
var rss_content = """<?xml version="1.0"?><rss version="2.0"><channel><title>Test</title></channel></rss>""";
var type = parser.detect_feed_type(rss_content);
if (type != FeedType.RSS_2_0) {
printerr("FAIL: Expected RSS 2.0, got %s\n", type.to_string());
return;
}
var atom_content = """<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"><title>Test</title></feed>""";
type = parser.detect_feed_type(atom_content);
if (type != FeedType.ATOM) {
printerr("FAIL: Expected Atom, got %s\n", type.to_string());
return;
}
var rdf_content = """<?xml version="1.0"?><RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><channel><title>Test</title></channel></RDF>""";
type = parser.detect_feed_type(rdf_content);
if (type != FeedType.RSS_1_0) {
printerr("FAIL: Expected RSS 1.0, got %s\n", type.to_string());
return;
}
print("PASS: test_feed_type_detection\n");
}
public void test_malformed_xml() {
var parser = new FeedParser();
var result = parser.parse("not xml at all", "https://example.com/feed.xml");
if (result.ok) {
printerr("FAIL: Expected parsing to fail for malformed XML\n");
return;
}
result = parser.parse("<rss><channel>", "https://example.com/feed.xml");
if (result.ok) {
printerr("FAIL: Expected parsing to fail for incomplete XML\n");
return;
}
print("PASS: test_malformed_xml\n");
}
public void test_itunes_namespace() {
var rss_content = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<channel>
<title>Podcast Feed</title>
<link>https://example.com</link>
<itunes:author>Podcast Author</itunes:author>
<itunes:summary>A podcast feed</itunes:summary>
<item>
<title>Episode 1</title>
<link>https://example.com/episode1</link>
<description>Episode summary</description>
<itunes:author>Episode Author</itunes:author>
<enclosure url="https://example.com/episode1.mp3" type="audio/mpeg" length="12345678"/>
</item>
</channel>
</rss>""";
var parser = new FeedParser();
var result = parser.parse(rss_content, "https://example.com/feed.xml");
if (!result.ok) {
printerr("FAIL: iTunes parsing failed: %s\n", result.get_error().message);
return;
}
var feed = result.get_value() as Feed;
if (feed == null) {
printerr("FAIL: Expected Feed object\n");
return;
}
if (feed.items.length != 1) {
printerr("FAIL: Expected 1 item, got %d\n", feed.items.length);
return;
}
if (feed.items[0].author != "Episode Author") {
printerr("FAIL: Expected author 'Episode Author', got '%s'\n", feed.items[0].author);
return;
}
if (feed.items[0].description != "Episode summary") {
printerr("FAIL: Expected description 'Episode summary', got '%s'\n", feed.items[0].description);
return;
}
print("PASS: test_itunes_namespace\n");
}
public void test_enclosures() {
var rss_content = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Enclosure Test</title>
<link>https://example.com</link>
<item>
<title>Post with Enclosure</title>
<link>https://example.com/post</link>
<enclosure url="https://example.com/file.mp3" type="audio/mpeg" length="12345678"/>
</item>
<item>
<title>Post without Enclosure</title>
<link>https://example.com/post2</link>
</item>
</channel>
</rss>""";
var parser = new FeedParser();
var result = parser.parse(rss_content, "https://example.com/feed.xml");
if (!result.ok) {
printerr("FAIL: Enclosure parsing failed: %s\n", result.get_error().message);
return;
}
var feed = result.get_value() as Feed;
if (feed == null) {
printerr("FAIL: Expected Feed object\n");
return;
}
if (feed.items.length != 2) {
printerr("FAIL: Expected 2 items, got %d\n", feed.items.length);
return;
}
if (feed.items[0].enclosure_url != "https://example.com/file.mp3") {
printerr("FAIL: Expected enclosure_url 'https://example.com/file.mp3', got '%s'\n", feed.items[0].enclosure_url);
return;
}
if (feed.items[0].enclosure_type != "audio/mpeg") {
printerr("FAIL: Expected enclosure_type 'audio/mpeg', got '%s'\n", feed.items[0].enclosure_type);
return;
}
if (feed.items[0].enclosure_length != "12345678") {
printerr("FAIL: Expected enclosure_length '12345678', got '%s'\n", feed.items[0].enclosure_length);
return;
}
if (feed.items[1].enclosure_url != null) {
printerr("FAIL: Expected no enclosure for second item\n");
return;
}
print("PASS: test_enclosures\n");
}
}

View File

@@ -0,0 +1,70 @@
/*
* FeedViewModel.vala
*
* ViewModel for feed state management
*/
namespace RSSuper {
/**
* FeedViewModel - Manages feed state for UI binding
*/
public class FeedViewModel : Object {
private FeedRepository repository;
private State<FeedItem[]> feedState;
private State<int> unreadCountState;
public FeedViewModel(FeedRepository repository) {
this.repository = repository;
this.feedState = new State<FeedItem[]>();
this.unreadCountState = new State<int>();
}
public State<FeedItem[]> get_feed_state() {
return feedState;
}
public State<int> get_unread_count_state() {
return unreadCountState;
}
public void load_feed_items(string? subscription_id = null) {
feedState.set_loading();
repository.get_feed_items(subscription_id, (state) => {
feedState = state;
});
}
public void load_unread_count(string? subscription_id = null) {
unreadCountState.set_loading();
try {
var count = repository.get_unread_count(subscription_id);
unreadCountState.set_success(count);
} catch (Error e) {
unreadCountState.set_error("Failed to load unread count", e);
}
}
public void mark_as_read(string id, bool is_read) {
try {
repository.mark_as_read(id, is_read);
load_unread_count();
} catch (Error e) {
unreadCountState.set_error("Failed to update read state", e);
}
}
public void mark_as_starred(string id, bool is_starred) {
try {
repository.mark_as_starred(id, is_starred);
} catch (Error e) {
feedState.set_error("Failed to update starred state", e);
}
}
public void refresh(string? subscription_id = null) {
load_feed_items(subscription_id);
load_unread_count(subscription_id);
}
}
}

View File

@@ -0,0 +1,83 @@
/*
* SubscriptionViewModel.vala
*
* ViewModel for subscription state management
*/
namespace RSSuper {
/**
* SubscriptionViewModel - Manages subscription state for UI binding
*/
public class SubscriptionViewModel : Object {
private SubscriptionRepository repository;
private State<FeedSubscription[]> subscriptionsState;
private State<FeedSubscription[]> enabledSubscriptionsState;
public SubscriptionViewModel(SubscriptionRepository repository) {
this.repository = repository;
this.subscriptionsState = new State<FeedSubscription[]>();
this.enabledSubscriptionsState = new State<FeedSubscription[]>();
}
public State<FeedSubscription[]> get_subscriptions_state() {
return subscriptionsState;
}
public State<FeedSubscription[]> get_enabled_subscriptions_state() {
return enabledSubscriptionsState;
}
public void load_all_subscriptions() {
subscriptionsState.set_loading();
repository.get_all_subscriptions((state) => {
subscriptionsState = state;
});
}
public void load_enabled_subscriptions() {
enabledSubscriptionsState.set_loading();
repository.get_enabled_subscriptions((state) => {
enabledSubscriptionsState = state;
});
}
public void set_enabled(string id, bool enabled) {
try {
repository.set_enabled(id, enabled);
load_enabled_subscriptions();
} catch (Error e) {
enabledSubscriptionsState.set_error("Failed to update subscription enabled state", e);
}
}
public void set_error(string id, string? error) {
try {
repository.set_error(id, error);
} catch (Error e) {
subscriptionsState.set_error("Failed to set subscription error", e);
}
}
public void update_last_fetched_at(string id, ulong last_fetched_at) {
try {
repository.update_last_fetched_at(id, last_fetched_at);
} catch (Error e) {
subscriptionsState.set_error("Failed to update last fetched time", e);
}
}
public void update_next_fetch_at(string id, ulong next_fetch_at) {
try {
repository.update_next_fetch_at(id, next_fetch_at);
} catch (Error e) {
subscriptionsState.set_error("Failed to update next fetch time", e);
}
}
public void refresh() {
load_all_subscriptions();
load_enabled_subscriptions();
}
}
}