diff --git a/check-identity.js b/check-identity.js new file mode 100644 index 0000000..e6cdf34 --- /dev/null +++ b/check-identity.js @@ -0,0 +1,65 @@ +const http = require('http'); + +const agentId = process.env.PAPERCLIP_AGENT_ID; +const apiKey = process.env.PAPERCLIP_API_KEY; +const apiUrl = process.env.PAPERCLIP_API_URL; +const runId = process.env.PAPERCLIP_RUN_ID; + +console.log('Agent ID:', agentId); +console.log('API URL:', apiUrl); +console.log('Run ID:', runId); + +if (!apiKey || !apiUrl) { + console.error('Missing environment variables'); + process.exit(1); +} + +function fetch(url, options = {}) { + return new Promise((resolve, reject) => { + const request = http.request({ + hostname: new URL(url).hostname, + port: new URL(url).port, + path: new URL(url).pathname, + method: options.method || 'GET', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'X-Paperclip-Run-Id': runId, + ...options.headers + } + }, (response) => { + let data = ''; + response.on('data', chunk => data += chunk); + response.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch { + resolve(data); + } + }); + }); + request.on('error', reject); + request.end(); + }); +} + +async function main() { + console.log('\n=== FETCHING AGENT IDENTITY ===\n'); + + try { + const identity = await fetch(`${apiUrl}/api/agents/me`); + console.log(JSON.stringify(identity, null, 2)); + } catch (err) { + console.error('Error fetching identity:', err.message); + } + + console.log('\n=== FETCHING INBOX-LITE ===\n'); + + try { + const inbox = await fetch(`${apiUrl}/api/agents/${agentId}/inbox-lite`); + console.log(JSON.stringify(inbox, null, 2)); + } catch (err) { + console.error('Error fetching inbox:', err.message); + } +} + +main(); \ No newline at end of file diff --git a/check-identity.py b/check-identity.py new file mode 100644 index 0000000..4015c45 --- /dev/null +++ b/check-identity.py @@ -0,0 +1,36 @@ +import urllib.request +import json +import os + +agentId = os.environ.get('PAPERCLIP_AGENT_ID', 'unknown') +apiKey = os.environ.get('PAPERCLIP_API_KEY', '') +apiUrl = os.environ.get('PAPERCLIP_API_URL', '') +runId = os.environ.get('PAPERCLIP_RUN_ID', '') + +print(f'Agent ID: {agentId}') +print(f'API URL: {apiUrl}') +print(f'Run ID: {runId}') + +if not apiKey or not apiUrl: + print('Missing environment variables') + exit(1) + +def fetch(url, method='GET', headers=None): + req = urllib.request.Request(url, method=method) + if headers: + for k, v in headers.items(): + req.add_header(k, str(v)) + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode()) + except Exception as e: + print(f'Error: {e}') + return None + +print('\n=== FETCHING AGENT IDENTITY ===\n') +identity = fetch(f'{apiUrl}/api/agents/me') +print(json.dumps(identity or {}, indent=2)) + +print('\n=== FETCHING INBOX-LITE ===\n') +inbox = fetch(f'{apiUrl}/api/agents/{agentId}/inbox-lite') +print(json.dumps(inbox or {}, indent=2)) \ No newline at end of file diff --git a/native-route/android/src/main/java/com/rssuper/database/daos/BookmarkDao.kt b/native-route/android/src/main/java/com/rssuper/database/daos/BookmarkDao.kt new file mode 100644 index 0000000..96677cb --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/database/daos/BookmarkDao.kt @@ -0,0 +1,52 @@ +package com.rssuper.database.daos + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.rssuper.database.entities.BookmarkEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface BookmarkDao { + @Query("SELECT * FROM bookmarks ORDER BY createdAt DESC") + fun getAllBookmarks(): Flow> + + @Query("SELECT * FROM bookmarks WHERE id = :id") + suspend fun getBookmarkById(id: String): BookmarkEntity? + + @Query("SELECT * FROM bookmarks WHERE feedItemId = :feedItemId") + suspend fun getBookmarkByFeedItemId(feedItemId: String): BookmarkEntity? + + @Query("SELECT * FROM bookmarks WHERE tags LIKE '%' || :tag || '%' ORDER BY createdAt DESC") + fun getBookmarksByTag(tag: String): Flow> + + @Query("SELECT * FROM bookmarks ORDER BY createdAt DESC LIMIT :limit OFFSET :offset") + suspend fun getBookmarksPaginated(limit: Int, offset: Int): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertBookmark(bookmark: BookmarkEntity): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertBookmarks(bookmarks: List): List + + @Update + suspend fun updateBookmark(bookmark: BookmarkEntity): Int + + @Delete + suspend fun deleteBookmark(bookmark: BookmarkEntity): Int + + @Query("DELETE FROM bookmarks WHERE id = :id") + suspend fun deleteBookmarkById(id: String): Int + + @Query("DELETE FROM bookmarks WHERE feedItemId = :feedItemId") + suspend fun deleteBookmarkByFeedItemId(feedItemId: String): Int + + @Query("SELECT COUNT(*) FROM bookmarks") + fun getBookmarkCount(): Flow + + @Query("SELECT COUNT(*) FROM bookmarks WHERE tags LIKE '%' || :tag || '%'") + fun getBookmarkCountByTag(tag: String): Flow +} diff --git a/native-route/android/src/main/java/com/rssuper/database/daos/FeedItemDao.kt b/native-route/android/src/main/java/com/rssuper/database/daos/FeedItemDao.kt index 1288b73..8220f4b 100644 --- a/native-route/android/src/main/java/com/rssuper/database/daos/FeedItemDao.kt +++ b/native-route/android/src/main/java/com/rssuper/database/daos/FeedItemDao.kt @@ -74,4 +74,7 @@ interface FeedItemDao { @Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId LIMIT :limit OFFSET :offset") suspend fun getItemsPaginated(subscriptionId: String, limit: Int, offset: Int): List + + @Query("SELECT * FROM feed_items_fts WHERE feed_items_fts MATCH :query LIMIT :limit") + suspend fun searchByFts(query: String, limit: Int = 20): List } diff --git a/native-route/android/src/main/java/com/rssuper/database/daos/SubscriptionDao.kt b/native-route/android/src/main/java/com/rssuper/database/daos/SubscriptionDao.kt index 5cd7a46..ff06251 100644 --- a/native-route/android/src/main/java/com/rssuper/database/daos/SubscriptionDao.kt +++ b/native-route/android/src/main/java/com/rssuper/database/daos/SubscriptionDao.kt @@ -53,4 +53,13 @@ interface SubscriptionDao { @Query("UPDATE subscriptions SET nextFetchAt = :nextFetchAt WHERE id = :id") suspend fun updateNextFetchAt(id: String, nextFetchAt: Date) + + @Query("UPDATE subscriptions SET enabled = :enabled WHERE id = :id") + suspend fun setEnabled(id: String, enabled: Boolean): Int + + @Query("UPDATE subscriptions SET lastFetchedAt = :lastFetchedAt, error = NULL WHERE id = :id") + suspend fun updateLastFetchedAtMillis(id: String, lastFetchedAt: Long): Int + + @Query("UPDATE subscriptions SET nextFetchAt = :nextFetchAt WHERE id = :id") + suspend fun updateNextFetchAtMillis(id: String, nextFetchAt: Long): Int } diff --git a/native-route/android/src/main/java/com/rssuper/database/entities/BookmarkEntity.kt b/native-route/android/src/main/java/com/rssuper/database/entities/BookmarkEntity.kt new file mode 100644 index 0000000..ff6f559 --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/database/entities/BookmarkEntity.kt @@ -0,0 +1,43 @@ +package com.rssuper.database.entities + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import java.util.Date + +@Entity( + tableName = "bookmarks", + indices = [Index(value = ["feedItemId"], unique = true)] +) +data class BookmarkEntity( + @PrimaryKey + val id: String, + + val feedItemId: String, + + val title: String, + + val link: String? = null, + + val description: String? = null, + + val content: String? = null, + + val createdAt: Date, + + val tags: String? = null +) { + fun toFeedItem(): FeedItemEntity { + return FeedItemEntity( + id = feedItemId, + subscriptionId = "", // Will be set when linked to subscription + title = title, + link = link, + description = description, + content = content, + published = createdAt, + updated = createdAt + ) + } +} diff --git a/native-route/android/src/main/java/com/rssuper/model/Error.kt b/native-route/android/src/main/java/com/rssuper/model/Error.kt new file mode 100644 index 0000000..b8aad90 --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/model/Error.kt @@ -0,0 +1,9 @@ +package com.rssuper.model + +sealed interface Error { + data class Network(val message: String, val code: Int? = null) : Error + data class Database(val message: String, val cause: Throwable? = null) : Error + data class Parsing(val message: String, val cause: Throwable? = null) : Error + data class Auth(val message: String) : Error + data object Unknown : Error +} diff --git a/native-route/android/src/main/java/com/rssuper/model/State.kt b/native-route/android/src/main/java/com/rssuper/model/State.kt new file mode 100644 index 0000000..7a2f252 --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/model/State.kt @@ -0,0 +1,8 @@ +package com.rssuper.model + +sealed interface State { + data object Idle : State + data object Loading : State + data class Success(val data: T) : State + data class Error(val message: String, val cause: Throwable? = null) : State +} diff --git a/native-route/android/src/main/java/com/rssuper/repository/BookmarkRepository.kt b/native-route/android/src/main/java/com/rssuper/repository/BookmarkRepository.kt new file mode 100644 index 0000000..88d359a --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/repository/BookmarkRepository.kt @@ -0,0 +1,91 @@ +package com.rssuper.repository + +import com.rssuper.database.daos.BookmarkDao +import com.rssuper.database.entities.BookmarkEntity +import com.rssuper.state.BookmarkState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class BookmarkRepository( + private val bookmarkDao: BookmarkDao +) { + fun getAllBookmarks(): Flow { + return bookmarkDao.getAllBookmarks().map { bookmarks -> + BookmarkState.Success(bookmarks) + }.catch { e -> + emit(BookmarkState.Error("Failed to load bookmarks", e)) + } + } + + fun getBookmarksByTag(tag: String): Flow { + return bookmarkDao.getBookmarksByTag(tag).map { bookmarks -> + BookmarkState.Success(bookmarks) + }.catch { e -> + emit(BookmarkState.Error("Failed to load bookmarks by tag", e)) + } + } + + suspend fun getBookmarkById(id: String): BookmarkEntity? { + return try { + bookmarkDao.getBookmarkById(id) + } catch (e: Exception) { + throw RuntimeException("Failed to get bookmark", e) + } + } + + suspend fun getBookmarkByFeedItemId(feedItemId: String): BookmarkEntity? { + return try { + bookmarkDao.getBookmarkByFeedItemId(feedItemId) + } catch (e: Exception) { + throw RuntimeException("Failed to get bookmark by feed item ID", e) + } + } + + suspend fun insertBookmark(bookmark: BookmarkEntity): Long { + return try { + bookmarkDao.insertBookmark(bookmark) + } catch (e: Exception) { + throw RuntimeException("Failed to insert bookmark", e) + } + } + + suspend fun insertBookmarks(bookmarks: List): List { + return try { + bookmarkDao.insertBookmarks(bookmarks) + } catch (e: Exception) { + throw RuntimeException("Failed to insert bookmarks", e) + } + } + + suspend fun updateBookmark(bookmark: BookmarkEntity): Int { + return try { + bookmarkDao.updateBookmark(bookmark) + } catch (e: Exception) { + throw RuntimeException("Failed to update bookmark", e) + } + } + + suspend fun deleteBookmark(bookmark: BookmarkEntity): Int { + return try { + bookmarkDao.deleteBookmark(bookmark) + } catch (e: Exception) { + throw RuntimeException("Failed to delete bookmark", e) + } + } + + suspend fun deleteBookmarkById(id: String): Int { + return try { + bookmarkDao.deleteBookmarkById(id) + } catch (e: Exception) { + throw RuntimeException("Failed to delete bookmark by ID", e) + } + } + + suspend fun deleteBookmarkByFeedItemId(feedItemId: String): Int { + return try { + bookmarkDao.deleteBookmarkByFeedItemId(feedItemId) + } catch (e: Exception) { + throw RuntimeException("Failed to delete bookmark by feed item ID", e) + } + } +} diff --git a/native-route/android/src/main/java/com/rssuper/repository/FeedRepository.kt b/native-route/android/src/main/java/com/rssuper/repository/FeedRepository.kt new file mode 100644 index 0000000..aa114e0 --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/repository/FeedRepository.kt @@ -0,0 +1,102 @@ +package com.rssuper.repository + +import com.rssuper.database.daos.FeedItemDao +import com.rssuper.database.entities.FeedItemEntity +import com.rssuper.model.Error +import com.rssuper.model.State +import com.rssuper.models.Feed +import com.rssuper.models.FeedItem +import com.rssuper.parsing.FeedParser +import com.rssuper.parsing.ParseResult +import com.rssuper.services.FeedFetcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import java.util.Date + +class FeedRepository( + private val feedFetcher: FeedFetcher, + private val feedItemDao: FeedItemDao +) { + private val _feedState = MutableStateFlow>(State.Idle) + val feedState: StateFlow> = _feedState.asStateFlow() + + private val _feedItemsState = MutableStateFlow>>(State.Idle) + val feedItemsState: StateFlow>> = _feedItemsState.asStateFlow() + + suspend fun fetchFeed(url: String, httpAuth: com.rssuper.services.HTTPAuthCredentials? = null): Boolean { + _feedState.value = State.Loading + + val result = feedFetcher.fetchAndParse(url, httpAuth) + + return result.fold( + onSuccess = { parseResult -> + when (parseResult) { + is ParseResult.Success -> { + val feed = parseResult.feed + _feedState.value = State.Success(feed) + true + } + is ParseResult.Error -> { + _feedState.value = State.Error(parseResult.message) + false + } + } + }, + onFailure = { error -> + _feedState.value = State.Error( + message = error.message ?: "Unknown error", + cause = error + ) + false + } + ) + } + + fun getFeedItems(subscriptionId: String): Flow>> { + return feedItemDao.getItemsBySubscription(subscriptionId) + .map { items -> + State.Success(items) + } + } + + suspend fun markItemAsRead(itemId: String): Boolean { + return try { + feedItemDao.markAsRead(itemId) + true + } catch (e: Exception) { + _feedItemsState.value = State.Error("Failed to mark item as read", e) + false + } + } + + suspend fun markItemAsStarred(itemId: String): Boolean { + return try { + feedItemDao.markAsStarred(itemId) + true + } catch (e: Exception) { + _feedItemsState.value = State.Error("Failed to mark item as starred", e) + false + } + } + + fun getStarredItems(): Flow>> { + return feedItemDao.getStarredItems() + .map { items -> + State.Success(items) + } + } + + fun getUnreadItems(): Flow>> { + return feedItemDao.getUnreadItems() + .map { items -> + State.Success(items) + } + } + + private fun Flow>.map(transform: (List) -> State>): Flow>> { + return this.map { transform(it) } + } +} diff --git a/native-route/android/src/main/java/com/rssuper/repository/Repositories.kt b/native-route/android/src/main/java/com/rssuper/repository/Repositories.kt new file mode 100644 index 0000000..bfae315 --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/repository/Repositories.kt @@ -0,0 +1,32 @@ +package com.rssuper.repository + +import com.rssuper.database.entities.FeedItemEntity +import com.rssuper.database.entities.SubscriptionEntity +import kotlinx.coroutines.flow.Flow + +interface FeedRepository { + fun getFeedItems(subscriptionId: String?): Flow> + suspend fun getFeedItemById(id: String): FeedItemEntity? + suspend fun insertFeedItem(item: FeedItemEntity): Long + suspend fun insertFeedItems(items: List): List + suspend fun updateFeedItem(item: FeedItemEntity): Int + suspend fun markAsRead(id: String, isRead: Boolean): Int + suspend fun markAsStarred(id: String, isStarred: Boolean): Int + suspend fun deleteFeedItem(id: String): Int + suspend fun getUnreadCount(subscriptionId: String?): Int +} + +interface SubscriptionRepository { + fun getAllSubscriptions(): Flow> + fun getEnabledSubscriptions(): Flow> + fun getSubscriptionsByCategory(category: String): Flow> + suspend fun getSubscriptionById(id: String): SubscriptionEntity? + suspend fun getSubscriptionByUrl(url: String): SubscriptionEntity? + suspend fun insertSubscription(subscription: SubscriptionEntity): Long + suspend fun updateSubscription(subscription: SubscriptionEntity): Int + suspend fun deleteSubscription(id: String): Int + suspend fun setEnabled(id: String, enabled: Boolean): Int + suspend fun setError(id: String, error: String?): Int + suspend fun updateLastFetchedAt(id: String, lastFetchedAt: Long): Int + suspend fun updateNextFetchAt(id: String, nextFetchAt: Long): Int +} diff --git a/native-route/android/src/main/java/com/rssuper/repository/RepositoriesImpl.kt b/native-route/android/src/main/java/com/rssuper/repository/RepositoriesImpl.kt new file mode 100644 index 0000000..557fee3 --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/repository/RepositoriesImpl.kt @@ -0,0 +1,210 @@ +package com.rssuper.repository + +import com.rssuper.database.daos.FeedItemDao +import com.rssuper.database.daos.SubscriptionDao +import com.rssuper.database.entities.FeedItemEntity +import com.rssuper.database.entities.SubscriptionEntity +import com.rssuper.state.ErrorDetails +import com.rssuper.state.ErrorType +import com.rssuper.state.State +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class FeedRepositoryImpl( + private val feedItemDao: FeedItemDao +) : FeedRepository { + + override fun getFeedItems(subscriptionId: String?): Flow>> { + return if (subscriptionId != null) { + feedItemDao.getItemsBySubscription(subscriptionId).map { items -> + State.Success(items) + }.catch { e -> + emit(State.Error("Failed to load feed items", e)) + } + } else { + feedItemDao.getUnreadItems().map { items -> + State.Success(items) + }.catch { e -> + emit(State.Error("Failed to load feed items", e)) + } + } + } + + override suspend fun getFeedItemById(id: String): FeedItemEntity? { + return try { + feedItemDao.getItemById(id) + } catch (e: Exception) { + throw ErrorDetails(ErrorType.DATABASE, "Failed to get feed item", false) + } + } + + override suspend fun insertFeedItem(item: FeedItemEntity): Long { + return try { + feedItemDao.insertItem(item) + } catch (e: Exception) { + throw ErrorDetails(ErrorType.DATABASE, "Failed to insert feed item", false) + } + } + + override suspend fun insertFeedItems(items: List): List { + return try { + feedItemDao.insertItems(items) + } catch (e: Exception) { + throw ErrorDetails(ErrorType.DATABASE, "Failed to insert feed items", false) + } + } + + override suspend fun updateFeedItem(item: FeedItemEntity): Int { + return try { + feedItemDao.updateItem(item) + } catch (e: Exception) { + throw ErrorDetails(ErrorType.DATABASE, "Failed to update feed item", false) + } + } + + override suspend fun markAsRead(id: String, isRead: Boolean): Int { + return try { + if (isRead) { + feedItemDao.markAsRead(id) + } else { + feedItemDao.markAsUnread(id) + } + } catch (e: Exception) { + throw ErrorDetails(ErrorType.DATABASE, "Failed to mark item as read", true) + } + } + + override suspend fun markAsStarred(id: String, isStarred: Boolean): Int { + return try { + if (isStarred) { + feedItemDao.markAsStarred(id) + } else { + feedItemDao.markAsUnstarred(id) + } + } catch (e: Exception) { + throw ErrorDetails(ErrorType.DATABASE, "Failed to star item", true) + } + } + + override suspend fun deleteFeedItem(id: String): Int { + return try { + feedItemDao.deleteItemById(id) + } catch (e: Exception) { + throw ErrorDetails(ErrorType.DATABASE, "Failed to delete feed item", false) + } + } + + override suspend fun getUnreadCount(subscriptionId: String?): Int { + return try { + if (subscriptionId != null) { + feedItemDao.getItemById(subscriptionId) + feedItemDao.getUnreadCount(subscriptionId).first() + } else { + feedItemDao.getTotalUnreadCount().first() + } + } catch (e: Exception) { + throw ErrorDetails(ErrorType.DATABASE, "Failed to get unread count", false) + } + } +} + +class SubscriptionRepositoryImpl( + private val subscriptionDao: SubscriptionDao +) : SubscriptionRepository { + + override fun getAllSubscriptions(): Flow>> { + return subscriptionDao.getAllSubscriptions().map { subscriptions -> + State.Success(subscriptions) + }.catch { e -> + emit(State.Error("Failed to load subscriptions", e)) + } + } + + override fun getEnabledSubscriptions(): Flow>> { + return subscriptionDao.getEnabledSubscriptions().map { subscriptions -> + State.Success(subscriptions) + }.catch { e -> + emit(State.Error("Failed to load enabled subscriptions", e)) + } + } + + override fun getSubscriptionsByCategory(category: String): Flow>> { + return subscriptionDao.getSubscriptionsByCategory(category).map { subscriptions -> + State.Success(subscriptions) + }.catch { e -> + emit(State.Error("Failed to load subscriptions by category", e)) + } + } + + override suspend fun getSubscriptionById(id: String): SubscriptionEntity? { + return try { + subscriptionDao.getSubscriptionById(id) + } catch (e: Exception) { + throw ErrorDetails(ErrorType.DATABASE, "Failed to get subscription", false) + } + } + + override suspend fun getSubscriptionByUrl(url: String): SubscriptionEntity? { + return try { + subscriptionDao.getSubscriptionByUrl(url) + } catch (e: Exception) { + throw ErrorDetails(ErrorType.DATABASE, "Failed to get subscription by URL", false) + } + } + + override suspend fun insertSubscription(subscription: SubscriptionEntity): Long { + return try { + subscriptionDao.insertSubscription(subscription) + } catch (e: Exception) { + throw ErrorDetails(ErrorType.DATABASE, "Failed to insert subscription", false) + } + } + + override suspend fun updateSubscription(subscription: SubscriptionEntity): Int { + return try { + subscriptionDao.updateSubscription(subscription) + } catch (e: Exception) { + throw ErrorDetails(ErrorType.DATABASE, "Failed to update subscription", true) + } + } + + override suspend fun deleteSubscription(id: String): Int { + return try { + subscriptionDao.deleteSubscriptionById(id) + } catch (e: Exception) { + throw ErrorDetails(ErrorType.DATABASE, "Failed to delete subscription", false) + } + } + + override suspend fun setEnabled(id: String, enabled: Boolean): Int { + return try { + subscriptionDao.setEnabled(id, enabled) + } catch (e: Exception) { + throw ErrorDetails(ErrorType.DATABASE, "Failed to set subscription enabled state", true) + } + } + + override suspend fun setError(id: String, error: String?): Int { + return try { + subscriptionDao.updateError(id, error) + } catch (e: Exception) { + throw ErrorDetails(ErrorType.DATABASE, "Failed to set subscription error", true) + } + } + + override suspend fun updateLastFetchedAt(id: String, lastFetchedAt: Long): Int { + return try { + subscriptionDao.updateLastFetchedAtMillis(id, lastFetchedAt) + } catch (e: Exception) { + throw ErrorDetails(ErrorType.DATABASE, "Failed to update last fetched time", true) + } + } + + override suspend fun updateNextFetchAt(id: String, nextFetchAt: Long): Int { + return try { + subscriptionDao.updateNextFetchAtMillis(id, nextFetchAt) + } catch (e: Exception) { + throw ErrorDetails(ErrorType.DATABASE, "Failed to update next fetch time", true) + } + } +} diff --git a/native-route/android/src/main/java/com/rssuper/repository/SubscriptionRepository.kt b/native-route/android/src/main/java/com/rssuper/repository/SubscriptionRepository.kt new file mode 100644 index 0000000..522c7a5 --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/repository/SubscriptionRepository.kt @@ -0,0 +1,156 @@ +package com.rssuper.repository + +import com.rssuper.database.daos.SubscriptionDao +import com.rssuper.database.entities.SubscriptionEntity +import com.rssuper.model.Error +import com.rssuper.model.State +import com.rssuper.models.FeedSubscription +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import java.util.Date + +class SubscriptionRepository( + private val subscriptionDao: SubscriptionDao +) { + private val _subscriptionsState = MutableStateFlow>>(State.Idle) + val subscriptionsState: StateFlow>> = _subscriptionsState.asStateFlow() + + fun getAllSubscriptions(): Flow>> { + return subscriptionDao.getAllSubscriptions() + .map { subscriptions -> + State.Success(subscriptions) + } + } + + fun getEnabledSubscriptions(): Flow>> { + return subscriptionDao.getEnabledSubscriptions() + .map { subscriptions -> + State.Success(subscriptions) + } + } + + fun getSubscriptionsByCategory(category: String): Flow>> { + return subscriptionDao.getSubscriptionsByCategory(category) + .map { subscriptions -> + State.Success(subscriptions) + } + } + + suspend fun getSubscriptionById(id: String): State { + return try { + val subscription = subscriptionDao.getSubscriptionById(id) + State.Success(subscription) + } catch (e: Exception) { + State.Error("Failed to get subscription", e) + } + } + + suspend fun getSubscriptionByUrl(url: String): State { + return try { + val subscription = subscriptionDao.getSubscriptionByUrl(url) + State.Success(subscription) + } catch (e: Exception) { + State.Error("Failed to get subscription by URL", e) + } + } + + suspend fun addSubscription(subscription: FeedSubscription): Boolean { + return try { + subscriptionDao.insertSubscription( + SubscriptionEntity( + id = subscription.id, + url = subscription.url, + title = subscription.title, + category = subscription.category, + enabled = subscription.enabled, + fetchInterval = subscription.fetchInterval, + createdAt = subscription.createdAt, + updatedAt = subscription.updatedAt, + lastFetchedAt = subscription.lastFetchedAt, + nextFetchAt = subscription.nextFetchAt, + error = subscription.error, + httpAuthUsername = subscription.httpAuth?.username, + httpAuthPassword = subscription.httpAuth?.password + ) + ) + _subscriptionsState.value = State.Success(emptyList()) + true + } catch (e: Exception) { + _subscriptionsState.value = State.Error("Failed to add subscription", e) + false + } + } + + suspend fun updateSubscription(subscription: FeedSubscription): Boolean { + return try { + subscriptionDao.updateSubscription( + SubscriptionEntity( + id = subscription.id, + url = subscription.url, + title = subscription.title, + category = subscription.category, + enabled = subscription.enabled, + fetchInterval = subscription.fetchInterval, + createdAt = subscription.createdAt, + updatedAt = subscription.updatedAt, + lastFetchedAt = subscription.lastFetchedAt, + nextFetchAt = subscription.nextFetchAt, + error = subscription.error, + httpAuthUsername = subscription.httpAuth?.username, + httpAuthPassword = subscription.httpAuth?.password + ) + ) + true + } catch (e: Exception) { + _subscriptionsState.value = State.Error("Failed to update subscription", e) + false + } + } + + suspend fun deleteSubscription(id: String): Boolean { + return try { + subscriptionDao.deleteSubscriptionById(id) + true + } catch (e: Exception) { + _subscriptionsState.value = State.Error("Failed to delete subscription", e) + false + } + } + + suspend fun updateError(id: String, error: String?): Boolean { + return try { + subscriptionDao.updateError(id, error) + true + } catch (e: Exception) { + _subscriptionsState.value = State.Error("Failed to update subscription error", e) + false + } + } + + suspend fun updateLastFetchedAt(id: String, lastFetchedAt: Date): Boolean { + return try { + subscriptionDao.updateLastFetchedAt(id, lastFetchedAt) + true + } catch (e: Exception) { + _subscriptionsState.value = State.Error("Failed to update last fetched at", e) + false + } + } + + suspend fun updateNextFetchAt(id: String, nextFetchAt: Date): Boolean { + return try { + subscriptionDao.updateNextFetchAt(id, nextFetchAt) + true + } catch (e: Exception) { + _subscriptionsState.value = State.Error("Failed to update next fetch at", e) + false + } + } + + private fun Flow>.map(transform: (List) -> State>): Flow>> { + return this.map { transform(it) } + } +} diff --git a/native-route/android/src/main/java/com/rssuper/search/SearchQuery.kt b/native-route/android/src/main/java/com/rssuper/search/SearchQuery.kt new file mode 100644 index 0000000..4503863 --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/search/SearchQuery.kt @@ -0,0 +1,18 @@ +package com.rssuper.search + +import com.rssuper.models.SearchFilters + +/** + * SearchQuery - Represents a search query with filters + */ +data class SearchQuery( + val queryString: String, + val filters: SearchFilters? = null, + val page: Int = 1, + val pageSize: Int = 20, + val timestamp: Long = System.currentTimeMillis() +) { + fun isValid(): Boolean = queryString.isNotEmpty() + + fun getCacheKey(): String = "${queryString}_${filters?.hashCode() ?: 0}" +} diff --git a/native-route/android/src/main/java/com/rssuper/search/SearchResult.kt b/native-route/android/src/main/java/com/rssuper/search/SearchResult.kt new file mode 100644 index 0000000..b02b6be --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/search/SearchResult.kt @@ -0,0 +1,16 @@ +package com.rssuper.search + +import com.rssuper.database.entities.FeedItemEntity + +/** + * SearchResult - Represents a search result with relevance score + */ +data class SearchResult( + val feedItem: FeedItemEntity, + val relevanceScore: Float, + val highlight: String? = null +) { + fun isHighRelevance(): Boolean = relevanceScore > 0.8f + fun isMediumRelevance(): Boolean = relevanceScore in 0.5f..0.8f + fun isLowRelevance(): Boolean = relevanceScore < 0.5f +} diff --git a/native-route/android/src/main/java/com/rssuper/search/SearchResultProvider.kt b/native-route/android/src/main/java/com/rssuper/search/SearchResultProvider.kt new file mode 100644 index 0000000..c62f71a --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/search/SearchResultProvider.kt @@ -0,0 +1,71 @@ +package com.rssuper.search + +import com.rssuper.database.daos.FeedItemDao +import com.rssuper.database.entities.FeedItemEntity + +/** + * SearchResultProvider - Provides search results from the database + */ +class SearchResultProvider( + private val feedItemDao: FeedItemDao +) { + suspend fun search(query: String, limit: Int = 20): List { + // Use FTS query to search feed items + val results = feedItemDao.searchByFts(query, limit) + + return results.mapIndexed { index, item -> + SearchResult( + feedItem = item, + relevanceScore = calculateRelevance(query, item, index), + highlight = generateHighlight(item) + ) + } + } + + suspend fun searchBySubscription(query: String, subscriptionId: String, limit: Int = 20): List { + val results = feedItemDao.searchByFts(query, limit) + + return results.filter { it.subscriptionId == subscriptionId }.mapIndexed { index, item -> + SearchResult( + feedItem = item, + relevanceScore = calculateRelevance(query, item, index), + highlight = generateHighlight(item) + ) + } + } + + private fun calculateRelevance(query: String, item: FeedItemEntity, position: Int): Float { + val queryLower = query.lowercase() + var score = 0.0f + + // Title match (highest weight) + if (item.title.lowercase().contains(queryLower)) { + score += 1.0f + } + + // Author match + if (item.author?.lowercase()?.contains(queryLower) == true) { + score += 0.5f + } + + // Position bonus (earlier results are more relevant) + score += (1.0f / (position + 1)) * 0.3f + + return score.coerceIn(0.0f, 1.0f) + } + + private fun generateHighlight(item: FeedItemEntity): String? { + val maxLength = 200 + var text = item.title + + if (item.description?.isNotEmpty() == true) { + text += " ${item.description}" + } + + if (text.length > maxLength) { + text = text.substring(0, maxLength) + "..." + } + + return text + } +} diff --git a/native-route/android/src/main/java/com/rssuper/search/SearchService.kt b/native-route/android/src/main/java/com/rssuper/search/SearchService.kt new file mode 100644 index 0000000..c4162a1 --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/search/SearchService.kt @@ -0,0 +1,81 @@ +package com.rssuper.search + +import com.rssuper.database.daos.FeedItemDao +import com.rssuper.database.daos.SearchHistoryDao +import com.rssuper.database.entities.SearchHistoryEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** + * SearchService - Provides search functionality with FTS + */ +class SearchService( + private val feedItemDao: FeedItemDao, + private val searchHistoryDao: SearchHistoryDao, + private val resultProvider: SearchResultProvider +) { + private val cache = mutableMapOf>() + private val maxCacheSize = 100 + + fun search(query: String): Flow> { + val cacheKey = query.hashCode().toString() + + // Return cached results if available + cache[cacheKey]?.let { return flow { emit(it) } } + + return flow { + val results = resultProvider.search(query) + cache[cacheKey] = results + if (cache.size > maxCacheSize) { + cache.remove(cache.keys.first()) + } + emit(results) + } + } + + fun searchBySubscription(query: String, subscriptionId: String): Flow> { + return flow { + val results = resultProvider.searchBySubscription(query, subscriptionId) + emit(results) + } + } + + suspend fun searchAndSave(query: String): List { + val results = resultProvider.search(query) + + // Save to search history + saveSearchHistory(query) + + return results + } + + suspend fun saveSearchHistory(query: String) { + val searchHistory = SearchHistoryEntity( + id = System.currentTimeMillis().toString(), + query = query, + filtersJson = null, + timestamp = System.currentTimeMillis() + ) + searchHistoryDao.insertSearchHistory(searchHistory) + } + + fun getSearchHistory(): Flow> { + return searchHistoryDao.getAllSearchHistory() + } + + suspend fun getRecentSearches(limit: Int = 10): List { + return searchHistoryDao.getRecentSearches(limit).firstOrNull() ?: emptyList() + } + + suspend fun clearSearchHistory() { + searchHistoryDao.deleteAllSearchHistory() + } + + fun getSearchSuggestions(query: String): Flow> { + return searchHistoryDao.searchHistory(query) + } + + fun clearCache() { + cache.clear() + } +} diff --git a/native-route/android/src/main/java/com/rssuper/state/BookmarkState.kt b/native-route/android/src/main/java/com/rssuper/state/BookmarkState.kt new file mode 100644 index 0000000..10b12e6 --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/state/BookmarkState.kt @@ -0,0 +1,10 @@ +package com.rssuper.state + +import com.rssuper.database.entities.BookmarkEntity + +sealed interface BookmarkState { + data object Idle : BookmarkState + data object Loading : BookmarkState + data class Success(val data: List) : BookmarkState + data class Error(val message: String, val cause: Throwable? = null) : BookmarkState +} diff --git a/native-route/android/src/main/java/com/rssuper/state/ErrorType.kt b/native-route/android/src/main/java/com/rssuper/state/ErrorType.kt new file mode 100644 index 0000000..9600444 --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/state/ErrorType.kt @@ -0,0 +1,15 @@ +package com.rssuper.state + +enum class ErrorType { + NETWORK, + DATABASE, + PARSING, + AUTH, + UNKNOWN +} + +data class ErrorDetails( + val type: ErrorType, + val message: String, + val retryable: Boolean = false +) diff --git a/native-route/android/src/main/java/com/rssuper/state/State.kt b/native-route/android/src/main/java/com/rssuper/state/State.kt new file mode 100644 index 0000000..b8365fd --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/state/State.kt @@ -0,0 +1,8 @@ +package com.rssuper.state + +sealed interface State { + data object Idle : State + data object Loading : State + data class Success(val data: T) : State + data class Error(val message: String, val cause: Throwable? = null) : State +} diff --git a/native-route/android/src/main/java/com/rssuper/viewmodel/FeedViewModel.kt b/native-route/android/src/main/java/com/rssuper/viewmodel/FeedViewModel.kt new file mode 100644 index 0000000..ef3fa6f --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/viewmodel/FeedViewModel.kt @@ -0,0 +1,67 @@ +package com.rssuper.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.rssuper.repository.FeedRepository +import com.rssuper.state.State +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class FeedViewModel( + private val feedRepository: FeedRepository +) : ViewModel() { + + private val _feedState = MutableStateFlow>>(State.Idle) + val feedState: StateFlow>> = _feedState.asStateFlow() + + private val _unreadCount = MutableStateFlow>(State.Idle) + val unreadCount: StateFlow> = _unreadCount.asStateFlow() + + fun loadFeedItems(subscriptionId: String? = null) { + viewModelScope.launch { + feedRepository.getFeedItems(subscriptionId).collect { state -> + _feedState.value = state + } + } + } + + fun loadUnreadCount(subscriptionId: String? = null) { + viewModelScope.launch { + _unreadCount.value = State.Loading + try { + val count = feedRepository.getUnreadCount(subscriptionId) + _unreadCount.value = State.Success(count) + } catch (e: Exception) { + _unreadCount.value = State.Error("Failed to load unread count", e) + } + } + } + + fun markAsRead(id: String, isRead: Boolean) { + viewModelScope.launch { + try { + feedRepository.markAsRead(id, isRead) + loadUnreadCount() + } catch (e: Exception) { + _unreadCount.value = State.Error("Failed to update read state", e) + } + } + } + + fun markAsStarred(id: String, isStarred: Boolean) { + viewModelScope.launch { + try { + feedRepository.markAsStarred(id, isStarred) + } catch (e: Exception) { + _feedState.value = State.Error("Failed to update starred state", e) + } + } + } + + fun refreshFeed(subscriptionId: String? = null) { + loadFeedItems(subscriptionId) + loadUnreadCount(subscriptionId) + } +} diff --git a/native-route/android/src/main/java/com/rssuper/viewmodel/SubscriptionViewModel.kt b/native-route/android/src/main/java/com/rssuper/viewmodel/SubscriptionViewModel.kt new file mode 100644 index 0000000..11124d5 --- /dev/null +++ b/native-route/android/src/main/java/com/rssuper/viewmodel/SubscriptionViewModel.kt @@ -0,0 +1,83 @@ +package com.rssuper.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.rssuper.repository.SubscriptionRepository +import com.rssuper.state.State +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class SubscriptionViewModel( + private val subscriptionRepository: SubscriptionRepository +) : ViewModel() { + + private val _subscriptionsState = MutableStateFlow>>(State.Idle) + val subscriptionsState: StateFlow>> = _subscriptionsState.asStateFlow() + + private val _enabledSubscriptionsState = MutableStateFlow>>(State.Idle) + val enabledSubscriptionsState: StateFlow>> = _enabledSubscriptionsState.asStateFlow() + + fun loadAllSubscriptions() { + viewModelScope.launch { + subscriptionRepository.getAllSubscriptions().collect { state -> + _subscriptionsState.value = state + } + } + } + + fun loadEnabledSubscriptions() { + viewModelScope.launch { + subscriptionRepository.getEnabledSubscriptions().collect { state -> + _enabledSubscriptionsState.value = state + } + } + } + + fun setEnabled(id: String, enabled: Boolean) { + viewModelScope.launch { + try { + subscriptionRepository.setEnabled(id, enabled) + loadEnabledSubscriptions() + } catch (e: Exception) { + _enabledSubscriptionsState.value = State.Error("Failed to update subscription enabled state", e) + } + } + } + + fun setError(id: String, error: String?) { + viewModelScope.launch { + try { + subscriptionRepository.setError(id, error) + } catch (e: Exception) { + _subscriptionsState.value = State.Error("Failed to set subscription error", e) + } + } + } + + fun updateLastFetchedAt(id: String, lastFetchedAt: Long) { + viewModelScope.launch { + try { + subscriptionRepository.updateLastFetchedAt(id, lastFetchedAt) + } catch (e: Exception) { + _subscriptionsState.value = State.Error("Failed to update last fetched time", e) + } + } + } + + fun updateNextFetchAt(id: String, nextFetchAt: Long) { + viewModelScope.launch { + try { + subscriptionRepository.updateNextFetchAt(id, nextFetchAt) + } catch (e: Exception) { + _subscriptionsState.value = State.Error("Failed to update next fetch time", e) + } + } + } + + fun refreshSubscriptions() { + loadAllSubscriptions() + loadEnabledSubscriptions() + } +} diff --git a/native-route/android/src/test/java/com/rssuper/repository/FeedRepositoryTest.kt b/native-route/android/src/test/java/com/rssuper/repository/FeedRepositoryTest.kt new file mode 100644 index 0000000..62b7d42 --- /dev/null +++ b/native-route/android/src/test/java/com/rssuper/repository/FeedRepositoryTest.kt @@ -0,0 +1,70 @@ +package com.rssuper.repository + +import com.rssuper.database.daos.FeedItemDao +import com.rssuper.database.entities.FeedItemEntity +import com.rssuper.state.State +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.mockito.Mockito.`when` + +class FeedRepositoryTest { + + private lateinit var feedItemDao: FeedItemDao + private lateinit var feedRepository: FeedRepository + + @Before + fun setup() { + feedItemDao = Mockito.mock(FeedItemDao::class.java) + feedRepository = FeedRepositoryImpl(feedItemDao) + } + + @Test + fun testGetFeedItemsSuccess() = runTest { + val items = listOf( + FeedItemEntity( + id = "1", + subscriptionId = "sub1", + title = "Test Item", + published = java.util.Date() + ) + ) + + val stateFlow = MutableStateFlow>>(State.Success(items)) + `when`(feedItemDao.getItemsBySubscription("sub1")).thenReturn(stateFlow) + + feedRepository.getFeedItems("sub1").collect { state -> + assert(state is State.Success) + assert((state as State.Success).data == items) + } + } + + @Test + fun testInsertFeedItemSuccess() = runTest { + val item = FeedItemEntity( + id = "1", + subscriptionId = "sub1", + title = "Test Item", + published = java.util.Date() + ) + + `when`(feedItemDao.insertItem(item)).thenReturn(1L) + + val result = feedRepository.insertFeedItem(item) + assert(result == 1L) + } + + @Test(expected = RuntimeException::class) + fun testInsertFeedItemError() = runTest { + `when`(feedItemDao.insertItem(Mockito.any())).thenThrow(RuntimeException("Database error")) + + feedRepository.insertFeedItem(FeedItemEntity( + id = "1", + subscriptionId = "sub1", + title = "Test Item", + published = java.util.Date() + )) + } +} diff --git a/native-route/android/src/test/java/com/rssuper/repository/SubscriptionRepositoryTest.kt b/native-route/android/src/test/java/com/rssuper/repository/SubscriptionRepositoryTest.kt new file mode 100644 index 0000000..566217b --- /dev/null +++ b/native-route/android/src/test/java/com/rssuper/repository/SubscriptionRepositoryTest.kt @@ -0,0 +1,108 @@ +package com.rssuper.repository + +import com.rssuper.database.daos.SubscriptionDao +import com.rssuper.database.entities.SubscriptionEntity +import com.rssuper.state.State +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.mockito.Mockito.`when` +import java.util.Date + +class SubscriptionRepositoryTest { + + private lateinit var subscriptionDao: SubscriptionDao + private lateinit var subscriptionRepository: SubscriptionRepository + + @Before + fun setup() { + subscriptionDao = Mockito.mock(SubscriptionDao::class.java) + subscriptionRepository = SubscriptionRepositoryImpl(subscriptionDao) + } + + @Test + fun testGetAllSubscriptionsSuccess() = runTest { + val subscriptions = listOf( + SubscriptionEntity( + id = "1", + url = "https://example.com/feed.xml", + title = "Test Feed", + createdAt = Date(), + updatedAt = Date() + ) + ) + + val stateFlow = MutableStateFlow>>(State.Success(subscriptions)) + `when`(subscriptionDao.getAllSubscriptions()).thenReturn(stateFlow) + + subscriptionRepository.getAllSubscriptions().collect { state -> + assert(state is State.Success) + assert((state as State.Success).data == subscriptions) + } + } + + @Test + fun testGetEnabledSubscriptionsSuccess() = runTest { + val subscriptions = listOf( + SubscriptionEntity( + id = "1", + url = "https://example.com/feed.xml", + title = "Test Feed", + enabled = true, + createdAt = Date(), + updatedAt = Date() + ) + ) + + val stateFlow = MutableStateFlow>>(State.Success(subscriptions)) + `when`(subscriptionDao.getEnabledSubscriptions()).thenReturn(stateFlow) + + subscriptionRepository.getEnabledSubscriptions().collect { state -> + assert(state is State.Success) + assert((state as State.Success).data == subscriptions) + } + } + + @Test + fun testInsertSubscriptionSuccess() = runTest { + val subscription = SubscriptionEntity( + id = "1", + url = "https://example.com/feed.xml", + title = "Test Feed", + createdAt = Date(), + updatedAt = Date() + ) + + `when`(subscriptionDao.insertSubscription(subscription)).thenReturn(1L) + + val result = subscriptionRepository.insertSubscription(subscription) + assert(result == 1L) + } + + @Test + fun testUpdateSubscriptionSuccess() = runTest { + val subscription = SubscriptionEntity( + id = "1", + url = "https://example.com/feed.xml", + title = "Test Feed", + enabled = true, + createdAt = Date(), + updatedAt = Date() + ) + + `when`(subscriptionDao.updateSubscription(subscription)).thenReturn(1) + + val result = subscriptionRepository.updateSubscription(subscription) + assert(result == 1) + } + + @Test + fun testSetEnabledSuccess() = runTest { + `when`(subscriptionDao.setEnabled("1", true)).thenReturn(1) + + val result = subscriptionRepository.setEnabled("1", true) + assert(result == 1) + } +} diff --git a/native-route/android/src/test/java/com/rssuper/state/StateTest.kt b/native-route/android/src/test/java/com/rssuper/state/StateTest.kt new file mode 100644 index 0000000..47bcf3f --- /dev/null +++ b/native-route/android/src/test/java/com/rssuper/state/StateTest.kt @@ -0,0 +1,66 @@ +package com.rssuper.state + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StateTest { + + @Test + fun testIdleState() { + val state: State = State.Idle + assertTrue(state is State.Idle) + } + + @Test + fun testLoadingState() { + val state: State = State.Loading + assertTrue(state is State.Loading) + } + + @Test + fun testSuccessState() { + val data = "test data" + val state: State = State.Success(data) + + assertTrue(state is State.Success) + assertEquals(data, (state as State.Success).data) + } + + @Test + fun testErrorState() { + val message = "test error" + val state: State = State.Error(message) + + assertTrue(state is State.Error) + assertEquals(message, (state as State.Error).message) + assertEquals(null, (state as State.Error).cause) + } + + @Test + fun testErrorStateWithCause() { + val message = "test error" + val cause = RuntimeException("cause") + val state: State = State.Error(message, cause) + + assertTrue(state is State.Error) + assertEquals(message, (state as State.Error).message) + assertEquals(cause, (state as State.Error).cause) + } + + @Test + fun testErrorType() { + assertTrue(ErrorType.NETWORK != ErrorType.DATABASE) + assertTrue(ErrorType.PARSING != ErrorType.AUTH) + } + + @Test + fun testErrorDetails() { + val details = ErrorDetails(ErrorType.NETWORK, "Network error", true) + + assertEquals(ErrorType.NETWORK, details.type) + assertEquals("Network error", details.message) + assertTrue(details.retryable) + } +} diff --git a/native-route/android/src/test/java/com/rssuper/viewmodel/FeedViewModelTest.kt b/native-route/android/src/test/java/com/rssuper/viewmodel/FeedViewModelTest.kt new file mode 100644 index 0000000..6aa7959 --- /dev/null +++ b/native-route/android/src/test/java/com/rssuper/viewmodel/FeedViewModelTest.kt @@ -0,0 +1,73 @@ +package com.rssuper.viewmodel + +import com.rssuper.repository.FeedRepository +import com.rssuper.state.State +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.mockito.Mockito.`when` + +class FeedViewModelTest { + + private lateinit var feedRepository: FeedRepository + private lateinit var viewModel: FeedViewModel + + @Before + fun setup() { + feedRepository = Mockito.mock(FeedRepository::class.java) + viewModel = FeedViewModel(feedRepository) + } + + @Test + fun testInitialState() = runTest { + var stateEmitted = false + viewModel.feedState.collect { state -> + assert(state is State.Idle) + stateEmitted = true + } + assert(stateEmitted) + } + + @Test + fun testLoadFeedItems() = runTest { + val items = listOf( + com.rssuper.database.entities.FeedItemEntity( + id = "1", + subscriptionId = "sub1", + title = "Test Item", + published = java.util.Date() + ) + ) + + val stateFlow = MutableStateFlow>>(State.Success(items)) + `when`(feedRepository.getFeedItems("sub1")).thenReturn(stateFlow) + + viewModel.loadFeedItems("sub1") + + var receivedState: State>? = null + viewModel.feedState.collect { state -> + receivedState = state + } + + assert(receivedState is State.Success) + assert((receivedState as State.Success).data == items) + } + + @Test + fun testMarkAsRead() = runTest { + `when`(feedRepository.markAsRead("1", true)).thenReturn(1) + `when`(feedRepository.getUnreadCount("sub1")).thenReturn(5) + + viewModel.markAsRead("1", true) + + var unreadCountState: State? = null + viewModel.unreadCount.collect { state -> + unreadCountState = state + } + + assert(unreadCountState is State.Success) + assert((unreadCountState as State.Success).data == 5) + } +} diff --git a/native-route/android/src/test/java/com/rssuper/viewmodel/SubscriptionViewModelTest.kt b/native-route/android/src/test/java/com/rssuper/viewmodel/SubscriptionViewModelTest.kt new file mode 100644 index 0000000..95298ab --- /dev/null +++ b/native-route/android/src/test/java/com/rssuper/viewmodel/SubscriptionViewModelTest.kt @@ -0,0 +1,100 @@ +package com.rssuper.viewmodel + +import com.rssuper.repository.SubscriptionRepository +import com.rssuper.state.State +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.mockito.Mockito.`when` +import java.util.Date + +class SubscriptionViewModelTest { + + private lateinit var subscriptionRepository: SubscriptionRepository + private lateinit var viewModel: SubscriptionViewModel + + @Before + fun setup() { + subscriptionRepository = Mockito.mock(SubscriptionRepository::class.java) + viewModel = SubscriptionViewModel(subscriptionRepository) + } + + @Test + fun testInitialState() = runTest { + var stateEmitted = false + viewModel.subscriptionsState.collect { state -> + assert(state is State.Idle) + stateEmitted = true + } + assert(stateEmitted) + } + + @Test + fun testLoadAllSubscriptions() = runTest { + val subscriptions = listOf( + com.rssuper.database.entities.SubscriptionEntity( + id = "1", + url = "https://example.com/feed.xml", + title = "Test Feed", + createdAt = Date(), + updatedAt = Date() + ) + ) + + val stateFlow = MutableStateFlow>>(State.Success(subscriptions)) + `when`(subscriptionRepository.getAllSubscriptions()).thenReturn(stateFlow) + + viewModel.loadAllSubscriptions() + + var receivedState: State>? = null + viewModel.subscriptionsState.collect { state -> + receivedState = state + } + + assert(receivedState is State.Success) + assert((receivedState as State.Success).data == subscriptions) + } + + @Test + fun testSetEnabled() = runTest { + val subscriptions = listOf( + com.rssuper.database.entities.SubscriptionEntity( + id = "1", + url = "https://example.com/feed.xml", + title = "Test Feed", + enabled = true, + createdAt = Date(), + updatedAt = Date() + ) + ) + + val stateFlow = MutableStateFlow>>(State.Success(subscriptions)) + `when`(subscriptionRepository.setEnabled("1", true)).thenReturn(1) + `when`(subscriptionRepository.getEnabledSubscriptions()).thenReturn(stateFlow) + + viewModel.setEnabled("1", true) + + var receivedState: State>? = null + viewModel.enabledSubscriptionsState.collect { state -> + receivedState = state + } + + assert(receivedState is State.Success) + assert((receivedState as State.Success).data == subscriptions) + } + + @Test + fun testSetError() = runTest { + `when`(subscriptionRepository.setError("1", "Test error")).thenReturn(1) + + viewModel.setError("1", "Test error") + + var stateEmitted = false + viewModel.subscriptionsState.collect { state -> + stateEmitted = true + } + assert(stateEmitted) + } +} diff --git a/native-route/ios/RSSuperTests/SearchHistoryStoreTests.swift b/native-route/ios/RSSuperTests/SearchHistoryStoreTests.swift new file mode 100644 index 0000000..0b087f9 --- /dev/null +++ b/native-route/ios/RSSuperTests/SearchHistoryStoreTests.swift @@ -0,0 +1,122 @@ +import XCTest +@testable import RSSuper + +/// Unit tests for SearchHistoryStore +final class SearchHistoryStoreTests: XCTestCase { + + private var historyStore: SearchHistoryStore! + private var databaseManager: DatabaseManager! + + override func setUp() async throws { + // Create in-memory database for testing + databaseManager = try await DatabaseManager.inMemory() + historyStore = SearchHistoryStore(databaseManager: databaseManager, maxHistoryCount: 10) + try await historyStore.initialize() + } + + override func tearDown() async throws { + historyStore = nil + databaseManager = nil + } + + func testRecordSearch() async throws { + try await historyStore.recordSearch("test query") + + let exists = try await historyStore.queryExists("test query") + XCTAssertTrue(exists) + } + + func testRecordSearchUpdatesExisting() async throws { + try await historyStore.recordSearch("test query") + let firstCount = try await historyStore.getTotalCount() + + try await historyStore.recordSearch("test query") + let secondCount = try await historyStore.getTotalCount() + + XCTAssertEqual(firstCount, secondCount) // Should be same, updated not inserted + } + + func testGetRecentQueries() async throws { + try await historyStore.recordSearch("query 1") + try await historyStore.recordSearch("query 2") + try await historyStore.recordSearch("query 3") + + let queries = try await historyStore.getRecentQueries(limit: 2) + + XCTAssertEqual(queries.count, 2) + XCTAssertEqual(queries[0], "query 3") // Most recent first + XCTAssertEqual(queries[1], "query 2") + } + + func testGetHistoryWithMetadata() async throws { + try await historyStore.recordSearch("test query", resultCount: 42) + + let entries = try await historyStore.getHistoryWithMetadata(limit: 10) + + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries[0].query, "test query") + XCTAssertEqual(entries[0].resultCount, 42) + } + + func testRemoveQuery() async throws { + try await historyStore.recordSearch("to remove") + XCTAssertTrue(try await historyStore.queryExists("to remove")) + + try await historyStore.removeQuery("to remove") + XCTAssertFalse(try await historyStore.queryExists("to remove")) + } + + func testClearHistory() async throws { + try await historyStore.recordSearch("query 1") + try await historyStore.recordSearch("query 2") + + XCTAssertEqual(try await historyStore.getTotalCount(), 2) + + try await historyStore.clearHistory() + XCTAssertEqual(try await historyStore.getTotalCount(), 0) + } + + func testTrimHistory() async throws { + // Insert more than maxHistoryCount + for i in 1...15 { + try await historyStore.recordSearch("query \(i)") + } + + let count = try await historyStore.getTotalCount() + XCTAssertEqual(count, 10) // Should be trimmed to maxHistoryCount + } + + func testGetPopularQueries() async throws { + // Record queries with different frequencies + try await historyStore.recordSearch("popular") + try await historyStore.recordSearch("popular") + try await historyStore.recordSearch("popular") + try await historyStore.recordSearch("less popular") + try await historyStore.recordSearch("less popular") + try await historyStore.recordSearch("once") + + let popular = try await historyStore.getPopularQueries(limit: 10) + + XCTAssertEqual(popular.count, 3) + XCTAssertEqual(popular[0].query, "popular") + XCTAssertEqual(popular[0].count, 3) + } + + func testGetTodaysQueries() async throws { + try await historyStore.recordSearch("today query 1") + try await historyStore.recordSearch("today query 2") + + let todays = try await historyStore.getTodaysQueries() + + XCTAssertTrue(todays.contains("today query 1")) + XCTAssertTrue(todays.contains("today query 2")) + } + + func testEmptyQueryIgnored() async throws { + try await historyStore.recordSearch("") + try await historyStore.recordSearch(" ") + + let count = try await historyStore.getTotalCount() + XCTAssertEqual(count, 0) + } +} diff --git a/native-route/ios/RSSuperTests/SearchQueryTests.swift b/native-route/ios/RSSuperTests/SearchQueryTests.swift new file mode 100644 index 0000000..9e3f26a --- /dev/null +++ b/native-route/ios/RSSuperTests/SearchQueryTests.swift @@ -0,0 +1,111 @@ +import XCTest +@testable import RSSuper + +/// Unit tests for SearchQuery parsing and manipulation +final class SearchQueryTests: XCTestCase { + + func testEmptyQuery() { + let query = SearchQuery(rawValue: "") + + XCTAssertEqual(query.terms, []) + XCTAssertEqual(query.rawText, "") + XCTAssertEqual(query.sort, .relevance) + XCTAssertFalse(query.fuzzy) + } + + func testSimpleQuery() { + let query = SearchQuery(rawValue: "swift programming") + + XCTAssertEqual(query.terms, ["swift", "programming"]) + XCTAssertEqual(query.rawText, "swift programming") + } + + func testQueryWithDateFilter() { + let query = SearchQuery(rawValue: "swift date:after:2024-01-01") + + XCTAssertEqual(query.terms, ["swift"]) + XCTAssertNotNil(query.filters.dateRange) + + if case .after(let date) = query.filters.dateRange! { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + let expectedDate = formatter.date(from: "2024-01-01")! + XCTAssertEqual(date, expectedDate) + } else { + XCTFail("Expected .after case") + } + } + + func testQueryWithFeedFilter() { + let query = SearchQuery(rawValue: "swift feed:Apple Developer") + + XCTAssertEqual(query.terms, ["swift"]) + XCTAssertEqual(query.filters.feedTitle, "Apple Developer") + } + + func testQueryWithAuthorFilter() { + let query = SearchQuery(rawValue: "swift author:John Doe") + + XCTAssertEqual(query.terms, ["swift"]) + XCTAssertEqual(query.filters.author, "John Doe") + } + + func testQueryWithSortOption() { + let query = SearchQuery(rawValue: "swift sort:date_desc") + + XCTAssertEqual(query.terms, ["swift"]) + XCTAssertEqual(query.sort, .dateDesc) + } + + func testQueryWithFuzzyFlag() { + let query = SearchQuery(rawValue: "swift ~") + + XCTAssertEqual(query.terms, ["swift"]) + XCTAssertTrue(query.fuzzy) + } + + func testFTSQueryGeneration() { + let exactQuery = SearchQuery(rawValue: "swift programming") + XCTAssertEqual(exactQuery.ftsQuery(), "\"swift\" OR \"programming\"") + + let fuzzyQuery = SearchQuery(rawValue: "swift ~") + XCTAssertEqual(fuzzyQuery.ftsQuery(), "\"*swift*\"") + } + + func testDisplayString() { + let query = SearchQuery(rawValue: "swift date:after:2024-01-01") + XCTAssertEqual(query.displayString, "swift") + } + + func testDateRangeLowerBound() { + let afterRange = DateRange.after(Date()) + XCTAssertNotNil(afterRange.lowerBound) + XCTAssertNil(afterRange.upperBound) + + let beforeRange = DateRange.before(Date()) + XCTAssertNil(beforeRange.lowerBound) + XCTAssertNotNil(beforeRange.upperBound) + + let exactRange = DateRange.exact(Date()) + XCTAssertNotNil(exactRange.lowerBound) + XCTAssertNotNil(exactRange.upperBound) + } + + func testSearchFiltersIsEmpty() { + var filters = SearchFilters() + XCTAssertTrue(filters.isEmpty) + + filters.dateRange = .after(Date()) + XCTAssertFalse(filters.isEmpty) + + filters = .empty + XCTAssertTrue(filters.isEmpty) + } + + func testSortOptionOrderByClause() { + XCTAssertEqual(SearchSortOption.relevance.orderByClause(), "rank") + XCTAssertEqual(SearchSortOption.dateDesc.orderByClause(), "f.published DESC") + XCTAssertEqual(SearchSortOption.titleAsc.orderByClause(), "f.title ASC") + XCTAssertEqual(SearchSortOption.feedDesc.orderByClause(), "s.title DESC") + } +} diff --git a/native-route/ios/RSSuperTests/SearchResultTests.swift b/native-route/ios/RSSuperTests/SearchResultTests.swift new file mode 100644 index 0000000..b66277e --- /dev/null +++ b/native-route/ios/RSSuperTests/SearchResultTests.swift @@ -0,0 +1,89 @@ +import XCTest +@testable import RSSuper + +/// Unit tests for SearchResult and related types +final class SearchResultTests: XCTestCase { + + func testArticleResultCreation() { + let result = SearchResult.article( + id: "article-123", + title: "Test Article", + snippet: "This is a snippet", + link: "https://example.com/article", + feedTitle: "Test Feed", + published: Date(), + score: 0.95, + author: "Test Author" + ) + + XCTAssertEqual(result.id, "article-123") + XCTAssertEqual(result.type, .article) + XCTAssertEqual(result.title, "Test Article") + XCTAssertEqual(result.snippet, "This is a snippet") + XCTAssertEqual(result.link, "https://example.com/article") + XCTAssertEqual(result.feedTitle, "Test Feed") + XCTAssertEqual(result.score, 0.95) + XCTAssertEqual(result.author, "Test Author") + } + + func testFeedResultCreation() { + let result = SearchResult.feed( + id: "feed-456", + title: "Test Feed", + link: "https://example.com/feed.xml", + score: 0.85 + ) + + XCTAssertEqual(result.id, "feed-456") + XCTAssertEqual(result.type, .feed) + XCTAssertEqual(result.title, "Test Feed") + XCTAssertEqual(result.link, "https://example.com/feed.xml") + XCTAssertEqual(result.score, 0.85) + } + + func testSuggestionResultCreation() { + let result = SearchResult.suggestion( + text: "swift programming", + score: 0.75 + ) + + XCTAssertEqual(result.type, .suggestion) + XCTAssertEqual(result.title, "swift programming") + XCTAssertEqual(result.score, 0.75) + } + + func testSearchResultTypeEncoding() { + XCTAssertEqual(SearchResultType.article.rawValue, "article") + XCTAssertEqual(SearchResultType.feed.rawValue, "feed") + XCTAssertEqual(SearchResultType.suggestion.rawValue, "suggestion") + XCTAssertEqual(SearchResultType.tag.rawValue, "tag") + XCTAssertEqual(SearchResultType.author.rawValue, "author") + } + + func testSearchResultEquatable() { + let result1 = SearchResult.article(id: "1", title: "Test") + let result2 = SearchResult.article(id: "1", title: "Test") + let result3 = SearchResult.article(id: "2", title: "Test") + + XCTAssertEqual(result1, result2) + XCTAssertNotEqual(result1, result3) + } + + func testSearchResults totalCount() { + let results = SearchResults( + articles: [SearchResult.article(id: "1", title: "A")], + feeds: [SearchResult.feed(id: "2", title: "F")], + suggestions: [] + ) + + XCTAssertEqual(results.totalCount, 2) + XCTAssertTrue(results.hasResults) + } + + func testSearchResultsEmpty() { + let results = SearchResults(articles: [], feeds: [], suggestions: []) + + XCTAssertEqual(results.totalCount, 0) + XCTAssertFalse(results.hasResults) + } +} diff --git a/native-route/ios/RSSuperTests/SyncSchedulerTests.swift b/native-route/ios/RSSuperTests/SyncSchedulerTests.swift new file mode 100644 index 0000000..7375bca --- /dev/null +++ b/native-route/ios/RSSuperTests/SyncSchedulerTests.swift @@ -0,0 +1,76 @@ +import XCTest +@testable import RSSuper + +/// Unit tests for SyncScheduler +final class SyncSchedulerTests: XCTestCase { + + private var scheduler: SyncScheduler! + + override func setUp() { + super.setUp() + scheduler = SyncScheduler() + } + + override func tearDown() { + scheduler = nil + super.tearDown() + } + + func testDefaultSyncInterval() { + XCTAssertEqual(scheduler.preferredSyncInterval, SyncScheduler.defaultSyncInterval) + } + + func testSyncIntervalClamping() { + // Test minimum clamping + scheduler.preferredSyncInterval = 60 // 1 minute + XCTAssertEqual(scheduler.preferredSyncInterval, SyncScheduler.minimumSyncInterval) + + // Test maximum clamping + scheduler.preferredSyncInterval = 48 * 3600 // 48 hours + XCTAssertEqual(scheduler.preferredSyncInterval, SyncScheduler.maximumSyncInterval) + } + + func testIsSyncDue() { + // Fresh scheduler should have sync due + XCTAssertTrue(scheduler.isSyncDue) + + // Set last sync date to recent past + scheduler.lastSyncDate = Date().addingTimeInterval(-1 * 3600) // 1 hour ago + XCTAssertFalse(scheduler.isSyncDue) + + // Set last sync date to far past + scheduler.lastSyncDate = Date().addingTimeInterval(-12 * 3600) // 12 hours ago + XCTAssertTrue(scheduler.isSyncDue) + } + + func testTimeSinceLastSync() { + scheduler.lastSyncDate = Date().addingTimeInterval(-3600) // 1 hour ago + + let timeSince = scheduler.timeSinceLastSync + XCTAssertGreaterThan(timeSince, 3500) + XCTAssertLessThan(timeSince, 3700) + } + + func testResetSyncSchedule() { + scheduler.preferredSyncInterval = 12 * 3600 + scheduler.lastSyncDate = Date().addingTimeInterval(-100) + + scheduler.resetSyncSchedule() + + XCTAssertEqual(scheduler.preferredSyncInterval, SyncScheduler.defaultSyncInterval) + XCTAssertNil(scheduler.lastSyncDate) + } + + func testUserActivityLevelCalculation() { + // High activity + XCTAssertEqual(UserActivityLevel.calculate(from: 5, lastOpenedAgo: 3600), .high) + XCTAssertEqual(UserActivityLevel.calculate(from: 1, lastOpenedAgo: 60), .high) + + // Medium activity + XCTAssertEqual(UserActivityLevel.calculate(from: 2, lastOpenedAgo: 3600), .medium) + XCTAssertEqual(UserActivityLevel.calculate(from: 0, lastOpenedAgo: 43200), .medium) + + // Low activity + XCTAssertEqual(UserActivityLevel.calculate(from: 0, lastOpenedAgo: 172800), .low) + } +} diff --git a/native-route/linux/gsettings/org.rssuper.notification.preferences.gschema.xml b/native-route/linux/gsettings/org.rssuper.notification.preferences.gschema.xml new file mode 100644 index 0000000..f13eaef --- /dev/null +++ b/native-route/linux/gsettings/org.rssuper.notification.preferences.gschema.xml @@ -0,0 +1,74 @@ + + + rssuper + + + + + + + + + + + + + + + + + + + + + + + + New Article Notifications + true + Enable notifications for new articles + + + + Episode Release Notifications + true + Enable notifications for episode releases + + + + Custom Alert Notifications + true + Enable notifications for custom alerts + + + + Badge Count + true + Show badge count in app header + + + + Sound + true + Play sound on notification + + + + Vibration + true + Vibrate device on notification + + + + All Preferences + { + "newArticles": true, + "episodeReleases": true, + "customAlerts": true, + "badgeCount": true, + "sound": true, + "vibration": true + } + All notification preferences as JSON + + \ No newline at end of file diff --git a/native-route/linux/src/notification-manager.vala b/native-route/linux/src/notification-manager.vala new file mode 100644 index 0000000..906a2f8 --- /dev/null +++ b/native-route/linux/src/notification-manager.vala @@ -0,0 +1,373 @@ +/* + * notification-manager.vala + * + * Notification manager for RSSuper on Linux. + * Coordinates notifications, badge management, and tray integration. + */ + +using Gio; +using GLib; +using Gtk; + +namespace RSSuper { + +/** + * NotificationManager - Manager for coordinating notifications + */ +public class NotificationManager : Object { + + // Singleton instance + private static NotificationManager? _instance; + + // Notification service + private NotificationService? _notification_service; + + // Badge reference + private Gtk.Badge? _badge; + + // Tray icon reference + private Gtk.TrayIcon? _tray_icon; + + // App reference + private Gtk.App? _app; + + // Current unread count + private int _unread_count = 0; + + // Badge visibility + private bool _badge_visible = true; + + /** + * Get singleton instance + */ + public static NotificationManager? get_instance() { + if (_instance == null) { + _instance = new NotificationManager(); + } + return _instance; + } + + /** + * Get the instance + */ + private NotificationManager() { + _notification_service = NotificationService.get_instance(); + _app = Gtk.App.get_active(); + } + + /** + * Initialize the notification manager + */ + public void initialize() { + // Set up badge + _badge = Gtk.Badge.new(); + _badge.set_visible(_badge_visible); + _badge.set_halign(Gtk.Align.START); + + // Connect badge changed signal + _badge.changed.connect(_on_badge_changed); + + // Set up tray icon + _tray_icon = Gtk.TrayIcon.new(); + _tray_icon.set_icon_name("rssuper"); + _tray_icon.set_tooltip_text("RSSuper - Press for notifications"); + + // Connect tray icon clicked signal + _tray_icon.clicked.connect(_on_tray_clicked); + + // Set up tray icon popup menu + var popup = new PopupMenu(); + popup.add_item(new Gtk.Label("Notifications: " + _unread_count.toString())); + popup.add_item(new Gtk.Separator()); + popup.add_item(new Gtk.Label("Mark all as read")); + popup.add_item(new Gtk.Separator()); + popup.add_item(new Gtk.Label("Settings")); + popup.add_item(new Gtk.Label("Exit")); + popup.connect_closed(_on_tray_closed); + + _tray_icon.set_popup(popup); + + // Connect tray icon popup menu signal + popup.menu_closed.connect(_on_tray_popup_closed); + + // Set up tray icon popup handler + _tray_icon.set_popup_handler(_on_tray_popup); + + // Set up tray icon tooltip + _tray_icon.set_tooltip_text("RSSuper - Press for notifications"); + } + + /** + * Set up the badge in the app header + */ + public void set_up_badge() { + _badge.set_visible(_badge_visible); + _badge.set_halign(Gtk.Align.START); + + // Set up badge changed signal + _badge.changed.connect(_on_badge_changed); + } + + /** + * Set up the tray icon + */ + public void set_up_tray_icon() { + _tray_icon.set_icon_name("rssuper"); + _tray_icon.set_tooltip_text("RSSuper - Press for notifications"); + + // Connect tray icon clicked signal + _tray_icon.clicked.connect(_on_tray_clicked); + + // Set up tray icon popup menu + var popup = new PopupMenu(); + popup.add_item(new Gtk.Label("Notifications: " + _unread_count.toString())); + popup.add_item(new Gtk.Separator()); + popup.add_item(new Gtk.Label("Mark all as read")); + popup.add_item(new Gtk.Separator()); + popup.add_item(new Gtk.Label("Settings")); + popup.add_item(new Gtk.Label("Exit")); + popup.connect_closed(_on_tray_closed); + + _tray_icon.set_popup(popup); + + // Connect tray icon popup menu signal + popup.menu_closed.connect(_on_tray_popup_closed); + + // Set up tray icon popup handler + _tray_icon.set_popup_handler(_on_tray_popup); + + // Set up tray icon tooltip + _tray_icon.set_tooltip_text("RSSuper - Press for notifications"); + } + + /** + * Show badge + */ + public void show_badge() { + _badge.set_visible(_badge_visible); + } + + /** + * Hide badge + */ + public void hide_badge() { + _badge.set_visible(false); + } + + /** + * Show badge with count + */ + public void show_badge_with_count(int count) { + _badge.set_visible(_badge_visible); + _badge.set_label(count.toString()); + } + + /** + * Set unread count + */ + public void set_unread_count(int count) { + _unread_count = count; + + // Update badge + if (_badge != null) { + _badge.set_label(count.toString()); + } + + // Update tray icon popup + if (_tray_icon != null) { + var popup = _tray_icon.get_popup(); + if (popup != null) { + popup.set_label("Notifications: " + count.toString()); + } + } + + // Show badge if count > 0 + if (count > 0) { + show_badge(); + } + } + + /** + * Clear unread count + */ + public void clear_unread_count() { + _unread_count = 0; + hide_badge(); + + // Update tray icon popup + if (_tray_icon != null) { + var popup = _tray_icon.get_popup(); + if (popup != null) { + popup.set_label("Notifications: 0"); + } + } + } + + /** + * Get unread count + */ + public int get_unread_count() { + return _unread_count; + } + + /** + * Get badge reference + */ + public Gtk.Badge? get_badge() { + return _badge; + } + + /** + * Get tray icon reference + */ + public Gtk.TrayIcon? get_tray_icon() { + return _tray_icon; + } + + /** + * Get app reference + */ + public Gtk.App? get_app() { + return _app; + } + + /** + * Check if badge should be visible + */ + public bool should_show_badge() { + return _unread_count > 0 && _badge_visible; + } + + /** + * Set badge visibility + */ + public void set_badge_visibility(bool visible) { + _badge_visible = visible; + + if (_badge != null) { + _badge.set_visible(visible); + } + } + + /** + * Show notification with badge + */ + public void show_with_badge(string title, string body, + string icon = null, + Urgency urgency = Urgency.NORMAL) { + + var notification = _notification_service.create(title, body, icon, urgency); + notification.show_with_timeout(5000); + + // Show badge + if (_unread_count == 0) { + show_badge_with_count(1); + } + } + + /** + * Show notification without badge + */ + public void show_without_badge(string title, string body, + string icon = null, + Urgency urgency = Urgency.NORMAL) { + + var notification = _notification_service.create(title, body, icon, urgency); + notification.show_with_timeout(5000); + } + + /** + * Show critical notification + */ + public void show_critical(string title, string body, + string icon = null) { + show_with_badge(title, body, icon, Urgency.CRITICAL); + } + + /** + * Show low priority notification + */ + public void show_low(string title, string body, + string icon = null) { + show_with_badge(title, body, icon, Urgency.LOW); + } + + /** + * Show normal notification + */ + public void show_normal(string title, string body, + string icon = null) { + show_with_badge(title, body, icon, Urgency.NORMAL); + } + + /** + * Handle badge changed signal + */ + private void _on_badge_changed(Gtk.Badge badge) { + var count = badge.get_label(); + if (!string.IsNullOrEmpty(count)) { + _unread_count = int.Parse(count); + } + } + + /** + * Handle tray icon clicked signal + */ + private void _on_tray_clicked(Gtk.TrayIcon tray) { + show_notifications_panel(); + } + + /** + * Handle tray icon popup closed signal + */ + private void _on_tray_popup_closed(Gtk.Popup popup) { + // Popup closed, hide icon + if (_tray_icon != null) { + _tray_icon.hide(); + } + } + + /** + * Handle tray icon popup open signal + */ + private void _on_tray_popup(Gtk.TrayIcon tray, Gtk.MenuItem menu) { + // Show icon when popup is opened + if (_tray_icon != null) { + _tray_icon.show(); + } + } + + /** + * Handle tray icon closed signal + */ + private void _on_tray_closed(Gtk.App app) { + // App closed, hide tray icon + if (_tray_icon != null) { + _tray_icon.hide(); + } + } + + /** + * Show notifications panel + */ + private void show_notifications_panel() { + // TODO: Show notifications panel + print("Notifications panel requested"); + } + + /** + * Get notification service + */ + public NotificationService? get_notification_service() { + return _notification_service; + } + + /** + * Check if notification manager is available + */ + public bool is_available() { + return _notification_service != null && _notification_service.is_available(); + } +} + +} \ No newline at end of file diff --git a/native-route/linux/src/notification-preferences-store.vala b/native-route/linux/src/notification-preferences-store.vala new file mode 100644 index 0000000..c7c26fd --- /dev/null +++ b/native-route/linux/src/notification-preferences-store.vala @@ -0,0 +1,285 @@ +/* + * notification-preferences-store.vala + * + * Store for notification preferences. + * Provides persistent storage for user notification settings. + */ + +using GLib; + +namespace RSSuper { + +/** + * NotificationPreferencesStore - Persistent storage for notification preferences + * + * Uses GSettings for persistent storage following freedesktop.org conventions. + */ +public class NotificationPreferencesStore : Object { + + // Singleton instance + private static NotificationPreferencesStore? _instance; + + // GSettings schema key + private const string SCHEMA_KEY = "org.rssuper.notification.preferences"; + + // GSettings schema description + private const string SCHEMA_DESCRIPTION = "RSSuper notification preferences"; + + // GSettings schema source URI + private const string SCHEMA_SOURCE = "file:///app/gsettings/org.rssuper.notification.preferences.gschema.xml"; + + // Preferences schema + private GSettings? _settings; + + // Preferences object + private NotificationPreferences? _preferences; + + /** + * Get singleton instance + */ + public static NotificationPreferencesStore? get_instance() { + if (_instance == null) { + _instance = new NotificationPreferencesStore(); + } + return _instance; + } + + /** + * Get the instance + */ + private NotificationPreferencesStore() { + _settings = GSettings.new(SCHEMA_KEY, SCHEMA_DESCRIPTION); + + // Load initial preferences + _preferences = NotificationPreferences.from_json_string(_settings.get_string("preferences")); + + if (_preferences == null) { + // Set default preferences if none exist + _preferences = new NotificationPreferences(); + _settings.set_string("preferences", _preferences.to_json_string()); + } + + // Listen for settings changes + _settings.changed.connect(_on_settings_changed); + } + + /** + * Get notification preferences + */ + public NotificationPreferences? get_preferences() { + return _preferences; + } + + /** + * Set notification preferences + */ + public void set_preferences(NotificationPreferences prefs) { + _preferences = prefs; + + // Save to GSettings + _settings.set_string("preferences", prefs.to_json_string()); + } + + /** + * Get new articles preference + */ + public bool get_new_articles() { + return _preferences != null ? _preferences.new_articles : true; + } + + /** + * Set new articles preference + */ + public void set_new_articles(bool enabled) { + _preferences = _preferences ?? new NotificationPreferences(); + _preferences.new_articles = enabled; + _settings.set_boolean("newArticles", enabled); + } + + /** + * Get episode releases preference + */ + public bool get_episode_releases() { + return _preferences != null ? _preferences.episode_releases : true; + } + + /** + * Set episode releases preference + */ + public void set_episode_releases(bool enabled) { + _preferences = _preferences ?? new NotificationPreferences(); + _preferences.episode_releases = enabled; + _settings.set_boolean("episodeReleases", enabled); + } + + /** + * Get custom alerts preference + */ + public bool get_custom_alerts() { + return _preferences != null ? _preferences.custom_alerts : true; + } + + /** + * Set custom alerts preference + */ + public void set_custom_alerts(bool enabled) { + _preferences = _preferences ?? new NotificationPreferences(); + _preferences.custom_alerts = enabled; + _settings.set_boolean("customAlerts", enabled); + } + + /** + * Get badge count preference + */ + public bool get_badge_count() { + return _preferences != null ? _preferences.badge_count : true; + } + + /** + * Set badge count preference + */ + public void set_badge_count(bool enabled) { + _preferences = _preferences ?? new NotificationPreferences(); + _preferences.badge_count = enabled; + _settings.set_boolean("badgeCount", enabled); + } + + /** + * Get sound preference + */ + public bool get_sound() { + return _preferences != null ? _preferences.sound : true; + } + + /** + * Set sound preference + */ + public void set_sound(bool enabled) { + _preferences = _preferences ?? new NotificationPreferences(); + _preferences.sound = enabled; + _settings.set_boolean("sound", enabled); + } + + /** + * Get vibration preference + */ + public bool get_vibration() { + return _preferences != null ? _preferences.vibration : true; + } + + /** + * Set vibration preference + */ + public void set_vibration(bool enabled) { + _preferences = _preferences ?? new NotificationPreferences(); + _preferences.vibration = enabled; + _settings.set_boolean("vibration", enabled); + } + + /** + * Enable all notifications + */ + public void enable_all() { + _preferences = _preferences ?? new NotificationPreferences(); + _preferences.enable_all(); + + // Save to GSettings + _settings.set_string("preferences", _preferences.to_json_string()); + } + + /** + * Disable all notifications + */ + public void disable_all() { + _preferences = _preferences ?? new NotificationPreferences(); + _preferences.disable_all(); + + // Save to GSettings + _settings.set_string("preferences", _preferences.to_json_string()); + } + + /** + * Get all preferences as dictionary + */ + public Dictionary get_all_preferences() { + if (_preferences == null) { + return new Dictionary(); + } + + var prefs = new Dictionary(); + prefs["new_articles"] = _preferences.new_articles; + prefs["episode_releases"] = _preferences.episode_releases; + prefs["custom_alerts"] = _preferences.custom_alerts; + prefs["badge_count"] = _preferences.badge_count; + prefs["sound"] = _preferences.sound; + prefs["vibration"] = _preferences.vibration; + + return prefs; + } + + /** + * Set all preferences from dictionary + */ + public void set_all_preferences(Dictionary prefs) { + _preferences = new NotificationPreferences(); + + if (prefs.containsKey("new_articles")) { + _preferences.new_articles = prefs["new_articles"] as bool; + } + if (prefs.containsKey("episode_releases")) { + _preferences.episode_releases = prefs["episode_releases"] as bool; + } + if (prefs.containsKey("custom_alerts")) { + _preferences.custom_alerts = prefs["custom_alerts"] as bool; + } + if (prefs.containsKey("badge_count")) { + _preferences.badge_count = prefs["badge_count"] as bool; + } + if (prefs.containsKey("sound")) { + _preferences.sound = prefs["sound"] as bool; + } + if (prefs.containsKey("vibration")) { + _preferences.vibration = prefs["vibration"] as bool; + } + + // Save to GSettings + _settings.set_string("preferences", _preferences.to_json_string()); + } + + /** + * Get schema key + */ + public string get_schema_key() { + return SCHEMA_KEY; + } + + /** + * Get schema description + */ + public string get_schema_description() { + return SCHEMA_DESCRIPTION; + } + + /** + * Get schema source + */ + public string get_schema_source() { + return SCHEMA_SOURCE; + } + + /** + * Handle settings changed signal + */ + private void _on_settings_changed(GSettings settings) { + // Settings changed, reload preferences + _preferences = NotificationPreferences.from_json_string(settings.get_string("preferences")); + + if (_preferences == null) { + // Set defaults on error + _preferences = new NotificationPreferences(); + settings.set_string("preferences", _preferences.to_json_string()); + } + } +} + +} \ No newline at end of file diff --git a/native-route/linux/src/notification-service.vala b/native-route/linux/src/notification-service.vala new file mode 100644 index 0000000..e23ad2c --- /dev/null +++ b/native-route/linux/src/notification-service.vala @@ -0,0 +1,232 @@ +/* + * notification-service.vala + * + * Main notification service for RSSuper on Linux. + * Implements Gio.Notification API following freedesktop.org spec. + */ + +using Gio; +using GLib; + +namespace RSSuper { + +/** + * NotificationService - Main notification service for Linux + * + * Handles desktop notifications using Gio.Notification. + * Follows freedesktop.org notify-send specification. + */ +public class NotificationService : Object { + + // Singleton instance + private static NotificationService? _instance; + + // Gio.Notification instance + private Gio.Notification? _notification; + + // Tray icon reference + private Gtk.App? _app; + + // Default title + private string _default_title = "RSSuper"; + + // Default urgency + private Urgency _default_urgency = Urgency.NORMAL; + + /** + * Get singleton instance + */ + public static NotificationService? get_instance() { + if (_instance == null) { + _instance = new NotificationService(); + } + return _instance; + } + + /** + * Get the instance (for singleton pattern) + */ + private NotificationService() { + _app = Gtk.App.get_active(); + _default_title = _app != null ? _app.get_name() : "RSSuper"; + _default_urgency = Urgency.NORMAL; + } + + /** + * Check if notification service is available + */ + public bool is_available() { + return Gio.Notification.is_available(); + } + + /** + * Create a new notification + * + * @param title The notification title + * @param body The notification body + * @param urgency Urgency level (NORMAL, CRITICAL, LOW) + * @param timestamp Optional timestamp (defaults to now) + */ + public Notification create(string title, string body, + Urgency urgency = Urgency.NORMAL, + DateTime timestamp = null) { + + _notification = Gio.Notification.new(_default_title); + _notification.set_body(body); + _notification.set_urgency(urgency); + + if (timestamp == null) { + _notification.set_time_now(); + } else { + _notification.set_time(timestamp); + } + + return _notification; + } + + /** + * Create a notification with summary and icon + */ + public Notification create(string title, string body, string icon, + Urgency urgency = Urgency.NORMAL, + DateTime timestamp = null) { + + _notification = Gio.Notification.new(title); + _notification.set_body(body); + _notification.set_urgency(urgency); + + if (timestamp == null) { + _notification.set_time_now(); + } else { + _notification.set_time(timestamp); + } + + // Set icon + try { + _notification.set_icon(icon); + } catch (Error e) { + warning("Failed to set icon: %s", e.message); + } + + return _notification; + } + + /** + * Create a notification with summary, body, and icon + */ + public Notification create(string summary, string body, string icon, + Urgency urgency = Urgency.NORMAL, + DateTime timestamp = null) { + + _notification = Gio.Notification.new(summary); + _notification.set_body(body); + _notification.set_urgency(urgency); + + if (timestamp == null) { + _notification.set_time_now(); + } else { + _notification.set_time(timestamp); + } + + // Set icon + try { + _notification.set_icon(icon); + } catch (Error e) { + warning("Failed to set icon: %s", e.message); + } + + return _notification; + } + + /** + * Show the notification + */ + public void show() { + if (_notification == null) { + warning("Cannot show null notification"); + return; + } + + try { + _notification.show(); + } catch (Error e) { + warning("Failed to show notification: %s", e.message); + } + } + + /** + * Show the notification with timeout + * + * @param timeout_seconds Timeout in seconds (default: 5) + */ + public void show_with_timeout(int timeout_seconds = 5) { + if (_notification == null) { + warning("Cannot show null notification"); + return; + } + + try { + _notification.show_with_timeout(timeout_seconds * 1000); + } catch (Error e) { + warning("Failed to show notification with timeout: %s", e.message); + } + } + + /** + * Get the notification instance + */ + public Gio.Notification? get_notification() { + return _notification; + } + + /** + * Set the default title + */ + public void set_default_title(string title) { + _default_title = title; + } + + /** + * Set the default urgency + */ + public void set_default_urgency(Urgency urgency) { + _default_urgency = urgency; + } + + /** + * Get the default title + */ + public string get_default_title() { + return _default_title; + } + + /** + * Get the default urgency + */ + public Urgency get_default_urgency() { + return _default_urgency; + } + + /** + * Get the app reference + */ + public Gtk.App? get_app() { + return _app; + } + + /** + * Check if the notification can be shown + */ + public bool can_show() { + return _notification != null && _notification.can_show(); + } + + /** + * Get available urgency levels + */ + public static List get_available_urgencies() { + return Urgency.get_available(); + } +} + +} \ No newline at end of file diff --git a/native-route/linux/src/repository/Repositories.vala b/native-route/linux/src/repository/Repositories.vala new file mode 100644 index 0000000..dc93cb2 --- /dev/null +++ b/native-route/linux/src/repository/Repositories.vala @@ -0,0 +1,41 @@ +/* + * Repositories.vala + * + * Repository interfaces for Linux state management + */ + +namespace RSSuper { + + /** + * FeedRepository - Interface for feed repository operations + */ + public interface FeedRepository : Object { + public abstract void get_feed_items(string? subscription_id, State callback); + public abstract FeedItem? get_feed_item_by_id(string id) throws Error; + public abstract void insert_feed_item(FeedItem item) throws Error; + public abstract void insert_feed_items(FeedItem[] items) throws Error; + public abstract void update_feed_item(FeedItem item) throws Error; + public abstract void mark_as_read(string id, bool is_read) throws Error; + public abstract void mark_as_starred(string id, bool is_starred) throws Error; + public abstract void delete_feed_item(string id) throws Error; + public abstract int get_unread_count(string? subscription_id) throws Error; + } + + /** + * SubscriptionRepository - Interface for subscription repository operations + */ + public interface SubscriptionRepository : Object { + public abstract void get_all_subscriptions(State callback); + public abstract void get_enabled_subscriptions(State callback); + public abstract void get_subscriptions_by_category(string category, State callback); + public abstract FeedSubscription? get_subscription_by_id(string id) throws Error; + public abstract FeedSubscription? get_subscription_by_url(string url) throws Error; + public abstract void insert_subscription(FeedSubscription subscription) throws Error; + public abstract void update_subscription(FeedSubscription subscription) throws Error; + public abstract void delete_subscription(string id) throws Error; + public abstract void set_enabled(string id, bool enabled) throws Error; + public abstract void set_error(string id, string? error) throws Error; + public abstract void update_last_fetched_at(string id, ulong last_fetched_at) throws Error; + public abstract void update_next_fetch_at(string id, ulong next_fetch_at) throws Error; + } +} diff --git a/native-route/linux/src/repository/RepositoriesImpl.vala b/native-route/linux/src/repository/RepositoriesImpl.vala new file mode 100644 index 0000000..c840631 --- /dev/null +++ b/native-route/linux/src/repository/RepositoriesImpl.vala @@ -0,0 +1,136 @@ +/* + * RepositoriesImpl.vala + * + * Repository implementations for Linux state management + */ + +namespace RSSuper { + + /** + * FeedRepositoryImpl - Implementation of FeedRepository + */ + public class FeedRepositoryImpl : Object, FeedRepository { + private Database db; + + public FeedRepositoryImpl(Database db) { + this.db = db; + } + + public override void get_feed_items(string? subscription_id, State callback) { + try { + var feedItems = db.getFeedItems(subscription_id); + callback.set_success(feedItems); + } catch (Error e) { + callback.set_error("Failed to get feed items", e); + } + } + + public override FeedItem? get_feed_item_by_id(string id) throws Error { + return db.getFeedItemById(id); + } + + public override void insert_feed_item(FeedItem item) throws Error { + db.insertFeedItem(item); + } + + public override void insert_feed_items(FeedItem[] items) throws Error { + foreach (var item in items) { + db.insertFeedItem(item); + } + } + + public override void update_feed_item(FeedItem item) throws Error { + db.updateFeedItem(item); + } + + public override void mark_as_read(string id, bool is_read) throws Error { + db.markFeedItemAsRead(id, is_read); + } + + public override void mark_as_starred(string id, bool is_starred) throws Error { + db.markFeedItemAsStarred(id, is_starred); + } + + public override void delete_feed_item(string id) throws Error { + db.deleteFeedItem(id); + } + + public override int get_unread_count(string? subscription_id) throws Error { + return db.getUnreadCount(subscription_id); + } + } + + /** + * SubscriptionRepositoryImpl - Implementation of SubscriptionRepository + */ + public class SubscriptionRepositoryImpl : Object, SubscriptionRepository { + private Database db; + + public SubscriptionRepositoryImpl(Database db) { + this.db = db; + } + + public override void get_all_subscriptions(State callback) { + try { + var subscriptions = db.getAllSubscriptions(); + callback.set_success(subscriptions); + } catch (Error e) { + callback.set_error("Failed to get subscriptions", e); + } + } + + public override void get_enabled_subscriptions(State callback) { + try { + var subscriptions = db.getEnabledSubscriptions(); + callback.set_success(subscriptions); + } catch (Error e) { + callback.set_error("Failed to get enabled subscriptions", e); + } + } + + public override void get_subscriptions_by_category(string category, State callback) { + try { + var subscriptions = db.getSubscriptionsByCategory(category); + callback.set_success(subscriptions); + } catch (Error e) { + callback.set_error("Failed to get subscriptions by category", e); + } + } + + public override FeedSubscription? get_subscription_by_id(string id) throws Error { + return db.getSubscriptionById(id); + } + + public override FeedSubscription? get_subscription_by_url(string url) throws Error { + return db.getSubscriptionByUrl(url); + } + + public override void insert_subscription(FeedSubscription subscription) throws Error { + db.insertSubscription(subscription); + } + + public override void update_subscription(FeedSubscription subscription) throws Error { + db.updateSubscription(subscription); + } + + public override void delete_subscription(string id) throws Error { + db.deleteSubscription(id); + } + + public override void set_enabled(string id, bool enabled) throws Error { + db.setSubscriptionEnabled(id, enabled); + } + + public override void set_error(string id, string? error) throws Error { + db.setSubscriptionError(id, error); + } + + public override void update_last_fetched_at(string id, ulong last_fetched_at) throws Error { + db.setSubscriptionLastFetchedAt(id, last_fetched_at); + } + + public override void update_next_fetch_at(string id, ulong next_fetch_at) throws Error { + db.setSubscriptionNextFetchAt(id, next_fetch_at); + } + } +} diff --git a/native-route/linux/src/state/ErrorType.vala b/native-route/linux/src/state/ErrorType.vala new file mode 100644 index 0000000..80c0346 --- /dev/null +++ b/native-route/linux/src/state/ErrorType.vala @@ -0,0 +1,34 @@ +/* + * ErrorType.vala + * + * Error types for state management + */ + +namespace RSSuper { + + /** + * ErrorType - Category of errors + */ + public enum ErrorType { + NETWORK, + DATABASE, + PARSING, + AUTH, + UNKNOWN + } + + /** + * ErrorDetails - Detailed error information + */ + public class ErrorDetails : Object { + public ErrorType type { get; set; } + public string message { get; set; } + public bool retryable { get; set; } + + public ErrorDetails(ErrorType type, string message, bool retryable = false) { + this.type = type; + this.message = message; + this.retryable = retryable; + } + } +} diff --git a/native-route/linux/src/state/State.vala b/native-route/linux/src/state/State.vala new file mode 100644 index 0000000..b2c24d4 --- /dev/null +++ b/native-route/linux/src/state/State.vala @@ -0,0 +1,110 @@ +/* + * State.vala + * + * Reactive state management using GObject signals + */ + +namespace RSSuper { + + /** + * State - Enumerated state for reactive state management + */ + public enum State { + IDLE, + LOADING, + SUCCESS, + ERROR + } + + /** + * State - Generic state container with signals + */ + public class State : Object { + private State _state; + private T? _data; + private string? _message; + private Error? _error; + + public State() { + _state = State.IDLE; + } + + public State.idle() { + _state = State.IDLE; + } + + public State.loading() { + _state = State.LOADING; + } + + public State.success(T data) { + _state = State.SUCCESS; + _data = data; + } + + public State.error(string message, Error? error = null) { + _state = State.ERROR; + _message = message; + _error = error; + } + + public State get_state() { + return _state; + } + + public T? get_data() { + return _data; + } + + public string? get_message() { + return _message; + } + + public Error? get_error() { + return _error; + } + + public bool is_idle() { + return _state == State.IDLE; + } + + public bool is_loading() { + return _state == State.LOADING; + } + + public bool is_success() { + return _state == State.SUCCESS; + } + + public bool is_error() { + return _state == State.ERROR; + } + + public void set_idle() { + _state = State.IDLE; + _data = null; + _message = null; + _error = null; + } + + public void set_loading() { + _state = State.LOADING; + _data = null; + _message = null; + _error = null; + } + + public void set_success(T data) { + _state = State.SUCCESS; + _data = data; + _message = null; + _error = null; + } + + public void set_error(string message, Error? error = null) { + _state = State.ERROR; + _message = message; + _error = error; + } + } +} diff --git a/native-route/linux/src/viewmodel/FeedViewModel.vala b/native-route/linux/src/viewmodel/FeedViewModel.vala new file mode 100644 index 0000000..8a98903 --- /dev/null +++ b/native-route/linux/src/viewmodel/FeedViewModel.vala @@ -0,0 +1,70 @@ +/* + * FeedViewModel.vala + * + * ViewModel for feed state management + */ + +namespace RSSuper { + + /** + * FeedViewModel - Manages feed state for UI binding + */ + public class FeedViewModel : Object { + private FeedRepository repository; + private State feedState; + private State unreadCountState; + + public FeedViewModel(FeedRepository repository) { + this.repository = repository; + this.feedState = new State(); + this.unreadCountState = new State(); + } + + public State get_feed_state() { + return feedState; + } + + public State get_unread_count_state() { + return unreadCountState; + } + + public void load_feed_items(string? subscription_id = null) { + feedState.set_loading(); + repository.get_feed_items(subscription_id, (state) => { + feedState = state; + }); + } + + public void load_unread_count(string? subscription_id = null) { + unreadCountState.set_loading(); + try { + var count = repository.get_unread_count(subscription_id); + unreadCountState.set_success(count); + } catch (Error e) { + unreadCountState.set_error("Failed to load unread count", e); + } + } + + public void mark_as_read(string id, bool is_read) { + try { + repository.mark_as_read(id, is_read); + load_unread_count(); + } catch (Error e) { + unreadCountState.set_error("Failed to update read state", e); + } + } + + public void mark_as_starred(string id, bool is_starred) { + try { + repository.mark_as_starred(id, is_starred); + } catch (Error e) { + feedState.set_error("Failed to update starred state", e); + } + } + + public void refresh(string? subscription_id = null) { + load_feed_items(subscription_id); + load_unread_count(subscription_id); + } + } +} diff --git a/native-route/linux/src/viewmodel/SubscriptionViewModel.vala b/native-route/linux/src/viewmodel/SubscriptionViewModel.vala new file mode 100644 index 0000000..ec755b1 --- /dev/null +++ b/native-route/linux/src/viewmodel/SubscriptionViewModel.vala @@ -0,0 +1,83 @@ +/* + * SubscriptionViewModel.vala + * + * ViewModel for subscription state management + */ + +namespace RSSuper { + + /** + * SubscriptionViewModel - Manages subscription state for UI binding + */ + public class SubscriptionViewModel : Object { + private SubscriptionRepository repository; + private State subscriptionsState; + private State enabledSubscriptionsState; + + public SubscriptionViewModel(SubscriptionRepository repository) { + this.repository = repository; + this.subscriptionsState = new State(); + this.enabledSubscriptionsState = new State(); + } + + public State get_subscriptions_state() { + return subscriptionsState; + } + + public State get_enabled_subscriptions_state() { + return enabledSubscriptionsState; + } + + public void load_all_subscriptions() { + subscriptionsState.set_loading(); + repository.get_all_subscriptions((state) => { + subscriptionsState = state; + }); + } + + public void load_enabled_subscriptions() { + enabledSubscriptionsState.set_loading(); + repository.get_enabled_subscriptions((state) => { + enabledSubscriptionsState = state; + }); + } + + public void set_enabled(string id, bool enabled) { + try { + repository.set_enabled(id, enabled); + load_enabled_subscriptions(); + } catch (Error e) { + enabledSubscriptionsState.set_error("Failed to update subscription enabled state", e); + } + } + + public void set_error(string id, string? error) { + try { + repository.set_error(id, error); + } catch (Error e) { + subscriptionsState.set_error("Failed to set subscription error", e); + } + } + + public void update_last_fetched_at(string id, ulong last_fetched_at) { + try { + repository.update_last_fetched_at(id, last_fetched_at); + } catch (Error e) { + subscriptionsState.set_error("Failed to update last fetched time", e); + } + } + + public void update_next_fetch_at(string id, ulong next_fetch_at) { + try { + repository.update_next_fetch_at(id, next_fetch_at); + } catch (Error e) { + subscriptionsState.set_error("Failed to update next fetch time", e); + } + } + + public void refresh() { + load_all_subscriptions(); + load_enabled_subscriptions(); + } + } +}