Auto-commit 2026-03-30 16:30

This commit is contained in:
2026-03-30 16:30:46 -04:00
parent 5fc7ed74c4
commit a6da9ef9cf
41 changed files with 3438 additions and 0 deletions

65
check-identity.js Normal file
View File

@@ -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();

36
check-identity.py Normal file
View File

@@ -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))

View File

@@ -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<List<BookmarkEntity>>
@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<List<BookmarkEntity>>
@Query("SELECT * FROM bookmarks ORDER BY createdAt DESC LIMIT :limit OFFSET :offset")
suspend fun getBookmarksPaginated(limit: Int, offset: Int): List<BookmarkEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertBookmark(bookmark: BookmarkEntity): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertBookmarks(bookmarks: List<BookmarkEntity>): List<Long>
@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<Int>
@Query("SELECT COUNT(*) FROM bookmarks WHERE tags LIKE '%' || :tag || '%'")
fun getBookmarkCountByTag(tag: String): Flow<Int>
}

View File

@@ -74,4 +74,7 @@ interface FeedItemDao {
@Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId LIMIT :limit OFFSET :offset") @Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId LIMIT :limit OFFSET :offset")
suspend fun getItemsPaginated(subscriptionId: String, limit: Int, offset: Int): List<FeedItemEntity> suspend fun getItemsPaginated(subscriptionId: String, limit: Int, offset: Int): List<FeedItemEntity>
@Query("SELECT * FROM feed_items_fts WHERE feed_items_fts MATCH :query LIMIT :limit")
suspend fun searchByFts(query: String, limit: Int = 20): List<FeedItemEntity>
} }

View File

@@ -53,4 +53,13 @@ interface SubscriptionDao {
@Query("UPDATE subscriptions SET nextFetchAt = :nextFetchAt WHERE id = :id") @Query("UPDATE subscriptions SET nextFetchAt = :nextFetchAt WHERE id = :id")
suspend fun updateNextFetchAt(id: String, nextFetchAt: Date) 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
} }

View File

@@ -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
)
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,8 @@
package com.rssuper.model
sealed interface State<out T> {
data object Idle : State<Nothing>
data object Loading : State<Nothing>
data class Success<T>(val data: T) : State<T>
data class Error(val message: String, val cause: Throwable? = null) : State<Nothing>
}

View File

@@ -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<BookmarkState> {
return bookmarkDao.getAllBookmarks().map { bookmarks ->
BookmarkState.Success(bookmarks)
}.catch { e ->
emit(BookmarkState.Error("Failed to load bookmarks", e))
}
}
fun getBookmarksByTag(tag: String): Flow<BookmarkState> {
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<BookmarkEntity>): List<Long> {
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)
}
}
}

View File

@@ -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<Feed>>(State.Idle)
val feedState: StateFlow<State<Feed>> = _feedState.asStateFlow()
private val _feedItemsState = MutableStateFlow<State<List<FeedItemEntity>>>(State.Idle)
val feedItemsState: StateFlow<State<List<FeedItemEntity>>> = _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<State<List<FeedItemEntity>>> {
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<State<List<FeedItemEntity>>> {
return feedItemDao.getStarredItems()
.map { items ->
State.Success(items)
}
}
fun getUnreadItems(): Flow<State<List<FeedItemEntity>>> {
return feedItemDao.getUnreadItems()
.map { items ->
State.Success(items)
}
}
private fun <T> Flow<List<T>>.map(transform: (List<T>) -> State<List<T>>): Flow<State<List<T>>> {
return this.map { transform(it) }
}
}

View File

@@ -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<List<FeedItemEntity>>
suspend fun getFeedItemById(id: String): FeedItemEntity?
suspend fun insertFeedItem(item: FeedItemEntity): Long
suspend fun insertFeedItems(items: List<FeedItemEntity>): List<Long>
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<List<SubscriptionEntity>>
fun getEnabledSubscriptions(): Flow<List<SubscriptionEntity>>
fun getSubscriptionsByCategory(category: String): Flow<List<SubscriptionEntity>>
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
}

View File

@@ -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<State<List<FeedItemEntity>>> {
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<FeedItemEntity>): List<Long> {
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<State<List<SubscriptionEntity>>> {
return subscriptionDao.getAllSubscriptions().map { subscriptions ->
State.Success(subscriptions)
}.catch { e ->
emit(State.Error("Failed to load subscriptions", e))
}
}
override fun getEnabledSubscriptions(): Flow<State<List<SubscriptionEntity>>> {
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<State<List<SubscriptionEntity>>> {
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)
}
}
}

View File

