Auto-commit 2026-03-30 16:30
This commit is contained in:
65
check-identity.js
Normal file
65
check-identity.js
Normal 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
36
check-identity.py
Normal 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))
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
122
native-route/ios/RSSuperTests/SearchHistoryStoreTests.swift
Normal file
122
native-route/ios/RSSuperTests/SearchHistoryStoreTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
111
native-route/ios/RSSuperTests/SearchQueryTests.swift
Normal file
111
native-route/ios/RSSuperTests/SearchQueryTests.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
89
native-route/ios/RSSuperTests/SearchResultTests.swift
Normal file
89
native-route/ios/RSSuperTests/SearchResultTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
76
native-route/ios/RSSuperTests/SyncSchedulerTests.swift
Normal file
76
native-route/ios/RSSuperTests/SyncSchedulerTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
373
native-route/linux/src/notification-manager.vala
Normal file
373
native-route/linux/src/notification-manager.vala
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
/*
|
||||||
|
* notification-manager.vala
|
||||||
|
*
|
||||||
|
* Notification manager for RSSuper on Linux.
|
||||||
|
* Coordinates notifications, badge management, and tray integration.
|
||||||
|
*/
|
||||||
|
|
||||||
|
using Gio;
|
||||||
|
using GLib;
|
||||||
|
using Gtk;
|
||||||
|
|
||||||
|
namespace RSSuper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NotificationManager - Manager for coordinating notifications
|
||||||
|
*/
|
||||||
|
public class NotificationManager : Object {
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
private static NotificationManager? _instance;
|
||||||
|
|
||||||
|
// Notification service
|
||||||
|
private NotificationService? _notification_service;
|
||||||
|
|
||||||
|
// Badge reference
|
||||||
|
private Gtk.Badge? _badge;
|
||||||
|
|
||||||
|
// Tray icon reference
|
||||||
|
private Gtk.TrayIcon? _tray_icon;
|
||||||
|
|
||||||
|
// App reference
|
||||||
|
private Gtk.App? _app;
|
||||||
|
|
||||||
|
// Current unread count
|
||||||
|
private int _unread_count = 0;
|
||||||
|
|
||||||
|
// Badge visibility
|
||||||
|
private bool _badge_visible = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get singleton instance
|
||||||
|
*/
|
||||||
|
public static NotificationManager? get_instance() {
|
||||||
|
if (_instance == null) {
|
||||||
|
_instance = new NotificationManager();
|
||||||
|
}
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the instance
|
||||||
|
*/
|
||||||
|
private NotificationManager() {
|
||||||
|
_notification_service = NotificationService.get_instance();
|
||||||
|
_app = Gtk.App.get_active();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the notification manager
|
||||||
|
*/
|
||||||
|
public void initialize() {
|
||||||
|
// Set up badge
|
||||||
|
_badge = Gtk.Badge.new();
|
||||||
|
_badge.set_visible(_badge_visible);
|
||||||
|
_badge.set_halign(Gtk.Align.START);
|
||||||
|
|
||||||
|
// Connect badge changed signal
|
||||||
|
_badge.changed.connect(_on_badge_changed);
|
||||||
|
|
||||||
|
// Set up tray icon
|
||||||
|
_tray_icon = Gtk.TrayIcon.new();
|
||||||
|
_tray_icon.set_icon_name("rssuper");
|
||||||
|
_tray_icon.set_tooltip_text("RSSuper - Press for notifications");
|
||||||
|
|
||||||
|
// Connect tray icon clicked signal
|
||||||
|
_tray_icon.clicked.connect(_on_tray_clicked);
|
||||||
|
|
||||||
|
// Set up tray icon popup menu
|
||||||
|
var popup = new PopupMenu();
|
||||||
|
popup.add_item(new Gtk.Label("Notifications: " + _unread_count.toString()));
|
||||||
|
popup.add_item(new Gtk.Separator());
|
||||||
|
popup.add_item(new Gtk.Label("Mark all as read"));
|
||||||
|
popup.add_item(new Gtk.Separator());
|
||||||
|
popup.add_item(new Gtk.Label("Settings"));
|
||||||
|
popup.add_item(new Gtk.Label("Exit"));
|
||||||
|
popup.connect_closed(_on_tray_closed);
|
||||||
|
|
||||||
|
_tray_icon.set_popup(popup);
|
||||||
|
|
||||||
|
// Connect tray icon popup menu signal
|
||||||
|
popup.menu_closed.connect(_on_tray_popup_closed);
|
||||||
|
|
||||||
|
// Set up tray icon popup handler
|
||||||
|
_tray_icon.set_popup_handler(_on_tray_popup);
|
||||||
|
|
||||||
|
// Set up tray icon tooltip
|
||||||
|
_tray_icon.set_tooltip_text("RSSuper - Press for notifications");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up the badge in the app header
|
||||||
|
*/
|
||||||
|
public void set_up_badge() {
|
||||||
|
_badge.set_visible(_badge_visible);
|
||||||
|
_badge.set_halign(Gtk.Align.START);
|
||||||
|
|
||||||
|
// Set up badge changed signal
|
||||||
|
_badge.changed.connect(_on_badge_changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up the tray icon
|
||||||
|
*/
|
||||||
|
public void set_up_tray_icon() {
|
||||||
|
_tray_icon.set_icon_name("rssuper");
|
||||||
|
_tray_icon.set_tooltip_text("RSSuper - Press for notifications");
|
||||||
|
|
||||||
|
// Connect tray icon clicked signal
|
||||||
|
_tray_icon.clicked.connect(_on_tray_clicked);
|
||||||
|
|
||||||
|
// Set up tray icon popup menu
|
||||||
|
var popup = new PopupMenu();
|
||||||
|
popup.add_item(new Gtk.Label("Notifications: " + _unread_count.toString()));
|
||||||
|
popup.add_item(new Gtk.Separator());
|
||||||
|
popup.add_item(new Gtk.Label("Mark all as read"));
|
||||||
|
popup.add_item(new Gtk.Separator());
|
||||||
|
popup.add_item(new Gtk.Label("Settings"));
|
||||||
|
popup.add_item(new Gtk.Label("Exit"));
|
||||||
|
popup.connect_closed(_on_tray_closed);
|
||||||
|
|
||||||
|
_tray_icon.set_popup(popup);
|
||||||
|
|
||||||
|
// Connect tray icon popup menu signal
|
||||||
|
popup.menu_closed.connect(_on_tray_popup_closed);
|
||||||
|
|
||||||
|
// Set up tray icon popup handler
|
||||||
|
_tray_icon.set_popup_handler(_on_tray_popup);
|
||||||
|
|
||||||
|
// Set up tray icon tooltip
|
||||||
|
_tray_icon.set_tooltip_text("RSSuper - Press for notifications");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show badge
|
||||||
|
*/
|
||||||
|
public void show_badge() {
|
||||||
|
_badge.set_visible(_badge_visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide badge
|
||||||
|
*/
|
||||||
|
public void hide_badge() {
|
||||||
|
_badge.set_visible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show badge with count
|
||||||
|
*/
|
||||||
|
public void show_badge_with_count(int count) {
|
||||||
|
_badge.set_visible(_badge_visible);
|
||||||
|
_badge.set_label(count.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set unread count
|
||||||
|
*/
|
||||||
|
public void set_unread_count(int count) {
|
||||||
|
_unread_count = count;
|
||||||
|
|
||||||
|
// Update badge
|
||||||
|
if (_badge != null) {
|
||||||
|
_badge.set_label(count.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tray icon popup
|
||||||
|
if (_tray_icon != null) {
|
||||||
|
var popup = _tray_icon.get_popup();
|
||||||
|
if (popup != null) {
|
||||||
|
popup.set_label("Notifications: " + count.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show badge if count > 0
|
||||||
|
if (count > 0) {
|
||||||
|
show_badge();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear unread count
|
||||||
|
*/
|
||||||
|
public void clear_unread_count() {
|
||||||
|
_unread_count = 0;
|
||||||
|
hide_badge();
|
||||||
|
|
||||||
|
// Update tray icon popup
|
||||||
|
if (_tray_icon != null) {
|
||||||
|
var popup = _tray_icon.get_popup();
|
||||||
|
if (popup != null) {
|
||||||
|
popup.set_label("Notifications: 0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread count
|
||||||
|
*/
|
||||||
|
public int get_unread_count() {
|
||||||
|
return _unread_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get badge reference
|
||||||
|
*/
|
||||||
|
public Gtk.Badge? get_badge() {
|
||||||
|
return _badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tray icon reference
|
||||||
|
*/
|
||||||
|
public Gtk.TrayIcon? get_tray_icon() {
|
||||||
|
return _tray_icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get app reference
|
||||||
|
*/
|
||||||
|
public Gtk.App? get_app() {
|
||||||
|
return _app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if badge should be visible
|
||||||
|
*/
|
||||||
|
public bool should_show_badge() {
|
||||||
|
return _unread_count > 0 && _badge_visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set badge visibility
|
||||||
|
*/
|
||||||
|
public void set_badge_visibility(bool visible) {
|
||||||
|
_badge_visible = visible;
|
||||||
|
|
||||||
|
if (_badge != null) {
|
||||||
|
_badge.set_visible(visible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show notification with badge
|
||||||
|
*/
|
||||||
|
public void show_with_badge(string title, string body,
|
||||||
|
string icon = null,
|
||||||
|
Urgency urgency = Urgency.NORMAL) {
|
||||||
|
|
||||||
|
var notification = _notification_service.create(title, body, icon, urgency);
|
||||||
|
notification.show_with_timeout(5000);
|
||||||
|
|
||||||
|
// Show badge
|
||||||
|
if (_unread_count == 0) {
|
||||||
|
show_badge_with_count(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show notification without badge
|
||||||
|
*/
|
||||||
|
public void show_without_badge(string title, string body,
|
||||||
|
string icon = null,
|
||||||
|
Urgency urgency = Urgency.NORMAL) {
|
||||||
|
|
||||||
|
var notification = _notification_service.create(title, body, icon, urgency);
|
||||||
|
notification.show_with_timeout(5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show critical notification
|
||||||
|
*/
|
||||||
|
public void show_critical(string title, string body,
|
||||||
|
string icon = null) {
|
||||||
|
show_with_badge(title, body, icon, Urgency.CRITICAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show low priority notification
|
||||||
|
*/
|
||||||
|
public void show_low(string title, string body,
|
||||||
|
string icon = null) {
|
||||||
|
show_with_badge(title, body, icon, Urgency.LOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show normal notification
|
||||||
|
*/
|
||||||
|
public void show_normal(string title, string body,
|
||||||
|
string icon = null) {
|
||||||
|
show_with_badge(title, body, icon, Urgency.NORMAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle badge changed signal
|
||||||
|
*/
|
||||||
|
private void _on_badge_changed(Gtk.Badge badge) {
|
||||||
|
var count = badge.get_label();
|
||||||
|
if (!string.IsNullOrEmpty(count)) {
|
||||||
|
_unread_count = int.Parse(count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle tray icon clicked signal
|
||||||
|
*/
|
||||||
|
private void _on_tray_clicked(Gtk.TrayIcon tray) {
|
||||||
|
show_notifications_panel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle tray icon popup closed signal
|
||||||
|
*/
|
||||||
|
private void _on_tray_popup_closed(Gtk.Popup popup) {
|
||||||
|
// Popup closed, hide icon
|
||||||
|
if (_tray_icon != null) {
|
||||||
|
_tray_icon.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle tray icon popup open signal
|
||||||
|
*/
|
||||||
|
private void _on_tray_popup(Gtk.TrayIcon tray, Gtk.MenuItem menu) {
|
||||||
|
// Show icon when popup is opened
|
||||||
|
if (_tray_icon != null) {
|
||||||
|
_tray_icon.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle tray icon closed signal
|
||||||
|
*/
|
||||||
|
private void _on_tray_closed(Gtk.App app) {
|
||||||
|
// App closed, hide tray icon
|
||||||
|
if (_tray_icon != null) {
|
||||||
|
_tray_icon.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show notifications panel
|
||||||
|
*/
|
||||||
|
private void show_notifications_panel() {
|
||||||
|
// TODO: Show notifications panel
|
||||||
|
print("Notifications panel requested");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notification service
|
||||||
|
*/
|
||||||
|
public NotificationService? get_notification_service() {
|
||||||
|
return _notification_service;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if notification manager is available
|
||||||
|
*/
|
||||||
|
public bool is_available() {
|
||||||
|
return _notification_service != null && _notification_service.is_available();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
285
native-route/linux/src/notification-preferences-store.vala
Normal file
285
native-route/linux/src/notification-preferences-store.vala
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
/*
|
||||||
|
* notification-preferences-store.vala
|
||||||
|
*
|
||||||
|
* Store for notification preferences.
|
||||||
|
* Provides persistent storage for user notification settings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
using GLib;
|
||||||
|
|
||||||
|
namespace RSSuper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NotificationPreferencesStore - Persistent storage for notification preferences
|
||||||
|
*
|
||||||
|
* Uses GSettings for persistent storage following freedesktop.org conventions.
|
||||||
|
*/
|
||||||
|
public class NotificationPreferencesStore : Object {
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
private static NotificationPreferencesStore? _instance;
|
||||||
|
|
||||||
|
// GSettings schema key
|
||||||
|
private const string SCHEMA_KEY = "org.rssuper.notification.preferences";
|
||||||
|
|
||||||
|
// GSettings schema description
|
||||||
|
private const string SCHEMA_DESCRIPTION = "RSSuper notification preferences";
|
||||||
|
|
||||||
|
// GSettings schema source URI
|
||||||
|
private const string SCHEMA_SOURCE = "file:///app/gsettings/org.rssuper.notification.preferences.gschema.xml";
|
||||||
|
|
||||||
|
// Preferences schema
|
||||||
|
private GSettings? _settings;
|
||||||
|
|
||||||
|
// Preferences object
|
||||||
|
private NotificationPreferences? _preferences;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get singleton instance
|
||||||
|
*/
|
||||||
|
public static NotificationPreferencesStore? get_instance() {
|
||||||
|
if (_instance == null) {
|
||||||
|
_instance = new NotificationPreferencesStore();
|
||||||
|
}
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the instance
|
||||||
|
*/
|
||||||
|
private NotificationPreferencesStore() {
|
||||||
|
_settings = GSettings.new(SCHEMA_KEY, SCHEMA_DESCRIPTION);
|
||||||
|
|
||||||
|
// Load initial preferences
|
||||||
|
_preferences = NotificationPreferences.from_json_string(_settings.get_string("preferences"));
|
||||||
|
|
||||||
|
if (_preferences == null) {
|
||||||
|
// Set default preferences if none exist
|
||||||
|
_preferences = new NotificationPreferences();
|
||||||
|
_settings.set_string("preferences", _preferences.to_json_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for settings changes
|
||||||
|
_settings.changed.connect(_on_settings_changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notification preferences
|
||||||
|
*/
|
||||||
|
public NotificationPreferences? get_preferences() {
|
||||||
|
return _preferences;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set notification preferences
|
||||||
|
*/
|
||||||
|
public void set_preferences(NotificationPreferences prefs) {
|
||||||
|
_preferences = prefs;
|
||||||
|
|
||||||
|
// Save to GSettings
|
||||||
|
_settings.set_string("preferences", prefs.to_json_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get new articles preference
|
||||||
|
*/
|
||||||
|
public bool get_new_articles() {
|
||||||
|
return _preferences != null ? _preferences.new_articles : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set new articles preference
|
||||||
|
*/
|
||||||
|
public void set_new_articles(bool enabled) {
|
||||||
|
_preferences = _preferences ?? new NotificationPreferences();
|
||||||
|
_preferences.new_articles = enabled;
|
||||||
|
_settings.set_boolean("newArticles", enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get episode releases preference
|
||||||
|
*/
|
||||||
|
public bool get_episode_releases() {
|
||||||
|
return _preferences != null ? _preferences.episode_releases : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set episode releases preference
|
||||||
|
*/
|
||||||
|
public void set_episode_releases(bool enabled) {
|
||||||
|
_preferences = _preferences ?? new NotificationPreferences();
|
||||||
|
_preferences.episode_releases = enabled;
|
||||||
|
_settings.set_boolean("episodeReleases", enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get custom alerts preference
|
||||||
|
*/
|
||||||
|
public bool get_custom_alerts() {
|
||||||
|
return _preferences != null ? _preferences.custom_alerts : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set custom alerts preference
|
||||||
|
*/
|
||||||
|
public void set_custom_alerts(bool enabled) {
|
||||||
|
_preferences = _preferences ?? new NotificationPreferences();
|
||||||
|
_preferences.custom_alerts = enabled;
|
||||||
|
_settings.set_boolean("customAlerts", enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get badge count preference
|
||||||
|
*/
|
||||||
|
public bool get_badge_count() {
|
||||||
|
return _preferences != null ? _preferences.badge_count : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set badge count preference
|
||||||
|
*/
|
||||||
|
public void set_badge_count(bool enabled) {
|
||||||
|
_preferences = _preferences ?? new NotificationPreferences();
|
||||||
|
_preferences.badge_count = enabled;
|
||||||
|
_settings.set_boolean("badgeCount", enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sound preference
|
||||||
|
*/
|
||||||
|
public bool get_sound() {
|
||||||
|
return _preferences != null ? _preferences.sound : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set sound preference
|
||||||
|
*/
|
||||||
|
public void set_sound(bool enabled) {
|
||||||
|
_preferences = _preferences ?? new NotificationPreferences();
|
||||||
|
_preferences.sound = enabled;
|
||||||
|
_settings.set_boolean("sound", enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get vibration preference
|
||||||
|
*/
|
||||||
|
public bool get_vibration() {
|
||||||
|
return _preferences != null ? _preferences.vibration : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set vibration preference
|
||||||
|
*/
|
||||||
|
public void set_vibration(bool enabled) {
|
||||||
|
_preferences = _preferences ?? new NotificationPreferences();
|
||||||
|
_preferences.vibration = enabled;
|
||||||
|
_settings.set_boolean("vibration", enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable all notifications
|
||||||
|
*/
|
||||||
|
public void enable_all() {
|
||||||
|
_preferences = _preferences ?? new NotificationPreferences();
|
||||||
|
_preferences.enable_all();
|
||||||
|
|
||||||
|
// Save to GSettings
|
||||||
|
_settings.set_string("preferences", _preferences.to_json_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable all notifications
|
||||||
|
*/
|
||||||
|
public void disable_all() {
|
||||||
|
_preferences = _preferences ?? new NotificationPreferences();
|
||||||
|
_preferences.disable_all();
|
||||||
|
|
||||||
|
// Save to GSettings
|
||||||
|
_settings.set_string("preferences", _preferences.to_json_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all preferences as dictionary
|
||||||
|
*/
|
||||||
|
public Dictionary<string, object> get_all_preferences() {
|
||||||
|
if (_preferences == null) {
|
||||||
|
return new Dictionary<string, object>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var prefs = new Dictionary<string, object>();
|
||||||
|
prefs["new_articles"] = _preferences.new_articles;
|
||||||
|
prefs["episode_releases"] = _preferences.episode_releases;
|
||||||
|
prefs["custom_alerts"] = _preferences.custom_alerts;
|
||||||
|
prefs["badge_count"] = _preferences.badge_count;
|
||||||
|
prefs["sound"] = _preferences.sound;
|
||||||
|
prefs["vibration"] = _preferences.vibration;
|
||||||
|
|
||||||
|
return prefs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set all preferences from dictionary
|
||||||
|
*/
|
||||||
|
public void set_all_preferences(Dictionary<string, object> prefs) {
|
||||||
|
_preferences = new NotificationPreferences();
|
||||||
|
|
||||||
|
if (prefs.containsKey("new_articles")) {
|
||||||
|
_preferences.new_articles = prefs["new_articles"] as bool;
|
||||||
|
}
|
||||||
|
if (prefs.containsKey("episode_releases")) {
|
||||||
|
_preferences.episode_releases = prefs["episode_releases"] as bool;
|
||||||
|
}
|
||||||
|
if (prefs.containsKey("custom_alerts")) {
|
||||||
|
_preferences.custom_alerts = prefs["custom_alerts"] as bool;
|
||||||
|
}
|
||||||
|
if (prefs.containsKey("badge_count")) {
|
||||||
|
_preferences.badge_count = prefs["badge_count"] as bool;
|
||||||
|
}
|
||||||
|
if (prefs.containsKey("sound")) {
|
||||||
|
_preferences.sound = prefs["sound"] as bool;
|
||||||
|
}
|
||||||
|
if (prefs.containsKey("vibration")) {
|
||||||
|
_preferences.vibration = prefs["vibration"] as bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to GSettings
|
||||||
|
_settings.set_string("preferences", _preferences.to_json_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get schema key
|
||||||
|
*/
|
||||||
|
public string get_schema_key() {
|
||||||
|
return SCHEMA_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get schema description
|
||||||
|
*/
|
||||||
|
public string get_schema_description() {
|
||||||
|
return SCHEMA_DESCRIPTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get schema source
|
||||||
|
*/
|
||||||
|
public string get_schema_source() {
|
||||||
|
return SCHEMA_SOURCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle settings changed signal
|
||||||
|
*/
|
||||||
|
private void _on_settings_changed(GSettings settings) {
|
||||||
|
// Settings changed, reload preferences
|
||||||
|
_preferences = NotificationPreferences.from_json_string(settings.get_string("preferences"));
|
||||||
|
|
||||||
|
if (_preferences == null) {
|
||||||
|
// Set defaults on error
|
||||||
|
_preferences = new NotificationPreferences();
|
||||||
|
settings.set_string("preferences", _preferences.to_json_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
232
native-route/linux/src/notification-service.vala
Normal file
232
native-route/linux/src/notification-service.vala
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
/*
|
||||||
|
* notification-service.vala
|
||||||
|
*
|
||||||
|
* Main notification service for RSSuper on Linux.
|
||||||
|
* Implements Gio.Notification API following freedesktop.org spec.
|
||||||
|
*/
|
||||||
|
|
||||||
|
using Gio;
|
||||||
|
using GLib;
|
||||||
|
|
||||||
|
namespace RSSuper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NotificationService - Main notification service for Linux
|
||||||
|
*
|
||||||
|
* Handles desktop notifications using Gio.Notification.
|
||||||
|
* Follows freedesktop.org notify-send specification.
|
||||||
|
*/
|
||||||
|
public class NotificationService : Object {
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
private static NotificationService? _instance;
|
||||||
|
|
||||||
|
// Gio.Notification instance
|
||||||
|
private Gio.Notification? _notification;
|
||||||
|
|
||||||
|
// Tray icon reference
|
||||||
|
private Gtk.App? _app;
|
||||||
|
|
||||||
|
// Default title
|
||||||
|
private string _default_title = "RSSuper";
|
||||||
|
|
||||||
|
// Default urgency
|
||||||
|
private Urgency _default_urgency = Urgency.NORMAL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get singleton instance
|
||||||
|
*/
|
||||||
|
public static NotificationService? get_instance() {
|
||||||
|
if (_instance == null) {
|
||||||
|
_instance = new NotificationService();
|
||||||
|
}
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the instance (for singleton pattern)
|
||||||
|
*/
|
||||||
|
private NotificationService() {
|
||||||
|
_app = Gtk.App.get_active();
|
||||||
|
_default_title = _app != null ? _app.get_name() : "RSSuper";
|
||||||
|
_default_urgency = Urgency.NORMAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if notification service is available
|
||||||
|
*/
|
||||||
|
public bool is_available() {
|
||||||
|
return Gio.Notification.is_available();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new notification
|
||||||
|
*
|
||||||
|
* @param title The notification title
|
||||||
|
* @param body The notification body
|
||||||
|
* @param urgency Urgency level (NORMAL, CRITICAL, LOW)
|
||||||
|
* @param timestamp Optional timestamp (defaults to now)
|
||||||
|
*/
|
||||||
|
public Notification create(string title, string body,
|
||||||
|
Urgency urgency = Urgency.NORMAL,
|
||||||
|
DateTime timestamp = null) {
|
||||||
|
|
||||||
|
_notification = Gio.Notification.new(_default_title);
|
||||||
|
_notification.set_body(body);
|
||||||
|
_notification.set_urgency(urgency);
|
||||||
|
|
||||||
|
if (timestamp == null) {
|
||||||
|
_notification.set_time_now();
|
||||||
|
} else {
|
||||||
|
_notification.set_time(timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a notification with summary and icon
|
||||||
|
*/
|
||||||
|
public Notification create(string title, string body, string icon,
|
||||||
|
Urgency urgency = Urgency.NORMAL,
|
||||||
|
DateTime timestamp = null) {
|
||||||
|
|
||||||
|
_notification = Gio.Notification.new(title);
|
||||||
|
_notification.set_body(body);
|
||||||
|
_notification.set_urgency(urgency);
|
||||||
|
|
||||||
|
if (timestamp == null) {
|
||||||
|
_notification.set_time_now();
|
||||||
|
} else {
|
||||||
|
_notification.set_time(timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set icon
|
||||||
|
try {
|
||||||
|
_notification.set_icon(icon);
|
||||||
|
} catch (Error e) {
|
||||||
|
warning("Failed to set icon: %s", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a notification with summary, body, and icon
|
||||||
|
*/
|
||||||
|
public Notification create(string summary, string body, string icon,
|
||||||
|
Urgency urgency = Urgency.NORMAL,
|
||||||
|
DateTime timestamp = null) {
|
||||||
|
|
||||||
|
_notification = Gio.Notification.new(summary);
|
||||||
|
_notification.set_body(body);
|
||||||
|
_notification.set_urgency(urgency);
|
||||||
|
|
||||||
|
if (timestamp == null) {
|
||||||
|
_notification.set_time_now();
|
||||||
|
} else {
|
||||||
|
_notification.set_time(timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set icon
|
||||||
|
try {
|
||||||
|
_notification.set_icon(icon);
|
||||||
|
} catch (Error e) {
|
||||||
|
warning("Failed to set icon: %s", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the notification
|
||||||
|
*/
|
||||||
|
public void show() {
|
||||||
|
if (_notification == null) {
|
||||||
|
warning("Cannot show null notification");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
_notification.show();
|
||||||
|
} catch (Error e) {
|
||||||
|
warning("Failed to show notification: %s", e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the notification with timeout
|
||||||
|
*
|
||||||
|
* @param timeout_seconds Timeout in seconds (default: 5)
|
||||||
|
*/
|
||||||
|
public void show_with_timeout(int timeout_seconds = 5) {
|
||||||
|
if (_notification == null) {
|
||||||
|
warning("Cannot show null notification");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
_notification.show_with_timeout(timeout_seconds * 1000);
|
||||||
|
} catch (Error e) {
|
||||||
|
warning("Failed to show notification with timeout: %s", e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notification instance
|
||||||
|
*/
|
||||||
|
public Gio.Notification? get_notification() {
|
||||||
|
return _notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the default title
|
||||||
|
*/
|
||||||
|
public void set_default_title(string title) {
|
||||||
|
_default_title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the default urgency
|
||||||
|
*/
|
||||||
|
public void set_default_urgency(Urgency urgency) {
|
||||||
|
_default_urgency = urgency;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default title
|
||||||
|
*/
|
||||||
|
public string get_default_title() {
|
||||||
|
return _default_title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default urgency
|
||||||
|
*/
|
||||||
|
public Urgency get_default_urgency() {
|
||||||
|
return _default_urgency;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the app reference
|
||||||
|
*/
|
||||||
|
public Gtk.App? get_app() {
|
||||||
|
return _app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the notification can be shown
|
||||||
|
*/
|
||||||
|
public bool can_show() {
|
||||||
|
return _notification != null && _notification.can_show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available urgency levels
|
||||||
|
*/
|
||||||
|
public static List<Urgency> get_available_urgencies() {
|
||||||
|
return Urgency.get_available();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
41
native-route/linux/src/repository/Repositories.vala
Normal file
41
native-route/linux/src/repository/Repositories.vala
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* Repositories.vala
|
||||||
|
*
|
||||||
|
* Repository interfaces for Linux state management
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace RSSuper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FeedRepository - Interface for feed repository operations
|
||||||
|
*/
|
||||||
|
public interface FeedRepository : Object {
|
||||||
|
public abstract void get_feed_items(string? subscription_id, State<FeedItem[]> callback);
|
||||||
|
public abstract FeedItem? get_feed_item_by_id(string id) throws Error;
|
||||||
|
public abstract void insert_feed_item(FeedItem item) throws Error;
|
||||||
|
public abstract void insert_feed_items(FeedItem[] items) throws Error;
|
||||||
|
public abstract void update_feed_item(FeedItem item) throws Error;
|
||||||
|
public abstract void mark_as_read(string id, bool is_read) throws Error;
|
||||||
|
public abstract void mark_as_starred(string id, bool is_starred) throws Error;
|
||||||
|
public abstract void delete_feed_item(string id) throws Error;
|
||||||
|
public abstract int get_unread_count(string? subscription_id) throws Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SubscriptionRepository - Interface for subscription repository operations
|
||||||
|
*/
|
||||||
|
public interface SubscriptionRepository : Object {
|
||||||
|
public abstract void get_all_subscriptions(State<FeedSubscription[]> callback);
|
||||||
|
public abstract void get_enabled_subscriptions(State<FeedSubscription[]> callback);
|
||||||
|
public abstract void get_subscriptions_by_category(string category, State<FeedSubscription[]> callback);
|
||||||
|
public abstract FeedSubscription? get_subscription_by_id(string id) throws Error;
|
||||||
|
public abstract FeedSubscription? get_subscription_by_url(string url) throws Error;
|
||||||
|
public abstract void insert_subscription(FeedSubscription subscription) throws Error;
|
||||||
|
public abstract void update_subscription(FeedSubscription subscription) throws Error;
|
||||||
|
public abstract void delete_subscription(string id) throws Error;
|
||||||
|
public abstract void set_enabled(string id, bool enabled) throws Error;
|
||||||
|
public abstract void set_error(string id, string? error) throws Error;
|
||||||
|
public abstract void update_last_fetched_at(string id, ulong last_fetched_at) throws Error;
|
||||||
|
public abstract void update_next_fetch_at(string id, ulong next_fetch_at) throws Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
136
native-route/linux/src/repository/RepositoriesImpl.vala
Normal file
136
native-route/linux/src/repository/RepositoriesImpl.vala
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/*
|
||||||
|
* RepositoriesImpl.vala
|
||||||
|
*
|
||||||
|
* Repository implementations for Linux state management
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace RSSuper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FeedRepositoryImpl - Implementation of FeedRepository
|
||||||
|
*/
|
||||||
|
public class FeedRepositoryImpl : Object, FeedRepository {
|
||||||
|
private Database db;
|
||||||
|
|
||||||
|
public FeedRepositoryImpl(Database db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void get_feed_items(string? subscription_id, State<FeedItem[]> callback) {
|
||||||
|
try {
|
||||||
|
var feedItems = db.getFeedItems(subscription_id);
|
||||||
|
callback.set_success(feedItems);
|
||||||
|
} catch (Error e) {
|
||||||
|
callback.set_error("Failed to get feed items", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override FeedItem? get_feed_item_by_id(string id) throws Error {
|
||||||
|
return db.getFeedItemById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void insert_feed_item(FeedItem item) throws Error {
|
||||||
|
db.insertFeedItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void insert_feed_items(FeedItem[] items) throws Error {
|
||||||
|
foreach (var item in items) {
|
||||||
|
db.insertFeedItem(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void update_feed_item(FeedItem item) throws Error {
|
||||||
|
db.updateFeedItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void mark_as_read(string id, bool is_read) throws Error {
|
||||||
|
db.markFeedItemAsRead(id, is_read);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void mark_as_starred(string id, bool is_starred) throws Error {
|
||||||
|
db.markFeedItemAsStarred(id, is_starred);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void delete_feed_item(string id) throws Error {
|
||||||
|
db.deleteFeedItem(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int get_unread_count(string? subscription_id) throws Error {
|
||||||
|
return db.getUnreadCount(subscription_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SubscriptionRepositoryImpl - Implementation of SubscriptionRepository
|
||||||
|
*/
|
||||||
|
public class SubscriptionRepositoryImpl : Object, SubscriptionRepository {
|
||||||
|
private Database db;
|
||||||
|
|
||||||
|
public SubscriptionRepositoryImpl(Database db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void get_all_subscriptions(State<FeedSubscription[]> callback) {
|
||||||
|
try {
|
||||||
|
var subscriptions = db.getAllSubscriptions();
|
||||||
|
callback.set_success(subscriptions);
|
||||||
|
} catch (Error e) {
|
||||||
|
callback.set_error("Failed to get subscriptions", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void get_enabled_subscriptions(State<FeedSubscription[]> callback) {
|
||||||
|
try {
|
||||||
|
var subscriptions = db.getEnabledSubscriptions();
|
||||||
|
callback.set_success(subscriptions);
|
||||||
|
} catch (Error e) {
|
||||||
|
callback.set_error("Failed to get enabled subscriptions", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void get_subscriptions_by_category(string category, State<FeedSubscription[]> callback) {
|
||||||
|
try {
|
||||||
|
var subscriptions = db.getSubscriptionsByCategory(category);
|
||||||
|
callback.set_success(subscriptions);
|
||||||
|
} catch (Error e) {
|
||||||
|
callback.set_error("Failed to get subscriptions by category", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override FeedSubscription? get_subscription_by_id(string id) throws Error {
|
||||||
|
return db.getSubscriptionById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override FeedSubscription? get_subscription_by_url(string url) throws Error {
|
||||||
|
return db.getSubscriptionByUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void insert_subscription(FeedSubscription subscription) throws Error {
|
||||||
|
db.insertSubscription(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void update_subscription(FeedSubscription subscription) throws Error {
|
||||||
|
db.updateSubscription(subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void delete_subscription(string id) throws Error {
|
||||||
|
db.deleteSubscription(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void set_enabled(string id, bool enabled) throws Error {
|
||||||
|
db.setSubscriptionEnabled(id, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void set_error(string id, string? error) throws Error {
|
||||||
|
db.setSubscriptionError(id, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void update_last_fetched_at(string id, ulong last_fetched_at) throws Error {
|
||||||
|
db.setSubscriptionLastFetchedAt(id, last_fetched_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void update_next_fetch_at(string id, ulong next_fetch_at) throws Error {
|
||||||
|
db.setSubscriptionNextFetchAt(id, next_fetch_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
native-route/linux/src/state/ErrorType.vala
Normal file
34
native-route/linux/src/state/ErrorType.vala
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* ErrorType.vala
|
||||||
|
*
|
||||||
|
* Error types for state management
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace RSSuper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ErrorType - Category of errors
|
||||||
|
*/
|
||||||
|
public enum ErrorType {
|
||||||
|
NETWORK,
|
||||||
|
DATABASE,
|
||||||
|
PARSING,
|
||||||
|
AUTH,
|
||||||
|
UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ErrorDetails - Detailed error information
|
||||||
|
*/
|
||||||
|
public class ErrorDetails : Object {
|
||||||
|
public ErrorType type { get; set; }
|
||||||
|
public string message { get; set; }
|
||||||
|
public bool retryable { get; set; }
|
||||||
|
|
||||||
|
public ErrorDetails(ErrorType type, string message, bool retryable = false) {
|
||||||
|
this.type = type;
|
||||||
|
this.message = message;
|
||||||
|
this.retryable = retryable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
110
native-route/linux/src/state/State.vala
Normal file
110
native-route/linux/src/state/State.vala
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/*
|
||||||
|
* State.vala
|
||||||
|
*
|
||||||
|
* Reactive state management using GObject signals
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace RSSuper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State - Enumerated state for reactive state management
|
||||||
|
*/
|
||||||
|
public enum State {
|
||||||
|
IDLE,
|
||||||
|
LOADING,
|
||||||
|
SUCCESS,
|
||||||
|
ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State<T> - Generic state container with signals
|
||||||
|
*/
|
||||||
|
public class State<T> : Object {
|
||||||
|
private State _state;
|
||||||
|
private T? _data;
|
||||||
|
private string? _message;
|
||||||
|
private Error? _error;
|
||||||
|
|
||||||
|
public State() {
|
||||||
|
_state = State.IDLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public State.idle() {
|
||||||
|
_state = State.IDLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public State.loading() {
|
||||||
|
_state = State.LOADING;
|
||||||
|
}
|
||||||
|
|
||||||
|
public State.success(T data) {
|
||||||
|
_state = State.SUCCESS;
|
||||||
|
_data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public State.error(string message, Error? error = null) {
|
||||||
|
_state = State.ERROR;
|
||||||
|
_message = message;
|
||||||
|
_error = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public State get_state() {
|
||||||
|
return _state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public T? get_data() {
|
||||||
|
return _data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? get_message() {
|
||||||
|
return _message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Error? get_error() {
|
||||||
|
return _error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool is_idle() {
|
||||||
|
return _state == State.IDLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool is_loading() {
|
||||||
|
return _state == State.LOADING;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool is_success() {
|
||||||
|
return _state == State.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool is_error() {
|
||||||
|
return _state == State.ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void set_idle() {
|
||||||
|
_state = State.IDLE;
|
||||||
|
_data = null;
|
||||||
|
_message = null;
|
||||||
|
_error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void set_loading() {
|
||||||
|
_state = State.LOADING;
|
||||||
|
_data = null;
|
||||||
|
_message = null;
|
||||||
|
_error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void set_success(T data) {
|
||||||
|
_state = State.SUCCESS;
|
||||||
|
_data = data;
|
||||||
|
_message = null;
|
||||||
|
_error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void set_error(string message, Error? error = null) {
|
||||||
|
_state = State.ERROR;
|
||||||
|
_message = message;
|
||||||
|
_error = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
native-route/linux/src/viewmodel/FeedViewModel.vala
Normal file
70
native-route/linux/src/viewmodel/FeedViewModel.vala
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/*
|
||||||
|
* FeedViewModel.vala
|
||||||
|
*
|
||||||
|
* ViewModel for feed state management
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace RSSuper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FeedViewModel - Manages feed state for UI binding
|
||||||
|
*/
|
||||||
|
public class FeedViewModel : Object {
|
||||||
|
private FeedRepository repository;
|
||||||
|
private State<FeedItem[]> feedState;
|
||||||
|
private State<int> unreadCountState;
|
||||||
|
|
||||||
|
public FeedViewModel(FeedRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.feedState = new State<FeedItem[]>();
|
||||||
|
this.unreadCountState = new State<int>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public State<FeedItem[]> get_feed_state() {
|
||||||
|
return feedState;
|
||||||
|
}
|
||||||
|
|
||||||
|
public State<int> get_unread_count_state() {
|
||||||
|
return unreadCountState;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void load_feed_items(string? subscription_id = null) {
|
||||||
|
feedState.set_loading();
|
||||||
|
repository.get_feed_items(subscription_id, (state) => {
|
||||||
|
feedState = state;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void load_unread_count(string? subscription_id = null) {
|
||||||
|
unreadCountState.set_loading();
|
||||||
|
try {
|
||||||
|
var count = repository.get_unread_count(subscription_id);
|
||||||
|
unreadCountState.set_success(count);
|
||||||
|
} catch (Error e) {
|
||||||
|
unreadCountState.set_error("Failed to load unread count", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void mark_as_read(string id, bool is_read) {
|
||||||
|
try {
|
||||||
|
repository.mark_as_read(id, is_read);
|
||||||
|
load_unread_count();
|
||||||
|
} catch (Error e) {
|
||||||
|
unreadCountState.set_error("Failed to update read state", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void mark_as_starred(string id, bool is_starred) {
|
||||||
|
try {
|
||||||
|
repository.mark_as_starred(id, is_starred);
|
||||||
|
} catch (Error e) {
|
||||||
|
feedState.set_error("Failed to update starred state", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void refresh(string? subscription_id = null) {
|
||||||
|
load_feed_items(subscription_id);
|
||||||
|
load_unread_count(subscription_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
native-route/linux/src/viewmodel/SubscriptionViewModel.vala
Normal file
83
native-route/linux/src/viewmodel/SubscriptionViewModel.vala
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/*
|
||||||
|
* SubscriptionViewModel.vala
|
||||||
|
*
|
||||||
|
* ViewModel for subscription state management
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace RSSuper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SubscriptionViewModel - Manages subscription state for UI binding
|
||||||
|
*/
|
||||||
|
public class SubscriptionViewModel : Object {
|
||||||
|
private SubscriptionRepository repository;
|
||||||
|
private State<FeedSubscription[]> subscriptionsState;
|
||||||
|
private State<FeedSubscription[]> enabledSubscriptionsState;
|
||||||
|
|
||||||
|
public SubscriptionViewModel(SubscriptionRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.subscriptionsState = new State<FeedSubscription[]>();
|
||||||
|
this.enabledSubscriptionsState = new State<FeedSubscription[]>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public State<FeedSubscription[]> get_subscriptions_state() {
|
||||||
|
return subscriptionsState;
|
||||||
|
}
|
||||||
|
|
||||||
|
public State<FeedSubscription[]> get_enabled_subscriptions_state() {
|
||||||
|
return enabledSubscriptionsState;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void load_all_subscriptions() {
|
||||||
|
subscriptionsState.set_loading();
|
||||||
|
repository.get_all_subscriptions((state) => {
|
||||||
|
subscriptionsState = state;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void load_enabled_subscriptions() {
|
||||||
|
enabledSubscriptionsState.set_loading();
|
||||||
|
repository.get_enabled_subscriptions((state) => {
|
||||||
|
enabledSubscriptionsState = state;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void set_enabled(string id, bool enabled) {
|
||||||
|
try {
|
||||||
|
repository.set_enabled(id, enabled);
|
||||||
|
load_enabled_subscriptions();
|
||||||
|
} catch (Error e) {
|
||||||
|
enabledSubscriptionsState.set_error("Failed to update subscription enabled state", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void set_error(string id, string? error) {
|
||||||
|
try {
|
||||||
|
repository.set_error(id, error);
|
||||||
|
} catch (Error e) {
|
||||||
|
subscriptionsState.set_error("Failed to set subscription error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void update_last_fetched_at(string id, ulong last_fetched_at) {
|
||||||
|
try {
|
||||||
|
repository.update_last_fetched_at(id, last_fetched_at);
|
||||||
|
} catch (Error e) {
|
||||||
|
subscriptionsState.set_error("Failed to update last fetched time", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void update_next_fetch_at(string id, ulong next_fetch_at) {
|
||||||
|
try {
|
||||||
|
repository.update_next_fetch_at(id, next_fetch_at);
|
||||||
|
} catch (Error e) {
|
||||||
|
subscriptionsState.set_error("Failed to update next fetch time", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void refresh() {
|
||||||
|
load_all_subscriptions();
|
||||||
|
load_enabled_subscriptions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user