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
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:
1
linux/.gitignore
vendored
Normal file
1
linux/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
build
|
||||
@@ -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
119
linux/meson.build
Normal 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)
|
||||
69
linux/rssuper-database.vapi
Normal file
69
linux/rssuper-database.vapi
Normal 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);
|
||||
}
|
||||
200
linux/src/database/database.vala
Normal file
200
linux/src/database/database.vala
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
24
linux/src/database/db-error.vala
Normal file
24
linux/src/database/db-error.vala
Normal 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 */
|
||||
}
|
||||
}
|
||||
416
linux/src/database/feed-item-store.vala
Normal file
416
linux/src/database/feed-item-store.vala
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
103
linux/src/database/schema.sql
Normal file
103
linux/src/database/schema.sql
Normal 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);
|
||||
171
linux/src/database/search-history-store.vala
Normal file
171
linux/src/database/search-history-store.vala
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
69
linux/src/database/sqlite3.vapi
Normal file
69
linux/src/database/sqlite3.vapi
Normal 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);
|
||||
}
|
||||
244
linux/src/database/subscription-store.vala
Normal file
244
linux/src/database/subscription-store.vala
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
313
linux/src/models/feed-item.vala
Normal file
313
linux/src/models/feed-item.vala
Normal 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");
|
||||
}
|
||||
}
|
||||
259
linux/src/models/feed-subscription.vala
Normal file
259
linux/src/models/feed-subscription.vala
Normal 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
282
linux/src/models/feed.vala
Normal 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;
|
||||
}
|
||||
}
|
||||
5
linux/src/models/namespaces.vala
Normal file
5
linux/src/models/namespaces.vala
Normal file
@@ -0,0 +1,5 @@
|
||||
/*
|
||||
* Namespace definition for RSSuper Linux models
|
||||
*/
|
||||
public namespace RSSuper {
|
||||
}
|
||||
190
linux/src/models/notification-preferences.vala
Normal file
190
linux/src/models/notification-preferences.vala
Normal 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;
|
||||
}
|
||||
}
|
||||
168
linux/src/models/reading-preferences.vala
Normal file
168
linux/src/models/reading-preferences.vala
Normal 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;
|
||||
}
|
||||
}
|
||||
435
linux/src/models/search-filters.vala
Normal file
435
linux/src/models/search-filters.vala
Normal 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;
|
||||
}
|
||||
}
|
||||
208
linux/src/models/search-result.vala
Normal file
208
linux/src/models/search-result.vala
Normal 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);
|
||||
}
|
||||
}
|
||||
503
linux/src/network/feed-fetcher.vala
Normal file
503
linux/src/network/feed-fetcher.vala
Normal 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;
|
||||
}
|
||||
}
|
||||
137
linux/src/network/fetch-result.vala
Normal file
137
linux/src/network/fetch-result.vala
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
63
linux/src/network/http-auth-credentials.vala
Normal file
63
linux/src/network/http-auth-credentials.vala
Normal 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;
|
||||
}
|
||||
}
|
||||
29
linux/src/network/network-error.vala
Normal file
29
linux/src/network/network-error.vala
Normal 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 */
|
||||
}
|
||||
}
|
||||
373
linux/src/notification-manager.vala
Normal file
373
linux/src/notification-manager.vala
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
285
linux/src/notification-preferences-store.vala
Normal file
285
linux/src/notification-preferences-store.vala
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
232
linux/src/notification-service.vala
Normal file
232
linux/src/notification-service.vala
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
245
linux/src/parser/atom-parser.vala
Normal file
245
linux/src/parser/atom-parser.vala
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
88
linux/src/parser/feed-parser.vala
Normal file
88
linux/src/parser/feed-parser.vala
Normal 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);
|
||||
}
|
||||
}
|
||||
41
linux/src/parser/feed-type.vala
Normal file
41
linux/src/parser/feed-type.vala
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
61
linux/src/parser/parse-result.vala
Normal file
61
linux/src/parser/parse-result.vala
Normal 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);
|
||||
}
|
||||
}
|
||||
348
linux/src/parser/rss-parser.vala
Normal file
348
linux/src/parser/rss-parser.vala
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
41
linux/src/repository/Repositories.vala
Normal file
41
linux/src/repository/Repositories.vala
Normal 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;
|
||||
}
|
||||
}
|
||||
136
linux/src/repository/RepositoriesImpl.vala
Normal file
136
linux/src/repository/RepositoriesImpl.vala
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
linux/src/state/ErrorType.vala
Normal file
34
linux/src/state/ErrorType.vala
Normal 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
110
linux/src/state/State.vala
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
423
linux/src/tests/database-tests.vala
Normal file
423
linux/src/tests/database-tests.vala
Normal 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;
|
||||
}
|
||||
}
|
||||
302
linux/src/tests/feed-fetcher-tests.vala
Normal file
302
linux/src/tests/feed-fetcher-tests.vala
Normal 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");
|
||||
}
|
||||
}
|
||||
347
linux/src/tests/parser-tests.vala
Normal file
347
linux/src/tests/parser-tests.vala
Normal 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");
|
||||
}
|
||||
}
|
||||
70
linux/src/viewmodel/FeedViewModel.vala
Normal file
70
linux/src/viewmodel/FeedViewModel.vala
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
83
linux/src/viewmodel/SubscriptionViewModel.vala
Normal file
83
linux/src/viewmodel/SubscriptionViewModel.vala
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user