@@ -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<List<SubscriptionEntity>>>(State.Idle)
val subscriptionsState: StateFlow<State<List<SubscriptionEntity>>> = _subscriptionsState.asStateFlow()
fun getAllSubscriptions(): Flow<State<List<SubscriptionEntity>>> {
return subscriptionDao.getAllSubscriptions()
.map { subscriptions ->
State.Success(subscriptions)
}
}
fun getEnabledSubscriptions(): Flow<State<List<SubscriptionEntity>>> {
return subscriptionDao.getEnabledSubscriptions()
.map { subscriptions ->
State.Success(subscriptions)
}
}
fun getSubscriptionsByCategory(category: String): Flow<State<List<SubscriptionEntity>>> {
return subscriptionDao.getSubscriptionsByCategory(category)
.map { subscriptions ->
State.Success(subscriptions)
}
}
suspend fun getSubscriptionById(id: String): State<SubscriptionEntity?> {
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<SubscriptionEntity?> {
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 <T> Flow<List<T>>.map(transform: (List<T>) -> State<List<T>>): Flow<State<List<T>>> {
return this.map { transform(it) }
}
}

View File

@@ -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}"
}

View File

@@ -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
}

View File

@@ -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<SearchResult> {
// 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<SearchResult> {
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
}
}

View File

@@ -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<String, List<SearchResult>>()
private val maxCacheSize = 100
fun search(query: String): Flow<List<SearchResult>> {
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<List<SearchResult>> {
return flow {
val results = resultProvider.searchBySubscription(query, subscriptionId)
emit(results)
}
}
suspend fun searchAndSave(query: String): List<SearchResult> {
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<List<SearchHistoryEntity>> {
return searchHistoryDao.getAllSearchHistory()
}
suspend fun getRecentSearches(limit: Int = 10): List<SearchHistoryEntity> {
return searchHistoryDao.getRecentSearches(limit).firstOrNull() ?: emptyList()
}
suspend fun clearSearchHistory() {
searchHistoryDao.deleteAllSearchHistory()
}
fun getSearchSuggestions(query: String): Flow<List<SearchHistoryEntity>> {
return searchHistoryDao.searchHistory(query)
}
fun clearCache() {
cache.clear()
}
}

View File

@@ -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<BookmarkEntity>) : BookmarkState
data class Error(val message: String, val cause: Throwable? = null) : BookmarkState
}

View File

@@ -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
)

View File

@@ -0,0 +1,8 @@
package com.rssuper.state
sealed interface State<out T> {
data object Idle : State<Nothing>
data object Loading : State<Nothing>
data class Success<T>(val data: T) : State<T>
data class Error(val message: String, val cause: Throwable? = null) : State<Nothing>
}

View File

@@ -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<List<com.rssuper.database.entities.FeedItemEntity>>>(State.Idle)
val feedState: StateFlow<State<List<com.rssuper.database.entities.FeedItemEntity>>> = _feedState.asStateFlow()
private val _unreadCount = MutableStateFlow<State<Int>>(State.Idle)
val unreadCount: StateFlow<State<Int>> = _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)
}
}

View File

@@ -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<List<com.rssuper.database.entities.SubscriptionEntity>>>(State.Idle)
val subscriptionsState: StateFlow<State<List<com.rssuper.database.entities.SubscriptionEntity>>> = _subscriptionsState.asStateFlow()
private val _enabledSubscriptionsState = MutableStateFlow<State<List<com.rssuper.database.entities.SubscriptionEntity>>>(State.Idle)
val enabledSubscriptionsState: StateFlow<State<List<com.rssuper.database.entities.SubscriptionEntity>>> = _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()
}
}

View File

@@ -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<List<FeedItemEntity>>>(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()
))
}
}

View File

@@ -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<List<SubscriptionEntity>>>(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<List<SubscriptionEntity>>>(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)
}
}

View File

@@ -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<String> = State.Idle
assertTrue(state is State.Idle)
}
@Test
fun testLoadingState() {
val state: State<String> = State.Loading
assertTrue(state is State.Loading)
}
@Test
fun testSuccessState() {
val data = "test data"
val state: State<String> = 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<String> = 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<String> = 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)
}
}

View File

@@ -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<List<com.rssuper.database.entities.FeedItemEntity>>>(State.Success(items))
`when`(feedRepository.getFeedItems("sub1")).thenReturn(stateFlow)
viewModel.loadFeedItems("sub1")
var receivedState: State<List<com.rssuper.database.entities.FeedItemEntity>>? = 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<Int>? = null
viewModel.unreadCount.collect { state ->
unreadCountState = state
}
assert(unreadCountState is State.Success)
assert((unreadCountState as State.Success).data == 5)
}
}

View File

@@ -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<List<com.rssuper.database.entities.SubscriptionEntity>>>(State.Success(subscriptions))
`when`(subscriptionRepository.getAllSubscriptions()).thenReturn(stateFlow)
viewModel.loadAllSubscriptions()
var receivedState: State<List<com.rssuper.database.entities.SubscriptionEntity>>? = 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<List<com.rssuper.database.entities.SubscriptionEntity>>>(State.Success(subscriptions))
`when`(subscriptionRepository.setEnabled("1", true)).thenReturn(1)
`when`(subscriptionRepository.getEnabledSubscriptions()).thenReturn(stateFlow)
viewModel.setEnabled("1", true)
var receivedState: State<List<com.rssuper.database.entities.SubscriptionEntity>>? = 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)
}
}

View File

@@ -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)
}
}

View File

@@ -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")
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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