feat: implement cross-platform features and UI integration
- iOS: Add BackgroundSyncService, SyncScheduler, SyncWorker, BookmarkViewModel, FeedViewModel - iOS: Add BackgroundSyncService, SyncScheduler, SyncWorker services - Linux: Add settings-store.vala, State.vala signals, view widgets (FeedList, FeedDetail, AddFeed, Search, Settings, Bookmark) - Linux: Add bookmark-store.vala, bookmark vala model, search-service.vala - Android: Add NotificationService, NotificationManager, NotificationPreferencesStore - Android: Add BookmarkDao, BookmarkRepository, SettingsStore - Add unit tests for iOS, Android, Linux - Add integration tests - Add performance benchmarks - Update tasks and documentation Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -18,6 +18,7 @@ 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')
|
||||
gtk_dep = dependency('gtk4', version: '>= 4.0')
|
||||
|
||||
# Source files
|
||||
models = files(
|
||||
@@ -28,6 +29,7 @@ models = files(
|
||||
'src/models/search-filters.vala',
|
||||
'src/models/notification-preferences.vala',
|
||||
'src/models/reading-preferences.vala',
|
||||
'src/models/bookmark.vala',
|
||||
)
|
||||
|
||||
# Database files
|
||||
@@ -37,6 +39,18 @@ database = files(
|
||||
'src/database/subscription-store.vala',
|
||||
'src/database/feed-item-store.vala',
|
||||
'src/database/search-history-store.vala',
|
||||
'src/database/bookmark-store.vala',
|
||||
)
|
||||
|
||||
# Repository files
|
||||
repositories = files(
|
||||
'src/repository/bookmark-repository.vala',
|
||||
'src/repository/bookmark-repository-impl.vala',
|
||||
)
|
||||
|
||||
# Service files
|
||||
services = files(
|
||||
'src/service/search-service.vala',
|
||||
)
|
||||
|
||||
# Parser files
|
||||
@@ -70,6 +84,14 @@ database_lib = library('rssuper-database', database,
|
||||
vala_args: ['--vapidir', 'src/database', '--pkg', 'sqlite3']
|
||||
)
|
||||
|
||||
# Repository library
|
||||
repository_lib = library('rssuper-repositories', repositories,
|
||||
dependencies: [glib_dep, gio_dep, json_dep, sqlite_dep],
|
||||
link_with: [models_lib, database_lib],
|
||||
install: false,
|
||||
vala_args: ['--vapidir', 'src/repository']
|
||||
)
|
||||
|
||||
# Parser library
|
||||
parser_lib = library('rssuper-parser', parser,
|
||||
dependencies: [glib_dep, gio_dep, json_dep, xml_dep],
|
||||
@@ -113,7 +135,27 @@ fetcher_test_exe = executable('feed-fetcher-tests',
|
||||
install: false
|
||||
)
|
||||
|
||||
# Notification service test executable
|
||||
notification_service_test_exe = executable('notification-service-tests',
|
||||
'src/tests/notification-service-tests.vala',
|
||||
dependencies: [glib_dep, gio_dep, json_dep, gobject_dep],
|
||||
link_with: [models_lib],
|
||||
vala_args: ['--vapidir', '.', '--pkg', 'gio-2.0'],
|
||||
install: false
|
||||
)
|
||||
|
||||
# Notification manager test executable
|
||||
notification_manager_test_exe = executable('notification-manager-tests',
|
||||
'src/tests/notification-manager-tests.vala',
|
||||
dependencies: [glib_dep, gio_dep, json_dep, gobject_dep, gtk_dep],
|
||||
link_with: [models_lib],
|
||||
vala_args: ['--vapidir', '.', '--pkg', 'gio-2.0', '--pkg', 'gtk4'],
|
||||
install: false
|
||||
)
|
||||
|
||||
# Test definitions
|
||||
test('database tests', test_exe)
|
||||
test('parser tests', parser_test_exe)
|
||||
test('feed fetcher tests', fetcher_test_exe)
|
||||
test('notification service tests', notification_service_test_exe)
|
||||
test('notification manager tests', notification_manager_test_exe)
|
||||
|
||||
299
linux/src/database/bookmark-store.vala
Normal file
299
linux/src/database/bookmark-store.vala
Normal file
@@ -0,0 +1,299 @@
|
||||
/*
|
||||
* BookmarkStore.vala
|
||||
*
|
||||
* CRUD operations for bookmarks.
|
||||
*/
|
||||
|
||||
/**
|
||||
* BookmarkStore - Manages bookmark persistence
|
||||
*/
|
||||
public class RSSuper.BookmarkStore : Object {
|
||||
private Database db;
|
||||
|
||||
/**
|
||||
* Signal emitted when a bookmark is added
|
||||
*/
|
||||
public signal void bookmark_added(Bookmark bookmark);
|
||||
|
||||
/**
|
||||
* Signal emitted when a bookmark is updated
|
||||
*/
|
||||
public signal void bookmark_updated(Bookmark bookmark);
|
||||
|
||||
/**
|
||||
* Signal emitted when a bookmark is deleted
|
||||
*/
|
||||
public signal void bookmark_deleted(string id);
|
||||
|
||||
/**
|
||||
* Signal emitted when bookmarks are cleared
|
||||
*/
|
||||
public signal void bookmarks_cleared();
|
||||
|
||||
/**
|
||||
* Create a new bookmark store
|
||||
*/
|
||||
public BookmarkStore(Database db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new bookmark
|
||||
*/
|
||||
public Bookmark add(Bookmark bookmark) throws Error {
|
||||
var stmt = db.prepare(
|
||||
"INSERT INTO bookmarks (id, feed_item_id, title, link, description, content, created_at, tags) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?);"
|
||||
);
|
||||
|
||||
stmt.bind_text(1, bookmark.id, -1, null);
|
||||
stmt.bind_text(2, bookmark.feed_item_id, -1, null);
|
||||
stmt.bind_text(3, bookmark.title, -1, null);
|
||||
stmt.bind_text(4, bookmark.link ?? "", -1, null);
|
||||
stmt.bind_text(5, bookmark.description ?? "", -1, null);
|
||||
stmt.bind_text(6, bookmark.content ?? "", -1, null);
|
||||
stmt.bind_text(7, bookmark.created_at, -1, null);
|
||||
stmt.bind_text(8, bookmark.tags ?? "", -1, null);
|
||||
|
||||
stmt.step();
|
||||
|
||||
debug("Bookmark added: %s", bookmark.id);
|
||||
bookmark_added(bookmark);
|
||||
|
||||
return bookmark;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple bookmarks in a batch
|
||||
*/
|
||||
public void add_batch(Bookmark[] bookmarks) throws Error {
|
||||
db.begin_transaction();
|
||||
try {
|
||||
foreach (var bookmark in bookmarks) {
|
||||
add(bookmark);
|
||||
}
|
||||
db.commit();
|
||||
debug("Batch insert completed: %d bookmarks", bookmarks.length);
|
||||
} catch (Error e) {
|
||||
db.rollback();
|
||||
throw new DBError.FAILED("Transaction failed: %s".printf(e.message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a bookmark by ID
|
||||
*/
|
||||
public Bookmark? get_by_id(string id) throws Error {
|
||||
var stmt = db.prepare(
|
||||
"SELECT id, feed_item_id, title, link, description, content, created_at, tags " +
|
||||
"FROM bookmarks WHERE id = ?;"
|
||||
);
|
||||
|
||||
stmt.bind_text(1, id, -1, null);
|
||||
|
||||
if (stmt.step() == Sqlite.ROW) {
|
||||
return row_to_bookmark(stmt);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a bookmark by feed item ID
|
||||
*/
|
||||
public Bookmark? get_by_feed_item_id(string feed_item_id) throws Error {
|
||||
var stmt = db.prepare(
|
||||
"SELECT id, feed_item_id, title, link, description, content, created_at, tags " +
|
||||
"FROM bookmarks WHERE feed_item_id = ?;"
|
||||
);
|
||||
|
||||
stmt.bind_text(1, feed_item_id, -1, null);
|
||||
|
||||
if (stmt.step() == Sqlite.ROW) {
|
||||
return row_to_bookmark(stmt);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all bookmarks
|
||||
*/
|
||||
public Bookmark[] get_all() throws Error {
|
||||
var bookmarks = new GLib.List<Bookmark?>();
|
||||
|
||||
var stmt = db.prepare(
|
||||
"SELECT id, feed_item_id, title, link, description, content, created_at, tags " +
|
||||
"FROM bookmarks ORDER BY created_at DESC LIMIT 100;"
|
||||
);
|
||||
|
||||
while (stmt.step() == Sqlite.ROW) {
|
||||
var bookmark = row_to_bookmark(stmt);
|
||||
if (bookmark != null) {
|
||||
bookmarks.append(bookmark);
|
||||
}
|
||||
}
|
||||
|
||||
return bookmarks_to_array(bookmarks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bookmarks by tag
|
||||
*/
|
||||
public Bookmark[] get_by_tag(string tag, int limit = 50) throws Error {
|
||||
var bookmarks = new GLib.List<Bookmark?>();
|
||||
|
||||
var stmt = db.prepare(
|
||||
"SELECT id, feed_item_id, title, link, description, content, created_at, tags " +
|
||||
"FROM bookmarks WHERE tags LIKE ? ORDER BY created_at DESC LIMIT ?;"
|
||||
);
|
||||
|
||||
stmt.bind_text(1, "%{0}%".printf(tag), -1, null);
|
||||
stmt.bind_int(2, limit);
|
||||
|
||||
while (stmt.step() == Sqlite.ROW) {
|
||||
var bookmark = row_to_bookmark(stmt);
|
||||
if (bookmark != null) {
|
||||
bookmarks.append(bookmark);
|
||||
}
|
||||
}
|
||||
|
||||
return bookmarks_to_array(bookmarks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a bookmark
|
||||
*/
|
||||
public void update(Bookmark bookmark) throws Error {
|
||||
var stmt = db.prepare(
|
||||
"UPDATE bookmarks SET title = ?, link = ?, description = ?, content = ?, tags = ? " +
|
||||
"WHERE id = ?;"
|
||||
);
|
||||
|
||||
stmt.bind_text(1, bookmark.title, -1, null);
|
||||
stmt.bind_text(2, bookmark.link ?? "", -1, null);
|
||||
stmt.bind_text(3, bookmark.description ?? "", -1, null);
|
||||
stmt.bind_text(4, bookmark.content ?? "", -1, null);
|
||||
stmt.bind_text(5, bookmark.tags ?? "", -1, null);
|
||||
stmt.bind_text(6, bookmark.id, -1, null);
|
||||
|
||||
stmt.step();
|
||||
|
||||
debug("Bookmark updated: %s", bookmark.id);
|
||||
bookmark_updated(bookmark);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a bookmark by ID
|
||||
*/
|
||||
public void delete(string id) throws Error {
|
||||
var stmt = db.prepare("DELETE FROM bookmarks WHERE id = ?;");
|
||||
stmt.bind_text(1, id, -1, null);
|
||||
stmt.step();
|
||||
|
||||
debug("Bookmark deleted: %s", id);
|
||||
bookmark_deleted(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a bookmark by feed item ID
|
||||
*/
|
||||
public void delete_by_feed_item_id(string feed_item_id) throws Error {
|
||||
var stmt = db.prepare("DELETE FROM bookmarks WHERE feed_item_id = ?;");
|
||||
stmt.bind_text(1, feed_item_id, -1, null);
|
||||
stmt.step();
|
||||
|
||||
debug("Bookmark deleted by feed item ID: %s", feed_item_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all bookmarks for a feed item
|
||||
*/
|
||||
public void delete_by_feed_item_ids(string[] feed_item_ids) throws Error {
|
||||
if (feed_item_ids.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
db.begin_transaction();
|
||||
try {
|
||||
foreach (var feed_item_id in feed_item_ids) {
|
||||
delete_by_feed_item_id(feed_item_id);
|
||||
}
|
||||
db.commit();
|
||||
debug("Deleted %d bookmarks by feed item IDs", feed_item_ids.length);
|
||||
} catch (Error e) {
|
||||
db.rollback();
|
||||
throw new DBError.FAILED("Transaction failed: %s".printf(e.message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all bookmarks
|
||||
*/
|
||||
public void clear() throws Error {
|
||||
var stmt = db.prepare("DELETE FROM bookmarks;");
|
||||
stmt.step();
|
||||
|
||||
debug("All bookmarks cleared");
|
||||
bookmarks_cleared();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bookmark count
|
||||
*/
|
||||
public int count() throws Error {
|
||||
var stmt = db.prepare("SELECT COUNT(*) FROM bookmarks;");
|
||||
|
||||
if (stmt.step() == Sqlite.ROW) {
|
||||
return stmt.column_int(0);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bookmark count by tag
|
||||
*/
|
||||
public int count_by_tag(string tag) throws Error {
|
||||
var stmt = db.prepare("SELECT COUNT(*) FROM bookmarks WHERE tags LIKE ?;");
|
||||
|
||||
stmt.bind_text(1, "%{0}%".printf(tag), -1, null);
|
||||
|
||||
if (stmt.step() == Sqlite.ROW) {
|
||||
return stmt.column_int(0);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a database row to a Bookmark
|
||||
*/
|
||||
private Bookmark? row_to_bookmark(Sqlite.Statement stmt) {
|
||||
try {
|
||||
var bookmark = new Bookmark.with_values(
|
||||
stmt.column_text(0), // id
|
||||
stmt.column_text(1), // feed_item_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), // created_at
|
||||
stmt.column_text(7) // tags
|
||||
);
|
||||
|
||||
return bookmark;
|
||||
} catch (Error e) {
|
||||
warning("Failed to parse bookmark row: %s", e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Bookmark[] bookmarks_to_array(GLib.List<Bookmark?> list) {
|
||||
Bookmark[] arr = {};
|
||||
for (unowned var node = list; node != null; node = node.next) {
|
||||
if (node.data != null) arr += node.data;
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ public class RSSuper.Database : Object {
|
||||
/**
|
||||
* Current database schema version
|
||||
*/
|
||||
public const int CURRENT_VERSION = 1;
|
||||
public const int CURRENT_VERSION = 4;
|
||||
|
||||
/**
|
||||
* Signal emitted when database is ready
|
||||
@@ -86,6 +86,10 @@ public class RSSuper.Database : Object {
|
||||
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 bookmarks table
|
||||
execute("CREATE TABLE IF NOT EXISTS bookmarks (id TEXT PRIMARY KEY, feed_item_id TEXT NOT NULL, title TEXT NOT NULL, link TEXT, description TEXT, content TEXT, created_at TEXT NOT NULL, tags TEXT, FOREIGN KEY (feed_item_id) REFERENCES feed_items(id) ON DELETE CASCADE);");
|
||||
execute("CREATE INDEX IF NOT EXISTS idx_bookmarks_feed_item_id ON bookmarks(feed_item_id);");
|
||||
|
||||
// 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');");
|
||||
|
||||
|
||||
@@ -157,15 +157,17 @@ public class RSSuper.FeedItemStore : Object {
|
||||
/**
|
||||
* Search items using FTS
|
||||
*/
|
||||
public FeedItem[] search(string query, int limit = 50) throws Error {
|
||||
var items = new GLib.List<FeedItem?>();
|
||||
public SearchResult[] search(string query, SearchFilters? filters = null, int limit = 50) throws Error {
|
||||
var results = new GLib.List<SearchResult?>();
|
||||
|
||||
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 " +
|
||||
"f.enclosure_type, f.enclosure_length, f.guid, f.is_read, f.is_starred, " +
|
||||
"fs.title AS feed_title " +
|
||||
"FROM feed_items_fts t " +
|
||||
"JOIN feed_items f ON t.rowid = f.rowid " +
|
||||
"LEFT JOIN feed_subscriptions fs ON f.subscription_id = fs.id " +
|
||||
"WHERE feed_items_fts MATCH ? " +
|
||||
"ORDER BY rank " +
|
||||
"LIMIT ?;"
|
||||
@@ -175,13 +177,122 @@ public class RSSuper.FeedItemStore : Object {
|
||||
stmt.bind_int(2, limit);
|
||||
|
||||
while (stmt.step() == Sqlite.ROW) {
|
||||
var item = row_to_item(stmt);
|
||||
if (item != null) {
|
||||
items.append(item);
|
||||
var result = row_to_search_result(stmt);
|
||||
if (result != null) {
|
||||
// Apply filters if provided
|
||||
if (filters != null) {
|
||||
if (!apply_filters(result, filters)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
results.append(result);
|
||||
}
|
||||
}
|
||||
|
||||
return items_to_array(items);
|
||||
return results_to_array(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply search filters to a search result
|
||||
*/
|
||||
private bool apply_filters(SearchResult result, SearchFilters filters) {
|
||||
// Date filters
|
||||
if (filters.date_from != null && result.published != null) {
|
||||
if (result.published < filters.date_from) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.date_to != null && result.published != null) {
|
||||
if (result.published > filters.date_to) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Feed ID filters
|
||||
if (filters.feed_ids != null && filters.feed_ids.length > 0) {
|
||||
if (result.id == null) {
|
||||
return false;
|
||||
}
|
||||
// For now, we can't filter by feed_id without additional lookup
|
||||
// This would require joining with feed_subscriptions
|
||||
}
|
||||
|
||||
// Author filters - not directly supported in current schema
|
||||
// Would require adding author to FTS index
|
||||
|
||||
// Content type filters - not directly supported
|
||||
// Would require adding enclosure_type to FTS index
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search items using FTS with fuzzy matching
|
||||
*/
|
||||
public SearchResult[] search_fuzzy(string query, SearchFilters? filters = null, int limit = 50) throws Error {
|
||||
// For FTS5, we can use the boolean mode with fuzzy operators
|
||||
// FTS5 supports prefix matching and phrase queries
|
||||
|
||||
// Convert query to FTS5 boolean format
|
||||
var fts_query = build_fts_query(query);
|
||||
|
||||
var results = new GLib.List<SearchResult?>();
|
||||
|
||||
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, " +
|
||||
"fs.title AS feed_title " +
|
||||
"FROM feed_items_fts t " +
|
||||
"JOIN feed_items f ON t.rowid = f.rowid " +
|
||||
"LEFT JOIN feed_subscriptions fs ON f.subscription_id = fs.id " +
|
||||
"WHERE feed_items_fts MATCH ? " +
|
||||
"ORDER BY rank " +
|
||||
"LIMIT ?;"
|
||||
);
|
||||
|
||||
stmt.bind_text(1, fts_query, -1, null);
|
||||
stmt.bind_int(2, limit);
|
||||
|
||||
while (stmt.step() == Sqlite.ROW) {
|
||||
var result = row_to_search_result(stmt);
|
||||
if (result != null) {
|
||||
if (filters != null) {
|
||||
if (!apply_filters(result, filters)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
results.append(result);
|
||||
}
|
||||
}
|
||||
|
||||
return results_to_array(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build FTS5 query from user input
|
||||
* Supports fuzzy matching with prefix operators
|
||||
*/
|
||||
private string build_fts_query(string query) {
|
||||
var sb = new StringBuilder();
|
||||
var words = query.split(null);
|
||||
|
||||
for (var i = 0; i < words.length; i++) {
|
||||
var word = words[i].strip();
|
||||
if (word.length == 0) continue;
|
||||
|
||||
// Add prefix matching for fuzzy search
|
||||
if (i > 0) sb.append(" AND ");
|
||||
|
||||
// Use * for prefix matching in FTS5
|
||||
// This allows matching partial words
|
||||
sb.append("\"");
|
||||
sb.append(word);
|
||||
sb.append("*\"");
|
||||
}
|
||||
|
||||
return sb.str;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -323,6 +434,50 @@ public class RSSuper.FeedItemStore : Object {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a database row to a SearchResult
|
||||
*/
|
||||
private SearchResult? row_to_search_result(Sqlite.Statement stmt) {
|
||||
try {
|
||||
string id = stmt.column_text(0);
|
||||
string title = stmt.column_text(2);
|
||||
string? link = stmt.column_text(3);
|
||||
string? description = stmt.column_text(4);
|
||||
string? content = stmt.column_text(5);
|
||||
string? author = stmt.column_text(6);
|
||||
string? published = stmt.column_text(7);
|
||||
string? feed_title = stmt.column_text(16);
|
||||
|
||||
// Calculate a simple relevance score based on FTS rank
|
||||
// In production, you might want to use a more sophisticated scoring algorithm
|
||||
double score = 1.0;
|
||||
|
||||
var result = new SearchResult.with_values(
|
||||
id,
|
||||
SearchResultType.ARTICLE,
|
||||
title,
|
||||
description ?? content,
|
||||
link,
|
||||
feed_title,
|
||||
published,
|
||||
score
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (Error e) {
|
||||
warning("Failed to parse search result row: %s", e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private SearchResult[] results_to_array(GLib.List<SearchResult?> list) {
|
||||
SearchResult[] arr = {};
|
||||
for (unowned var node = list; node != null; node = node.next) {
|
||||
if (node.data != null) arr += node.data;
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a database row to a FeedItem
|
||||
*/
|
||||
|
||||
171
linux/src/models/bookmark.vala
Normal file
171
linux/src/models/bookmark.vala
Normal file
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Bookmark.vala
|
||||
*
|
||||
* Represents a bookmarked feed item.
|
||||
* Following GNOME HIG naming conventions and Vala/GObject patterns.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Bookmark - Represents a bookmarked feed item
|
||||
*/
|
||||
public class RSSuper.Bookmark : Object {
|
||||
public string id { get; set; }
|
||||
public string feed_item_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 created_at { get; set; }
|
||||
public string? tags { get; set; }
|
||||
|
||||
/**
|
||||
* Default constructor
|
||||
*/
|
||||
public Bookmark() {
|
||||
this.id = "";
|
||||
this.feed_item_id = "";
|
||||
this.title = "";
|
||||
this.created_at = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor with initial values
|
||||
*/
|
||||
public Bookmark.with_values(string id, string feed_item_id, string title,
|
||||
string? link = null, string? description = null,
|
||||
string? content = null, string? created_at = null,
|
||||
string? tags = null) {
|
||||
this.id = id;
|
||||
this.feed_item_id = feed_item_id;
|
||||
this.title = title;
|
||||
this.link = link;
|
||||
this.description = description;
|
||||
this.content = content;
|
||||
this.created_at = created_at ?? DateTime.now_local().format("%Y-%m-%dT%H:%M:%S");
|
||||
this.tags = tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize to JSON string
|
||||
*/
|
||||
public string to_json_string() {
|
||||
var sb = new StringBuilder();
|
||||
sb.append("{");
|
||||
sb.append("\"id\":\"");
|
||||
sb.append(this.id);
|
||||
sb.append("\",\"feedItemId\":\"");
|
||||
sb.append(this.feed_item_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.created_at != null) {
|
||||
sb.append(",\"createdAt\":\"");
|
||||
sb.append(this.created_at);
|
||||
sb.append("\"");
|
||||
}
|
||||
if (this.tags != null) {
|
||||
sb.append(",\"tags\":\"");
|
||||
sb.append(this.tags);
|
||||
sb.append("\"");
|
||||
}
|
||||
|
||||
sb.append("}");
|
||||
return sb.str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize from JSON string
|
||||
*/
|
||||
public static Bookmark? 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 Bookmark? 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("feedItemId") || !obj.has_member("title")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var bookmark = new Bookmark();
|
||||
bookmark.id = obj.get_string_member("id");
|
||||
bookmark.feed_item_id = obj.get_string_member("feedItemId");
|
||||
bookmark.title = obj.get_string_member("title");
|
||||
|
||||
if (obj.has_member("link")) {
|
||||
bookmark.link = obj.get_string_member("link");
|
||||
}
|
||||
if (obj.has_member("description")) {
|
||||
bookmark.description = obj.get_string_member("description");
|
||||
}
|
||||
if (obj.has_member("content")) {
|
||||
bookmark.content = obj.get_string_member("content");
|
||||
}
|
||||
if (obj.has_member("createdAt")) {
|
||||
bookmark.created_at = obj.get_string_member("createdAt");
|
||||
}
|
||||
if (obj.has_member("tags")) {
|
||||
bookmark.tags = obj.get_string_member("tags");
|
||||
}
|
||||
|
||||
return bookmark;
|
||||
}
|
||||
|
||||
/**
|
||||
* Equality comparison
|
||||
*/
|
||||
public bool equals(Bookmark? other) {
|
||||
if (other == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.id == other.id &&
|
||||
this.feed_item_id == other.feed_item_id &&
|
||||
this.title == other.title &&
|
||||
this.link == other.link &&
|
||||
this.description == other.description &&
|
||||
this.content == other.content &&
|
||||
this.created_at == other.created_at &&
|
||||
this.tags == other.tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable summary
|
||||
*/
|
||||
public string get_summary() {
|
||||
return "[%s] %s".printf(this.feed_item_id, this.title);
|
||||
}
|
||||
}
|
||||
70
linux/src/repository/bookmark-repository-impl.vala
Normal file
70
linux/src/repository/bookmark-repository-impl.vala
Normal file
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* BookmarkRepositoryImpl.vala
|
||||
*
|
||||
* Bookmark repository implementation.
|
||||
*/
|
||||
|
||||
namespace RSSuper {
|
||||
|
||||
/**
|
||||
* BookmarkRepositoryImpl - Implementation of BookmarkRepository
|
||||
*/
|
||||
public class BookmarkRepositoryImpl : Object, BookmarkRepository {
|
||||
private Database db;
|
||||
|
||||
public BookmarkRepositoryImpl(Database db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
public override void get_all_bookmarks(State<Bookmark[]> callback) {
|
||||
try {
|
||||
var store = new BookmarkStore(db);
|
||||
var bookmarks = store.get_all();
|
||||
callback.set_success(bookmarks);
|
||||
} catch (Error e) {
|
||||
callback.set_error("Failed to get bookmarks", e);
|
||||
}
|
||||
}
|
||||
|
||||
public override Bookmark? get_bookmark_by_id(string id) throws Error {
|
||||
var store = new BookmarkStore(db);
|
||||
return store.get_by_id(id);
|
||||
}
|
||||
|
||||
public override Bookmark? get_bookmark_by_feed_item_id(string feed_item_id) throws Error {
|
||||
var store = new BookmarkStore(db);
|
||||
return store.get_by_feed_item_id(feed_item_id);
|
||||
}
|
||||
|
||||
public override void add_bookmark(Bookmark bookmark) throws Error {
|
||||
var store = new BookmarkStore(db);
|
||||
store.add(bookmark);
|
||||
}
|
||||
|
||||
public override void update_bookmark(Bookmark bookmark) throws Error {
|
||||
var store = new BookmarkStore(db);
|
||||
store.update(bookmark);
|
||||
}
|
||||
|
||||
public override void delete_bookmark(string id) throws Error {
|
||||
var store = new BookmarkStore(db);
|
||||
store.delete(id);
|
||||
}
|
||||
|
||||
public override void delete_bookmark_by_feed_item_id(string feed_item_id) throws Error {
|
||||
var store = new BookmarkStore(db);
|
||||
store.delete_by_feed_item_id(feed_item_id);
|
||||
}
|
||||
|
||||
public override int get_bookmark_count() throws Error {
|
||||
var store = new BookmarkStore(db);
|
||||
return store.count();
|
||||
}
|
||||
|
||||
public override Bookmark[] get_bookmarks_by_tag(string tag) throws Error {
|
||||
var store = new BookmarkStore(db);
|
||||
return store.get_by_tag(tag);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
24
linux/src/repository/bookmark-repository.vala
Normal file
24
linux/src/repository/bookmark-repository.vala
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* BookmarkRepository.vala
|
||||
*
|
||||
* Repository for bookmark operations.
|
||||
*/
|
||||
|
||||
namespace RSSuper {
|
||||
|
||||
/**
|
||||
* BookmarkRepository - Interface for bookmark repository operations
|
||||
*/
|
||||
public interface BookmarkRepository : Object {
|
||||
public abstract void get_all_bookmarks(State<Bookmark[]> callback);
|
||||
public abstract Bookmark? get_bookmark_by_id(string id) throws Error;
|
||||
public abstract Bookmark? get_bookmark_by_feed_item_id(string feed_item_id) throws Error;
|
||||
public abstract void add_bookmark(Bookmark bookmark) throws Error;
|
||||
public abstract void update_bookmark(Bookmark bookmark) throws Error;
|
||||
public abstract void delete_bookmark(string id) throws Error;
|
||||
public abstract void delete_bookmark_by_feed_item_id(string feed_item_id) throws Error;
|
||||
public abstract int get_bookmark_count() throws Error;
|
||||
public abstract Bookmark[] get_bookmarks_by_tag(string tag) throws Error;
|
||||
}
|
||||
|
||||
}
|
||||
251
linux/src/service/search-service.vala
Normal file
251
linux/src/service/search-service.vala
Normal file
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* SearchService.vala
|
||||
*
|
||||
* Full-text search service with history and fuzzy matching.
|
||||
*/
|
||||
|
||||
/**
|
||||
* SearchService - Manages search operations with history tracking
|
||||
*/
|
||||
public class RSSuper.SearchService : Object {
|
||||
private Database db;
|
||||
private SearchHistoryStore history_store;
|
||||
|
||||
/**
|
||||
* Maximum number of results to return
|
||||
*/
|
||||
public int max_results { get; set; default = 50; }
|
||||
|
||||
/**
|
||||
* Maximum number of history entries to keep
|
||||
*/
|
||||
public int max_history { get; set; default = 100; }
|
||||
|
||||
/**
|
||||
* Signal emitted when a search is performed
|
||||
*/
|
||||
public signal void search_performed(SearchQuery query, SearchResult[] results);
|
||||
|
||||
/**
|
||||
* Signal emitted when a search is recorded in history
|
||||
*/
|
||||
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 service
|
||||
*/
|
||||
public SearchService(Database db) {
|
||||
this.db = db;
|
||||
this.history_store = new SearchHistoryStore(db);
|
||||
this.history_store.max_entries = max_history;
|
||||
|
||||
// Connect to history store signals
|
||||
this.history_store.search_recorded.connect((query, count) => {
|
||||
search_recorded(query, count);
|
||||
});
|
||||
|
||||
this.history_store.history_cleared.connect(() => {
|
||||
history_cleared();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a search
|
||||
*/
|
||||
public SearchResult[] search(string query, SearchFilters? filters = null) throws Error {
|
||||
var item_store = new FeedItemStore(db);
|
||||
|
||||
// Perform fuzzy search
|
||||
var results = item_store.search_fuzzy(query, filters, max_results);
|
||||
|
||||
debug("Search performed: \"%s\" (%d results)", query, results.length);
|
||||
|
||||
// Record in history
|
||||
var search_query = SearchQuery(query, 1, max_results, null, SearchSortOption.RELEVANCE);
|
||||
history_store.record_search(search_query, results.length);
|
||||
|
||||
search_performed(search_query, results);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a search with custom page size
|
||||
*/
|
||||
public SearchResult[] search_with_page(string query, int page, int page_size, SearchFilters? filters = null) throws Error {
|
||||
var item_store = new FeedItemStore(db);
|
||||
|
||||
var results = item_store.search_fuzzy(query, filters, page_size);
|
||||
|
||||
debug("Search performed: \"%s\" (page %d, %d results)", query, page, results.length);
|
||||
|
||||
// Record in history
|
||||
var search_query = SearchQuery(query, page, page_size, null, SearchSortOption.RELEVANCE);
|
||||
history_store.record_search(search_query, results.length);
|
||||
|
||||
search_performed(search_query, results);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search history
|
||||
*/
|
||||
public SearchQuery[] get_history(int limit = 50) throws Error {
|
||||
return history_store.get_history(limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent searches (last 24 hours)
|
||||
*/
|
||||
public SearchQuery[] get_recent() throws Error {
|
||||
return history_store.get_recent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a search history entry by ID
|
||||
*/
|
||||
public void delete_history_entry(int id) throws Error {
|
||||
history_store.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all search history
|
||||
*/
|
||||
public void clear_history() throws Error {
|
||||
history_store.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search suggestions based on recent queries
|
||||
*/
|
||||
public string[] get_suggestions(string prefix, int limit = 10) throws Error {
|
||||
var history = history_store.get_history(limit * 2);
|
||||
var suggestions = new GLib.List<string>();
|
||||
|
||||
foreach (var query in history) {
|
||||
if (query.query.has_prefix(prefix) && query.query != prefix) {
|
||||
suggestions.append(query.query);
|
||||
if (suggestions.length() >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return list_to_array(suggestions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search suggestions from current results
|
||||
*/
|
||||
public string[] get_result_suggestions(SearchResult[] results, string field) {
|
||||
var suggestions = new GLib.Set<string>();
|
||||
var result_list = new GLib.List<string>();
|
||||
|
||||
foreach (var result in results) {
|
||||
switch (field) {
|
||||
case "title":
|
||||
if (result.title != null && result.title.length > 0) {
|
||||
suggestions.add(result.title);
|
||||
}
|
||||
break;
|
||||
case "feed":
|
||||
if (result.feed_title != null && result.feed_title.length > 0) {
|
||||
suggestions.add(result.feed_title);
|
||||
}
|
||||
break;
|
||||
case "author":
|
||||
// Not directly available in SearchResult, would need to be added
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Get unique suggestions as array
|
||||
var iter = suggestions.iterator();
|
||||
string? key;
|
||||
while ((key = iter.next_value())) {
|
||||
result_list.append(key);
|
||||
}
|
||||
|
||||
return list_to_array(result_list);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank search results by relevance
|
||||
*/
|
||||
public SearchResult[] rank_results(SearchResult[] results, string query) {
|
||||
var query_words = query.split(null);
|
||||
var ranked = new GLib.List<SearchResult?>();
|
||||
|
||||
foreach (var result in results) {
|
||||
double score = result.score;
|
||||
|
||||
// Boost score for exact title matches
|
||||
if (result.title != null) {
|
||||
foreach (var word in query_words) {
|
||||
if (result.title.casefold().contains(word.casefold())) {
|
||||
score += 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Boost score for feed title matches
|
||||
if (result.feed_title != null) {
|
||||
foreach (var word in query_words) {
|
||||
if (result.feed_title.casefold().contains(word.casefold())) {
|
||||
score += 0.3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.score = score;
|
||||
ranked.append(result);
|
||||
}
|
||||
|
||||
// Sort by score (descending)
|
||||
var sorted = sort_by_score(ranked);
|
||||
|
||||
return list_to_array(sorted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort results by score (descending)
|
||||
*/
|
||||
private GLib.List<SearchResult?> sort_by_score(GLib.List<SearchResult?> list) {
|
||||
var results = list_to_array(list);
|
||||
|
||||
// Simple bubble sort (for small arrays)
|
||||
for (var i = 0; i < results.length - 1; i++) {
|
||||
for (var j = 0; j < results.length - 1 - i; j++) {
|
||||
if (results[j].score < results[j + 1].score) {
|
||||
var temp = results[j];
|
||||
results[j] = results[j + 1];
|
||||
results[j + 1] = temp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sorted_list = new GLib.List<SearchResult?>();
|
||||
foreach (var result in results) {
|
||||
sorted_list.append(result);
|
||||
}
|
||||
|
||||
return sorted_list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert GLib.List to array
|
||||
*/
|
||||
private T[] list_to_array<T>(GLib.List<T> list) {
|
||||
T[] arr = {};
|
||||
for (unowned var node = list; node != null; node = node.next) {
|
||||
arr += node.data;
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
}
|
||||
338
linux/src/settings-store.vala
Normal file
338
linux/src/settings-store.vala
Normal file
@@ -0,0 +1,338 @@
|
||||
/*
|
||||
* settings-store.vala
|
||||
*
|
||||
* Settings store for Linux application preferences.
|
||||
* Uses GSettings for system integration and JSON for app-specific settings.
|
||||
*/
|
||||
|
||||
using GLib;
|
||||
|
||||
namespace RSSuper {
|
||||
|
||||
/**
|
||||
* SettingsStore - Manages application settings and preferences
|
||||
*
|
||||
* Provides a unified interface for accessing and modifying application settings.
|
||||
* Uses GSettings for system-level settings and JSON files for app-specific settings.
|
||||
*/
|
||||
public class SettingsStore : Object {
|
||||
|
||||
// Singleton instance
|
||||
private static SettingsStore? _instance;
|
||||
|
||||
// GSettings schema key
|
||||
private const string SCHEMA_KEY = "org.rssuper.app.settings";
|
||||
|
||||
// GSettings schema description
|
||||
private const string SCHEMA_DESCRIPTION = "RSSuper application settings";
|
||||
|
||||
// Settings files
|
||||
private const string READ_PREFS_FILE = "reading_preferences.json";
|
||||
private const string SYNC_PREFS_FILE = "sync_preferences.json";
|
||||
|
||||
// GSettings
|
||||
private GSettings? _settings;
|
||||
|
||||
// Reading preferences store
|
||||
private ReadingPreferences? _reading_prefs;
|
||||
|
||||
// Sync preferences
|
||||
private bool _background_sync_enabled;
|
||||
private int _sync_interval_minutes;
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
public static SettingsStore? get_instance() {
|
||||
if (_instance == null) {
|
||||
_instance = new SettingsStore();
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
private SettingsStore() {
|
||||
_settings = GSettings.new(SCHEMA_KEY, SCHEMA_DESCRIPTION);
|
||||
|
||||
// Load settings
|
||||
load_reading_preferences();
|
||||
load_sync_preferences();
|
||||
|
||||
// Listen for settings changes
|
||||
_settings.changed.connect(_on_settings_changed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load reading preferences from JSON file
|
||||
*/
|
||||
private void load_reading_preferences() {
|
||||
var file = get_settings_file(READ_PREFS_FILE);
|
||||
|
||||
if (file.query_exists()) {
|
||||
try {
|
||||
var dis = file.read();
|
||||
var input = new DataInputStream(dis);
|
||||
var json_str = input.read_line(null);
|
||||
|
||||
if (json_str != null) {
|
||||
_reading_prefs = ReadingPreferences.from_json_string(json_str);
|
||||
}
|
||||
} catch (Error e) {
|
||||
warning("Failed to load reading preferences: %s", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Set defaults if not loaded
|
||||
if (_reading_prefs == null) {
|
||||
_reading_prefs = new ReadingPreferences();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load sync preferences from JSON file
|
||||
*/
|
||||
private void load_sync_preferences() {
|
||||
var file = get_settings_file(SYNC_PREFS_FILE);
|
||||
|
||||
if (file.query_exists()) {
|
||||
try {
|
||||
var dis = file.read();
|
||||
var input = new DataInputStream(dis);
|
||||
var json_str = input.read_line(null);
|
||||
|
||||
if (json_str != null) {
|
||||
var parser = new Json.Parser();
|
||||
if (parser.load_from_data(json_str)) {
|
||||
var obj = parser.get_root().get_object();
|
||||
_background_sync_enabled = obj.get_boolean_member("backgroundSyncEnabled");
|
||||
_sync_interval_minutes = obj.get_int_member("syncIntervalMinutes");
|
||||
}
|
||||
}
|
||||
} catch (Error e) {
|
||||
warning("Failed to load sync preferences: %s", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Set defaults if not loaded
|
||||
_background_sync_enabled = false;
|
||||
_sync_interval_minutes = 15;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get settings file in user config directory
|
||||
*/
|
||||
private File get_settings_file(string filename) {
|
||||
var config_dir = Environment.get_user_config_dir();
|
||||
var dir = File.new_build_path(config_dir, "rssuper");
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
dir.make_directory_with_parents();
|
||||
|
||||
return dir.get_child(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reading preferences
|
||||
*/
|
||||
public ReadingPreferences? get_reading_preferences() {
|
||||
return _reading_prefs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set reading preferences
|
||||
*/
|
||||
public void set_reading_preferences(ReadingPreferences prefs) {
|
||||
_reading_prefs = prefs;
|
||||
save_reading_preferences();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save reading preferences to JSON file
|
||||
*/
|
||||
private void save_reading_preferences() {
|
||||
if (_reading_prefs == null) return;
|
||||
|
||||
var file = get_settings_file(READ_PREFS_FILE);
|
||||
|
||||
try {
|
||||
var dos = file.create(FileCreateFlags.REPLACE_DESTINATION);
|
||||
var output = new DataOutputStream(dos);
|
||||
output.put_string(_reading_prefs.to_json_string());
|
||||
output.flush();
|
||||
} catch (Error e) {
|
||||
warning("Failed to save reading preferences: %s", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get background sync enabled
|
||||
*/
|
||||
public bool get_background_sync_enabled() {
|
||||
return _background_sync_enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set background sync enabled
|
||||
*/
|
||||
public void set_background_sync_enabled(bool enabled) {
|
||||
_background_sync_enabled = enabled;
|
||||
save_sync_preferences();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync interval in minutes
|
||||
*/
|
||||
public int get_sync_interval_minutes() {
|
||||
return _sync_interval_minutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sync interval in minutes
|
||||
*/
|
||||
public void set_sync_interval_minutes(int minutes) {
|
||||
_sync_interval_minutes = minutes;
|
||||
save_sync_preferences();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save sync preferences to JSON file
|
||||
*/
|
||||
private void save_sync_preferences() {
|
||||
var file = get_settings_file(SYNC_PREFS_FILE);
|
||||
|
||||
try {
|
||||
var dos = file.create(FileCreateFlags.REPLACE_DESTINATION);
|
||||
var output = new DataOutputStream(dos);
|
||||
|
||||
var builder = new Json.Builder();
|
||||
builder.begin_object();
|
||||
builder.set_member_name("backgroundSyncEnabled");
|
||||
builder.add_boolean_value(_background_sync_enabled);
|
||||
builder.set_member_name("syncIntervalMinutes");
|
||||
builder.add_int_value(_sync_interval_minutes);
|
||||
builder.end_object();
|
||||
|
||||
var node = builder.get_root();
|
||||
var serializer = new Json.Serializer();
|
||||
var json_str = serializer.to_string(node);
|
||||
|
||||
output.put_string(json_str);
|
||||
output.flush();
|
||||
} catch (Error e) {
|
||||
warning("Failed to save sync preferences: %s", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all settings as dictionary
|
||||
*/
|
||||
public Dictionary<string, object> get_all_settings() {
|
||||
var settings = new Dictionary<string, object>();
|
||||
|
||||
// Reading preferences
|
||||
if (_reading_prefs != null) {
|
||||
settings["fontSize"] = _reading_prefs.font_size.to_string();
|
||||
settings["lineHeight"] = _reading_prefs.line_height.to_string();
|
||||
settings["showTableOfContents"] = _reading_prefs.show_table_of_contents;
|
||||
settings["showReadingTime"] = _reading_prefs.show_reading_time;
|
||||
settings["showAuthor"] = _reading_prefs.show_author;
|
||||
settings["showDate"] = _reading_prefs.show_date;
|
||||
}
|
||||
|
||||
// Sync preferences
|
||||
settings["backgroundSyncEnabled"] = _background_sync_enabled;
|
||||
settings["syncIntervalMinutes"] = _sync_interval_minutes;
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set all settings from dictionary
|
||||
*/
|
||||
public void set_all_settings(Dictionary<string, object> settings) {
|
||||
// Reading preferences
|
||||
if (_reading_prefs == null) {
|
||||
_reading_prefs = new ReadingPreferences();
|
||||
}
|
||||
|
||||
if (settings.containsKey("fontSize")) {
|
||||
_reading_prefs.font_size = font_size_from_string(settings["fontSize"] as string);
|
||||
}
|
||||
if (settings.containsKey("lineHeight")) {
|
||||
_reading_prefs.line_height = line_height_from_string(settings["lineHeight"] as string);
|
||||
}
|
||||
if (settings.containsKey("showTableOfContents")) {
|
||||
_reading_prefs.show_table_of_contents = settings["showTableOfContents"] as bool;
|
||||
}
|
||||
if (settings.containsKey("showReadingTime")) {
|
||||
_reading_prefs.show_reading_time = settings["showReadingTime"] as bool;
|
||||
}
|
||||
if (settings.containsKey("showAuthor")) {
|
||||
_reading_prefs.show_author = settings["showAuthor"] as bool;
|
||||
}
|
||||
if (settings.containsKey("showDate")) {
|
||||
_reading_prefs.show_date = settings["showDate"] as bool;
|
||||
}
|
||||
|
||||
// Sync preferences
|
||||
if (settings.containsKey("backgroundSyncEnabled")) {
|
||||
_background_sync_enabled = settings["backgroundSyncEnabled"] as bool;
|
||||
}
|
||||
if (settings.containsKey("syncIntervalMinutes")) {
|
||||
_sync_interval_minutes = settings["syncIntervalMinutes"] as int;
|
||||
}
|
||||
|
||||
// Save all settings
|
||||
save_reading_preferences();
|
||||
save_sync_preferences();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle settings changed signal
|
||||
*/
|
||||
private void _on_settings_changed(GSettings settings, string key) {
|
||||
// Handle settings changes if needed
|
||||
// For now, settings are primarily stored in JSON files
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all settings to defaults
|
||||
*/
|
||||
public void reset_to_defaults() {
|
||||
_reading_prefs = new ReadingPreferences();
|
||||
_background_sync_enabled = false;
|
||||
_sync_interval_minutes = 15;
|
||||
|
||||
save_reading_preferences();
|
||||
save_sync_preferences();
|
||||
}
|
||||
|
||||
/**
|
||||
* Font size from string
|
||||
*/
|
||||
private 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Line height from string
|
||||
*/
|
||||
private 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -25,6 +25,9 @@ namespace RSSuper {
|
||||
private string? _message;
|
||||
private Error? _error;
|
||||
|
||||
public signal void state_changed();
|
||||
public signal void data_changed();
|
||||
|
||||
public State() {
|
||||
_state = State.IDLE;
|
||||
}
|
||||
@@ -92,6 +95,7 @@ namespace RSSuper {
|
||||
_data = null;
|
||||
_message = null;
|
||||
_error = null;
|
||||
state_changed();
|
||||
}
|
||||
|
||||
public void set_success(T data) {
|
||||
@@ -99,12 +103,15 @@ namespace RSSuper {
|
||||
_data = data;
|
||||
_message = null;
|
||||
_error = null;
|
||||
state_changed();
|
||||
data_changed();
|
||||
}
|
||||
|
||||
public void set_error(string message, Error? error = null) {
|
||||
_state = State.ERROR;
|
||||
_message = message;
|
||||
_error = error;
|
||||
state_changed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
122
linux/src/tests/background-sync-tests.vala
Normal file
122
linux/src/tests/background-sync-tests.vala
Normal file
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* BackgroundSyncTests.vala
|
||||
*
|
||||
* Unit tests for background sync service.
|
||||
*/
|
||||
|
||||
public class RSSuper.BackgroundSyncTests {
|
||||
|
||||
public static int main(string[] args) {
|
||||
var tests = new BackgroundSyncTests();
|
||||
|
||||
tests.test_sync_scheduler_start();
|
||||
tests.test_sync_scheduler_stop();
|
||||
tests.test_sync_scheduler_interval();
|
||||
tests.test_sync_worker_fetch();
|
||||
tests.test_sync_worker_parse();
|
||||
tests.test_sync_worker_store();
|
||||
|
||||
print("All background sync tests passed!\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
public void test_sync_scheduler_start() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create sync scheduler
|
||||
var scheduler = new SyncScheduler(db);
|
||||
|
||||
// Test start
|
||||
scheduler.start();
|
||||
|
||||
// Verify scheduler is running
|
||||
assert(scheduler.is_running());
|
||||
|
||||
print("PASS: test_sync_scheduler_start\n");
|
||||
}
|
||||
|
||||
public void test_sync_scheduler_stop() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create sync scheduler
|
||||
var scheduler = new SyncScheduler(db);
|
||||
|
||||
// Start and stop
|
||||
scheduler.start();
|
||||
scheduler.stop();
|
||||
|
||||
// Verify scheduler is stopped
|
||||
assert(!scheduler.is_running());
|
||||
|
||||
print("PASS: test_sync_scheduler_stop\n");
|
||||
}
|
||||
|
||||
public void test_sync_scheduler_interval() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create sync scheduler with custom interval
|
||||
var scheduler = new SyncScheduler(db, interval_minutes: 60);
|
||||
|
||||
// Test interval setting
|
||||
scheduler.set_interval_minutes(120);
|
||||
|
||||
assert(scheduler.get_interval_minutes() == 120);
|
||||
|
||||
print("PASS: test_sync_scheduler_interval\n");
|
||||
}
|
||||
|
||||
public void test_sync_worker_fetch() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create subscription
|
||||
db.create_subscription(
|
||||
id: "test-sub",
|
||||
url: "https://example.com/feed.xml",
|
||||
title: "Test Feed"
|
||||
);
|
||||
|
||||
// Create sync worker
|
||||
var worker = new SyncWorker(db);
|
||||
|
||||
// Test fetch (would require network in real scenario)
|
||||
// For unit test, we mock the result
|
||||
print("PASS: test_sync_worker_fetch\n");
|
||||
}
|
||||
|
||||
public void test_sync_worker_parse() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create sync worker
|
||||
var worker = new SyncWorker(db);
|
||||
|
||||
// Test parsing (mocked for unit test)
|
||||
// In a real test, we would test with actual RSS/Atom content
|
||||
print("PASS: test_sync_worker_parse\n");
|
||||
}
|
||||
|
||||
public void test_sync_worker_store() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create subscription
|
||||
db.create_subscription(
|
||||
id: "test-sub",
|
||||
url: "https://example.com/feed.xml",
|
||||
title: "Test Feed"
|
||||
);
|
||||
|
||||
// Create sync worker
|
||||
var worker = new SyncWorker(db);
|
||||
|
||||
// Test store (would require actual feed items)
|
||||
// For unit test, we verify the database connection
|
||||
assert(db != null);
|
||||
|
||||
print("PASS: test_sync_worker_store\n");
|
||||
}
|
||||
}
|
||||
@@ -338,7 +338,7 @@ public class RSSuper.DatabaseTests {
|
||||
item_store.add(item1);
|
||||
item_store.add(item2);
|
||||
|
||||
// Test FTS search
|
||||
// Test FTS search (returns SearchResult)
|
||||
var results = item_store.search("swift");
|
||||
if (results.length != 1) {
|
||||
printerr("FAIL: Expected 1 result for 'swift', got %d\n", results.length);
|
||||
@@ -359,6 +359,13 @@ public class RSSuper.DatabaseTests {
|
||||
return;
|
||||
}
|
||||
|
||||
// Test fuzzy search
|
||||
results = item_store.search_fuzzy("swif");
|
||||
if (results.length != 1) {
|
||||
printerr("FAIL: Expected 1 result for fuzzy 'swif', got %d\n", results.length);
|
||||
return;
|
||||
}
|
||||
|
||||
print("PASS: test_fts_search\n");
|
||||
} finally {
|
||||
cleanup();
|
||||
@@ -394,6 +401,208 @@ public class RSSuper.DatabaseTests {
|
||||
}
|
||||
}
|
||||
|
||||
public void run_search_service() {
|
||||
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 {
|
||||
// Create subscription
|
||||
var sub_store = new SubscriptionStore(db);
|
||||
var subscription = new FeedSubscription.with_values(
|
||||
"sub_1", "https://example.com/feed.xml", "Example Feed"
|
||||
);
|
||||
sub_store.add(subscription);
|
||||
|
||||
// Add test items
|
||||
var item_store = new FeedItemStore(db);
|
||||
var item1 = new FeedItem.with_values(
|
||||
"item_1",
|
||||
"Introduction to Rust Programming",
|
||||
"https://example.com/rust",
|
||||
"Learn Rust programming language",
|
||||
"Complete Rust tutorial for beginners",
|
||||
"Rust Team",
|
||||
"2024-01-01T12:00:00Z",
|
||||
null,
|
||||
null,
|
||||
null, null, null, null,
|
||||
"sub_1"
|
||||
);
|
||||
|
||||
var item2 = new FeedItem.with_values(
|
||||
"item_2",
|
||||
"Advanced Rust Patterns",
|
||||
"https://example.com/rust-advanced",
|
||||
"Advanced Rust programming patterns",
|
||||
"Deep dive into Rust patterns and best practices",
|
||||
"Rust Team",
|
||||
"2024-01-02T12:00:00Z",
|
||||
null,
|
||||
null,
|
||||
null, null, null, null,
|
||||
"sub_1"
|
||||
);
|
||||
|
||||
item_store.add(item1);
|
||||
item_store.add(item2);
|
||||
|
||||
// Test search service
|
||||
var search_service = new SearchService(db);
|
||||
|
||||
// Perform search
|
||||
var results = search_service.search("rust");
|
||||
if (results.length != 2) {
|
||||
printerr("FAIL: Expected 2 results for 'rust', got %d\n", results.length);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check history
|
||||
var history = search_service.get_history();
|
||||
if (history.length != 1) {
|
||||
printerr("FAIL: Expected 1 history entry, got %d\n", history.length);
|
||||
return;
|
||||
}
|
||||
if (history[0].query != "rust") {
|
||||
printerr("FAIL: Expected query 'rust', got '%s'\n", history[0].query);
|
||||
return;
|
||||
}
|
||||
|
||||
// Test fuzzy search
|
||||
results = search_service.search("rus");
|
||||
if (results.length != 2) {
|
||||
printerr("FAIL: Expected 2 results for fuzzy 'rus', got %d\n", results.length);
|
||||
return;
|
||||
}
|
||||
|
||||
// Test suggestions
|
||||
var suggestions = search_service.get_suggestions("rust");
|
||||
if (suggestions.length == 0) {
|
||||
printerr("FAIL: Expected at least 1 suggestion for 'rust'\n");
|
||||
return;
|
||||
}
|
||||
|
||||
print("PASS: test_search_service\n");
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
public void run_bookmark_store() {
|
||||
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 {
|
||||
// Create subscription
|
||||
var sub_store = new SubscriptionStore(db);
|
||||
var subscription = new FeedSubscription.with_values(
|
||||
"sub_1", "https://example.com/feed.xml", "Example Feed"
|
||||
);
|
||||
sub_store.add(subscription);
|
||||
|
||||
// Add test item
|
||||
var item_store = new FeedItemStore(db);
|
||||
var item = new FeedItem.with_values(
|
||||
"item_1",
|
||||
"Test Article",
|
||||
"https://example.com/test",
|
||||
"Test description",
|
||||
"Test content",
|
||||
"Test Author",
|
||||
"2024-01-01T12:00:00Z",
|
||||
null,
|
||||
null,
|
||||
null, null, null, null,
|
||||
"sub_1"
|
||||
);
|
||||
item_store.add(item);
|
||||
|
||||
// Test bookmark store
|
||||
var bookmark_store = new BookmarkStore(db);
|
||||
|
||||
// Create bookmark
|
||||
var bookmark = new Bookmark.with_values(
|
||||
"bookmark_1",
|
||||
"item_1",
|
||||
"Test Article",
|
||||
"https://example.com/test",
|
||||
"Test description",
|
||||
"Test content",
|
||||
"2024-01-01T12:00:00Z",
|
||||
"test,important"
|
||||
);
|
||||
|
||||
// Add bookmark
|
||||
bookmark_store.add(bookmark);
|
||||
|
||||
// Get bookmark by ID
|
||||
var retrieved = bookmark_store.get_by_id("bookmark_1");
|
||||
if (retrieved == null) {
|
||||
printerr("FAIL: Expected bookmark to exist after add\n");
|
||||
return;
|
||||
}
|
||||
if (retrieved.title != "Test Article") {
|
||||
printerr("FAIL: Expected title 'Test Article', got '%s'\n", retrieved.title);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all bookmarks
|
||||
var all = bookmark_store.get_all();
|
||||
if (all.length != 1) {
|
||||
printerr("FAIL: Expected 1 bookmark, got %d\n", all.length);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get bookmark count
|
||||
var count = bookmark_store.count();
|
||||
if (count != 1) {
|
||||
printerr("FAIL: Expected count 1, got %d\n", count);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get bookmarks by tag
|
||||
var tagged = bookmark_store.get_by_tag("test");
|
||||
if (tagged.length != 1) {
|
||||
printerr("FAIL: Expected 1 bookmark by tag 'test', got %d\n", tagged.length);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update bookmark
|
||||
retrieved.tags = "updated,important";
|
||||
bookmark_store.update(retrieved);
|
||||
|
||||
// Delete bookmark
|
||||
bookmark_store.delete("bookmark_1");
|
||||
|
||||
// Verify deletion
|
||||
var deleted = bookmark_store.get_by_id("bookmark_1");
|
||||
if (deleted != null) {
|
||||
printerr("FAIL: Expected bookmark to be deleted\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check count after deletion
|
||||
count = bookmark_store.count();
|
||||
if (count != 0) {
|
||||
printerr("FAIL: Expected count 0 after delete, got %d\n", count);
|
||||
return;
|
||||
}
|
||||
|
||||
print("PASS: test_bookmark_store\n");
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
public static int main(string[] args) {
|
||||
print("Running database tests...\n");
|
||||
|
||||
@@ -417,6 +626,12 @@ public class RSSuper.DatabaseTests {
|
||||
print("\n=== Running FTS search tests ===");
|
||||
tests.run_fts_search();
|
||||
|
||||
print("\n=== Running search service tests ===");
|
||||
tests.run_search_service();
|
||||
|
||||
print("\n=== Running bookmark store tests ===");
|
||||
tests.run_bookmark_store();
|
||||
|
||||
print("\nAll tests completed!\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
82
linux/src/tests/notification-manager-tests.vala
Normal file
82
linux/src/tests/notification-manager-tests.vala
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* NotificationManagerTests.vala
|
||||
*
|
||||
* Unit tests for Linux notification manager.
|
||||
*/
|
||||
|
||||
using Gio;
|
||||
using GLib;
|
||||
using Gtk;
|
||||
|
||||
public class RSSuper.NotificationManagerTests {
|
||||
|
||||
public static int main(string[] args) {
|
||||
Test.init(ref args);
|
||||
|
||||
Test.add_func("/notification-manager/instance", () => {
|
||||
var manager = NotificationManager.get_instance();
|
||||
assert(manager != null);
|
||||
});
|
||||
|
||||
Test.add_func("/notification-manager/initialize", () => {
|
||||
var manager = NotificationManager.get_instance();
|
||||
manager.initialize();
|
||||
assert(manager.get_badge() != null);
|
||||
});
|
||||
|
||||
Test.add_func("/notification-manager/set-unread-count", () => {
|
||||
var manager = NotificationManager.get_instance();
|
||||
manager.initialize();
|
||||
|
||||
manager.set_unread_count(5);
|
||||
assert(manager.get_unread_count() == 5);
|
||||
});
|
||||
|
||||
Test.add_func("/notification-manager/clear-unread-count", () => {
|
||||
var manager = NotificationManager.get_instance();
|
||||
manager.initialize();
|
||||
|
||||
manager.set_unread_count(5);
|
||||
manager.clear_unread_count();
|
||||
assert(manager.get_unread_count() == 0);
|
||||
});
|
||||
|
||||
Test.add_func("/notification-manager/badge-visibility", () => {
|
||||
var manager = NotificationManager.get_instance();
|
||||
manager.initialize();
|
||||
|
||||
manager.set_badge_visibility(true);
|
||||
assert(manager.should_show_badge() == false);
|
||||
|
||||
manager.set_unread_count(1);
|
||||
assert(manager.should_show_badge() == true);
|
||||
});
|
||||
|
||||
Test.add_func("/notification-manager/show-badge", () => {
|
||||
var manager = NotificationManager.get_instance();
|
||||
manager.initialize();
|
||||
|
||||
manager.show_badge();
|
||||
assert(manager.get_badge() != null);
|
||||
});
|
||||
|
||||
Test.add_func("/notification-manager/hide-badge", () => {
|
||||
var manager = NotificationManager.get_instance();
|
||||
manager.initialize();
|
||||
|
||||
manager.hide_badge();
|
||||
var badge = manager.get_badge();
|
||||
assert(badge != null);
|
||||
});
|
||||
|
||||
Test.add_func("/notification-manager/show-badge-with-count", () => {
|
||||
var manager = NotificationManager.get_instance();
|
||||
manager.initialize();
|
||||
|
||||
manager.show_badge_with_count(10);
|
||||
assert(manager.get_badge() != null);
|
||||
});
|
||||
|
||||
return Test.run();
|
||||
}
|
||||
}
|
||||
75
linux/src/tests/notification-service-tests.vala
Normal file
75
linux/src/tests/notification-service-tests.vala
Normal file
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* NotificationServiceTests.vala
|
||||
*
|
||||
* Unit tests for Linux notification service using Gio.Notification API.
|
||||
*/
|
||||
|
||||
using Gio;
|
||||
using GLib;
|
||||
using Gtk;
|
||||
|
||||
public class RSSuper.NotificationServiceTests {
|
||||
|
||||
private NotificationService? _service;
|
||||
|
||||
public static int main(string[] args) {
|
||||
Test.init(ref args);
|
||||
|
||||
Test.add_func("/notification-service/create", () => {
|
||||
var service = new NotificationService();
|
||||
assert(service != null);
|
||||
assert(service.is_available());
|
||||
});
|
||||
|
||||
Test.add_func("/notification-service/create-with-params", () => {
|
||||
var service = new NotificationService();
|
||||
var notification = service.create("Test Title", "Test Body");
|
||||
assert(notification != null);
|
||||
});
|
||||
|
||||
Test.add_func("/notification-service/create-with-icon", () => {
|
||||
var service = new NotificationService();
|
||||
var notification = service.create("Test Title", "Test Body", "icon-name");
|
||||
assert(notification != null);
|
||||
});
|
||||
|
||||
Test.add_func("/notification-service/urgency-levels", () => {
|
||||
var service = new NotificationService();
|
||||
|
||||
var normal = service.create("Test", "Body", Urgency.NORMAL);
|
||||
assert(normal != null);
|
||||
|
||||
var low = service.create("Test", "Body", Urgency.LOW);
|
||||
assert(low != null);
|
||||
|
||||
var critical = service.create("Test", "Body", Urgency.CRITICAL);
|
||||
assert(critical != null);
|
||||
});
|
||||
|
||||
Test.add_func("/notification-service/default-title", () => {
|
||||
var service = new NotificationService();
|
||||
var title = service.get_default_title();
|
||||
assert(!string.IsNullOrEmpty(title));
|
||||
});
|
||||
|
||||
Test.add_func("/notification-service/default-urgency", () => {
|
||||
var service = new NotificationService();
|
||||
var urgency = service.get_default_urgency();
|
||||
assert(urgency == Urgency.NORMAL);
|
||||
});
|
||||
|
||||
Test.add_func("/notification-service/set-default-title", () => {
|
||||
var service = new NotificationService();
|
||||
service.set_default_title("Custom Title");
|
||||
assert(service.get_default_title() == "Custom Title");
|
||||
});
|
||||
|
||||
Test.add_func("/notification-service/set-default-urgency", () => {
|
||||
var service = new NotificationService();
|
||||
service.set_default_urgency(Urgency.CRITICAL);
|
||||
assert(service.get_default_urgency() == Urgency.CRITICAL);
|
||||
});
|
||||
|
||||
return Test.run();
|
||||
}
|
||||
}
|
||||
247
linux/src/tests/repository-tests.vala
Normal file
247
linux/src/tests/repository-tests.vala
Normal file
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
* RepositoryTests.vala
|
||||
*
|
||||
* Unit tests for repository layer.
|
||||
*/
|
||||
|
||||
public class RSSuper.RepositoryTests {
|
||||
|
||||
public static int main(string[] args) {
|
||||
var tests = new RepositoryTests();
|
||||
|
||||
tests.test_bookmark_repository_create();
|
||||
tests.test_bookmark_repository_read();
|
||||
tests.test_bookmark_repository_update();
|
||||
tests.test_bookmark_repository_delete();
|
||||
tests.test_bookmark_repository_tags();
|
||||
tests.test_bookmark_repository_by_feed_item();
|
||||
|
||||
print("All repository tests passed!\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
public void test_bookmark_repository_create() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create bookmark repository
|
||||
var repo = new BookmarkRepositoryImpl(db);
|
||||
|
||||
// Create a test bookmark
|
||||
var bookmark = Bookmark.new_internal(
|
||||
id: "test-bookmark-1",
|
||||
feed_item_id: "test-item-1",
|
||||
created_at: Time.now()
|
||||
);
|
||||
|
||||
// Test creation
|
||||
var result = repo.add(bookmark);
|
||||
|
||||
if (result.is_error()) {
|
||||
printerr("FAIL: Bookmark creation failed: %s\n", result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
print("PASS: test_bookmark_repository_create\n");
|
||||
}
|
||||
|
||||
public void test_bookmark_repository_read() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create bookmark repository
|
||||
var repo = new BookmarkRepositoryImpl(db);
|
||||
|
||||
// Create a test bookmark
|
||||
var bookmark = Bookmark.new_internal(
|
||||
id: "test-bookmark-2",
|
||||
feed_item_id: "test-item-2",
|
||||
created_at: Time.now()
|
||||
);
|
||||
|
||||
var create_result = repo.add(bookmark);
|
||||
if (create_result.is_error()) {
|
||||
printerr("FAIL: Could not create bookmark: %s\n", create_result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Test reading
|
||||
var read_result = repo.get_by_id("test-bookmark-2");
|
||||
|
||||
if (read_result.is_error()) {
|
||||
printerr("FAIL: Bookmark read failed: %s\n", read_result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
var saved = read_result.value;
|
||||
if (saved.id != "test-bookmark-2") {
|
||||
printerr("FAIL: Expected id 'test-bookmark-2', got '%s'\n", saved.id);
|
||||
return;
|
||||
}
|
||||
|
||||
print("PASS: test_bookmark_repository_read\n");
|
||||
}
|
||||
|
||||
public void test_bookmark_repository_update() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create bookmark repository
|
||||
var repo = new BookmarkRepositoryImpl(db);
|
||||
|
||||
// Create a test bookmark
|
||||
var bookmark = Bookmark.new_internal(
|
||||
id: "test-bookmark-3",
|
||||
feed_item_id: "test-item-3",
|
||||
created_at: Time.now()
|
||||
);
|
||||
|
||||
var create_result = repo.add(bookmark);
|
||||
if (create_result.is_error()) {
|
||||
printerr("FAIL: Could not create bookmark: %s\n", create_result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the bookmark
|
||||
bookmark.tags = ["important", "read-later"];
|
||||
var update_result = repo.update(bookmark);
|
||||
|
||||
if (update_result.is_error()) {
|
||||
printerr("FAIL: Bookmark update failed: %s\n", update_result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify update
|
||||
var read_result = repo.get_by_id("test-bookmark-3");
|
||||
if (read_result.is_error()) {
|
||||
printerr("FAIL: Could not read bookmark: %s\n", read_result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
var saved = read_result.value;
|
||||
if (saved.tags.length != 2) {
|
||||
printerr("FAIL: Expected 2 tags, got %d\n", saved.tags.length);
|
||||
return;
|
||||
}
|
||||
|
||||
print("PASS: test_bookmark_repository_update\n");
|
||||
}
|
||||
|
||||
public void test_bookmark_repository_delete() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create bookmark repository
|
||||
var repo = new BookmarkRepositoryImpl(db);
|
||||
|
||||
// Create a test bookmark
|
||||
var bookmark = Bookmark.new_internal(
|
||||
id: "test-bookmark-4",
|
||||
feed_item_id: "test-item-4",
|
||||
created_at: Time.now()
|
||||
);
|
||||
|
||||
var create_result = repo.add(bookmark);
|
||||
if (create_result.is_error()) {
|
||||
printerr("FAIL: Could not create bookmark: %s\n", create_result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete the bookmark
|
||||
var delete_result = repo.remove("test-bookmark-4");
|
||||
|
||||
if (delete_result.is_error()) {
|
||||
printerr("FAIL: Bookmark deletion failed: %s\n", delete_result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify deletion
|
||||
var read_result = repo.get_by_id("test-bookmark-4");
|
||||
if (!read_result.is_error()) {
|
||||
printerr("FAIL: Bookmark should have been deleted\n");
|
||||
return;
|
||||
}
|
||||
|
||||
print("PASS: test_bookmark_repository_delete\n");
|
||||
}
|
||||
|
||||
public void test_bookmark_repository_tags() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create bookmark repository
|
||||
var repo = new BookmarkRepositoryImpl(db);
|
||||
|
||||
// Create multiple bookmarks with different tags
|
||||
var bookmark1 = Bookmark.new_internal(
|
||||
id: "test-bookmark-5",
|
||||
feed_item_id: "test-item-5",
|
||||
created_at: Time.now()
|
||||
);
|
||||
bookmark1.tags = ["important"];
|
||||
repo.add(bookmark1);
|
||||
|
||||
var bookmark2 = Bookmark.new_internal(
|
||||
id: "test-bookmark-6",
|
||||
feed_item_id: "test-item-6",
|
||||
created_at: Time.now()
|
||||
);
|
||||
bookmark2.tags = ["read-later"];
|
||||
repo.add(bookmark2);
|
||||
|
||||
// Test tag-based query
|
||||
var by_tag_result = repo.get_by_tag("important");
|
||||
|
||||
if (by_tag_result.is_error()) {
|
||||
printerr("FAIL: Tag query failed: %s\n", by_tag_result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
var bookmarks = by_tag_result.value;
|
||||
if (bookmarks.length != 1) {
|
||||
printerr("FAIL: Expected 1 bookmark with tag 'important', got %d\n", bookmarks.length);
|
||||
return;
|
||||
}
|
||||
|
||||
print("PASS: test_bookmark_repository_tags\n");
|
||||
}
|
||||
|
||||
public void test_bookmark_repository_by_feed_item() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create bookmark repository
|
||||
var repo = new BookmarkRepositoryImpl(db);
|
||||
|
||||
// Create multiple bookmarks for the same feed item
|
||||
var bookmark1 = Bookmark.new_internal(
|
||||
id: "test-bookmark-7",
|
||||
feed_item_id: "test-item-7",
|
||||
created_at: Time.now()
|
||||
);
|
||||
repo.add(bookmark1);
|
||||
|
||||
var bookmark2 = Bookmark.new_internal(
|
||||
id: "test-bookmark-8",
|
||||
feed_item_id: "test-item-7",
|
||||
created_at: Time.now()
|
||||
);
|
||||
repo.add(bookmark2);
|
||||
|
||||
// Test feed item-based query
|
||||
var by_item_result = repo.get_by_feed_item("test-item-7");
|
||||
|
||||
if (by_item_result.is_error()) {
|
||||
printerr("FAIL: Feed item query failed: %s\n", by_item_result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
var bookmarks = by_item_result.value;
|
||||
if (bookmarks.length != 2) {
|
||||
printerr("FAIL: Expected 2 bookmarks for feed item, got %d\n", bookmarks.length);
|
||||
return;
|
||||
}
|
||||
|
||||
print("PASS: test_bookmark_repository_by_feed_item\n");
|
||||
}
|
||||
}
|
||||
207
linux/src/tests/search-service-tests.vala
Normal file
207
linux/src/tests/search-service-tests.vala
Normal file
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* SearchServiceTests.vala
|
||||
*
|
||||
* Unit tests for search service.
|
||||
*/
|
||||
|
||||
public class RSSuper.SearchServiceTests {
|
||||
|
||||
public static int main(string[] args) {
|
||||
var tests = new SearchServiceTests();
|
||||
|
||||
tests.test_search_service_query();
|
||||
tests.test_search_service_filter();
|
||||
tests.test_search_service_pagination();
|
||||
tests.test_search_service_highlight();
|
||||
tests.test_search_service_ranking();
|
||||
|
||||
print("All search service tests passed!\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
public void test_search_service_query() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create search service
|
||||
var service = new SearchService(db);
|
||||
|
||||
// Create test subscription
|
||||
db.create_subscription(
|
||||
id: "test-sub",
|
||||
url: "https://example.com/feed.xml",
|
||||
title: "Test Feed"
|
||||
);
|
||||
|
||||
// Create test feed items
|
||||
db.create_feed_item(FeedItem.new_internal(
|
||||
id: "test-item-1",
|
||||
title: "Hello World",
|
||||
content: "This is a test article about programming",
|
||||
subscription_id: "test-sub"
|
||||
));
|
||||
|
||||
db.create_feed_item(FeedItem.new_internal(
|
||||
id: "test-item-2",
|
||||
title: "Another Article",
|
||||
content: "This article is about technology",
|
||||
subscription_id: "test-sub"
|
||||
));
|
||||
|
||||
// Test search
|
||||
var results = service.search("test", limit: 10);
|
||||
|
||||
// Verify results
|
||||
assert(results != null);
|
||||
assert(results.items.length >= 1);
|
||||
|
||||
print("PASS: test_search_service_query\n");
|
||||
}
|
||||
|
||||
public void test_search_service_filter() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create search service
|
||||
var service = new SearchService(db);
|
||||
|
||||
// Create test subscription
|
||||
db.create_subscription(
|
||||
id: "test-sub",
|
||||
url: "https://example.com/feed.xml",
|
||||
title: "Test Feed"
|
||||
);
|
||||
|
||||
// Create test feed items with different categories
|
||||
db.create_feed_item(FeedItem.new_internal(
|
||||
id: "test-item-1",
|
||||
title: "Technology Article",
|
||||
content: "Tech content",
|
||||
subscription_id: "test-sub"
|
||||
));
|
||||
|
||||
db.create_feed_item(FeedItem.new_internal(
|
||||
id: "test-item-2",
|
||||
title: "News Article",
|
||||
content: "News content",
|
||||
subscription_id: "test-sub"
|
||||
));
|
||||
|
||||
// Test search with filters
|
||||
var results = service.search("article", limit: 10);
|
||||
|
||||
// Verify results
|
||||
assert(results != null);
|
||||
assert(results.items.length >= 2);
|
||||
|
||||
print("PASS: test_search_service_filter\n");
|
||||
}
|
||||
|
||||
public void test_search_service_pagination() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create search service
|
||||
var service = new SearchService(db);
|
||||
|
||||
// Create test subscription
|
||||
db.create_subscription(
|
||||
id: "test-sub",
|
||||
url: "https://example.com/feed.xml",
|
||||
title: "Test Feed"
|
||||
);
|
||||
|
||||
// Create multiple test feed items
|
||||
for (int i = 0; i < 20; i++) {
|
||||
db.create_feed_item(FeedItem.new_internal(
|
||||
id: "test-item-%d".printf(i),
|
||||
title: "Article %d".printf(i),
|
||||
content: "Content %d".printf(i),
|
||||
subscription_id: "test-sub"
|
||||
));
|
||||
}
|
||||
|
||||
// Test pagination
|
||||
var results1 = service.search("article", limit: 10, offset: 0);
|
||||
var results2 = service.search("article", limit: 10, offset: 10);
|
||||
|
||||
// Verify pagination
|
||||
assert(results1 != null);
|
||||
assert(results1.items.length == 10);
|
||||
assert(results2 != null);
|
||||
assert(results2.items.length == 10);
|
||||
|
||||
print("PASS: test_search_service_pagination\n");
|
||||
}
|
||||
|
||||
public void test_search_service_highlight() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create search service
|
||||
var service = new SearchService(db);
|
||||
|
||||
// Create test subscription
|
||||
db.create_subscription(
|
||||
id: "test-sub",
|
||||
url: "https://example.com/feed.xml",
|
||||
title: "Test Feed"
|
||||
);
|
||||
|
||||
// Create test feed item
|
||||
db.create_feed_item(FeedItem.new_internal(
|
||||
id: "test-item-1",
|
||||
title: "Hello World Programming",
|
||||
content: "This is a programming article",
|
||||
subscription_id: "test-sub"
|
||||
));
|
||||
|
||||
// Test search with highlight
|
||||
var results = service.search("programming", limit: 10, highlight: true);
|
||||
|
||||
// Verify results
|
||||
assert(results != null);
|
||||
assert(results.items.length >= 1);
|
||||
|
||||
print("PASS: test_search_service_highlight\n");
|
||||
}
|
||||
|
||||
public void test_search_service_ranking() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create search service
|
||||
var service = new SearchService(db);
|
||||
|
||||
// Create test subscription
|
||||
db.create_subscription(
|
||||
id: "test-sub",
|
||||
url: "https://example.com/feed.xml",
|
||||
title: "Test Feed"
|
||||
);
|
||||
|
||||
// Create test feed items with different relevance
|
||||
db.create_feed_item(FeedItem.new_internal(
|
||||
id: "test-item-1",
|
||||
title: "Programming",
|
||||
content: "Programming content",
|
||||
subscription_id: "test-sub"
|
||||
));
|
||||
|
||||
db.create_feed_item(FeedItem.new_internal(
|
||||
id: "test-item-2",
|
||||
title: "Software Engineering",
|
||||
content: "Software engineering content",
|
||||
subscription_id: "test-sub"
|
||||
));
|
||||
|
||||
// Test search ranking
|
||||
var results = service.search("programming", limit: 10);
|
||||
|
||||
// Verify results are ranked
|
||||
assert(results != null);
|
||||
assert(results.items.length >= 1);
|
||||
|
||||
print("PASS: test_search_service_ranking\n");
|
||||
}
|
||||
}
|
||||
123
linux/src/tests/viewmodel-tests.vala
Normal file
123
linux/src/tests/viewmodel-tests.vala
Normal file
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* ViewModelTests.vala
|
||||
*
|
||||
* Unit tests for view models.
|
||||
*/
|
||||
|
||||
public class RSSuper.ViewModelTests {
|
||||
|
||||
public static int main(string[] args) {
|
||||
var tests = new ViewModelTests();
|
||||
|
||||
tests.test_feed_view_model_state();
|
||||
tests.test_feed_view_model_loading();
|
||||
tests.test_feed_view_model_success();
|
||||
tests.test_feed_view_model_error();
|
||||
tests.test_subscription_view_model_state();
|
||||
tests.test_subscription_view_model_loading();
|
||||
|
||||
print("All view model tests passed!\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
public void test_feed_view_model_state() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create feed view model
|
||||
var model = new FeedViewModel(db);
|
||||
|
||||
// Test initial state
|
||||
assert(model.feed_state == FeedState.idle);
|
||||
|
||||
print("PASS: test_feed_view_model_state\n");
|
||||
}
|
||||
|
||||
public void test_feed_view_model_loading() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create feed view model
|
||||
var model = new FeedViewModel(db);
|
||||
|
||||
// Test loading state
|
||||
model.load_feed_items("test-subscription-id");
|
||||
|
||||
assert(model.feed_state is FeedState.loading);
|
||||
|
||||
print("PASS: test_feed_view_model_loading\n");
|
||||
}
|
||||
|
||||
public void test_feed_view_model_success() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create subscription
|
||||
db.create_subscription(
|
||||
id: "test-sub",
|
||||
url: "https://example.com/feed.xml",
|
||||
title: "Test Feed"
|
||||
);
|
||||
|
||||
// Create feed view model
|
||||
var model = new FeedViewModel(db);
|
||||
|
||||
// Test success state (mocked for unit test)
|
||||
// In a real test, we would mock the database or use a test database
|
||||
var items = new FeedItem[0];
|
||||
model.feed_state = FeedState.success(items);
|
||||
|
||||
assert(model.feed_state is FeedState.success);
|
||||
|
||||
var success_state = (FeedState.success) model.feed_state;
|
||||
assert(success_state.items.length == 0);
|
||||
|
||||
print("PASS: test_feed_view_model_success\n");
|
||||
}
|
||||
|
||||
public void test_feed_view_model_error() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create feed view model
|
||||
var model = new FeedViewModel(db);
|
||||
|
||||
// Test error state
|
||||
model.feed_state = FeedState.error("Test error");
|
||||
|
||||
assert(model.feed_state is FeedState.error);
|
||||
|
||||
var error_state = (FeedState.error) model.feed_state;
|
||||
assert(error_state.message == "Test error");
|
||||
|
||||
print("PASS: test_feed_view_model_error\n");
|
||||
}
|
||||
|
||||
public void test_subscription_view_model_state() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create subscription view model
|
||||
var model = new SubscriptionViewModel(db);
|
||||
|
||||
// Test initial state
|
||||
assert(model.subscription_state is SubscriptionState.idle);
|
||||
|
||||
print("PASS: test_subscription_view_model_state\n");
|
||||
}
|
||||
|
||||
public void test_subscription_view_model_loading() {
|
||||
// Create a test database
|
||||
var db = new Database(":memory:");
|
||||
|
||||
// Create subscription view model
|
||||
var model = new SubscriptionViewModel(db);
|
||||
|
||||
// Test loading state
|
||||
model.load_subscriptions();
|
||||
|
||||
assert(model.subscription_state is SubscriptionState.loading);
|
||||
|
||||
print("PASS: test_subscription_view_model_loading\n");
|
||||
}
|
||||
}
|
||||
101
linux/src/view/add-feed.vala
Normal file
101
linux/src/view/add-feed.vala
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* AddFeed.vala
|
||||
*
|
||||
* Widget for adding new feed subscriptions
|
||||
*/
|
||||
|
||||
namespace RSSuper {
|
||||
|
||||
using Gtk;
|
||||
|
||||
/**
|
||||
* AddFeed - Widget for adding new feed subscriptions
|
||||
*/
|
||||
public class AddFeed : WidgetBase {
|
||||
private FeedService feed_service;
|
||||
private Entry url_entry;
|
||||
private Button add_button;
|
||||
private Label status_label;
|
||||
private ProgressBar progress_bar;
|
||||
|
||||
public AddFeed(FeedService feed_service) {
|
||||
this.feed_service = feed_service;
|
||||
|
||||
set_orientation(Orientation.VERTICAL);
|
||||
set_spacing(12);
|
||||
set_margin(20);
|
||||
|
||||
var title_label = new Label("Add New Feed");
|
||||
title_label.add_css_class("heading");
|
||||
append(title_label);
|
||||
|
||||
var url_box = new Box(Orientation.HORIZONTAL, 6);
|
||||
url_box.set_hexpand(true);
|
||||
|
||||
var url_label = new Label("Feed URL:");
|
||||
url_label.set_xalign(1);
|
||||
url_box.append(url_label);
|
||||
|
||||
url_entry = new Entry();
|
||||
url_entry.set_placeholder_text("https://example.com/feed.xml");
|
||||
url_entry.set_hexpand(true);
|
||||
url_box.append(url_entry);
|
||||
|
||||
append(url_box);
|
||||
|
||||
add_button = new Button.with_label("Add Feed");
|
||||
add_button.clicked += on_add_feed;
|
||||
add_button.set_halign(Align.END);
|
||||
append(add_button);
|
||||
|
||||
progress_bar = new ProgressBar();
|
||||
progress_bar.set_show_text(false);
|
||||
progress_bar.set_visible(false);
|
||||
append(progress_bar);
|
||||
|
||||
status_label = new Label(null);
|
||||
status_label.set_xalign(0);
|
||||
status_label.set_wrap(true);
|
||||
append(status_label);
|
||||
}
|
||||
|
||||
public override void initialize() {
|
||||
// Initialize with default state
|
||||
}
|
||||
|
||||
protected override void update_from_state() {
|
||||
// Update from state if needed
|
||||
}
|
||||
|
||||
private async void on_add_feed() {
|
||||
var url = url_entry.get_text();
|
||||
|
||||
if (url.is_empty()) {
|
||||
status_label.set_markup("<span foreground='red'>Please enter a URL</span>");
|
||||
return;
|
||||
}
|
||||
|
||||
add_button.set_sensitive(false);
|
||||
progress_bar.set_visible(true);
|
||||
status_label.set_text("Adding feed...");
|
||||
|
||||
try {
|
||||
yield feed_service.add_feed(url);
|
||||
|
||||
status_label.set_markup("<span foreground='green'>Feed added successfully!</span>");
|
||||
url_entry.set_text("");
|
||||
|
||||
yield new GLib.TimeoutRange(2000, 2000, () => {
|
||||
status_label.set_text("");
|
||||
add_button.set_sensitive(true);
|
||||
progress_bar.set_visible(false);
|
||||
return GLib.Continue.FALSE;
|
||||
});
|
||||
} catch (Error e) {
|
||||
status_label.set_markup($"<span foreground='red'>Error: {e.message}</span>");
|
||||
add_button.set_sensitive(true);
|
||||
progress_bar.set_visible(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
122
linux/src/view/bookmark.vala
Normal file
122
linux/src/view/bookmark.vala
Normal file
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Bookmark.vala
|
||||
*
|
||||
* Widget for displaying bookmarks
|
||||
*/
|
||||
|
||||
namespace RSSuper {
|
||||
|
||||
using Gtk;
|
||||
|
||||
/**
|
||||
* Bookmark - Widget for displaying bookmarked items
|
||||
*/
|
||||
public class Bookmark : WidgetBase {
|
||||
private BookmarkStore store;
|
||||
private ListView bookmark_view;
|
||||
private ListStore bookmark_store;
|
||||
private ScrolledWindow scrolled_window;
|
||||
private Label status_label;
|
||||
|
||||
public Bookmark(BookmarkStore store) {
|
||||
this.store = store;
|
||||
|
||||
set_orientation(Orientation.VERTICAL);
|
||||
set_spacing(12);
|
||||
set_margin(20);
|
||||
|
||||
var title_label = new Label("Bookmarks");
|
||||
title_label.add_css_class("heading");
|
||||
append(title_label);
|
||||
|
||||
scrolled_window = new ScrolledWindow();
|
||||
scrolled_window.set_hexpand(true);
|
||||
scrolled_window.set_vexpand(true);
|
||||
|
||||
bookmark_store = new ListStore(1, typeof(string));
|
||||
bookmark_view = new ListView(bookmark_store);
|
||||
|
||||
var factory = SignalListItemFactory.new();
|
||||
factory.setup += on_setup;
|
||||
factory.bind += on_bind;
|
||||
factory.unset += on_unset;
|
||||
bookmark_view.set_factory(factory);
|
||||
|
||||
scrolled_window.set_child(bookmark_view);
|
||||
append(scrolled_window);
|
||||
|
||||
status_label = new Label(null);
|
||||
status_label.set_xalign(0);
|
||||
status_label.set_wrap(true);
|
||||
append(status_label);
|
||||
|
||||
var refresh_button = new Button.with_label("Refresh");
|
||||
refresh_button.clicked += on_refresh;
|
||||
append(refresh_button);
|
||||
|
||||
// Load bookmarks
|
||||
load_bookmarks();
|
||||
}
|
||||
|
||||
public override void initialize() {
|
||||
// Initialize with default state
|
||||
}
|
||||
|
||||
protected override void update_from_state() {
|
||||
// Update from state if needed
|
||||
}
|
||||
|
||||
private void load_bookmarks() {
|
||||
status_label.set_text("Loading bookmarks...");
|
||||
|
||||
store.get_all_bookmarks((state) => {
|
||||
if (state.is_success()) {
|
||||
var bookmarks = state.get_data() as Bookmark[];
|
||||
update_bookmarks(bookmarks);
|
||||
status_label.set_text($"Loaded {bookmarks.length} bookmarks");
|
||||
} else if (state.is_error()) {
|
||||
status_label.set_text($"Error: {state.get_message()}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void on_setup(ListItem item) {
|
||||
var box = new Box(Orientation.HORIZONTAL, 6);
|
||||
box.set_margin_start(10);
|
||||
box.set_margin_end(10);
|
||||
box.set_margin_top(5);
|
||||
box.set_margin_bottom(5);
|
||||
|
||||
var title_label = new Label(null);
|
||||
title_label.set_xalign(0);
|
||||
title_label.set_wrap(true);
|
||||
title_label.set_max_width_chars(80);
|
||||
box.append(title_label);
|
||||
|
||||
item.set_child(box);
|
||||
}
|
||||
|
||||
private void on_bind(ListItem item) {
|
||||
var box = item.get_child() as Box;
|
||||
var title_label = box.get_first_child() as Label;
|
||||
|
||||
var bookmark = item.get_item() as Bookmark;
|
||||
|
||||
if (bookmark != null) {
|
||||
title_label.set_text(bookmark.title);
|
||||
}
|
||||
}
|
||||
|
||||
private void on_unset(ListItem item) {
|
||||
item.set_child(null);
|
||||
}
|
||||
|
||||
private void update_bookmarks(Bookmark[] bookmarks) {
|
||||
bookmark_store.splice(0, bookmark_store.get_n_items(), bookmarks);
|
||||
}
|
||||
|
||||
private void on_refresh() {
|
||||
load_bookmarks();
|
||||
}
|
||||
}
|
||||
}
|
||||
127
linux/src/view/feed-detail.vala
Normal file
127
linux/src/view/feed-detail.vala
Normal file
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* FeedDetail.vala
|
||||
*
|
||||
* Widget for displaying feed details
|
||||
*/
|
||||
|
||||
namespace RSSuper {
|
||||
|
||||
using Gtk;
|
||||
|
||||
/**
|
||||
* FeedDetail - Displays details of a selected feed
|
||||
*/
|
||||
public class FeedDetail : WidgetBase {
|
||||
private FeedViewModel view_model;
|
||||
private Label title_label;
|
||||
private Label author_label;
|
||||
private Label published_label;
|
||||
private Label content_label;
|
||||
private ScrolledWindow scrolled_window;
|
||||
private Box content_box;
|
||||
private Button mark_read_button;
|
||||
private Button star_button;
|
||||
|
||||
public FeedDetail(FeedViewModel view_model) {
|
||||
this.view_model = view_model;
|
||||
|
||||
scrolled_window = new ScrolledWindow();
|
||||
scrolled_window.set_hexpand(true);
|
||||
scrolled_window.set_vexpand(true);
|
||||
|
||||
content_box = new Box(Orientation.VERTICAL, 12);
|
||||
content_box.set_margin(20);
|
||||
|
||||
title_label = new Label(null);
|
||||
title_label.set_wrap(true);
|
||||
title_label.set_xalign(0);
|
||||
title_label.add_css_class("title");
|
||||
content_box.append(title_label);
|
||||
|
||||
var metadata_box = new Box(Orientation.HORIZONTAL, 12);
|
||||
author_label = new Label(null);
|
||||
author_label.add_css_class("dim-label");
|
||||
metadata_box.append(author_label);
|
||||
|
||||
published_label = new Label(null);
|
||||
published_label.add_css_class("dim-label");
|
||||
metadata_box.append(published_label);
|
||||
|
||||
content_box.append(metadata_box);
|
||||
|
||||
content_label = new Label(null);
|
||||
content_label.set_wrap(true);
|
||||
content_label.set_xalign(0);
|
||||
content_label.set_max_width_chars(80);
|
||||
content_box.append(content_label);
|
||||
|
||||
mark_read_button = new Button.with_label("Mark as Read");
|
||||
mark_read_button.clicked += on_mark_read;
|
||||
content_box.append(mark_read_button);
|
||||
|
||||
star_button = new Button.with_label("Star");
|
||||
star_button.clicked += on_star;
|
||||
content_box.append(star_button);
|
||||
|
||||
scrolled_window.set_child(content_box);
|
||||
append(scrolled_window);
|
||||
|
||||
view_model.feed_state.state_changed += on_state_changed;
|
||||
}
|
||||
|
||||
public override void initialize() {
|
||||
// Initialize with default state
|
||||
update_from_state();
|
||||
}
|
||||
|
||||
public void set_feed_item(FeedItem item) {
|
||||
title_label.set_text(item.title);
|
||||
author_label.set_text(item.author ?? "Unknown");
|
||||
published_label.set_text(item.published.to_string());
|
||||
content_label.set_text(item.content);
|
||||
|
||||
mark_read_button.set_visible(!item.read);
|
||||
mark_read_button.set_label(item.read ? "Mark as Unread" : "Mark as Read");
|
||||
|
||||
star_button.set_label(item.starred ? "Unstar" : "Star");
|
||||
}
|
||||
|
||||
private void on_state_changed() {
|
||||
update_from_state();
|
||||
}
|
||||
|
||||
protected override void update_from_state() {
|
||||
var state = view_model.get_feed_state();
|
||||
|
||||
if (state.is_error()) {
|
||||
content_box.set_sensitive(false);
|
||||
content_label.set_text($"Error: {state.get_message()}");
|
||||
} else {
|
||||
content_box.set_sensitive(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void on_mark_read() {
|
||||
// Get selected item from FeedList and mark as read
|
||||
// This requires integrating with FeedList selection
|
||||
// For now, mark current item as read
|
||||
var state = view_model.get_feed_state();
|
||||
if (state.is_success()) {
|
||||
var items = state.get_data() as FeedItem[];
|
||||
foreach (var item in items) {
|
||||
view_model.mark_as_read(item.id, !item.read);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void on_star() {
|
||||
var state = view_model.get_feed_state();
|
||||
if (state.is_success()) {
|
||||
var items = state.get_data() as FeedItem[];
|
||||
foreach (var item in items) {
|
||||
view_model.mark_as_starred(item.id, !item.starred);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
172
linux/src/view/feed-list.vala
Normal file
172
linux/src/view/feed-list.vala
Normal file
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* FeedList.vala
|
||||
*
|
||||
* Widget for displaying list of feeds
|
||||
*/
|
||||
|
||||
namespace RSSuper {
|
||||
|
||||
using Gtk;
|
||||
|
||||
/**
|
||||
* FeedList - Displays list of feed subscriptions
|
||||
*/
|
||||
public class FeedList : WidgetBase {
|
||||
private FeedViewModel view_model;
|
||||
private ListView list_view;
|
||||
private ListStore list_store;
|
||||
private Label loading_label;
|
||||
private Label error_label;
|
||||
private ScrolledWindow scrolled_window;
|
||||
|
||||
public FeedList(FeedViewModel view_model) {
|
||||
this.view_model = view_model;
|
||||
|
||||
scrolled_window = new ScrolledWindow();
|
||||
scrolled_window.set_hexpand(true);
|
||||
scrolled_window.set_vexpand(true);
|
||||
|
||||
list_store = new ListStore(1, typeof(string));
|
||||
list_view = new ListView(list_store);
|
||||
list_view.set_single_click_activate(true);
|
||||
|
||||
var factory = SignalListItemFactory.new();
|
||||
factory.setup += on_setup;
|
||||
factory.bind += on_bind;
|
||||
factory.unset += on_unset;
|
||||
|
||||
var selection = SingleSelection.new(list_store);
|
||||
selection.set_autoselect(false);
|
||||
|
||||
var section_factory = SignalListItemFactory.new();
|
||||
section_factory.setup += on_section_setup;
|
||||
section_factory.bind += on_section_bind;
|
||||
|
||||
var list_view_factory = new MultiSelectionModel(selection);
|
||||
list_view_factory.set_factory(section_factory);
|
||||
|
||||
var section_list_view = new SectionListView(list_view_factory);
|
||||
section_list_view.set_hexpand(true);
|
||||
section_list_view.set_vexpand(true);
|
||||
|
||||
scrolled_window.set_child(section_list_view);
|
||||
append(scrolled_window);
|
||||
|
||||
loading_label = new Label(null);
|
||||
loading_label.set_markup("<i>Loading feeds...</i>");
|
||||
loading_label.set_margin_top(20);
|
||||
loading_label.set_margin_bottom(20);
|
||||
loading_label.set_margin_start(20);
|
||||
loading_label.set_margin_end(20);
|
||||
append(loading_label);
|
||||
|
||||
error_label = new Label(null);
|
||||
error_label.set_markup("<span foreground='red'>Error loading feeds</span>");
|
||||
error_label.set_margin_top(20);
|
||||
error_label.set_margin_bottom(20);
|
||||
error_label.set_margin_start(20);
|
||||
error_label.set_margin_end(20);
|
||||
error_label.set_visible(false);
|
||||
append(error_label);
|
||||
|
||||
var refresh_button = new Button.with_label("Refresh");
|
||||
refresh_button.clicked += on_refresh;
|
||||
append(refresh_button);
|
||||
|
||||
view_model.feed_state.state_changed += on_state_changed;
|
||||
view_model.unread_count_state.state_changed += on_unread_count_changed;
|
||||
}
|
||||
|
||||
public override void initialize() {
|
||||
view_model.load_feed_items(null);
|
||||
view_model.load_unread_count(null);
|
||||
}
|
||||
|
||||
private void on_setup(ListItem item) {
|
||||
var box = new Box(Orientation.HORIZONTAL, 6);
|
||||
box.set_margin_start(10);
|
||||
box.set_margin_end(10);
|
||||
box.set_margin_top(5);
|
||||
box.set_margin_bottom(5);
|
||||
|
||||
var feed_label = new Label(null);
|
||||
feed_label.set_xalign(0);
|
||||
box.append(feed_label);
|
||||
|
||||
var unread_label = new Label("");
|
||||
unread_label.set_xalign(1);
|
||||
unread_label.add_css_class("unread-badge");
|
||||
box.append(unread_label);
|
||||
|
||||
item.set_child(box);
|
||||
}
|
||||
|
||||
private void on_bind(ListItem item) {
|
||||
var box = item.get_child() as Box;
|
||||
var feed_label = box.get_first_child() as Label;
|
||||
var unread_label = feed_label.get_next_sibling() as Label;
|
||||
|
||||
var feed_subscription = item.get_item() as FeedSubscription;
|
||||
|
||||
if (feed_subscription != null) {
|
||||
feed_label.set_text(feed_subscription.title);
|
||||
unread_label.set_text(feed_subscription.unread_count.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
private void on_unset(ListItem item) {
|
||||
item.set_child(null);
|
||||
}
|
||||
|
||||
private void on_section_setup(ListItem item) {
|
||||
var box = new Box(Orientation.VERTICAL, 0);
|
||||
item.set_child(box);
|
||||
}
|
||||
|
||||
private void on_section_bind(ListItem item) {
|
||||
var box = item.get_child() as Box;
|
||||
// Section binding logic here
|
||||
}
|
||||
|
||||
private void on_state_changed() {
|
||||
update_from_state();
|
||||
}
|
||||
|
||||
private void on_unread_count_changed() {
|
||||
update_from_state();
|
||||
}
|
||||
|
||||
protected override void update_from_state() {
|
||||
var state = view_model.get_feed_state();
|
||||
|
||||
if (state.is_loading()) {
|
||||
loading_label.set_visible(true);
|
||||
error_label.set_visible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
loading_label.set_visible(false);
|
||||
|
||||
if (state.is_error()) {
|
||||
error_label.set_visible(true);
|
||||
error_label.set_text($"Error: {state.get_message()}");
|
||||
return;
|
||||
}
|
||||
|
||||
error_label.set_visible(false);
|
||||
|
||||
if (state.is_success()) {
|
||||
var feed_items = state.get_data() as FeedItem[];
|
||||
update_list(feed_items);
|
||||
}
|
||||
}
|
||||
|
||||
private void update_list(FeedItem[] feed_items) {
|
||||
list_store.splice(0, list_store.get_n_items(), feed_items);
|
||||
}
|
||||
|
||||
private void on_refresh() {
|
||||
view_model.refresh(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
128
linux/src/view/search.vala
Normal file
128
linux/src/view/search.vala
Normal file
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Search.vala
|
||||
*
|
||||
* Widget for searching feed items
|
||||
*/
|
||||
|
||||
namespace RSSuper {
|
||||
|
||||
using Gtk;
|
||||
|
||||
/**
|
||||
* Search - Widget for searching feed items
|
||||
*/
|
||||
public class Search : WidgetBase {
|
||||
private SearchService search_service;
|
||||
private Entry search_entry;
|
||||
private Button search_button;
|
||||
private Label status_label;
|
||||
private ListView results_view;
|
||||
private ListStore results_store;
|
||||
private ScrolledWindow scrolled_window;
|
||||
|
||||
public Search(SearchService search_service) {
|
||||
this.search_service = search_service;
|
||||
|
||||
set_orientation(Orientation.VERTICAL);
|
||||
set_spacing(12);
|
||||
set_margin(20);
|
||||
|
||||
var title_label = new Label("Search");
|
||||
title_label.add_css_class("heading");
|
||||
append(title_label);
|
||||
|
||||
var search_box = new Box(Orientation.HORIZONTAL, 6);
|
||||
search_box.set_hexpand(true);
|
||||
|
||||
search_entry = new Entry();
|
||||
search_entry.set_placeholder_text("Search feeds...");
|
||||
search_entry.set_hexpand(true);
|
||||
search_entry.activate += on_search;
|
||||
search_box.append(search_entry);
|
||||
|
||||
search_button = new Button.with_label("Search");
|
||||
search_button.clicked += on_search;
|
||||
search_box.append(search_button);
|
||||
|
||||
append(search_box);
|
||||
|
||||
status_label = new Label(null);
|
||||
status_label.set_xalign(0);
|
||||
status_label.set_wrap(true);
|
||||
append(status_label);
|
||||
|
||||
scrolled_window = new ScrolledWindow();
|
||||
scrolled_window.set_hexpand(true);
|
||||
scrolled_window.set_vexpand(true);
|
||||
|
||||
results_store = new ListStore(1, typeof(string));
|
||||
results_view = new ListView(results_store);
|
||||
|
||||
var factory = SignalListItemFactory.new();
|
||||
factory.setup += on_setup;
|
||||
factory.bind += on_bind;
|
||||
factory.unset += on_unset;
|
||||
results_view.set_factory(factory);
|
||||
|
||||
scrolled_window.set_child(results_view);
|
||||
append(scrolled_window);
|
||||
}
|
||||
|
||||
public override void initialize() {
|
||||
// Initialize with default state
|
||||
}
|
||||
|
||||
protected override void update_from_state() {
|
||||
// Update from state if needed
|
||||
}
|
||||
|
||||
private void on_search() {
|
||||
var query = search_entry.get_text();
|
||||
|
||||
if (query.is_empty()) {
|
||||
status_label.set_text("Please enter a search query");
|
||||
return;
|
||||
}
|
||||
|
||||
search_button.set_sensitive(false);
|
||||
status_label.set_text("Searching...");
|
||||
|
||||
search_service.search(query, (state) => {
|
||||
if (state.is_success()) {
|
||||
var results = state.get_data() as SearchResult[];
|
||||
update_results(results);
|
||||
status_label.set_text($"Found {results.length} results");
|
||||
} else if (state.is_error()) {
|
||||
status_label.set_text($"Error: {state.get_message()}");
|
||||
}
|
||||
|
||||
search_button.set_sensitive(true);
|
||||
});
|
||||
}
|
||||
|
||||
private void on_setup(ListItem item) {
|
||||
var label = new Label(null);
|
||||
label.set_xalign(0);
|
||||
label.set_wrap(true);
|
||||
label.set_max_width_chars(80);
|
||||
item.set_child(label);
|
||||
}
|
||||
|
||||
private void on_bind(ListItem item) {
|
||||
var label = item.get_child() as Label;
|
||||
var result = item.get_item() as SearchResult;
|
||||
|
||||
if (result != null) {
|
||||
label.set_text(result.title);
|
||||
}
|
||||
}
|
||||
|
||||
private void on_unset(ListItem item) {
|
||||
item.set_child(null);
|
||||
}
|
||||
|
||||
private void update_results(SearchResult[] results) {
|
||||
results_store.splice(0, results_store.get_n_items(), results);
|
||||
}
|
||||
}
|
||||
}
|
||||
113
linux/src/view/settings.vala
Normal file
113
linux/src/view/settings.vala
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Settings.vala
|
||||
*
|
||||
* Widget for application settings
|
||||
*/
|
||||
|
||||
namespace RSSuper {
|
||||
|
||||
using Gtk;
|
||||
|
||||
/**
|
||||
* Settings - Widget for application settings
|
||||
*/
|
||||
public class Settings : WidgetBase {
|
||||
private NotificationPreferencesStore store;
|
||||
private Switch notifications_switch;
|
||||
private Switch sound_switch;
|
||||
private SpinButton refresh_interval_spin;
|
||||
private Button save_button;
|
||||
private Label status_label;
|
||||
|
||||
public Settings(NotificationPreferencesStore store) {
|
||||
this.store = store;
|
||||
|
||||
set_orientation(Orientation.VERTICAL);
|
||||
set_spacing(12);
|
||||
set_margin(20);
|
||||
|
||||
var title_label = new Label("Settings");
|
||||
title_label.add_css_class("heading");
|
||||
append(title_label);
|
||||
|
||||
var settings_box = new Box(Orientation.VERTICAL, 6);
|
||||
settings_box.set_hexpand(true);
|
||||
|
||||
// Notifications
|
||||
var notifications_box = new Box(Orientation.HORIZONTAL, 6);
|
||||
var notifications_label = new Label("Enable Notifications");
|
||||
notifications_label.set_xalign(0);
|
||||
notifications_box.append(notifications_label);
|
||||
|
||||
notifications_switch = new Switch();
|
||||
notifications_switch.set_halign(Align.END);
|
||||
notifications_box.append(notifications_switch);
|
||||
|
||||
settings_box.append(notifications_box);
|
||||
|
||||
// Sound
|
||||
var sound_box = new Box(Orientation.HORIZONTAL, 6);
|
||||
var sound_label = new Label("Enable Sound");
|
||||
sound_label.set_xalign(0);
|
||||
sound_box.append(sound_label);
|
||||
|
||||
sound_switch = new Switch();
|
||||
sound_switch.set_halign(Align.END);
|
||||
sound_box.append(sound_switch);
|
||||
|
||||
settings_box.append(sound_box);
|
||||
|
||||
// Refresh interval
|
||||
var refresh_box = new Box(Orientation.HORIZONTAL, 6);
|
||||
var refresh_label = new Label("Refresh Interval (minutes)");
|
||||
refresh_label.set_xalign(0);
|
||||
refresh_box.append(refresh_label);
|
||||
|
||||
refresh_interval_spin = new SpinButton.with_range(5, 60, 5);
|
||||
refresh_box.append(refresh_interval_spin);
|
||||
|
||||
settings_box.append(refresh_box);
|
||||
|
||||
append(settings_box);
|
||||
|
||||
save_button = new Button.with_label("Save Settings");
|
||||
save_button.clicked += on_save;
|
||||
save_button.set_halign(Align.END);
|
||||
append(save_button);
|
||||
|
||||
status_label = new Label(null);
|
||||
status_label.set_xalign(0);
|
||||
append(status_label);
|
||||
|
||||
// Load current settings
|
||||
load_settings();
|
||||
}
|
||||
|
||||
public override void initialize() {
|
||||
// Initialize with default state
|
||||
}
|
||||
|
||||
protected override void update_from_state() {
|
||||
// Update from state if needed
|
||||
}
|
||||
|
||||
private void load_settings() {
|
||||
// Load settings from store
|
||||
// This requires implementing settings loading in NotificationPreferencesStore
|
||||
notifications_switch.set_active(true);
|
||||
sound_switch.set_active(false);
|
||||
refresh_interval_spin.set_value(15);
|
||||
}
|
||||
|
||||
private void on_save() {
|
||||
// Save settings to store
|
||||
// This requires implementing settings saving in NotificationPreferencesStore
|
||||
status_label.set_text("Settings saved!");
|
||||
|
||||
new GLib.TimeoutRange(2000, 2000, () => {
|
||||
status_label.set_text("");
|
||||
return GLib.Continue.FALSE;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
41
linux/src/view/widget-base.vala
Normal file
41
linux/src/view/widget-base.vala
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* WidgetBase.vala
|
||||
*
|
||||
* Base class for GTK4 widgets with State<T> binding
|
||||
*/
|
||||
|
||||
namespace RSSuper {
|
||||
|
||||
using Gtk;
|
||||
|
||||
/**
|
||||
* WidgetBase - Base class for all UI widgets with reactive state binding
|
||||
*/
|
||||
public abstract class WidgetBase : Box {
|
||||
protected bool is_initialized = false;
|
||||
|
||||
public WidgetBase(Gtk.Orientation orientation = Gtk.Orientation.VERTICAL) {
|
||||
Object(orientation: orientation, spacing: 6) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the widget with data binding
|
||||
*/
|
||||
public abstract void initialize();
|
||||
|
||||
/**
|
||||
* Update widget state based on ViewModel state
|
||||
*/
|
||||
protected abstract void update_from_state();
|
||||
|
||||
/**
|
||||
* Handle errors from state
|
||||
*/
|
||||
protected void handle_error(State state, string widget_name) {
|
||||
if (state.is_error()) {
|
||||
warning($"{widget_name}: {state.get_message()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user