Compare commits

...

5 Commits

Author SHA1 Message Date
dc17a71be4 grundle
Some checks failed
CI - Multi-Platform Native / Build iOS (RSSuper) (push) Has been cancelled
CI - Multi-Platform Native / Build macOS (push) Has been cancelled
CI - Multi-Platform Native / Build Android (push) Has been cancelled
CI - Multi-Platform Native / Build Linux (push) Has been cancelled
CI - Multi-Platform Native / Build Summary (push) Has been cancelled
2026-03-29 23:04:47 -04:00
473457df2f feat: implement Android database layer with Room
- Add SubscriptionEntity, FeedItemEntity, SearchHistoryEntity
- Create SubscriptionDao, FeedItemDao, SearchHistoryDao with CRUD operations
- Implement RssDatabase with FTS5 virtual table for full-text search
- Add type converters for Date, String lists, and FeedItem lists
- Implement cascade delete for feed items when subscription is removed
- Add comprehensive unit tests for all DAOs
- Add database integration tests for entity round-trips and FTS
- Configure Room testing dependencies
2026-03-29 20:41:51 -04:00
f0922e3c03 Implement Linux data models (C/Vala)
- Add FeedItem, Feed, FeedSubscription models
- Add SearchResult, SearchFilters, SearchQuery models
- Add NotificationPreferences, ReadingPreferences models
- Implement JSON serialization/deserialization for all models
- Add equality comparison methods
- Following GNOME HIG naming conventions
- Build system configured with Meson/Ninja
2026-03-29 17:40:59 -04:00
fdd4fd8a46 feat: implement Android data models in Kotlin
- Add FeedItem, Feed, FeedSubscription models with Moshi JSON support
- Add SearchResult, SearchFilters models with sealed classes for enums
- Add NotificationPreferences, ReadingPreferences models
- Add Room Entity annotations for database readiness
- Add TypeConverters for Date and List<String> serialization
- Add Parcelize for passing models between Activities/Fragments
- Write comprehensive unit tests for serialization/deserialization
- Write tests for copy(), equals/hashCode, and toString functionality
2026-03-29 15:40:38 -04:00
fade9fd5b1 feat: Update iOS submodule with data models implementation 2026-03-29 15:13:51 -04:00
65 changed files with 6960 additions and 6 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

View File

@@ -0,0 +1,2 @@
#Sun Mar 29 20:35:39 EDT 2026
gradle.version=9.3.0

View File

View File

@@ -0,0 +1,2 @@
#Sun Mar 29 20:35:09 EDT 2026
gradle.version=9.3.0

View File

@@ -0,0 +1,57 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("kotlin-parcelize")
id("kotlin-kapt")
}
android {
namespace = "com.rssuper"
compileSdk = 34
defaultConfig {
minSdk = 24
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
// AndroidX
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
// Moshi for JSON serialization
implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
kapt("com.squareup.moshi:moshi-kotlin-codegen:1.15.1")
implementation("com.squareup.moshi:moshi-kotlin-reflect:1.15.1")
// Testing
testImplementation("junit:junit:4.13.2")
testImplementation("com.squareup.moshi:moshi-kotlin:1.15.1")
testImplementation("com.squareup.moshi:moshi-kotlin-reflect:1.15.1")
testImplementation("org.mockito:mockito-core:5.7.0")
testImplementation("org.mockito:mockito-inline:5.2.0")
testImplementation("androidx.room:room-testing:2.6.1")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
testImplementation("androidx.arch.core:core-testing:2.2.0")
testImplementation("androidx.test:core:1.5.0")
testImplementation("androidx.test.ext:junit:1.1.5")
testImplementation("androidx.test:runner:1.5.2")
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "rssuper-android"
include(":android")

View File

@@ -0,0 +1,16 @@
package com.rssuper.converters
import androidx.room.TypeConverter
import java.util.Date
class DateConverter {
@TypeConverter
fun fromTimestamp(value: Long?): Date? {
return value?.let { Date(it) }
}
@TypeConverter
fun dateToTimestamp(date: Date?): Long? {
return date?.time
}
}

View File

@@ -0,0 +1,23 @@
package com.rssuper.converters
import androidx.room.TypeConverter
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import com.rssuper.models.FeedItem
class FeedItemListConverter {
private val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
private val adapter = moshi.adapter(List::class.java)
@TypeConverter
fun fromFeedItemList(value: List<FeedItem>?): String? {
return value?.let { adapter.toJson(it) }
}
@TypeConverter
fun toFeedItemList(value: String?): List<FeedItem>? {
return value?.let { adapter.fromJson(it) }
}
}

View File

@@ -0,0 +1,15 @@
package com.rssuper.converters
import androidx.room.TypeConverter
class StringListConverter {
@TypeConverter
fun fromStringList(value: List<String>?): String? {
return value?.joinToString(",")
}
@TypeConverter
fun toStringList(value: String?): List<String>? {
return value?.split(",")?.filter { it.isNotEmpty() }
}
}

View File

@@ -0,0 +1,87 @@
package com.rssuper.database
import android.content.Context
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.sqlite.db.SupportSQLiteDatabase
import com.rssuper.converters.DateConverter
import com.rssuper.converters.FeedItemListConverter
import com.rssuper.converters.StringListConverter
import com.rssuper.database.daos.FeedItemDao
import com.rssuper.database.daos.SearchHistoryDao
import com.rssuper.database.daos.SubscriptionDao
import com.rssuper.database.entities.FeedItemEntity
import com.rssuper.database.entities.SearchHistoryEntity
import com.rssuper.database.entities.SubscriptionEntity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.Date
@Database(
entities = [
SubscriptionEntity::class,
FeedItemEntity::class,
SearchHistoryEntity::class
],
version = 1,
exportSchema = true
)
@TypeConverters(DateConverter::class, StringListConverter::class, FeedItemListConverter::class)
abstract class RssDatabase : RoomDatabase() {
abstract fun subscriptionDao(): SubscriptionDao
abstract fun feedItemDao(): FeedItemDao
abstract fun searchHistoryDao(): SearchHistoryDao
companion object {
@Volatile
private var INSTANCE: RssDatabase? = null
fun getDatabase(context: Context): RssDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
RssDatabase::class.java,
"rss_database"
)
.addCallback(DatabaseCallback())
.build()
INSTANCE = instance
instance
}
}
}
private class DatabaseCallback : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
INSTANCE?.let { database ->
CoroutineScope(Dispatchers.IO).launch {
createFTSVirtualTable(db)
}
}
}
override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db)
createFTSVirtualTable(db)
}
private fun createFTSVirtualTable(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE VIRTUAL TABLE IF NOT EXISTS feed_items_fts USING fts5(
title,
description,
content,
author,
content='feed_items',
contentless_delete=true
)
""".trimIndent())
}
}
}

View File

@@ -0,0 +1,77 @@
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.FeedItemEntity
import kotlinx.coroutines.flow.Flow
import java.util.Date
@Dao
interface FeedItemDao {
@Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId ORDER BY published DESC")
fun getItemsBySubscription(subscriptionId: String): Flow<List<FeedItemEntity>>
@Query("SELECT * FROM feed_items WHERE id = :id")
suspend fun getItemById(id: String): FeedItemEntity?
@Query("SELECT * FROM feed_items WHERE subscriptionId IN (:subscriptionIds) ORDER BY published DESC")
fun getItemsBySubscriptions(subscriptionIds: List<String>): Flow<List<FeedItemEntity>>
@Query("SELECT * FROM feed_items WHERE isRead = 0 ORDER BY published DESC")
fun getUnreadItems(): Flow<List<FeedItemEntity>>
@Query("SELECT * FROM feed_items WHERE isStarred = 1 ORDER BY published DESC")
fun getStarredItems(): Flow<List<FeedItemEntity>>
@Query("SELECT * FROM feed_items WHERE published > :date ORDER BY published DESC")
fun getItemsAfterDate(date: Date): Flow<List<FeedItemEntity>>
@Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId AND published > :date ORDER BY published DESC")
fun getSubscriptionItemsAfterDate(subscriptionId: String, date: Date): Flow<List<FeedItemEntity>>
@Query("SELECT COUNT(*) FROM feed_items WHERE subscriptionId = :subscriptionId AND isRead = 0")
fun getUnreadCount(subscriptionId: String): Flow<Int>
@Query("SELECT COUNT(*) FROM feed_items WHERE isRead = 0")
fun getTotalUnreadCount(): Flow<Int>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItem(item: FeedItemEntity): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItems(items: List<FeedItemEntity>): List<Long>
@Update
suspend fun updateItem(item: FeedItemEntity): Int
@Delete
suspend fun deleteItem(item: FeedItemEntity): Int
@Query("DELETE FROM feed_items WHERE id = :id")
suspend fun deleteItemById(id: String): Int
@Query("DELETE FROM feed_items WHERE subscriptionId = :subscriptionId")
suspend fun deleteItemsBySubscription(subscriptionId: String): Int
@Query("UPDATE feed_items SET isRead = 1 WHERE id = :id")
suspend fun markAsRead(id: String): Int
@Query("UPDATE feed_items SET isRead = 0 WHERE id = :id")
suspend fun markAsUnread(id: String): Int
@Query("UPDATE feed_items SET isStarred = 1 WHERE id = :id")
suspend fun markAsStarred(id: String): Int
@Query("UPDATE feed_items SET isStarred = 0 WHERE id = :id")
suspend fun markAsUnstarred(id: String): Int
@Query("UPDATE feed_items SET isRead = 1 WHERE subscriptionId = :subscriptionId")
suspend fun markAllAsRead(subscriptionId: String): Int
@Query("SELECT * FROM feed_items WHERE subscriptionId = :subscriptionId LIMIT :limit OFFSET :offset")
suspend fun getItemsPaginated(subscriptionId: String, limit: Int, offset: Int): List<FeedItemEntity>
}

View File

@@ -0,0 +1,49 @@
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.SearchHistoryEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface SearchHistoryDao {
@Query("SELECT * FROM search_history ORDER BY timestamp DESC")
fun getAllSearchHistory(): Flow<List<SearchHistoryEntity>>
@Query("SELECT * FROM search_history WHERE id = :id")
suspend fun getSearchHistoryById(id: String): SearchHistoryEntity?
@Query("SELECT * FROM search_history WHERE query LIKE '%' || :query || '%' ORDER BY timestamp DESC")
fun searchHistory(query: String): Flow<List<SearchHistoryEntity>>
@Query("SELECT * FROM search_history ORDER BY timestamp DESC LIMIT :limit")
fun getRecentSearches(limit: Int): Flow<List<SearchHistoryEntity>>
@Query("SELECT COUNT(*) FROM search_history")
fun getSearchHistoryCount(): Flow<Int>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertSearchHistory(search: SearchHistoryEntity): Long
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertSearchHistories(searches: List<SearchHistoryEntity>): List<Long>
@Update
suspend fun updateSearchHistory(search: SearchHistoryEntity): Int
@Delete
suspend fun deleteSearchHistory(search: SearchHistoryEntity): Int
@Query("DELETE FROM search_history WHERE id = :id")
suspend fun deleteSearchHistoryById(id: String): Int
@Query("DELETE FROM search_history")
suspend fun deleteAllSearchHistory(): Int
@Query("DELETE FROM search_history WHERE timestamp < :timestamp")
suspend fun deleteSearchHistoryOlderThan(timestamp: Long): Int
}

View File

@@ -0,0 +1,56 @@
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.SubscriptionEntity
import kotlinx.coroutines.flow.Flow
import java.util.Date
@Dao
interface SubscriptionDao {
@Query("SELECT * FROM subscriptions ORDER BY title ASC")
fun getAllSubscriptions(): Flow<List<SubscriptionEntity>>
@Query("SELECT * FROM subscriptions WHERE id = :id")
suspend fun getSubscriptionById(id: String): SubscriptionEntity?
@Query("SELECT * FROM subscriptions WHERE url = :url")
suspend fun getSubscriptionByUrl(url: String): SubscriptionEntity?
@Query("SELECT * FROM subscriptions WHERE enabled = 1 ORDER BY title ASC")
fun getEnabledSubscriptions(): Flow<List<SubscriptionEntity>>
@Query("SELECT * FROM subscriptions WHERE category = :category ORDER BY title ASC")
fun getSubscriptionsByCategory(category: String): Flow<List<SubscriptionEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSubscription(subscription: SubscriptionEntity): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSubscriptions(subscriptions: List<SubscriptionEntity>): List<Long>
@Update
suspend fun updateSubscription(subscription: SubscriptionEntity): Int
@Delete
suspend fun deleteSubscription(subscription: SubscriptionEntity): Int
@Query("DELETE FROM subscriptions WHERE id = :id")
suspend fun deleteSubscriptionById(id: String): Int
@Query("SELECT COUNT(*) FROM subscriptions")
fun getSubscriptionCount(): Flow<Int>
@Query("UPDATE subscriptions SET error = :error WHERE id = :id")
suspend fun updateError(id: String, error: String?)
@Query("UPDATE subscriptions SET lastFetchedAt = :lastFetchedAt, error = NULL WHERE id = :id")
suspend fun updateLastFetchedAt(id: String, lastFetchedAt: Date)
@Query("UPDATE subscriptions SET nextFetchAt = :nextFetchAt WHERE id = :id")
suspend fun updateNextFetchAt(id: String, nextFetchAt: Date)
}

View File

@@ -0,0 +1,57 @@
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 = "feed_items",
foreignKeys = [
ForeignKey(
entity = SubscriptionEntity::class,
parentColumns = ["id"],
childColumns = ["subscriptionId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index(value = ["subscriptionId"]),
Index(value = ["published"])
]
)
data class FeedItemEntity(
@PrimaryKey
val id: String,
val subscriptionId: String,
val title: String,
val link: String? = null,
val description: String? = null,
val content: String? = null,
val author: String? = null,
val published: Date? = null,
val updated: Date? = null,
val categories: String? = null,
val enclosureUrl: String? = null,
val enclosureType: String? = null,
val enclosureLength: Long? = null,
val guid: String? = null,
val isRead: Boolean = false,
val isStarred: Boolean = false
)

View File

@@ -0,0 +1,19 @@
package com.rssuper.database.entities
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import java.util.Date
@Entity(
tableName = "search_history",
indices = [Index(value = ["timestamp"])]
)
data class SearchHistoryEntity(
@PrimaryKey
val id: String,
val query: String,
val timestamp: Date
)

View File

@@ -0,0 +1,54 @@
package com.rssuper.database.entities
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.rssuper.models.HttpAuth
import java.util.Date
@Entity(
tableName = "subscriptions",
indices = [Index(value = ["url"], unique = true)]
)
data class SubscriptionEntity(
@PrimaryKey
val id: String,
val url: String,
val title: String,
val category: String? = null,
val enabled: Boolean = true,
val fetchInterval: Long = 3600000,
val createdAt: Date,
val updatedAt: Date,
val lastFetchedAt: Date? = null,
val nextFetchAt: Date? = null,
val error: String? = null,
val httpAuthUsername: String? = null,
val httpAuthPassword: String? = null
) {
fun toHttpAuth(): HttpAuth? {
return if (httpAuthUsername != null && httpAuthPassword != null) {
HttpAuth(httpAuthUsername, httpAuthPassword)
} else null
}
fun fromHttpAuth(auth: HttpAuth?): SubscriptionEntity {
return copy(
httpAuthUsername = auth?.username,
httpAuthPassword = auth?.password
)
}
}

View File

@@ -0,0 +1,60 @@
package com.rssuper.models
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.rssuper.converters.DateConverter
import com.rssuper.converters.FeedItemListConverter
import kotlinx.parcelize.Parcelize
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.Date
@JsonClass(generateAdapter = true)
@Parcelize
@TypeConverters(DateConverter::class, FeedItemListConverter::class)
@Entity(tableName = "feeds")
data class Feed(
@PrimaryKey
val id: String,
@Json(name = "title")
val title: String,
@Json(name = "link")
val link: String? = null,
@Json(name = "description")
val description: String? = null,
@Json(name = "subtitle")
val subtitle: String? = null,
@Json(name = "language")
val language: String? = null,
@Json(name = "lastBuildDate")
val lastBuildDate: Date? = null,
@Json(name = "updated")
val updated: Date? = null,
@Json(name = "generator")
val generator: String? = null,
@Json(name = "ttl")
val ttl: Int? = null,
@Json(name = "items")
val items: List<FeedItem> = emptyList(),
@Json(name = "rawUrl")
val rawUrl: String,
@Json(name = "lastFetchedAt")
val lastFetchedAt: Date? = null,
@Json(name = "nextFetchAt")
val nextFetchAt: Date? = null
) : Parcelable

View File

@@ -0,0 +1,67 @@
package com.rssuper.models
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.rssuper.converters.DateConverter
import com.rssuper.converters.StringListConverter
import kotlinx.parcelize.Parcelize
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.Date
@JsonClass(generateAdapter = true)
@Parcelize
@TypeConverters(DateConverter::class, StringListConverter::class)
@Entity(tableName = "feed_items")
data class FeedItem(
@PrimaryKey
val id: String,
@Json(name = "title")
val title: String,
@Json(name = "link")
val link: String? = null,
@Json(name = "description")
val description: String? = null,
@Json(name = "content")
val content: String? = null,
@Json(name = "author")
val author: String? = null,
@Json(name = "published")
val published: Date? = null,
@Json(name = "updated")
val updated: Date? = null,
@Json(name = "categories")
val categories: List<String>? = null,
@Json(name = "enclosure")
val enclosure: Enclosure? = null,
@Json(name = "guid")
val guid: String? = null,
@Json(name = "subscriptionTitle")
val subscriptionTitle: String? = null
) : Parcelable
@JsonClass(generateAdapter = true)
@Parcelize
data class Enclosure(
@Json(name = "url")
val url: String,
@Json(name = "type")
val type: String,
@Json(name = "length")
val length: Long? = null
) : Parcelable

View File

@@ -0,0 +1,63 @@
package com.rssuper.models
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.rssuper.converters.DateConverter
import kotlinx.parcelize.Parcelize
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.Date
@JsonClass(generateAdapter = true)
@Parcelize
@TypeConverters(DateConverter::class)
@Entity(tableName = "feed_subscriptions")
data class FeedSubscription(
@PrimaryKey
val id: String,
@Json(name = "url")
val url: String,
@Json(name = "title")
val title: String,
@Json(name = "category")
val category: String? = null,
@Json(name = "enabled")
val enabled: Boolean = true,
@Json(name = "fetchInterval")
val fetchInterval: Long,
@Json(name = "createdAt")
val createdAt: Date,
@Json(name = "updatedAt")
val updatedAt: Date,
@Json(name = "lastFetchedAt")
val lastFetchedAt: Date? = null,
@Json(name = "nextFetchAt")
val nextFetchAt: Date? = null,
@Json(name = "error")
val error: String? = null,
@Json(name = "httpAuth")
val httpAuth: HttpAuth? = null
) : Parcelable
@JsonClass(generateAdapter = true)
@Parcelize
data class HttpAuth(
@Json(name = "username")
val username: String,
@Json(name = "password")
val password: String
) : Parcelable

View File

@@ -0,0 +1,34 @@
package com.rssuper.models
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.parcelize.Parcelize
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
@Parcelize
@Entity(tableName = "notification_preferences")
data class NotificationPreferences(
@PrimaryKey
val id: String = "default",
@Json(name = "newArticles")
val newArticles: Boolean = true,
@Json(name = "episodeReleases")
val episodeReleases: Boolean = true,
@Json(name = "customAlerts")
val customAlerts: Boolean = false,
@Json(name = "badgeCount")
val badgeCount: Boolean = true,
@Json(name = "sound")
val sound: Boolean = true,
@Json(name = "vibration")
val vibration: Boolean = true
) : Parcelable

View File

@@ -0,0 +1,59 @@
package com.rssuper.models
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.parcelize.Parcelize
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
@Parcelize
@Entity(tableName = "reading_preferences")
data class ReadingPreferences(
@PrimaryKey
val id: String = "default",
@Json(name = "fontSize")
val fontSize: FontSize = FontSize.MEDIUM,
@Json(name = "lineHeight")
val lineHeight: LineHeight = LineHeight.NORMAL,
@Json(name = "showTableOfContents")
val showTableOfContents: Boolean = false,
@Json(name = "showReadingTime")
val showReadingTime: Boolean = true,
@Json(name = "showAuthor")
val showAuthor: Boolean = true,
@Json(name = "showDate")
val showDate: Boolean = true
) : Parcelable
sealed class FontSize(val value: String) {
@Json(name = "small")
data object SMALL : FontSize("small")
@Json(name = "medium")
data object MEDIUM : FontSize("medium")
@Json(name = "large")
data object LARGE : FontSize("large")
@Json(name = "xlarge")
data object XLARGE : FontSize("xlarge")
}
sealed class LineHeight(val value: String) {
@Json(name = "normal")
data object NORMAL : LineHeight("normal")
@Json(name = "relaxed")
data object RELAXED : LineHeight("relaxed")
@Json(name = "loose")
data object LOOSE : LineHeight("loose")
}

View File

@@ -0,0 +1,73 @@
package com.rssuper.models
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.rssuper.converters.DateConverter
import com.rssuper.converters.StringListConverter
import kotlinx.parcelize.Parcelize
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.Date
@JsonClass(generateAdapter = true)
@Parcelize
@TypeConverters(DateConverter::class, StringListConverter::class)
@Entity(tableName = "search_filters")
data class SearchFilters(
@PrimaryKey
val id: String = "default",
@Json(name = "dateFrom")
val dateFrom: Date? = null,
@Json(name = "dateTo")
val dateTo: Date? = null,
@Json(name = "feedIds")
val feedIds: List<String>? = null,
@Json(name = "authors")
val authors: List<String>? = null,
@Json(name = "contentType")
val contentType: ContentType? = null,
@Json(name = "sortOption")
val sortOption: SearchSortOption = SearchSortOption.RELEVANCE
) : Parcelable
sealed class ContentType(val value: String) {
@Json(name = "article")
data object ARTICLE : ContentType("article")
@Json(name = "audio")
data object AUDIO : ContentType("audio")
@Json(name = "video")
data object VIDEO : ContentType("video")
}
sealed class SearchSortOption(val value: String) {
@Json(name = "relevance")
data object RELEVANCE : SearchSortOption("relevance")
@Json(name = "date_desc")
data object DATE_DESC : SearchSortOption("date_desc")
@Json(name = "date_asc")
data object DATE_ASC : SearchSortOption("date_asc")
@Json(name = "title_asc")
data object TITLE_ASC : SearchSortOption("title_asc")
@Json(name = "title_desc")
data object TITLE_DESC : SearchSortOption("title_desc")
@Json(name = "feed_asc")
data object FEED_ASC : SearchSortOption("feed_asc")
@Json(name = "feed_desc")
data object FEED_DESC : SearchSortOption("feed_desc")
}

View File

@@ -0,0 +1,49 @@
package com.rssuper.models
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.rssuper.converters.DateConverter
import kotlinx.parcelize.Parcelize
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.Date
@JsonClass(generateAdapter = true)
@Parcelize
@TypeConverters(DateConverter::class)
@Entity(tableName = "search_results")
data class SearchResult(
@PrimaryKey
val id: String,
@Json(name = "type")
val type: SearchResultType,
@Json(name = "title")
val title: String,
@Json(name = "snippet")
val snippet: String? = null,
@Json(name = "link")
val link: String? = null,
@Json(name = "feedTitle")
val feedTitle: String? = null,
@Json(name = "published")
val published: Date? = null,
@Json(name = "score")
val score: Double? = null
) : Parcelable
enum class SearchResultType {
@Json(name = "article")
ARTICLE,
@Json(name = "feed")
FEED
}

View File

@@ -0,0 +1,294 @@
package com.rssuper.database
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.rssuper.database.daos.FeedItemDao
import com.rssuper.database.entities.FeedItemEntity
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import java.util.Date
class FeedItemDaoTest {
private lateinit var database: RssDatabase
private lateinit var dao: FeedItemDao
@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(
context,
RssDatabase::class.java
)
.allowMainThreadQueries()
.build()
dao = database.feedItemDao()
}
@After
fun closeDb() {
database.close()
}
@Test
fun insertAndGetItem() = runTest {
val item = createTestItem("1", "sub1")
dao.insertItem(item)
val result = dao.getItemById("1")
assertNotNull(result)
assertEquals("1", result?.id)
assertEquals("Test Item", result?.title)
}
@Test
fun getItemsBySubscription() = runTest {
val item1 = createTestItem("1", "sub1")
val item2 = createTestItem("2", "sub1")
val item3 = createTestItem("3", "sub2")
dao.insertItems(listOf(item1, item2, item3))
val result = dao.getItemsBySubscription("sub1").first()
assertEquals(2, result.size)
}
@Test
fun getItemsBySubscriptions() = runTest {
val item1 = createTestItem("1", "sub1")
val item2 = createTestItem("2", "sub2")
val item3 = createTestItem("3", "sub3")
dao.insertItems(listOf(item1, item2, item3))
val result = dao.getItemsBySubscriptions(listOf("sub1", "sub2")).first()
assertEquals(2, result.size)
}
@Test
fun getUnreadItems() = runTest {
val unread = createTestItem("1", "sub1", isRead = false)
val read = createTestItem("2", "sub1", isRead = true)
dao.insertItems(listOf(unread, read))
val result = dao.getUnreadItems().first()
assertEquals(1, result.size)
assertEquals("1", result[0].id)
}
@Test
fun getStarredItems() = runTest {
val starred = createTestItem("1", "sub1", isStarred = true)
val notStarred = createTestItem("2", "sub1", isStarred = false)
dao.insertItems(listOf(starred, notStarred))
val result = dao.getStarredItems().first()
assertEquals(1, result.size)
assertEquals("1", result[0].id)
}
@Test
fun getItemsAfterDate() = runTest {
val oldDate = Date(System.currentTimeMillis() - 86400000 * 2)
val newDate = Date(System.currentTimeMillis() - 86400000)
val today = Date()
val oldItem = createTestItem("1", "sub1", published = oldDate)
val newItem = createTestItem("2", "sub1", published = newDate)
val todayItem = createTestItem("3", "sub1", published = today)
dao.insertItems(listOf(oldItem, newItem, todayItem))
val result = dao.getItemsAfterDate(newDate).first()
assertEquals(1, result.size)
assertEquals("3", result[0].id)
}
@Test
fun getUnreadCount() = runTest {
val unread1 = createTestItem("1", "sub1", isRead = false)
val unread2 = createTestItem("2", "sub1", isRead = false)
val read = createTestItem("3", "sub1", isRead = true)
dao.insertItems(listOf(unread1, unread2, read))
val count = dao.getUnreadCount("sub1").first()
assertEquals(2, count)
}
@Test
fun getTotalUnreadCount() = runTest {
val unread1 = createTestItem("1", "sub1", isRead = false)
val unread2 = createTestItem("2", "sub2", isRead = false)
val read = createTestItem("3", "sub1", isRead = true)
dao.insertItems(listOf(unread1, unread2, read))
val count = dao.getTotalUnreadCount().first()
assertEquals(2, count)
}
@Test
fun updateItem() = runTest {
val item = createTestItem("1", "sub1")
dao.insertItem(item)
val updated = item.copy(title = "Updated Title")
dao.updateItem(updated)
val result = dao.getItemById("1")
assertEquals("Updated Title", result?.title)
}
@Test
fun deleteItem() = runTest {
val item = createTestItem("1", "sub1")
dao.insertItem(item)
dao.deleteItem(item)
val result = dao.getItemById("1")
assertNull(result)
}
@Test
fun deleteItemById() = runTest {
val item = createTestItem("1", "sub1")
dao.insertItem(item)
dao.deleteItemById("1")
val result = dao.getItemById("1")
assertNull(result)
}
@Test
fun deleteItemsBySubscription() = runTest {
val item1 = createTestItem("1", "sub1")
val item2 = createTestItem("2", "sub1")
val item3 = createTestItem("3", "sub2")
dao.insertItems(listOf(item1, item2, item3))
dao.deleteItemsBySubscription("sub1")
val sub1Items = dao.getItemsBySubscription("sub1").first()
val sub2Items = dao.getItemsBySubscription("sub2").first()
assertEquals(0, sub1Items.size)
assertEquals(1, sub2Items.size)
}
@Test
fun markAsRead() = runTest {
val item = createTestItem("1", "sub1", isRead = false)
dao.insertItem(item)
dao.markAsRead("1")
val result = dao.getItemById("1")
assertEquals(true, result?.isRead)
}
@Test
fun markAsUnread() = runTest {
val item = createTestItem("1", "sub1", isRead = true)
dao.insertItem(item)
dao.markAsUnread("1")
val result = dao.getItemById("1")
assertEquals(false, result?.isRead)
}
@Test
fun markAsStarred() = runTest {
val item = createTestItem("1", "sub1", isStarred = false)
dao.insertItem(item)
dao.markAsStarred("1")
val result = dao.getItemById("1")
assertEquals(true, result?.isStarred)
}
@Test
fun markAsUnstarred() = runTest {
val item = createTestItem("1", "sub1", isStarred = true)
dao.insertItem(item)
dao.markAsUnstarred("1")
val result = dao.getItemById("1")
assertEquals(false, result?.isStarred)
}
@Test
fun markAllAsRead() = runTest {
val item1 = createTestItem("1", "sub1", isRead = false)
val item2 = createTestItem("2", "sub1", isRead = false)
val item3 = createTestItem("3", "sub2", isRead = false)
dao.insertItems(listOf(item1, item2, item3))
dao.markAllAsRead("sub1")
val sub1Items = dao.getItemsBySubscription("sub1").first()
val sub2Items = dao.getItemsBySubscription("sub2").first()
assertEquals(true, sub1Items[0].isRead)
assertEquals(true, sub1Items[1].isRead)
assertEquals(false, sub2Items[0].isRead)
}
@Test
fun getItemsPaginated() = runTest {
for (i in 1..10) {
val item = createTestItem(i.toString(), "sub1")
dao.insertItem(item)
}
val firstPage = dao.getItemsPaginated("sub1", 5, 0)
val secondPage = dao.getItemsPaginated("sub1", 5, 5)
assertEquals(5, firstPage.size)
assertEquals(5, secondPage.size)
}
private fun createTestItem(
id: String,
subscriptionId: String,
title: String = "Test Item",
isRead: Boolean = false,
isStarred: Boolean = false,
published: Date = Date()
): FeedItemEntity {
return FeedItemEntity(
id = id,
subscriptionId = subscriptionId,
title = title,
link = "https://example.com/$id",
description = "Test description",
content = "Test content",
author = "Test Author",
published = published,
updated = published,
categories = "Tech,News",
enclosureUrl = null,
enclosureType = null,
enclosureLength = null,
guid = "guid-$id",
isRead = isRead,
isStarred = isStarred
)
}
}

View File

@@ -0,0 +1,196 @@
package com.rssuper.database
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.rssuper.database.entities.FeedItemEntity
import com.rssuper.database.entities.SearchHistoryEntity
import com.rssuper.database.entities.SubscriptionEntity
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
import java.util.Date
import java.util.UUID
class RssDatabaseTest {
private lateinit var database: RssDatabase
@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(
context,
RssDatabase::class.java
)
.allowMainThreadQueries()
.build()
}
@After
fun closeDb() {
database.close()
}
@Test
fun databaseConstruction() {
assertNotNull(database.subscriptionDao())
assertNotNull(database.feedItemDao())
assertNotNull(database.searchHistoryDao())
}
@Test
fun ftsVirtualTableExists() {
val cursor = database.run {
openHelper.writableDatabase.rawQuery(
"SELECT name FROM sqlite_master WHERE type='table' AND name='feed_items_fts'",
null
)
}
assertEquals(true, cursor.moveToFirst())
cursor.close()
}
@Test
fun subscriptionEntityRoundTrip() = runTest {
val now = Date()
val subscription = SubscriptionEntity(
id = UUID.randomUUID().toString(),
url = "https://example.com/feed",
title = "Test Feed",
category = "Tech",
enabled = true,
fetchInterval = 3600000,
createdAt = now,
updatedAt = now,
lastFetchedAt = null,
nextFetchAt = null,
error = null,
httpAuthUsername = null,
httpAuthPassword = null
)
database.subscriptionDao().insertSubscription(subscription)
val result = database.subscriptionDao().getSubscriptionById(subscription.id)
assertNotNull(result)
assertEquals(subscription.id, result?.id)
assertEquals(subscription.title, result?.title)
}
@Test
fun feedItemEntityRoundTrip() = runTest {
val now = Date()
val subscription = SubscriptionEntity(
id = "sub1",
url = "https://example.com/feed",
title = "Test Feed",
category = "Tech",
enabled = true,
fetchInterval = 3600000,
createdAt = now,
updatedAt = now,
lastFetchedAt = null,
nextFetchAt = null,
error = null,
httpAuthUsername = null,
httpAuthPassword = null
)
database.subscriptionDao().insertSubscription(subscription)
val item = FeedItemEntity(
id = UUID.randomUUID().toString(),
subscriptionId = "sub1",
title = "Test Item",
link = "https://example.com/item",
description = "Test description",
content = "Test content",
author = "Test Author",
published = now,
updated = now,
categories = "Tech",
enclosureUrl = null,
enclosureType = null,
enclosureLength = null,
guid = "guid-1",
isRead = false,
isStarred = false
)
database.feedItemDao().insertItem(item)
val result = database.feedItemDao().getItemById(item.id)
assertNotNull(result)
assertEquals(item.id, result?.id)
assertEquals(item.title, result?.title)
assertEquals("sub1", result?.subscriptionId)
}
@Test
fun searchHistoryEntityRoundTrip() = runTest {
val now = Date()
val search = SearchHistoryEntity(
id = UUID.randomUUID().toString(),
query = "kotlin coroutines",
timestamp = now
)
database.searchHistoryDao().insertSearchHistory(search)
val result = database.searchHistoryDao().getSearchHistoryById(search.id)
assertNotNull(result)
assertEquals(search.id, result?.id)
assertEquals(search.query, result?.query)
}
@Test
fun cascadeDeleteFeedItems() = runTest {
val now = Date()
val subscription = SubscriptionEntity(
id = "sub1",
url = "https://example.com/feed",
title = "Test Feed",
category = "Tech",
enabled = true,
fetchInterval = 3600000,
createdAt = now,
updatedAt = now,
lastFetchedAt = null,
nextFetchAt = null,
error = null,
httpAuthUsername = null,
httpAuthPassword = null
)
database.subscriptionDao().insertSubscription(subscription)
val item = FeedItemEntity(
id = "item1",
subscriptionId = "sub1",
title = "Test Item",
link = "https://example.com/item",
description = "Test description",
content = "Test content",
author = "Test Author",
published = now,
updated = now,
categories = "Tech",
enclosureUrl = null,
enclosureType = null,
enclosureLength = null,
guid = "guid-1",
isRead = false,
isStarred = false
)
database.feedItemDao().insertItem(item)
database.subscriptionDao().deleteSubscription(subscription)
val items = database.feedItemDao().getItemsBySubscription("sub1").first()
assertEquals(0, items.size)
}
}

View File

@@ -0,0 +1,188 @@
package com.rssuper.database
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.rssuper.database.daos.SearchHistoryDao
import com.rssuper.database.entities.SearchHistoryEntity
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import java.util.Date
class SearchHistoryDaoTest {
private lateinit var database: RssDatabase
private lateinit var dao: SearchHistoryDao
@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(
context,
RssDatabase::class.java
)
.allowMainThreadQueries()
.build()
dao = database.searchHistoryDao()
}
@After
fun closeDb() {
database.close()
}
@Test
fun insertAndGetSearchHistory() = runTest {
val search = createTestSearch("1", "kotlin")
dao.insertSearchHistory(search)
val result = dao.getSearchHistoryById("1")
assertNotNull(result)
assertEquals("1", result?.id)
assertEquals("kotlin", result?.query)
}
@Test
fun getAllSearchHistory() = runTest {
val search1 = createTestSearch("1", "kotlin")
val search2 = createTestSearch("2", "android")
val search3 = createTestSearch("3", "room database")
dao.insertSearchHistories(listOf(search1, search2, search3))
val result = dao.getAllSearchHistory().first()
assertEquals(3, result.size)
}
@Test
fun searchHistory() = runTest {
val search1 = createTestSearch("1", "kotlin coroutines")
val search2 = createTestSearch("2", "android kotlin")
val search3 = createTestSearch("3", "java")
dao.insertSearchHistories(listOf(search1, search2, search3))
val result = dao.searchHistory("kotlin").first()
assertEquals(2, result.size)
}
@Test
fun getRecentSearches() = runTest {
val search1 = createTestSearch("1", "query1", timestamp = Date(System.currentTimeMillis() - 300000))
val search2 = createTestSearch("2", "query2", timestamp = Date(System.currentTimeMillis() - 200000))
val search3 = createTestSearch("3", "query3", timestamp = Date(System.currentTimeMillis() - 100000))
dao.insertSearchHistories(listOf(search1, search2, search3))
val result = dao.getRecentSearches(2).first()
assertEquals(2, result.size)
assertEquals("3", result[0].id)
assertEquals("2", result[1].id)
}
@Test
fun getSearchHistoryCount() = runTest {
val search1 = createTestSearch("1", "query1")
val search2 = createTestSearch("2", "query2")
val search3 = createTestSearch("3", "query3")
dao.insertSearchHistories(listOf(search1, search2, search3))
val count = dao.getSearchHistoryCount().first()
assertEquals(3, count)
}
@Test
fun updateSearchHistory() = runTest {
val search = createTestSearch("1", "old query")
dao.insertSearchHistory(search)
val updated = search.copy(query = "new query")
dao.updateSearchHistory(updated)
val result = dao.getSearchHistoryById("1")
assertEquals("new query", result?.query)
}
@Test
fun deleteSearchHistory() = runTest {
val search = createTestSearch("1", "kotlin")
dao.insertSearchHistory(search)
dao.deleteSearchHistory(search)
val result = dao.getSearchHistoryById("1")
assertNull(result)
}
@Test
fun deleteSearchHistoryById() = runTest {
val search = createTestSearch("1", "kotlin")
dao.insertSearchHistory(search)
dao.deleteSearchHistoryById("1")
val result = dao.getSearchHistoryById("1")
assertNull(result)
}
@Test
fun deleteAllSearchHistory() = runTest {
val search1 = createTestSearch("1", "query1")
val search2 = createTestSearch("2", "query2")
dao.insertSearchHistories(listOf(search1, search2))
dao.deleteAllSearchHistory()
val result = dao.getAllSearchHistory().first()
assertEquals(0, result.size)
}
@Test
fun deleteSearchHistoryOlderThan() = runTest {
val oldSearch = createTestSearch("1", "old query", timestamp = Date(System.currentTimeMillis() - 86400000 * 2))
val recentSearch = createTestSearch("2", "recent query", timestamp = Date(System.currentTimeMillis() - 86400000))
dao.insertSearchHistories(listOf(oldSearch, recentSearch))
dao.deleteSearchHistoryOlderThan(System.currentTimeMillis() - 86400000)
val result = dao.getAllSearchHistory().first()
assertEquals(1, result.size)
assertEquals("2", result[0].id)
}
@Test
fun insertSearchHistoryWithConflict() = runTest {
val search = createTestSearch("1", "kotlin")
dao.insertSearchHistory(search)
val duplicate = search.copy(query = "android")
val result = dao.insertSearchHistory(duplicate)
assertEquals(-1L, result)
val dbSearch = dao.getSearchHistoryById("1")
assertEquals("kotlin", dbSearch?.query)
}
private fun createTestSearch(
id: String,
query: String,
timestamp: Date = Date()
): SearchHistoryEntity {
return SearchHistoryEntity(
id = id,
query = query,
timestamp = timestamp
)
}
}

View File

@@ -0,0 +1,204 @@
package com.rssuper.database
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.rssuper.database.daos.SubscriptionDao
import com.rssuper.database.entities.SubscriptionEntity
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import java.util.Date
class SubscriptionDaoTest {
private lateinit var database: RssDatabase
private lateinit var dao: SubscriptionDao
@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(
context,
RssDatabase::class.java
)
.allowMainThreadQueries()
.build()
dao = database.subscriptionDao()
}
@After
fun closeDb() {
database.close()
}
@Test
fun insertAndGetSubscription() = runTest {
val subscription = createTestSubscription("1")
dao.insertSubscription(subscription)
val result = dao.getSubscriptionById("1")
assertNotNull(result)
assertEquals("1", result?.id)
assertEquals("Test Feed", result?.title)
}
@Test
fun getSubscriptionByUrl() = runTest {
val subscription = createTestSubscription("1", url = "https://example.com/feed")
dao.insertSubscription(subscription)
val result = dao.getSubscriptionByUrl("https://example.com/feed")
assertNotNull(result)
assertEquals("1", result?.id)
}
@Test
fun getAllSubscriptions() = runTest {
val subscription1 = createTestSubscription("1")
val subscription2 = createTestSubscription("2")
dao.insertSubscriptions(listOf(subscription1, subscription2))
val result = dao.getAllSubscriptions().first()
assertEquals(2, result.size)
}
@Test
fun getEnabledSubscriptions() = runTest {
val enabled = createTestSubscription("1", enabled = true)
val disabled = createTestSubscription("2", enabled = false)
dao.insertSubscriptions(listOf(enabled, disabled))
val result = dao.getEnabledSubscriptions().first()
assertEquals(1, result.size)
assertEquals("1", result[0].id)
}
@Test
fun updateSubscription() = runTest {
val subscription = createTestSubscription("1")
dao.insertSubscription(subscription)
val updated = subscription.copy(title = "Updated Title")
dao.updateSubscription(updated)
val result = dao.getSubscriptionById("1")
assertEquals("Updated Title", result?.title)
}
@Test
fun deleteSubscription() = runTest {
val subscription = createTestSubscription("1")
dao.insertSubscription(subscription)
dao.deleteSubscription(subscription)
val result = dao.getSubscriptionById("1")
assertNull(result)
}
@Test
fun deleteSubscriptionById() = runTest {
val subscription = createTestSubscription("1")
dao.insertSubscription(subscription)
dao.deleteSubscriptionById("1")
val result = dao.getSubscriptionById("1")
assertNull(result)
}
@Test
fun getSubscriptionCount() = runTest {
val subscription1 = createTestSubscription("1")
val subscription2 = createTestSubscription("2")
dao.insertSubscriptions(listOf(subscription1, subscription2))
val count = dao.getSubscriptionCount().first()
assertEquals(2, count)
}
@Test
fun updateError() = runTest {
val subscription = createTestSubscription("1")
dao.insertSubscription(subscription)
dao.updateError("1", "Feed not found")
val result = dao.getSubscriptionById("1")
assertEquals("Feed not found", result?.error)
}
@Test
fun updateLastFetchedAt() = runTest {
val subscription = createTestSubscription("1")
val now = Date()
dao.insertSubscription(subscription)
dao.updateLastFetchedAt("1", now)
val result = dao.getSubscriptionById("1")
assertEquals(now, result?.lastFetchedAt)
assertNull(result?.error)
}
@Test
fun updateNextFetchAt() = runTest {
val subscription = createTestSubscription("1")
val future = Date(System.currentTimeMillis() + 3600000)
dao.insertSubscription(subscription)
dao.updateNextFetchAt("1", future)
val result = dao.getSubscriptionById("1")
assertEquals(future, result?.nextFetchAt)
}
@Test
fun insertSubscriptionWithConflict() = runTest {
val subscription = createTestSubscription("1")
dao.insertSubscription(subscription)
val updated = subscription.copy(title = "Updated")
dao.insertSubscription(updated)
val result = dao.getSubscriptionById("1")
assertEquals("Updated", result?.title)
}
private fun createTestSubscription(
id: String,
url: String = "https://example.com/feed/$id",
title: String = "Test Feed",
enabled: Boolean = true
): SubscriptionEntity {
val now = Date()
return SubscriptionEntity(
id = id,
url = url,
title = title,
category = "Tech",
enabled = enabled,
fetchInterval = 3600000,
createdAt = now,
updatedAt = now,
lastFetchedAt = null,
nextFetchAt = null,
error = null,
httpAuthUsername = null,
httpAuthPassword = null
)
}
}

View File

@@ -0,0 +1,134 @@
package com.rssuper.models
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import java.util.Date
class FeedItemTest {
private lateinit var moshi: Moshi
private lateinit var adapter: com.squareup.moshi.JsonAdapter<FeedItem>
@Before
fun setup() {
moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
adapter = moshi.adapter(FeedItem::class.java)
}
@Test
fun testSerialization() {
val feedItem = FeedItem(
id = "item-1",
title = "Test Article",
link = "https://example.com/article",
description = "Short description",
content = "Full content here",
author = "John Doe",
published = Date(1672531200000),
categories = listOf("Tech", "News"),
guid = "guid-123",
subscriptionTitle = "Tech News"
)
val json = adapter.toJson(feedItem)
assertNotNull(json)
}
@Test
fun testDeserialization() {
val json = """{
"id": "item-1",
"title": "Test Article",
"link": "https://example.com/article",
"description": "Short description",
"author": "John Doe",
"published": 1672531200000,
"categories": ["Tech", "News"],
"guid": "guid-123",
"subscriptionTitle": "Tech News"
}"""
val feedItem = adapter.fromJson(json)
assertNotNull(feedItem)
assertEquals("item-1", feedItem?.id)
assertEquals("Test Article", feedItem?.title)
assertEquals("John Doe", feedItem?.author)
}
@Test
fun testOptionalFieldsNull() {
val json = """{
"id": "item-1",
"title": "Test Article"
}"""
val feedItem = adapter.fromJson(json)
assertNotNull(feedItem)
assertNull(feedItem?.link)
assertNull(feedItem?.description)
assertNull(feedItem?.author)
}
@Test
fun testEnclosureSerialization() {
val feedItem = FeedItem(
id = "item-1",
title = "Podcast Episode",
enclosure = Enclosure(
url = "https://example.com/episode.mp3",
type = "audio/mpeg",
length = 12345678
)
)
val json = adapter.toJson(feedItem)
assertNotNull(json)
}
@Test
fun testCopy() {
val original = FeedItem(
id = "item-1",
title = "Original Title",
author = "Original Author"
)
val modified = original.copy(title = "Modified Title")
assertEquals("item-1", modified.id)
assertEquals("Modified Title", modified.title)
assertEquals("Original Author", modified.author)
}
@Test
fun testEqualsAndHashCode() {
val item1 = FeedItem(id = "item-1", title = "Test")
val item2 = FeedItem(id = "item-1", title = "Test")
val item3 = FeedItem(id = "item-2", title = "Test")
assertEquals(item1, item2)
assertEquals(item1.hashCode(), item2.hashCode())
assert(item1 != item3)
}
@Test
fun testToString() {
val feedItem = FeedItem(
id = "item-1",
title = "Test Article",
author = "John Doe"
)
val toString = feedItem.toString()
assertNotNull(toString)
assert(toString.contains("id=item-1"))
assert(toString.contains("title=Test Article"))
}
}

View File

@@ -0,0 +1,199 @@
package com.rssuper.models
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import java.util.Date
class FeedSubscriptionTest {
private lateinit var moshi: Moshi
private lateinit var adapter: com.squareup.moshi.JsonAdapter<FeedSubscription>
@Before
fun setup() {
moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
adapter = moshi.adapter(FeedSubscription::class.java)
}
@Test
fun testSerialization() {
val subscription = FeedSubscription(
id = "sub-1",
url = "https://example.com/feed.xml",
title = "Tech News",
category = "Technology",
enabled = true,
fetchInterval = 60,
createdAt = Date(1672531200000),
updatedAt = Date(1672617600000)
)
val json = adapter.toJson(subscription)
assertNotNull(json)
}
@Test
fun testDeserialization() {
val json = """{
"id": "sub-1",
"url": "https://example.com/feed.xml",
"title": "Tech News",
"category": "Technology",
"enabled": true,
"fetchInterval": 60,
"createdAt": 1672531200000,
"updatedAt": 1672617600000
}"""
val subscription = adapter.fromJson(json)
assertNotNull(subscription)
assertEquals("sub-1", subscription?.id)
assertEquals("https://example.com/feed.xml", subscription?.url)
assertEquals("Tech News", subscription?.title)
assertEquals("Technology", subscription?.category)
assertEquals(true, subscription?.enabled)
assertEquals(60, subscription?.fetchInterval)
}
@Test
fun testOptionalFieldsNull() {
val json = """{
"id": "sub-1",
"url": "https://example.com/feed.xml",
"title": "Tech News",
"enabled": true,
"fetchInterval": 60,
"createdAt": 1672531200000,
"updatedAt": 1672617600000
}"""
val subscription = adapter.fromJson(json)
assertNotNull(subscription)
assertNull(subscription?.category)
assertNull(subscription?.error)
assertNull(subscription?.httpAuth)
}
@Test
fun testHttpAuthSerialization() {
val subscription = FeedSubscription(
id = "sub-1",
url = "https://example.com/feed.xml",
title = "Private Feed",
enabled = true,
fetchInterval = 60,
createdAt = Date(1672531200000),
updatedAt = Date(1672617600000),
httpAuth = HttpAuth(
username = "user123",
password = "pass456"
)
)
val json = adapter.toJson(subscription)
assertNotNull(json)
}
@Test
fun testHttpAuthDeserialization() {
val json = """{
"id": "sub-1",
"url": "https://example.com/feed.xml",
"title": "Private Feed",
"enabled": true,
"fetchInterval": 60,
"createdAt": 1672531200000,
"updatedAt": 1672617600000,
"httpAuth": {
"username": "user123",
"password": "pass456"
}
}"""
val subscription = adapter.fromJson(json)
assertNotNull(subscription)
assertNotNull(subscription?.httpAuth)
assertEquals("user123", subscription?.httpAuth?.username)
assertEquals("pass456", subscription?.httpAuth?.password)
}
@Test
fun testCopy() {
val original = FeedSubscription(
id = "sub-1",
url = "https://example.com/feed.xml",
title = "Original Title",
enabled = true,
fetchInterval = 60,
createdAt = Date(1672531200000),
updatedAt = Date(1672617600000)
)
val modified = original.copy(title = "Modified Title", enabled = false)
assertEquals("sub-1", modified.id)
assertEquals("Modified Title", modified.title)
assertEquals(false, modified.enabled)
assertEquals(60, modified.fetchInterval)
}
@Test
fun testEqualsAndHashCode() {
val sub1 = FeedSubscription(
id = "sub-1",
url = "https://example.com",
title = "Test",
enabled = true,
fetchInterval = 60,
createdAt = Date(1672531200000),
updatedAt = Date(1672617600000)
)
val sub2 = FeedSubscription(
id = "sub-1",
url = "https://example.com",
title = "Test",
enabled = true,
fetchInterval = 60,
createdAt = Date(1672531200000),
updatedAt = Date(1672617600000)
)
val sub3 = FeedSubscription(
id = "sub-2",
url = "https://example.com",
title = "Test",
enabled = true,
fetchInterval = 60,
createdAt = Date(1672531200000),
updatedAt = Date(1672617600000)
)
assertEquals(sub1, sub2)
assertEquals(sub1.hashCode(), sub2.hashCode())
assert(sub1 != sub3)
}
@Test
fun testToString() {
val subscription = FeedSubscription(
id = "sub-1",
url = "https://example.com/feed.xml",
title = "Tech News",
enabled = true,
fetchInterval = 60,
createdAt = Date(1672531200000),
updatedAt = Date(1672617600000)
)
val toString = subscription.toString()
assertNotNull(toString)
assert(toString.contains("id=sub-1"))
assert(toString.contains("title=Tech News"))
}
}

View File

@@ -0,0 +1,139 @@
package com.rssuper.models
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.Date
class FeedTest {
private lateinit var moshi: Moshi
private lateinit var adapter: com.squareup.moshi.JsonAdapter<Feed>
@Before
fun setup() {
moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
adapter = moshi.adapter(Feed::class.java)
}
@Test
fun testSerialization() {
val feed = Feed(
id = "feed-1",
title = "Tech News",
link = "https://example.com",
description = "Technology news feed",
subtitle = "Daily tech updates",
language = "en",
rawUrl = "https://example.com/feed.xml",
ttl = 60,
items = listOf(
FeedItem(id = "item-1", title = "Article 1"),
FeedItem(id = "item-2", title = "Article 2")
)
)
val json = adapter.toJson(feed)
assertNotNull(json)
}
@Test
fun testDeserialization() {
val json = """{
"id": "feed-1",
"title": "Tech News",
"link": "https://example.com",
"description": "Technology news feed",
"subtitle": "Daily tech updates",
"language": "en",
"rawUrl": "https://example.com/feed.xml",
"ttl": 60,
"items": [
{"id": "item-1", "title": "Article 1"},
{"id": "item-2", "title": "Article 2"}
]
}"""
val feed = adapter.fromJson(json)
assertNotNull(feed)
assertEquals("feed-1", feed?.id)
assertEquals("Tech News", feed?.title)
assertEquals(2, feed?.items?.size)
}
@Test
fun testOptionalFieldsNull() {
val json = """{
"id": "feed-1",
"title": "Tech News",
"rawUrl": "https://example.com/feed.xml"
}"""
val feed = adapter.fromJson(json)
assertNotNull(feed)
assertNull(feed?.link)
assertNull(feed?.description)
assertNull(feed?.language)
}
@Test
fun testEmptyItemsList() {
val json = """{
"id": "feed-1",
"title": "Tech News",
"rawUrl": "https://example.com/feed.xml",
"items": []
}"""
val feed = adapter.fromJson(json)
assertNotNull(feed)
assertTrue(feed?.items?.isEmpty() == true)
}
@Test
fun testCopy() {
val original = Feed(
id = "feed-1",
title = "Original Title",
rawUrl = "https://example.com/feed.xml"
)
val modified = original.copy(title = "Modified Title")
assertEquals("feed-1", modified.id)
assertEquals("Modified Title", modified.title)
assertEquals("https://example.com/feed.xml", modified.rawUrl)
}
@Test
fun testEqualsAndHashCode() {
val feed1 = Feed(id = "feed-1", title = "Test", rawUrl = "https://example.com")
val feed2 = Feed(id = "feed-1", title = "Test", rawUrl = "https://example.com")
val feed3 = Feed(id = "feed-2", title = "Test", rawUrl = "https://example.com")
assertEquals(feed1, feed2)
assertEquals(feed1.hashCode(), feed2.hashCode())
assert(feed1 != feed3)
}
@Test
fun testToString() {
val feed = Feed(
id = "feed-1",
title = "Tech News",
rawUrl = "https://example.com/feed.xml"
)
val toString = feed.toString()
assertNotNull(toString)
assert(toString.contains("id=feed-1"))
assert(toString.contains("title=Tech News"))
}
}

View File

@@ -0,0 +1,108 @@
package com.rssuper.models
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
class NotificationPreferencesTest {
private lateinit var moshi: Moshi
private lateinit var adapter: com.squareup.moshi.JsonAdapter<NotificationPreferences>
@Before
fun setup() {
moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
adapter = moshi.adapter(NotificationPreferences::class.java)
}
@Test
fun testSerialization() {
val preferences = NotificationPreferences(
newArticles = true,
episodeReleases = true,
customAlerts = false,
badgeCount = true,
sound = true,
vibration = false
)
val json = adapter.toJson(preferences)
assertNotNull(json)
}
@Test
fun testDeserialization() {
val json = """{
"newArticles": true,
"episodeReleases": true,
"customAlerts": false,
"badgeCount": true,
"sound": true,
"vibration": false
}"""
val preferences = adapter.fromJson(json)
assertNotNull(preferences)
assertEquals(true, preferences?.newArticles)
assertEquals(true, preferences?.episodeReleases)
assertEquals(false, preferences?.customAlerts)
assertEquals(true, preferences?.badgeCount)
assertEquals(true, preferences?.sound)
assertEquals(false, preferences?.vibration)
}
@Test
fun testDefaultValues() {
val preferences = NotificationPreferences()
assertEquals(true, preferences.newArticles)
assertEquals(true, preferences.episodeReleases)
assertEquals(false, preferences.customAlerts)
assertEquals(true, preferences.badgeCount)
assertEquals(true, preferences.sound)
assertEquals(true, preferences.vibration)
}
@Test
fun testCopy() {
val original = NotificationPreferences(
newArticles = true,
sound = true
)
val modified = original.copy(newArticles = false, sound = false)
assertEquals(false, modified.newArticles)
assertEquals(false, modified.sound)
assertEquals(true, modified.episodeReleases)
}
@Test
fun testEqualsAndHashCode() {
val pref1 = NotificationPreferences(newArticles = true, sound = true)
val pref2 = NotificationPreferences(newArticles = true, sound = true)
val pref3 = NotificationPreferences(newArticles = false, sound = true)
assertEquals(pref1, pref2)
assertEquals(pref1.hashCode(), pref2.hashCode())
assert(pref1 != pref3)
}
@Test
fun testToString() {
val preferences = NotificationPreferences(
newArticles = true,
sound = true
)
val toString = preferences.toString()
assertNotNull(toString)
assert(toString.contains("newArticles"))
assert(toString.contains("sound"))
}
}

View File

@@ -0,0 +1,141 @@
package com.rssuper.models
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
class ReadingPreferencesTest {
private lateinit var moshi: Moshi
private lateinit var adapter: com.squareup.moshi.JsonAdapter<ReadingPreferences>
@Before
fun setup() {
moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
adapter = moshi.adapter(ReadingPreferences::class.java)
}
@Test
fun testSerialization() {
val preferences = ReadingPreferences(
fontSize = FontSize.LARGE,
lineHeight = LineHeight.RELAXED,
showTableOfContents = true,
showReadingTime = true,
showAuthor = false,
showDate = true
)
val json = adapter.toJson(preferences)
assertNotNull(json)
}
@Test
fun testDeserialization() {
val json = """{
"fontSize": "large",
"lineHeight": "relaxed",
"showTableOfContents": true,
"showReadingTime": true,
"showAuthor": false,
"showDate": true
}"""
val preferences = adapter.fromJson(json)
assertNotNull(preferences)
assertEquals(FontSize.LARGE, preferences?.fontSize)
assertEquals(LineHeight.RELAXED, preferences?.lineHeight)
assertEquals(true, preferences?.showTableOfContents)
assertEquals(true, preferences?.showReadingTime)
assertEquals(false, preferences?.showAuthor)
assertEquals(true, preferences?.showDate)
}
@Test
fun testFontSizeOptions() {
val fontSizes = listOf(
"small" to FontSize.SMALL,
"medium" to FontSize.MEDIUM,
"large" to FontSize.LARGE,
"xlarge" to FontSize.XLARGE
)
for ((jsonValue, expectedEnum) in fontSizes) {
val json = """{"fontSize": "$jsonValue"}"""
val preferences = adapter.fromJson(json)
assertNotNull("Failed for fontSize: $jsonValue", preferences)
assertEquals("Failed for fontSize: $jsonValue", expectedEnum, preferences?.fontSize)
}
}
@Test
fun testLineHeightOptions() {
val lineHeights = listOf(
"normal" to LineHeight.NORMAL,
"relaxed" to LineHeight.RELAXED,
"loose" to LineHeight.LOOSE
)
for ((jsonValue, expectedEnum) in lineHeights) {
val json = """{"lineHeight": "$jsonValue"}"""
val preferences = adapter.fromJson(json)
assertNotNull("Failed for lineHeight: $jsonValue", preferences)
assertEquals("Failed for lineHeight: $jsonValue", expectedEnum, preferences?.lineHeight)
}
}
@Test
fun testDefaultValues() {
val preferences = ReadingPreferences()
assertEquals(FontSize.MEDIUM, preferences.fontSize)
assertEquals(LineHeight.NORMAL, preferences.lineHeight)
assertEquals(false, preferences.showTableOfContents)
assertEquals(true, preferences.showReadingTime)
assertEquals(true, preferences.showAuthor)
assertEquals(true, preferences.showDate)
}
@Test
fun testCopy() {
val original = ReadingPreferences(
fontSize = FontSize.MEDIUM,
showReadingTime = true
)
val modified = original.copy(fontSize = FontSize.XLARGE, showReadingTime = false)
assertEquals(FontSize.XLARGE, modified.fontSize)
assertEquals(false, modified.showReadingTime)
assertEquals(LineHeight.NORMAL, modified.lineHeight)
}
@Test
fun testEqualsAndHashCode() {
val pref1 = ReadingPreferences(fontSize = FontSize.MEDIUM, showReadingTime = true)
val pref2 = ReadingPreferences(fontSize = FontSize.MEDIUM, showReadingTime = true)
val pref3 = ReadingPreferences(fontSize = FontSize.LARGE, showReadingTime = true)
assertEquals(pref1, pref2)
assertEquals(pref1.hashCode(), pref2.hashCode())
assert(pref1 != pref3)
}
@Test
fun testToString() {
val preferences = ReadingPreferences(
fontSize = FontSize.LARGE,
showReadingTime = true
)
val toString = preferences.toString()
assertNotNull(toString)
assert(toString.contains("fontSize"))
assert(toString.contains("showReadingTime"))
}
}

View File

@@ -0,0 +1,156 @@
package com.rssuper.models
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import java.util.Date
class SearchFiltersTest {
private lateinit var moshi: Moshi
private lateinit var adapter: com.squareup.moshi.JsonAdapter<SearchFilters>
@Before
fun setup() {
moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
adapter = moshi.adapter(SearchFilters::class.java)
}
@Test
fun testSerialization() {
val filters = SearchFilters(
dateFrom = Date(1672531200000),
dateTo = Date(1672617600000),
feedIds = listOf("feed-1", "feed-2"),
authors = listOf("John Doe", "Jane Smith"),
contentType = ContentType.ARTICLE,
sortOption = SearchSortOption.DATE_DESC
)
val json = adapter.toJson(filters)
assertNotNull(json)
}
@Test
fun testDeserialization() {
val json = """{
"dateFrom": 1672531200000,
"dateTo": 1672617600000,
"feedIds": ["feed-1", "feed-2"],
"authors": ["John Doe", "Jane Smith"],
"contentType": "article",
"sortOption": "date_desc"
}"""
val filters = adapter.fromJson(json)
assertNotNull(filters)
assertNotNull(filters?.dateFrom)
assertNotNull(filters?.dateTo)
assertEquals(2, filters?.feedIds?.size)
assertEquals(2, filters?.authors?.size)
assertEquals(ContentType.ARTICLE, filters?.contentType)
assertEquals(SearchSortOption.DATE_DESC, filters?.sortOption)
}
@Test
fun testContentTypeAudio() {
val json = """{
"contentType": "audio"
}"""
val filters = adapter.fromJson(json)
assertNotNull(filters)
assertEquals(ContentType.AUDIO, filters?.contentType)
}
@Test
fun testContentTypeVideo() {
val json = """{
"contentType": "video"
}"""
val filters = adapter.fromJson(json)
assertNotNull(filters)
assertEquals(ContentType.VIDEO, filters?.contentType)
}
@Test
fun testSortOptions() {
val sortOptions = listOf(
"relevance" to SearchSortOption.RELEVANCE,
"date_desc" to SearchSortOption.DATE_DESC,
"date_asc" to SearchSortOption.DATE_ASC,
"title_asc" to SearchSortOption.TITLE_ASC,
"title_desc" to SearchSortOption.TITLE_DESC,
"feed_asc" to SearchSortOption.FEED_ASC,
"feed_desc" to SearchSortOption.FEED_DESC
)
for ((jsonValue, expectedEnum) in sortOptions) {
val json = """{"sortOption": "$jsonValue"}"""
val filters = adapter.fromJson(json)
assertNotNull("Failed for sortOption: $jsonValue", filters)
assertEquals("Failed for sortOption: $jsonValue", expectedEnum, filters?.sortOption)
}
}
@Test
fun testOptionalFieldsNull() {
val json = "{}"
val filters = adapter.fromJson(json)
assertNotNull(filters)
assertNull(filters?.dateFrom)
assertNull(filters?.dateTo)
assertNull(filters?.feedIds)
assertNull(filters?.authors)
assertNull(filters?.contentType)
assertEquals(SearchSortOption.RELEVANCE, filters?.sortOption)
}
@Test
fun testCopy() {
val original = SearchFilters(
feedIds = listOf("feed-1"),
sortOption = SearchSortOption.RELEVANCE
)
val modified = original.copy(
feedIds = listOf("feed-1", "feed-2"),
sortOption = SearchSortOption.DATE_DESC
)
assertEquals(2, modified.feedIds?.size)
assertEquals(SearchSortOption.DATE_DESC, modified.sortOption)
}
@Test
fun testEqualsAndHashCode() {
val filters1 = SearchFilters(feedIds = listOf("feed-1"), sortOption = SearchSortOption.RELEVANCE)
val filters2 = SearchFilters(feedIds = listOf("feed-1"), sortOption = SearchSortOption.RELEVANCE)
val filters3 = SearchFilters(feedIds = listOf("feed-2"), sortOption = SearchSortOption.RELEVANCE)
assertEquals(filters1, filters2)
assertEquals(filters1.hashCode(), filters2.hashCode())
assert(filters1 != filters3)
}
@Test
fun testToString() {
val filters = SearchFilters(
feedIds = listOf("feed-1"),
sortOption = SearchSortOption.DATE_DESC
)
val toString = filters.toString()
assertNotNull(toString)
assert(toString.contains("feedIds"))
assert(toString.contains("sortOption"))
}
}

View File

@@ -0,0 +1,153 @@
package com.rssuper.models
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import java.util.Date
class SearchResultTest {
private lateinit var moshi: Moshi
private lateinit var adapter: com.squareup.moshi.JsonAdapter<SearchResult>
@Before
fun setup() {
moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
adapter = moshi.adapter(SearchResult::class.java)
}
@Test
fun testArticleSerialization() {
val result = SearchResult(
id = "article-1",
type = SearchResultType.ARTICLE,
title = "Test Article",
snippet = "This is a snippet",
link = "https://example.com/article",
feedTitle = "Tech News",
published = Date(1672531200000),
score = 0.95
)
val json = adapter.toJson(result)
assertNotNull(json)
}
@Test
fun testFeedSerialization() {
val result = SearchResult(
id = "feed-1",
type = SearchResultType.FEED,
title = "Tech News Feed",
snippet = "Technology news and updates",
link = "https://example.com",
score = 0.85
)
val json = adapter.toJson(result)
assertNotNull(json)
}
@Test
fun testArticleDeserialization() {
val json = """{
"id": "article-1",
"type": "article",
"title": "Test Article",
"snippet": "This is a snippet",
"link": "https://example.com/article",
"feedTitle": "Tech News",
"published": 1672531200000,
"score": 0.95
}"""
val result = adapter.fromJson(json)
assertNotNull(result)
assertEquals("article-1", result?.id)
assertEquals(SearchResultType.ARTICLE, result?.type)
assertEquals("Test Article", result?.title)
assertEquals("This is a snippet", result?.snippet)
}
@Test
fun testFeedDeserialization() {
val json = """{
"id": "feed-1",
"type": "feed",
"title": "Tech News Feed",
"snippet": "Technology news and updates",
"link": "https://example.com",
"score": 0.85
}"""
val result = adapter.fromJson(json)
assertNotNull(result)
assertEquals("feed-1", result?.id)
assertEquals(SearchResultType.FEED, result?.type)
}
@Test
fun testOptionalFieldsNull() {
val json = """{
"id": "article-1",
"type": "article",
"title": "Test Article"
}"""
val result = adapter.fromJson(json)
assertNotNull(result)
assertNull(result?.snippet)
assertNull(result?.link)
assertNull(result?.feedTitle)
assertNull(result?.published)
assertNull(result?.score)
}
@Test
fun testCopy() {
val original = SearchResult(
id = "article-1",
type = SearchResultType.ARTICLE,
title = "Original Title"
)
val modified = original.copy(title = "Modified Title", score = 0.99)
assertEquals("article-1", modified.id)
assertEquals(SearchResultType.ARTICLE, modified.type)
assertEquals("Modified Title", modified.title)
assertEquals(0.99, modified.score, 0.001)
}
@Test
fun testEqualsAndHashCode() {
val result1 = SearchResult(id = "article-1", type = SearchResultType.ARTICLE, title = "Test")
val result2 = SearchResult(id = "article-1", type = SearchResultType.ARTICLE, title = "Test")
val result3 = SearchResult(id = "article-2", type = SearchResultType.ARTICLE, title = "Test")
assertEquals(result1, result2)
assertEquals(result1.hashCode(), result2.hashCode())
assert(result1 != result3)
}
@Test
fun testToString() {
val result = SearchResult(
id = "article-1",
type = SearchResultType.ARTICLE,
title = "Test Article",
score = 0.95
)
val toString = result.toString()
assertNotNull(toString)
assert(toString.contains("id=article-1"))
assert(toString.contains("title=Test Article"))
}
}

View File

@@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.2.0" apply false
id("com.android.library") version "8.2.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.20" apply false
}

Submodule native-route/ios/RSSuper updated: 86e278d272...914c13a734

View File

@@ -0,0 +1,65 @@
project('rssuper-linux', 'vala', 'c',
version: '0.1.0',
default_options: [
'c_std=c11',
'warning_level=3',
'werror=false',
]
)
vala = find_program('valac')
meson_version_check = run_command(vala, '--version', check: true)
# Dependencies
glib_dep = dependency('glib-2.0', version: '>= 2.58')
gio_dep = dependency('gio-2.0', version: '>= 2.58')
json_dep = dependency('json-glib-1.0', version: '>= 1.4')
sqlite_dep = dependency('sqlite3', version: '>= 3.0')
gobject_dep = dependency('gobject-2.0', version: '>= 2.58')
# Source files
models = files(
'src/models/feed-item.vala',
'src/models/feed.vala',
'src/models/feed-subscription.vala',
'src/models/search-result.vala',
'src/models/search-filters.vala',
'src/models/notification-preferences.vala',
'src/models/reading-preferences.vala',
)
# Database files
database = files(
'src/database/database.vala',
'src/database/subscription-store.vala',
'src/database/feed-item-store.vala',
'src/database/search-history-store.vala',
'src/database/sqlite3.vapi',
'src/database/errors.vapi',
)
# Main library
models_lib = library('rssuper-models', models,
dependencies: [glib_dep, gio_dep, json_dep],
install: false
)
# Database library
database_lib = library('rssuper-database', database,
dependencies: [glib_dep, gio_dep, json_dep, sqlite_dep, gobject_dep],
link_with: [models_lib],
install: false,
vala_args: ['--vapidir', 'src/database', '--pkg', 'sqlite3']
)
# Test executable
test_exe = executable('database-tests',
'src/tests/database-tests.vala',
dependencies: [glib_dep, gio_dep, json_dep, sqlite_dep, gobject_dep],
link_with: [models_lib, database_lib],
vala_args: ['--vapidir', 'src/database', '--pkg', 'sqlite3'],
install: false
)
# Test definition
test('database tests', test_exe)

View File

@@ -0,0 +1,213 @@
/*
* Database.vala
*
* Core database connection and migration management for RSSuper Linux.
* Uses SQLite with FTS5 for full-text search capabilities.
*/
/**
* Database - Manages SQLite database connection and migrations
*/
public class RSSuper.Database : Object {
private SQLite.DB db;
private string db_path;
/**
* Current database schema version
*/
public const int CURRENT_VERSION = 1;
/**
* Signal emitted when database is ready
*/
public signal void ready();
/**
* Signal emitted on error
*/
public signal void error(string message);
/**
* Create a new database connection
*
* @param db_path Path to the SQLite database file
*/
public Database(string db_path) throws Error {
this.db_path = db_path;
this.open();
this.migrate();
}
/**
* Open database connection
*/
private void open() throws Error {
var file = File.new_for_path(db_path);
var parent = file.get_parent();
if (parent != null && !parent.query_exists()) {
try {
parent.make_directory_with_parents();
} catch (Error e) {
throw throw new Error.FAILED("Failed to create database directory: %s", e.message);
}
}
int result = SQLite.DB.open(db_path, out db);
if (result != SQLite.SQLITE_OK) {
throw throw new Error.FAILED("Failed to open database: %s".printf(db.errmsg()));
}
execute("PRAGMA foreign_keys = ON;");
execute("PRAGMA journal_mode = WAL;");
debug("Database opened: %s", db_path);
}
/**
* Run database migrations
*/
private void migrate() throws Error {
execute(@"CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);");
int current_version = get_current_version();
debug("Current migration version: %d", current_version);
if (current_version >= CURRENT_VERSION) {
debug("Database is up to date");
return;
}
try {
var schema_path = Path.build_filename(Path.get_dirname(db_path), "schema.sql");
var schema_file = File.new_for_path(schema_path);
if (!schema_file.query_exists()) {
schema_path = "src/database/schema.sql";
schema_file = File.new_for_path(schema_path);
}
if (!schema_file.query_exists()) {
throw throw new Error.FAILED("Schema file not found: %s".printf(schema_path));
}
uint8[] schema_bytes;
GLib.Cancellable? cancellable = null;
string? schema_str = null;
try {
schema_file.load_contents(cancellable, out schema_bytes, out schema_str);
} catch (Error e) {
throw throw new Error.FAILED("Failed to read schema file: %s", e.message);
}
string schema = schema_str ?? (string) schema_bytes;
execute(schema);
execute("INSERT OR REPLACE INTO schema_migrations (version, applied_at) VALUES (" + CURRENT_VERSION.to_string() + ", datetime('now'));");
debug("Database migrated to version %d", CURRENT_VERSION);
} catch (Error e) {
throw throw new Error.FAILED("Migration failed: %s".printf(e.message));
}
}
/**
* Get current migration version
*/
private int get_current_version() throws Error {
try {
SQLite.Stmt stmt;
int result = db.prepare_v2("SELECT COALESCE(MAX(version), 0) FROM schema_migrations;", -1, out stmt, null);
if (result != SQLite.SQLITE_OK) {
throw throw new Error.FAILED("Failed to prepare statement: %s".printf(db.errmsg()));
}
int version = 0;
if (stmt.step() == SQLite.SQLITE_ROW) {
version = stmt.column_int(0);
}
return version;
} catch (Error e) {
throw throw new Error.FAILED("Failed to get migration version: %s".printf(e.message));
}
}
/**
* Execute a SQL statement
*/
public void execute(string sql) throws Error {
string errmsg = null;
int result = db.exec(sql, null, null, out errmsg);
if (result != SQLite.SQLITE_OK) {
throw throw new Error.FAILED("SQL execution failed: %s\nSQL: %s".printf(errmsg, sql));
}
}
/**
* Prepare a SQL statement
*/
public SQLite.Stmt prepare(string sql) throws Error {
SQLite.Stmt stmt;
int result = db.prepare_v2(sql, -1, out stmt, null);
if (result != SQLite.SQLITE_OK) {
throw throw new Error.FAILED("Failed to prepare statement: %s\nSQL: %s".printf(db.errmsg(), sql));
}
return stmt;
}
/**
* Get the database connection handle
*/
public SQLite.DB get_handle() {
return db;
}
/**
* Close database connection
*/
public void close() {
if (db != null) {
db = null;
debug("Database closed: %s", db_path);
}
}
/**
* Begin a transaction
*/
public void begin_transaction() throws Error {
execute("BEGIN TRANSACTION;");
}
/**
* Commit a transaction
*/
public void commit() throws Error {
execute("COMMIT;");
}
/**
* Rollback a transaction
*/
public void rollback() throws Error {
execute("ROLLBACK;");
}
/* Helper to convert GLib.List to array */
private T[] toArray<T>(GLib.List<T> list) {
T[] arr = {};
for (unowned var node = list; node != null; node = node.next) {
arr += node.data;
}
return arr;
}
}

View File

@@ -0,0 +1,416 @@
/*
* FeedItemStore.vala
*
* CRUD operations for feed items with FTS search support.
*/
/**
* FeedItemStore - Manages feed item persistence
*/
public class RSSuper.FeedItemStore : Object {
private Database db;
/**
* Signal emitted when an item is added
*/
public signal void item_added(FeedItem item);
/**
* Signal emitted when an item is updated
*/
public signal void item_updated(FeedItem item);
/**
* Signal emitted when an item is deleted
*/
public signal void item_deleted(string id);
/**
* Create a new feed item store
*/
public FeedItemStore(Database db) {
this.db = db;
}
/**
* Add a new feed item
*/
public FeedItem add(FeedItem item) throws Error {
var stmt = db.prepare(
"INSERT INTO feed_items (id, subscription_id, title, link, description, content, " +
"author, published, updated, categories, enclosure_url, enclosure_type, " +
"enclosure_length, guid, is_read, is_starred) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"
);
stmt.bind_text(1, item.id, -1, null);
stmt.bind_text(2, item.subscription_title ?? "", -1, null);
stmt.bind_text(3, item.title, -1, null);
stmt.bind_text(4, item.link ?? "", -1, null);
stmt.bind_text(5, item.description ?? "", -1, null);
stmt.bind_text(6, item.content ?? "", -1, null);
stmt.bind_text(7, item.author ?? "", -1, null);
stmt.bind_text(8, item.published ?? "", -1, null);
stmt.bind_text(9, item.updated ?? "", -1, null);
stmt.bind_text(10, format_categories(item.categories), -1, null);
stmt.bind_text(11, item.enclosure_url ?? "", -1, null);
stmt.bind_text(12, item.enclosure_type ?? "", -1, null);
stmt.bind_text(13, item.enclosure_length ?? "", -1, null);
stmt.bind_text(14, item.guid ?? "", -1, null);
stmt.bind_int(15, 0); // is_read
stmt.bind_int(16, 0); // is_starred
stmt.step();
debug("Feed item added: %s", item.id);
item_added(item);
return item;
}
/**
* Add multiple items in a batch
*/
public void add_batch(FeedItem[] items) throws Error {
db.begin_transaction();
try {
foreach (var item in items) {
add(item);
}
db.commit();
debug("Batch insert completed: %d items", items.length);
} catch (Error e) {
db.rollback();
throw new DBError.FAILED("Transaction failed: %s".printf(e.message));
}
}
/**
* Get an item by ID
*/
public FeedItem? get_by_id(string id) throws Error {
var stmt = db.prepare(
"SELECT id, subscription_id, title, link, description, content, author, " +
"published, updated, categories, enclosure_url, enclosure_type, " +
"enclosure_length, guid, is_read, is_starred " +
"FROM feed_items WHERE id = ?;"
);
stmt.bind_text(1, id, -1, null);
if (stmt.step() == SQLite.SQLITE_ROW) {
return row_to_item(stmt);
}
return null;
}
/**
* Get items by subscription ID
*/
public FeedItem[] get_by_subscription(string subscription_id) throws Error {
var items = new GLib.List<FeedItem?>();
var stmt = db.prepare(
"SELECT id, subscription_id, title, link, description, content, author, " +
"published, updated, categories, enclosure_url, enclosure_type, " +
"enclosure_length, guid, is_read, is_starred " +
"FROM feed_items WHERE subscription_id = ? " +
"ORDER BY published DESC LIMIT 100;"
);
stmt.bind_text(1, subscription_id, -1, null);
while (stmt.step() == SQLite.SQLITE_ROW) {
var item = row_to_item(stmt);
if (item != null) {
items.append(item);
}
}
return items_to_array(items);
}
/**
* Get all items
*/
public FeedItem[] get_all() throws Error {
var items = new GLib.List<FeedItem?>();
var stmt = db.prepare(
"SELECT id, subscription_id, title, link, description, content, author, " +
"published, updated, categories, enclosure_url, enclosure_type, " +
"enclosure_length, guid, is_read, is_starred " +
"FROM feed_items ORDER BY published DESC LIMIT 1000;"
);
while (stmt.step() == SQLite.SQLITE_ROW) {
var item = row_to_item(stmt);
if (item != null) {
items.append(item);
}
}
return items_to_array(items);
}
/**
* Search items using FTS
*/
public FeedItem[] search(string query, int limit = 50) throws Error {
var items = new GLib.List<FeedItem?>();
var stmt = db.prepare(
"SELECT f.id, f.subscription_id, f.title, f.link, f.description, f.content, " +
"f.author, f.published, f.updated, f.categories, f.enclosure_url, " +
"f.enclosure_type, f.enclosure_length, f.guid, f.is_read, f.is_starred " +
"FROM feed_items_fts t " +
"JOIN feed_items f ON t.rowid = f.rowid " +
"WHERE feed_items_fts MATCH ? " +
"ORDER BY rank " +
"LIMIT ?;"
);
stmt.bind_text(1, query, -1, null);
stmt.bind_int(2, limit);
while (stmt.step() == SQLite.SQLITE_ROW) {
var item = row_to_item(stmt);
if (item != null) {
items.append(item);
}
}
return items_to_array(items);
}
/**
* Mark an item as read
*/
public void mark_as_read(string id) throws Error {
var stmt = db.prepare("UPDATE feed_items SET is_read = 1 WHERE id = ?;");
stmt.bind_text(1, id, -1, null);
stmt.step();
debug("Item marked as read: %s", id);
}
/**
* Mark an item as unread
*/
public void mark_as_unread(string id) throws Error {
var stmt = db.prepare("UPDATE feed_items SET is_read = 0 WHERE id = ?;");
stmt.bind_text(1, id, -1, null);
stmt.step();
debug("Item marked as unread: %s", id);
}
/**
* Mark an item as starred
*/
public void mark_as_starred(string id) throws Error {
var stmt = db.prepare("UPDATE feed_items SET is_starred = 1 WHERE id = ?;");
stmt.bind_text(1, id, -1, null);
stmt.step();
debug("Item starred: %s", id);
}
/**
* Unmark an item from starred
*/
public void unmark_starred(string id) throws Error {
var stmt = db.prepare("UPDATE feed_items SET is_starred = 0 WHERE id = ?;");
stmt.bind_text(1, id, -1, null);
stmt.step();
debug("Item unstarred: %s", id);
}
/**
* Get unread items
*/
public FeedItem[] get_unread() throws Error {
var items = new GLib.List<FeedItem?>();
var stmt = db.prepare(
"SELECT id, subscription_id, title, link, description, content, author, " +
"published, updated, categories, enclosure_url, enclosure_type, " +
"enclosure_length, guid, is_read, is_starred " +
"FROM feed_items WHERE is_read = 0 " +
"ORDER BY published DESC LIMIT 100;"
);
while (stmt.step() == SQLite.SQLITE_ROW) {
var item = row_to_item(stmt);
if (item != null) {
items.append(item);
}
}
return items_to_array(items);
}
/**
* Get starred items
*/
public FeedItem[] get_starred() throws Error {
var items = new GLib.List<FeedItem?>();
var stmt = db.prepare(
"SELECT id, subscription_id, title, link, description, content, author, " +
"published, updated, categories, enclosure_url, enclosure_type, " +
"enclosure_length, guid, is_read, is_starred " +
"FROM feed_items WHERE is_starred = 1 " +
"ORDER BY published DESC LIMIT 100;"
);
while (stmt.step() == SQLite.SQLITE_ROW) {
var item = row_to_item(stmt);
if (item != null) {
items.append(item);
}
}
return items_to_array(items);
}
/**
* Delete an item by ID
*/
public void delete(string id) throws Error {
var stmt = db.prepare("DELETE FROM feed_items WHERE id = ?;");
stmt.bind_text(1, id, -1, null);
stmt.step();
debug("Item deleted: %s", id);
item_deleted(id);
}
/**
* Delete items by subscription ID
*/
public void delete_by_subscription(string subscription_id) throws Error {
var stmt = db.prepare("DELETE FROM feed_items WHERE subscription_id = ?;");
stmt.bind_text(1, subscription_id, -1, null);
stmt.step();
debug("Items deleted for subscription: %s", subscription_id);
}
/**
* Delete old items (keep last N items per subscription)
*/
public void cleanup_old_items(int keep_count = 100) throws Error {
db.begin_transaction();
try {
var stmt = db.prepare(
"DELETE FROM feed_items WHERE id NOT IN (" +
"SELECT id FROM feed_items " +
"ORDER BY published DESC " +
"LIMIT -1 OFFSET ?" +
");"
);
stmt.bind_int(1, keep_count);
stmt.step();
db.commit();
debug("Old items cleaned up, kept %d", keep_count);
} catch (Error e) {
db.rollback();
throw new DBError.FAILED("Transaction failed: %s".printf(e.message));
}
}
/**
* Convert a database row to a FeedItem
*/
private FeedItem? row_to_item(SQLite.Stmt stmt) {
try {
string categories_str = stmt.column_text(9);
string[] categories = parse_categories(categories_str);
var item = new FeedItem.with_values(
stmt.column_text(0), // id
stmt.column_text(2), // title
stmt.column_text(3), // link
stmt.column_text(4), // description
stmt.column_text(5), // content
stmt.column_text(6), // author
stmt.column_text(7), // published
stmt.column_text(8), // updated
categories,
stmt.column_text(10), // enclosure_url
stmt.column_text(11), // enclosure_type
stmt.column_text(12), // enclosure_length
stmt.column_text(13), // guid
stmt.column_text(1) // subscription_id (stored as subscription_title)
);
return item;
} catch (Error e) {
warning("Failed to parse item row: %s", e.message);
return null;
}
}
/**
* Format categories array as JSON string
*/
private string format_categories(string[] categories) {
if (categories.length == 0) {
return "[]";
}
var sb = new StringBuilder();
sb.append("[");
for (var i = 0; i < categories.length; i++) {
if (i > 0) sb.append(",");
sb.append("\"");
sb.append(categories[i]);
sb.append("\"");
}
sb.append("]");
return sb.str;
}
/**
* Parse categories from JSON string
*/
private string[] parse_categories(string json) {
if (json == null || json.length == 0 || json == "[]") {
return {};
}
try {
var parser = new Json.Parser();
if (parser.load_from_data(json)) {
var node = parser.get_root();
if (node.get_node_type() == Json.NodeType.ARRAY) {
var array = node.get_array();
var categories = new string[array.get_length()];
for (var i = 0; i < array.get_length(); i++) {
categories[i] = array.get_string_element(i);
}
return categories;
}
}
} catch (Error e) {
warning("Failed to parse categories: %s", e.message);
}
return {};
}
private FeedItem[] items_to_array(GLib.List<FeedItem?> list) {
FeedItem[] arr = {};
for (unowned var node = list; node != null; node = node.next) {
if (node.data != null) arr += node.data;
}
return arr;
}
}

View File

@@ -0,0 +1,103 @@
-- RSSuper Database Schema
-- SQLite with FTS5 for full-text search
-- Enable foreign keys
PRAGMA foreign_keys = ON;
-- Migration tracking table
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Feed subscriptions table
CREATE TABLE IF NOT EXISTS feed_subscriptions (
id TEXT PRIMARY KEY,
url TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
category TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
fetch_interval INTEGER NOT NULL DEFAULT 60,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_fetched_at TEXT,
next_fetch_at TEXT,
error TEXT,
http_auth_username TEXT,
http_auth_password TEXT
);
-- Feed items table
CREATE TABLE IF NOT EXISTS feed_items (
id TEXT PRIMARY KEY,
subscription_id TEXT NOT NULL,
title TEXT NOT NULL,
link TEXT,
description TEXT,
content TEXT,
author TEXT,
published TEXT,
updated TEXT,
categories TEXT, -- JSON array as text
enclosure_url TEXT,
enclosure_type TEXT,
enclosure_length TEXT,
guid TEXT,
is_read INTEGER NOT NULL DEFAULT 0,
is_starred INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (subscription_id) REFERENCES feed_subscriptions(id) ON DELETE CASCADE
);
-- Create index for feed items
CREATE INDEX IF NOT EXISTS idx_feed_items_subscription ON feed_items(subscription_id);
CREATE INDEX IF NOT EXISTS idx_feed_items_published ON feed_items(published DESC);
CREATE INDEX IF NOT EXISTS idx_feed_items_read ON feed_items(is_read);
CREATE INDEX IF NOT EXISTS idx_feed_items_starred ON feed_items(is_starred);
-- Search history table
CREATE TABLE IF NOT EXISTS search_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
query TEXT NOT NULL,
filters_json TEXT,
sort_option TEXT NOT NULL DEFAULT 'relevance',
page INTEGER NOT NULL DEFAULT 1,
page_size INTEGER NOT NULL DEFAULT 20,
result_count INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_search_history_created ON search_history(created_at DESC);
-- FTS5 virtual table for full-text search on feed items
CREATE VIRTUAL TABLE IF NOT EXISTS feed_items_fts USING fts5(
title,
description,
content,
author,
content='feed_items',
content_rowid='rowid'
);
-- Trigger to keep FTS table in sync on INSERT
CREATE TRIGGER IF NOT EXISTS feed_items_ai AFTER INSERT ON feed_items BEGIN
INSERT INTO feed_items_fts(rowid, title, description, content, author)
VALUES (new.rowid, new.title, new.description, new.content, new.author);
END;
-- Trigger to keep FTS table in sync on DELETE
CREATE TRIGGER IF NOT EXISTS feed_items_ad AFTER DELETE ON feed_items BEGIN
INSERT INTO feed_items_fts(feed_items_fts, rowid, title, description, content, author)
VALUES('delete', old.rowid, old.title, old.description, old.content, old.author);
END;
-- Trigger to keep FTS table in sync on UPDATE
CREATE TRIGGER IF NOT EXISTS feed_items_au AFTER UPDATE ON feed_items BEGIN
INSERT INTO feed_items_fts(feed_items_fts, rowid, title, description, content, author)
VALUES('delete', old.rowid, old.title, old.description, old.content, old.author);
INSERT INTO feed_items_fts(rowid, title, description, content, author)
VALUES (new.rowid, new.title, new.description, new.content, new.author);
END;
-- Initial migration record
INSERT OR IGNORE INTO schema_migrations (version) VALUES (1);

View File

@@ -0,0 +1,171 @@
/*
* SearchHistoryStore.vala
*
* CRUD operations for search history.
*/
/**
* SearchHistoryStore - Manages search history persistence
*/
public class RSSuper.SearchHistoryStore : Object {
private Database db;
/**
* Maximum number of history entries to keep
*/
public int max_entries { get; set; default = 100; }
/**
* Signal emitted when a search is recorded
*/
public signal void search_recorded(SearchQuery query, int result_count);
/**
* Signal emitted when history is cleared
*/
public signal void history_cleared();
/**
* Create a new search history store
*/
public SearchHistoryStore(Database db) {
this.db = db;
}
/**
* Record a search query
*/
public int record_search(SearchQuery query, int result_count = 0) throws Error {
var stmt = db.prepare(
"INSERT INTO search_history (query, filters_json, sort_option, page, page_size, result_count) " +
"VALUES (?, ?, ?, ?, ?, ?);"
);
stmt.bind_text(1, query.query, -1, null);
stmt.bind_text(2, query.filters_json ?? "", -1, null);
stmt.bind_text(3, SearchFilters.sort_option_to_string(query.sort), -1, null);
stmt.bind_int(4, query.page);
stmt.bind_int(5, query.page_size);
stmt.bind_int(6, result_count);
stmt.step();
debug("Search recorded: %s (%d results)", query.query, result_count);
search_recorded(query, result_count);
// Clean up old entries if needed
cleanup_old_entries();
return 0; // Returns the last inserted row ID in SQLite
}
/**
* Get search history
*/
public SearchQuery[] get_history(int limit = 50) throws Error {
var queries = new GLib.List<SearchQuery?>();
var stmt = db.prepare(
"SELECT query, filters_json, sort_option, page, page_size, result_count, created_at " +
"FROM search_history " +
"ORDER BY created_at DESC " +
"LIMIT ?;"
);
stmt.bind_int(1, limit);
while (stmt.step() == SQLite.SQLITE_ROW) {
var query = row_to_query(stmt);
queries.append(query);
}
return queries_to_array(queries);
}
/**
* Get recent searches (last 24 hours)
*/
public SearchQuery[] get_recent() throws Error {
var queries = new GLib.List<SearchQuery?>();
var now = new DateTime.now_local();
var yesterday = now.add_days(-1);
var threshold = yesterday.format("%Y-%m-%dT%H:%M:%S");
var stmt = db.prepare(
"SELECT query, filters_json, sort_option, page, page_size, result_count, created_at " +
"FROM search_history " +
"WHERE created_at >= ? " +
"ORDER BY created_at DESC " +
"LIMIT 20;"
);
stmt.bind_text(1, threshold, -1, null);
while (stmt.step() == SQLite.SQLITE_ROW) {
var query = row_to_query(stmt);
queries.append(query);
}
return queries_to_array(queries);
}
/**
* Delete a search history entry by ID
*/
public void delete(int id) throws Error {
var stmt = db.prepare("DELETE FROM search_history WHERE id = ?;");
stmt.bind_int(1, id);
stmt.step();
debug("Search history entry deleted: %d", id);
}
/**
* Clear all search history
*/
public void clear() throws Error {
var stmt = db.prepare("DELETE FROM search_history;");
stmt.step();
debug("Search history cleared");
history_cleared();
}
/**
* Clear old search history entries
*/
private void cleanup_old_entries() throws Error {
var stmt = db.prepare(
"DELETE FROM search_history WHERE id NOT IN (" +
"SELECT id FROM search_history ORDER BY created_at DESC LIMIT ?" +
");"
);
stmt.bind_int(1, max_entries);
stmt.step();
}
/**
* Convert a database row to a SearchQuery
*/
private SearchQuery row_to_query(SQLite.Stmt stmt) {
string query_str = stmt.column_text(0);
string? filters_json = stmt.column_text(1);
string sort_str = stmt.column_text(2);
int page = stmt.column_int(3);
int page_size = stmt.column_int(4);
return SearchQuery(query_str, page, page_size, filters_json,
SearchFilters.sort_option_from_string(sort_str));
}
private SearchQuery[] queries_to_array(GLib.List<SearchQuery?> list) {
SearchQuery[] arr = {};
for (unowned var node = list; node != null; node = node.next) {
arr += node.data;
}
return arr;
}
}

View File

@@ -0,0 +1,63 @@
/*
* SQLite3 C API bindings for Vala
*/
[CCode (cheader_filename = "sqlite3.h")]
namespace SQLite {
[CCode (cname = "sqlite3", free_function = "sqlite3_close")]
public class DB {
[CCode (cname = "sqlite3_open")]
public static int open(string filename, [CCode (array_length = false)] out DB db);
[CCode (cname = "sqlite3_exec")]
public int exec(string sql, [CCode (array_length = false)] DBCallback callback = null, void* arg = null, [CCode (array_length = false)] out string errmsg = null);
[CCode (cname = "sqlite3_errmsg")]
public unowned string errmsg();
[CCode (cname = "sqlite3_prepare_v2")]
public int prepare_v2(string zSql, int nByte, [CCode (array_length = false)] out Stmt stmt, void* pzTail = null);
}
[CCode (cname = "sqlite3_stmt", free_function = "sqlite3_finalize")]
public class Stmt {
[CCode (cname = "sqlite3_step")]
public int step();
[CCode (cname = "sqlite3_column_count")]
public int column_count();
[CCode (cname = "sqlite3_column_text")]
public unowned string column_text(int i);
[CCode (cname = "sqlite3_column_int")]
public int column_int(int i);
[CCode (cname = "sqlite3_column_double")]
public double column_double(int i);
[CCode (cname = "sqlite3_bind_text")]
public int bind_text(int i, string z, int n, void* x);
[CCode (cname = "sqlite3_bind_int")]
public int bind_int(int i, int val);
[CCode (cname = "sqlite3_bind_double")]
public int bind_double(int i, double val);
[CCode (cname = "sqlite3_bind_null")]
public int bind_null(int i);
}
[CCode (cname = "SQLITE_OK")]
public const int SQLITE_OK;
[CCode (cname = "SQLITE_ROW")]
public const int SQLITE_ROW;
[CCode (cname = "SQLITE_DONE")]
public const int SQLITE_DONE;
[CCode (cname = "SQLITE_ERROR")]
public const int SQLITE_ERROR;
[CCode (simple_type = true)]
public delegate int DBCallback(void* arg, int argc, string[] argv, string[] col_names);
}

View File

@@ -0,0 +1,244 @@
/*
* SubscriptionStore.vala
*
* CRUD operations for feed subscriptions.
*/
/**
* SubscriptionStore - Manages feed subscription persistence
*/
public class RSSuper.SubscriptionStore : Object {
private Database db;
/**
* Signal emitted when a subscription is added
*/
public signal void subscription_added(FeedSubscription subscription);
/**
* Signal emitted when a subscription is updated
*/
public signal void subscription_updated(FeedSubscription subscription);
/**
* Signal emitted when a subscription is deleted
*/
public signal void subscription_deleted(string id);
/**
* Create a new subscription store
*/
public SubscriptionStore(Database db) {
this.db = db;
}
/**
* Add a new subscription
*/
public FeedSubscription add(FeedSubscription subscription) throws Error {
var stmt = db.prepare(
"INSERT INTO feed_subscriptions (id, url, title, category, enabled, fetch_interval, " +
"created_at, updated_at, last_fetched_at, next_fetch_at, error, http_auth_username, http_auth_password) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"
);
stmt.bind_text(1, subscription.id, -1, null);
stmt.bind_text(2, subscription.url, -1, null);
stmt.bind_text(3, subscription.title, -1, null);
stmt.bind_text(4, subscription.category ?? "", -1, null);
stmt.bind_int(5, subscription.enabled ? 1 : 0);
stmt.bind_int(6, subscription.fetch_interval);
stmt.bind_text(7, subscription.created_at, -1, null);
stmt.bind_text(8, subscription.updated_at, -1, null);
stmt.bind_text(9, subscription.last_fetched_at ?? "", -1, null);
stmt.bind_text(10, subscription.next_fetch_at ?? "", -1, null);
stmt.bind_text(11, subscription.error ?? "", -1, null);
stmt.bind_text(12, subscription.http_auth_username ?? "", -1, null);
stmt.bind_text(13, subscription.http_auth_password ?? "", -1, null);
stmt.step();
debug("Subscription added: %s", subscription.id);
subscription_added(subscription);
return subscription;
}
/**
* Get a subscription by ID
*/
public FeedSubscription? get_by_id(string id) throws Error {
var stmt = db.prepare(
"SELECT id, url, title, category, enabled, fetch_interval, created_at, updated_at, " +
"last_fetched_at, next_fetch_at, error, http_auth_username, http_auth_password " +
"FROM feed_subscriptions WHERE id = ?;"
);
stmt.bind_text(1, id, -1, null);
if (stmt.step() == SQLite.SQLITE_ROW) {
return row_to_subscription(stmt);
}
return null;
}
/**
* Get all subscriptions
*/
public FeedSubscription[] get_all() throws Error {
var subscriptions = new GLib.List<FeedSubscription?>();
var stmt = db.prepare(
"SELECT id, url, title, category, enabled, fetch_interval, created_at, updated_at, " +
"last_fetched_at, next_fetch_at, error, http_auth_username, http_auth_password " +
"FROM feed_subscriptions ORDER BY title;"
);
while (stmt.step() == SQLite.SQLITE_ROW) {
var subscription = row_to_subscription(stmt);
if (subscription != null) {
subscriptions.append(subscription);
}
}
return list_to_array(subscriptions);
}
/**
* Update a subscription
*/
public void update(FeedSubscription subscription) throws Error {
var stmt = db.prepare(
"UPDATE feed_subscriptions SET url = ?, title = ?, category = ?, enabled = ?, " +
"fetch_interval = ?, updated_at = ?, last_fetched_at = ?, next_fetch_at = ?, " +
"error = ?, http_auth_username = ?, http_auth_password = ? " +
"WHERE id = ?;"
);
stmt.bind_text(1, subscription.url, -1, null);
stmt.bind_text(2, subscription.title, -1, null);
stmt.bind_text(3, subscription.category ?? "", -1, null);
stmt.bind_int(4, subscription.enabled ? 1 : 0);
stmt.bind_int(5, subscription.fetch_interval);
stmt.bind_text(6, subscription.updated_at, -1, null);
stmt.bind_text(7, subscription.last_fetched_at ?? "", -1, null);
stmt.bind_text(8, subscription.next_fetch_at ?? "", -1, null);
stmt.bind_text(9, subscription.error ?? "", -1, null);
stmt.bind_text(10, subscription.http_auth_username ?? "", -1, null);
stmt.bind_text(11, subscription.http_auth_password ?? "", -1, null);
stmt.bind_text(12, subscription.id, -1, null);
stmt.step();
debug("Subscription updated: %s", subscription.id);
subscription_updated(subscription);
}
/**
* Delete a subscription
*/
public void remove_subscription(string id) throws Error {
var stmt = db.prepare("DELETE FROM feed_subscriptions WHERE id = ?;");
stmt.bind_text(1, id, -1, null);
stmt.step();
debug("Subscription deleted: %s", id);
subscription_deleted(id);
}
/**
* Delete a subscription by object
*/
public void delete_subscription(FeedSubscription subscription) throws Error {
remove_subscription(subscription.id);
}
/**
* Get enabled subscriptions
*/
public FeedSubscription[] get_enabled() throws Error {
var subscriptions = new GLib.List<FeedSubscription?>();
var stmt = db.prepare(
"SELECT id, url, title, category, enabled, fetch_interval, created_at, updated_at, " +
"last_fetched_at, next_fetch_at, error, http_auth_username, http_auth_password " +
"FROM feed_subscriptions WHERE enabled = 1 ORDER BY title;"
);
while (stmt.step() == SQLite.SQLITE_ROW) {
var subscription = row_to_subscription(stmt);
if (subscription != null) {
subscriptions.append(subscription);
}
}
return list_to_array(subscriptions);
}
/**
* Get subscriptions that need fetching
*/
public FeedSubscription[] get_due_for_fetch() throws Error {
var subscriptions = new GLib.List<FeedSubscription?>();
var now = new DateTime.now_local();
var now_str = now.format("%Y-%m-%dT%H:%M:%S");
var stmt = db.prepare(
"SELECT id, url, title, category, enabled, fetch_interval, created_at, updated_at, " +
"last_fetched_at, next_fetch_at, error, http_auth_username, http_auth_password " +
"FROM feed_subscriptions WHERE enabled = 1 AND " +
"(next_fetch_at IS NULL OR next_fetch_at <= ?) " +
"ORDER BY next_fetch_at ASC;"
);
stmt.bind_text(1, now_str, -1, null);
while (stmt.step() == SQLite.SQLITE_ROW) {
var subscription = row_to_subscription(stmt);
if (subscription != null) {
subscriptions.append(subscription);
}
}
return list_to_array(subscriptions);
}
/**
* Convert a database row to a FeedSubscription
*/
private FeedSubscription? row_to_subscription(SQLite.Stmt stmt) {
try {
var subscription = new FeedSubscription.with_values(
stmt.column_text(0), // id
stmt.column_text(1), // url
stmt.column_text(2), // title
stmt.column_int(5), // fetch_interval
stmt.column_text(3), // category
stmt.column_int(4) == 1, // enabled
stmt.column_text(6), // created_at
stmt.column_text(7), // updated_at
stmt.column_text(8), // last_fetched_at
stmt.column_text(9), // next_fetch_at
stmt.column_text(10), // error
stmt.column_text(11), // http_auth_username
stmt.column_text(12) // http_auth_password
);
return subscription;
} catch (Error e) {
warning("Failed to parse subscription row: %s", e.message);
return null;
}
}
private FeedSubscription[] list_to_array(GLib.List<FeedSubscription?> list) {
FeedSubscription[] arr = {};
for (unowned var node = list; node != null; node = node.next) {
if (node.data != null) arr += node.data;
}
return arr;
}
}

View File

@@ -0,0 +1,313 @@
/*
* FeedItem.vala
*
* Represents a single RSS/Atom feed item (article, episode, etc.)
* Following GNOME HIG naming conventions and Vala/GObject patterns.
*/
/**
* Enclosure metadata for media attachments (podcasts, videos, etc.)
*/
public struct RSSuper.Enclosure {
public string url { get; set; }
public string item_type { get; set; }
public string? length { get; set; }
public Enclosure(string url, string type, string? length = null) {
this.url = url;
this.item_type = type;
this.length = length;
}
}
/**
* FeedItem - Represents a single RSS/Atom entry
*/
public class RSSuper.FeedItem : Object {
public string id { get; set; }
public string title { get; set; }
public string? link { get; set; }
public string? description { get; set; }
public string? content { get; set; }
public string? author { get; set; }
public string? published { get; set; }
public string? updated { get; set; }
public string[] categories { get; set; }
public string? enclosure_url { get; set; }
public string? enclosure_type { get; set; }
public string? enclosure_length { get; set; }
public string? guid { get; set; }
public string? subscription_title { get; set; }
/**
* Default constructor
*/
public FeedItem() {
this.id = "";
this.title = "";
this.categories = {};
}
/**
* Constructor with initial values
*/
public FeedItem.with_values(string id, string title, string? link = null,
string? description = null, string? content = null,
string? author = null, string? published = null,
string? updated = null, string[]? categories = null,
string? enclosure_url = null, string? enclosure_type = null,
string? enclosure_length = null, string? guid = null,
string? subscription_title = null) {
this.id = id;
this.title = title;
this.link = link;
this.description = description;
this.content = content;
this.author = author;
this.published = published;
this.updated = updated;
this.categories = categories;
this.enclosure_url = enclosure_url;
this.enclosure_type = enclosure_type;
this.enclosure_length = enclosure_length;
this.guid = guid;
this.subscription_title = subscription_title;
}
/**
* Get enclosure as struct
*/
public Enclosure? get_enclosure() {
if (this.enclosure_url == null) {
return null;
}
return Enclosure(this.enclosure_url, this.enclosure_type ?? "", this.enclosure_length);
}
/**
* Set enclosure from struct
*/
public void set_enclosure(Enclosure? enclosure) {
if (enclosure == null) {
this.enclosure_url = null;
this.enclosure_type = null;
this.enclosure_length = null;
} else {
this.enclosure_url = enclosure.url;
this.enclosure_type = enclosure_type;
this.enclosure_length = enclosure.length;
}
}
/**
* Serialize to JSON string
*/
public string to_json_string() {
var sb = new StringBuilder();
sb.append("{");
sb.append("\"id\":\"");
sb.append(this.id);
sb.append("\",\"title\":\"");
sb.append(this.title);
sb.append("\"");
if (this.link != null) {
sb.append(",\"link\":\"");
sb.append(this.link);
sb.append("\"");
}
if (this.description != null) {
sb.append(",\"description\":\"");
sb.append(this.description);
sb.append("\"");
}
if (this.content != null) {
sb.append(",\"content\":\"");
sb.append(this.content);
sb.append("\"");
}
if (this.author != null) {
sb.append(",\"author\":\"");
sb.append(this.author);
sb.append("\"");
}
if (this.published != null) {
sb.append(",\"published\":\"");
sb.append(this.published);
sb.append("\"");
}
if (this.updated != null) {
sb.append(",\"updated\":\"");
sb.append(this.updated);
sb.append("\"");
}
if (this.categories.length > 0) {
sb.append(",\"categories\":[");
for (var i = 0; i < this.categories.length; i++) {
if (i > 0) sb.append(",");
sb.append("\"");
sb.append(this.categories[i]);
sb.append("\"");
}
sb.append("]");
}
if (this.enclosure_url != null) {
sb.append(",\"enclosure\":{\"url\":\"");
sb.append(this.enclosure_url);
sb.append("\"");
if (this.enclosure_type != null) {
sb.append(",\"type\":\"");
sb.append(this.enclosure_type);
sb.append("\"");
}
if (this.enclosure_length != null) {
sb.append(",\"length\":\"");
sb.append(this.enclosure_length);
sb.append("\"");
}
sb.append("}");
}
if (this.guid != null) {
sb.append(",\"guid\":\"");
sb.append(this.guid);
sb.append("\"");
}
if (this.subscription_title != null) {
sb.append(",\"subscription_title\":\"");
sb.append(this.subscription_title);
sb.append("\"");
}
sb.append("}");
return sb.str;
}
/**
* Deserialize from JSON string (simple parser)
*/
public static FeedItem? from_json_string(string json_string) {
var parser = new Json.Parser();
try {
if (!parser.load_from_data(json_string)) {
return null;
}
} catch (Error e) {
warning("Failed to parse JSON: %s", e.message);
return null;
}
return from_json_node(parser.get_root());
}
/**
* Deserialize from Json.Node
*/
public static FeedItem? from_json_node(Json.Node node) {
if (node.get_node_type() != Json.NodeType.OBJECT) {
return null;
}
var obj = node.get_object();
if (!obj.has_member("id") || !obj.has_member("title")) {
return null;
}
var item = new FeedItem();
item.id = obj.get_string_member("id");
item.title = obj.get_string_member("title");
if (obj.has_member("link")) {
item.link = obj.get_string_member("link");
}
if (obj.has_member("description")) {
item.description = obj.get_string_member("description");
}
if (obj.has_member("content")) {
item.content = obj.get_string_member("content");
}
if (obj.has_member("author")) {
item.author = obj.get_string_member("author");
}
if (obj.has_member("published")) {
item.published = obj.get_string_member("published");
}
if (obj.has_member("updated")) {
item.updated = obj.get_string_member("updated");
}
if (obj.has_member("categories")) {
var categories_array = obj.get_array_member("categories");
var categories = new string[categories_array.get_length()];
for (var i = 0; i < categories_array.get_length(); i++) {
categories[i] = categories_array.get_string_element(i);
}
item.categories = categories;
}
if (obj.has_member("enclosure")) {
var enclosure_obj = obj.get_object_member("enclosure");
item.enclosure_url = enclosure_obj.get_string_member("url");
if (enclosure_obj.has_member("type")) {
item.enclosure_type = enclosure_obj.get_string_member("type");
}
if (enclosure_obj.has_member("length")) {
item.enclosure_length = enclosure_obj.get_string_member("length");
}
}
if (obj.has_member("guid")) {
item.guid = obj.get_string_member("guid");
}
if (obj.has_member("subscription_title")) {
item.subscription_title = obj.get_string_member("subscription_title");
}
return item;
}
/**
* Equality comparison
*/
public bool equals(FeedItem? other) {
if (other == null) {
return false;
}
return this.id == other.id &&
this.title == other.title &&
this.link == other.link &&
this.description == other.description &&
this.content == other.content &&
this.author == other.author &&
this.published == other.published &&
this.updated == other.updated &&
this.categories_equal(other.categories) &&
this.enclosure_url == other.enclosure_url &&
this.enclosure_type == other.enclosure_type &&
this.enclosure_length == other.enclosure_length &&
this.guid == other.guid &&
this.subscription_title == other.subscription_title;
}
/**
* Helper for category array comparison
*/
private bool categories_equal(string[] other) {
if (this.categories.length != other.length) {
return false;
}
for (var i = 0; i < this.categories.length; i++) {
if (this.categories[i] != other[i]) {
return false;
}
}
return true;
}
/**
* Get a human-readable summary
*/
public string get_summary() {
return "%s by %s".printf(this.title, this.author ?? "Unknown");
}
}

View File

@@ -0,0 +1,259 @@
/*
* FeedSubscription.vala
*
* Represents a user's subscription to a feed with sync settings.
* Following GNOME HIG naming conventions and Vala/GObject patterns.
*/
/**
* HTTP Authentication credentials
*/
public struct RSSuper.HttpAuth {
public string username { get; set; }
public string password { get; set; }
public HttpAuth(string username, string password) {
this.username = username;
this.password = password;
}
}
/**
* FeedSubscription - Represents a user's subscription to a feed
*/
public class RSSuper.FeedSubscription : Object {
public string id { get; set; }
public string url { get; set; }
public string title { get; set; }
public string? category { get; set; }
public bool enabled { get; set; }
public int fetch_interval { get; set; }
public string created_at { get; set; }
public string updated_at { get; set; }
public string? last_fetched_at { get; set; }
public string? next_fetch_at { get; set; }
public string? error { get; set; }
public string? http_auth_username { get; set; }
public string? http_auth_password { get; set; }
/**
* Default constructor
*/
public FeedSubscription() {
this.id = "";
this.url = "";
this.title = "";
this.enabled = true;
this.fetch_interval = 60;
this.created_at = "";
this.updated_at = "";
}
/**
* Constructor with initial values
*/
public FeedSubscription.with_values(string id, string url, string title,
int fetch_interval = 60,
string? category = null, bool enabled = true,
string? created_at = null, string? updated_at = null,
string? last_fetched_at = null,
string? next_fetch_at = null,
string? error = null,
string? http_auth_username = null,
string? http_auth_password = null) {
this.id = id;
this.url = url;
this.title = title;
this.category = category;
this.enabled = enabled;
this.fetch_interval = fetch_interval;
this.created_at = created_at ?? "";
this.updated_at = updated_at ?? "";
this.last_fetched_at = last_fetched_at;
this.next_fetch_at = next_fetch_at;
this.error = error;
this.http_auth_username = http_auth_username;
this.http_auth_password = http_auth_password;
}
/**
* Get HTTP auth as struct
*/
public HttpAuth? get_http_auth() {
if (this.http_auth_username == null) {
return null;
}
return HttpAuth(this.http_auth_username, this.http_auth_password ?? "");
}
/**
* Set HTTP auth from struct
*/
public void set_http_auth(HttpAuth? auth) {
if (auth == null) {
this.http_auth_username = null;
this.http_auth_password = null;
} else {
this.http_auth_username = auth.username;
this.http_auth_password = auth.password;
}
}
/**
* Check if subscription has an error
*/
public bool has_error() {
return this.error != null && this.error.length > 0;
}
/**
* Serialize to JSON string
*/
public string to_json_string() {
var sb = new StringBuilder();
sb.append("{");
sb.append("\"id\":\"");
sb.append(this.id);
sb.append("\",\"url\":\"");
sb.append(this.url);
sb.append("\",\"title\":\"");
sb.append(this.title);
sb.append("\",\"enabled\":");
sb.append(this.enabled ? "true" : "false");
sb.append(",\"fetchInterval\":%d".printf(this.fetch_interval));
sb.append(",\"createdAt\":\"");
sb.append(this.created_at);
sb.append("\",\"updatedAt\":\"");
sb.append(this.updated_at);
sb.append("\"");
if (this.category != null) {
sb.append(",\"category\":\"");
sb.append(this.category);
sb.append("\"");
}
if (this.last_fetched_at != null) {
sb.append(",\"lastFetchedAt\":\"");
sb.append(this.last_fetched_at);
sb.append("\"");
}
if (this.next_fetch_at != null) {
sb.append(",\"nextFetchAt\":\"");
sb.append(this.next_fetch_at);
sb.append("\"");
}
if (this.error != null) {
sb.append(",\"error\":\"");
sb.append(this.error);
sb.append("\"");
}
if (this.http_auth_username != null) {
sb.append(",\"httpAuth\":{\"username\":\"");
sb.append(this.http_auth_username);
sb.append("\"");
if (this.http_auth_password != null) {
sb.append(",\"password\":\"");
sb.append(this.http_auth_password);
sb.append("\"");
}
sb.append("}");
}
sb.append("}");
return sb.str;
}
/**
* Deserialize from JSON string
*/
public static FeedSubscription? from_json_string(string json_string) {
var parser = new Json.Parser();
try {
if (!parser.load_from_data(json_string)) {
return null;
}
} catch (Error e) {
warning("Failed to parse JSON: %s", e.message);
return null;
}
return from_json_node(parser.get_root());
}
/**
* Deserialize from Json.Node
*/
public static FeedSubscription? from_json_node(Json.Node node) {
if (node.get_node_type() != Json.NodeType.OBJECT) {
return null;
}
var obj = node.get_object();
if (!obj.has_member("id") || !obj.has_member("url") ||
!obj.has_member("title") || !obj.has_member("createdAt") ||
!obj.has_member("updatedAt")) {
return null;
}
var subscription = new FeedSubscription();
subscription.id = obj.get_string_member("id");
subscription.url = obj.get_string_member("url");
subscription.title = obj.get_string_member("title");
if (obj.has_member("category")) {
subscription.category = obj.get_string_member("category");
}
if (obj.has_member("enabled")) {
subscription.enabled = obj.get_boolean_member("enabled");
}
if (obj.has_member("fetchInterval")) {
subscription.fetch_interval = (int)obj.get_int_member("fetchInterval");
}
subscription.created_at = obj.get_string_member("createdAt");
subscription.updated_at = obj.get_string_member("updatedAt");
if (obj.has_member("lastFetchedAt")) {
subscription.last_fetched_at = obj.get_string_member("lastFetchedAt");
}
if (obj.has_member("nextFetchAt")) {
subscription.next_fetch_at = obj.get_string_member("nextFetchAt");
}
if (obj.has_member("error")) {
subscription.error = obj.get_string_member("error");
}
if (obj.has_member("httpAuth")) {
var auth_obj = obj.get_object_member("httpAuth");
subscription.http_auth_username = auth_obj.get_string_member("username");
if (auth_obj.has_member("password")) {
subscription.http_auth_password = auth_obj.get_string_member("password");
}
}
return subscription;
}
/**
* Equality comparison
*/
public bool equals(FeedSubscription? other) {
if (other == null) {
return false;
}
return this.id == other.id &&
this.url == other.url &&
this.title == other.title &&
this.category == other.category &&
this.enabled == other.enabled &&
this.fetch_interval == other.fetch_interval &&
this.created_at == other.created_at &&
this.updated_at == other.updated_at &&
this.last_fetched_at == other.last_fetched_at &&
this.next_fetch_at == other.next_fetch_at &&
this.error == other.error &&
this.http_auth_username == other.http_auth_username &&
this.http_auth_password == other.http_auth_password;
}
}

View File

@@ -0,0 +1,282 @@
/*
* Feed.vala
*
* Represents an RSS/Atom feed with its metadata and items.
* Following GNOME HIG naming conventions and Vala/GObject patterns.
*/
/**
* Feed - Represents an RSS/Atom feed
*/
public class RSSuper.Feed : Object {
public string id { get; set; }
public string title { get; set; }
public string? link { get; set; }
public string? description { get; set; }
public string? subtitle { get; set; }
public string? language { get; set; }
public string? last_build_date { get; set; }
public string? updated { get; set; }
public string? generator { get; set; }
public int ttl { get; set; }
public string raw_url { get; set; }
public string? last_fetched_at { get; set; }
public string? next_fetch_at { get; set; }
public FeedItem[] items { get; set; }
/**
* Default constructor
*/
public Feed() {
this.id = "";
this.title = "";
this.raw_url = "";
this.ttl = 60;
this.items = {};
}
/**
* Constructor with initial values
*/
public Feed.with_values(string id, string title, string raw_url,
string? link = null, string? description = null,
string? subtitle = null, string? language = null,
string? last_build_date = null, string? updated = null,
string? generator = null, int ttl = 60,
FeedItem[]? items = null, string? last_fetched_at = null,
string? next_fetch_at = null) {
this.id = id;
this.title = title;
this.link = link;
this.description = description;
this.subtitle = subtitle;
this.language = language;
this.last_build_date = last_build_date;
this.updated = updated;
this.generator = generator;
this.ttl = ttl;
this.items = items;
this.raw_url = raw_url;
this.last_fetched_at = last_fetched_at;
this.next_fetch_at = next_fetch_at;
}
/**
* Add an item to the feed
*/
public void add_item(FeedItem item) {
var new_items = new FeedItem[this.items.length + 1];
for (var i = 0; i < this.items.length; i++) {
new_items[i] = this.items[i];
}
new_items[this.items.length] = item;
this.items = new_items;
}
/**
* Get item count
*/
public int get_item_count() {
return this.items.length;
}
/**
* Serialize to JSON string
*/
public string to_json_string() {
var sb = new StringBuilder();
sb.append("{");
sb.append("\"id\":\"");
sb.append(this.id);
sb.append("\",\"title\":\"");
sb.append(this.title);
sb.append("\",\"raw_url\":\"");
sb.append(this.raw_url);
sb.append("\"");
if (this.link != null) {
sb.append(",\"link\":\"");
sb.append(this.link);
sb.append("\"");
}
if (this.description != null) {
sb.append(",\"description\":\"");
sb.append(this.description);
sb.append("\"");
}
if (this.subtitle != null) {
sb.append(",\"subtitle\":\"");
sb.append(this.subtitle);
sb.append("\"");
}
if (this.language != null) {
sb.append(",\"language\":\"");
sb.append(this.language);
sb.append("\"");
}
if (this.last_build_date != null) {
sb.append(",\"lastBuildDate\":\"");
sb.append(this.last_build_date);
sb.append("\"");
}
if (this.updated != null) {
sb.append(",\"updated\":\"");
sb.append(this.updated);
sb.append("\"");
}
if (this.generator != null) {
sb.append(",\"generator\":\"");
sb.append(this.generator);
sb.append("\"");
}
if (this.ttl != 60) {
sb.append(",\"ttl\":%d".printf(this.ttl));
}
if (this.items.length > 0) {
sb.append(",\"items\":[");
for (var i = 0; i < this.items.length; i++) {
if (i > 0) sb.append(",");
sb.append(this.items[i].to_json_string());
}
sb.append("]");
}
if (this.last_fetched_at != null) {
sb.append(",\"lastFetchedAt\":\"");
sb.append(this.last_fetched_at);
sb.append("\"");
}
if (this.next_fetch_at != null) {
sb.append(",\"nextFetchAt\":\"");
sb.append(this.next_fetch_at);
sb.append("\"");
}
sb.append("}");
return sb.str;
}
/**
* Deserialize from JSON string
*/
public static Feed? from_json_string(string json_string) {
var parser = new Json.Parser();
try {
if (!parser.load_from_data(json_string)) {
return null;
}
} catch (Error e) {
warning("Failed to parse JSON: %s", e.message);
return null;
}
return from_json_node(parser.get_root());
}
/**
* Deserialize from Json.Node
*/
public static Feed? from_json_node(Json.Node node) {
if (node.get_node_type() != Json.NodeType.OBJECT) {
return null;
}
var obj = node.get_object();
if (!obj.has_member("id") || !obj.has_member("title") || !obj.has_member("raw_url")) {
return null;
}
var feed = new Feed();
feed.id = obj.get_string_member("id");
feed.title = obj.get_string_member("title");
feed.raw_url = obj.get_string_member("raw_url");
if (obj.has_member("link")) {
feed.link = obj.get_string_member("link");
}
if (obj.has_member("description")) {
feed.description = obj.get_string_member("description");
}
if (obj.has_member("subtitle")) {
feed.subtitle = obj.get_string_member("subtitle");
}
if (obj.has_member("language")) {
feed.language = obj.get_string_member("language");
}
if (obj.has_member("lastBuildDate")) {
feed.last_build_date = obj.get_string_member("lastBuildDate");
}
if (obj.has_member("updated")) {
feed.updated = obj.get_string_member("updated");
}
if (obj.has_member("generator")) {
feed.generator = obj.get_string_member("generator");
}
if (obj.has_member("ttl")) {
feed.ttl = (int)obj.get_int_member("ttl");
}
if (obj.has_member("lastFetchedAt")) {
feed.last_fetched_at = obj.get_string_member("lastFetchedAt");
}
if (obj.has_member("nextFetchAt")) {
feed.next_fetch_at = obj.get_string_member("nextFetchAt");
}
// Deserialize items
if (obj.has_member("items")) {
var items_array = obj.get_array_member("items");
var items = new FeedItem[items_array.get_length()];
for (var i = 0; i < items_array.get_length(); i++) {
var item_node = items_array.get_element(i);
var item = FeedItem.from_json_node(item_node);
if (item != null) {
items[i] = item;
}
}
feed.items = items;
}
return feed;
}
/**
* Equality comparison
*/
public bool equals(Feed? other) {
if (other == null) {
return false;
}
return this.id == other.id &&
this.title == other.title &&
this.link == other.link &&
this.description == other.description &&
this.subtitle == other.subtitle &&
this.language == other.language &&
this.last_build_date == other.last_build_date &&
this.updated == other.updated &&
this.generator == other.generator &&
this.ttl == other.ttl &&
this.raw_url == other.raw_url &&
this.last_fetched_at == other.last_fetched_at &&
this.next_fetch_at == other.next_fetch_at &&
this.items_equal(other.items);
}
/**
* Helper for item array comparison
*/
private bool items_equal(FeedItem[] other) {
if (this.items.length != other.length) {
return false;
}
for (var i = 0; i < this.items.length; i++) {
if (!this.items[i].equals(other[i])) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,5 @@
/*
* Namespace definition for RSSuper Linux models
*/
public namespace RSSuper {
}

View File

@@ -0,0 +1,190 @@
/*
* NotificationPreferences.vala
*
* Represents user notification preferences.
* Following GNOME HIG naming conventions and Vala/GObject patterns.
*/
/**
* NotificationPreferences - User notification settings
*/
public class RSSuper.NotificationPreferences : Object {
public bool new_articles { get; set; }
public bool episode_releases { get; set; }
public bool custom_alerts { get; set; }
public bool badge_count { get; set; }
public bool sound { get; set; }
public bool vibration { get; set; }
/**
* Default constructor (all enabled by default)
*/
public NotificationPreferences() {
this.new_articles = true;
this.episode_releases = true;
this.custom_alerts = true;
this.badge_count = true;
this.sound = true;
this.vibration = true;
}
/**
* Constructor with initial values
*/
public NotificationPreferences.with_values(bool new_articles = true,
bool episode_releases = true,
bool custom_alerts = true,
bool badge_count = true,
bool sound = true,
bool vibration = true) {
this.new_articles = new_articles;
this.episode_releases = episode_releases;
this.custom_alerts = custom_alerts;
this.badge_count = badge_count;
this.sound = sound;
this.vibration = vibration;
}
/**
* Enable all notifications
*/
public void enable_all() {
this.new_articles = true;
this.episode_releases = true;
this.custom_alerts = true;
this.badge_count = true;
this.sound = true;
this.vibration = true;
}
/**
* Disable all notifications
*/
public void disable_all() {
this.new_articles = false;
this.episode_releases = false;
this.custom_alerts = false;
this.badge_count = false;
this.sound = false;
this.vibration = false;
}
/**
* Check if any notifications are enabled
*/
public bool has_any_enabled() {
return this.new_articles ||
this.episode_releases ||
this.custom_alerts ||
this.badge_count ||
this.sound ||
this.vibration;
}
/**
* Check if content notifications are enabled
*/
public bool has_content_notifications() {
return this.new_articles || this.episode_releases || this.custom_alerts;
}
/**
* Serialize to JSON string
*/
public string to_json_string() {
var sb = new StringBuilder();
sb.append("{");
sb.append("\"newArticles\":");
sb.append(this.new_articles ? "true" : "false");
sb.append(",\"episodeReleases\":");
sb.append(this.episode_releases ? "true" : "false");
sb.append(",\"customAlerts\":");
sb.append(this.custom_alerts ? "true" : "false");
sb.append(",\"badgeCount\":");
sb.append(this.badge_count ? "true" : "false");
sb.append(",\"sound\":");
sb.append(this.sound ? "true" : "false");
sb.append(",\"vibration\":");
sb.append(this.vibration ? "true" : "false");
sb.append("}");
return sb.str;
}
/**
* Deserialize from JSON string
*/
public static NotificationPreferences? from_json_string(string json_string) {
var parser = new Json.Parser();
try {
if (!parser.load_from_data(json_string)) {
return null;
}
} catch (Error e) {
warning("Failed to parse JSON: %s", e.message);
return null;
}
return from_json_node(parser.get_root());
}
/**
* Deserialize from Json.Node
*/
public static NotificationPreferences? from_json_node(Json.Node node) {
if (node.get_node_type() != Json.NodeType.OBJECT) {
return null;
}
var obj = node.get_object();
var prefs = new NotificationPreferences();
if (obj.has_member("newArticles")) {
prefs.new_articles = obj.get_boolean_member("newArticles");
}
if (obj.has_member("episodeReleases")) {
prefs.episode_releases = obj.get_boolean_member("episodeReleases");
}
if (obj.has_member("customAlerts")) {
prefs.custom_alerts = obj.get_boolean_member("customAlerts");
}
if (obj.has_member("badgeCount")) {
prefs.badge_count = obj.get_boolean_member("badgeCount");
}
if (obj.has_member("sound")) {
prefs.sound = obj.get_boolean_member("sound");
}
if (obj.has_member("vibration")) {
prefs.vibration = obj.get_boolean_member("vibration");
}
return prefs;
}
/**
* Equality comparison
*/
public bool equals(NotificationPreferences? other) {
if (other == null) {
return false;
}
return this.new_articles == other.new_articles &&
this.episode_releases == other.episode_releases &&
this.custom_alerts == other.custom_alerts &&
this.badge_count == other.badge_count &&
this.sound == other.sound &&
this.vibration == other.vibration;
}
/**
* Copy preferences from another instance
*/
public void copy_from(NotificationPreferences other) {
this.new_articles = other.new_articles;
this.episode_releases = other.episode_releases;
this.custom_alerts = other.custom_alerts;
this.badge_count = other.badge_count;
this.sound = other.sound;
this.vibration = other.vibration;
}
}

View File

@@ -0,0 +1,168 @@
/*
* ReadingPreferences.vala
*
* Represents user reading/display preferences.
* Following GNOME HIG naming conventions and Vala/GObject patterns.
*/
/**
* FontSize - Available font size options
*/
public enum RSSuper.FontSize {
SMALL,
MEDIUM,
LARGE,
XLARGE
}
/**
* LineHeight - Available line height options
*/
public enum RSSuper.LineHeight {
NORMAL,
RELAXED,
LOOSE
}
/**
* ReadingPreferences - User reading/display settings
*/
public class RSSuper.ReadingPreferences : Object {
public FontSize font_size { get; set; }
public LineHeight line_height { get; set; }
public bool show_table_of_contents { get; set; }
public bool show_reading_time { get; set; }
public bool show_author { get; set; }
public bool show_date { get; set; }
public ReadingPreferences() {
this.font_size = FontSize.MEDIUM;
this.line_height = LineHeight.NORMAL;
this.show_table_of_contents = true;
this.show_reading_time = true;
this.show_author = true;
this.show_date = true;
}
public ReadingPreferences.with_values(FontSize font_size = FontSize.MEDIUM,
LineHeight line_height = LineHeight.NORMAL,
bool show_table_of_contents = true,
bool show_reading_time = true,
bool show_author = true,
bool show_date = true) {
this.font_size = font_size;
this.line_height = line_height;
this.show_table_of_contents = show_table_of_contents;
this.show_reading_time = show_reading_time;
this.show_author = show_author;
this.show_date = show_date;
}
public string get_font_size_string() {
switch (this.font_size) {
case FontSize.SMALL: return "small";
case FontSize.MEDIUM: return "medium";
case FontSize.LARGE: return "large";
case FontSize.XLARGE: return "xlarge";
default: return "medium";
}
}
public static FontSize font_size_from_string(string str) {
switch (str) {
case "small": return FontSize.SMALL;
case "medium": return FontSize.MEDIUM;
case "large": return FontSize.LARGE;
case "xlarge": return FontSize.XLARGE;
default: return FontSize.MEDIUM;
}
}
public string get_line_height_string() {
switch (this.line_height) {
case LineHeight.NORMAL: return "normal";
case LineHeight.RELAXED: return "relaxed";
case LineHeight.LOOSE: return "loose";
default: return "normal";
}
}
public static LineHeight line_height_from_string(string str) {
switch (str) {
case "normal": return LineHeight.NORMAL;
case "relaxed": return LineHeight.RELAXED;
case "loose": return LineHeight.LOOSE;
default: return LineHeight.NORMAL;
}
}
public void reset_to_defaults() {
this.font_size = FontSize.MEDIUM;
this.line_height = LineHeight.NORMAL;
this.show_table_of_contents = true;
this.show_reading_time = true;
this.show_author = true;
this.show_date = true;
}
public string to_json_string() {
var sb = new StringBuilder();
sb.append("{\"fontSize\":\"");
sb.append(this.get_font_size_string());
sb.append("\",\"lineHeight\":\"");
sb.append(this.get_line_height_string());
sb.append("\",\"showTableOfContents\":");
sb.append(this.show_table_of_contents ? "true" : "false");
sb.append(",\"showReadingTime\":");
sb.append(this.show_reading_time ? "true" : "false");
sb.append(",\"showAuthor\":");
sb.append(this.show_author ? "true" : "false");
sb.append(",\"showDate\":");
sb.append(this.show_date ? "true" : "false");
sb.append("}");
return sb.str;
}
public static ReadingPreferences? from_json_string(string json_string) {
var parser = new Json.Parser();
try {
if (!parser.load_from_data(json_string)) return null;
} catch (Error e) {
warning("Failed to parse JSON: %s", e.message);
return null;
}
return from_json_node(parser.get_root());
}
public static ReadingPreferences? from_json_node(Json.Node node) {
if (node.get_node_type() != Json.NodeType.OBJECT) return null;
var obj = node.get_object();
var prefs = new ReadingPreferences();
if (obj.has_member("fontSize")) prefs.font_size = font_size_from_string(obj.get_string_member("fontSize"));
if (obj.has_member("lineHeight")) prefs.line_height = line_height_from_string(obj.get_string_member("lineHeight"));
if (obj.has_member("showTableOfContents")) prefs.show_table_of_contents = obj.get_boolean_member("showTableOfContents");
if (obj.has_member("showReadingTime")) prefs.show_reading_time = obj.get_boolean_member("showReadingTime");
if (obj.has_member("showAuthor")) prefs.show_author = obj.get_boolean_member("showAuthor");
if (obj.has_member("showDate")) prefs.show_date = obj.get_boolean_member("showDate");
return prefs;
}
public bool equals(ReadingPreferences? other) {
if (other == null) return false;
return this.font_size == other.font_size &&
this.line_height == other.line_height &&
this.show_table_of_contents == other.show_table_of_contents &&
this.show_reading_time == other.show_reading_time &&
this.show_author == other.show_author &&
this.show_date == other.show_date;
}
public void copy_from(ReadingPreferences other) {
this.font_size = other.font_size;
this.line_height = other.line_height;
this.show_table_of_contents = other.show_table_of_contents;
this.show_reading_time = other.show_reading_time;
this.show_author = other.show_author;
this.show_date = other.show_date;
}
}

View File

@@ -0,0 +1,435 @@
/*
* SearchFilters.vala
*
* Represents search query parameters and filters.
* Following GNOME HIG naming conventions and Vala/GObject patterns.
*/
/**
* SearchContentType - Type of content to search for
*/
public enum RSSuper.SearchContentType {
ARTICLE,
AUDIO,
VIDEO
}
/**
* SearchSortOption - Sorting options for search results
*/
public enum RSSuper.SearchSortOption {
RELEVANCE,
DATE_DESC,
DATE_ASC,
TITLE_ASC,
TITLE_DESC,
FEED_ASC,
FEED_DESC
}
/**
* SearchFilters - Represents search filters and query parameters
*/
public struct RSSuper.SearchFilters {
public string? date_from { get; set; }
public string? date_to { get; set; }
public string[] feed_ids { get; set; }
public string[] authors { get; set; }
public SearchContentType? content_type { get; set; }
/**
* Default constructor
*/
public SearchFilters(string? date_from = null, string? date_to = null,
string[]? feed_ids = null, string[]? authors = null,
SearchContentType? content_type = null) {
this.date_from = date_from;
this.date_to = date_to;
this.feed_ids = feed_ids;
this.authors = authors;
this.content_type = content_type;
}
/**
* Get content type as string
*/
public string? get_content_type_string() {
if (this.content_type == null) {
return null;
}
switch (this.content_type) {
case SearchContentType.ARTICLE:
return "article";
case SearchContentType.AUDIO:
return "audio";
case SearchContentType.VIDEO:
return "video";
default:
return null;
}
}
/**
* Parse content type from string
*/
public static SearchContentType? content_type_from_string(string? str) {
if (str == null) {
return null;
}
switch (str) {
case "article":
return SearchContentType.ARTICLE;
case "audio":
return SearchContentType.AUDIO;
case "video":
return SearchContentType.VIDEO;
default:
return null;
}
}
/**
* Get sort option as string
*/
public static string sort_option_to_string(SearchSortOption option) {
switch (option) {
case SearchSortOption.RELEVANCE:
return "relevance";
case SearchSortOption.DATE_DESC:
return "date_desc";
case SearchSortOption.DATE_ASC:
return "date_asc";
case SearchSortOption.TITLE_ASC:
return "title_asc";
case SearchSortOption.TITLE_DESC:
return "title_desc";
case SearchSortOption.FEED_ASC:
return "feed_asc";
case SearchSortOption.FEED_DESC:
return "feed_desc";
default:
return "relevance";
}
}
/**
* Parse sort option from string
*/
public static SearchSortOption sort_option_from_string(string str) {
switch (str) {
case "relevance":
return SearchSortOption.RELEVANCE;
case "date_desc":
return SearchSortOption.DATE_DESC;
case "date_asc":
return SearchSortOption.DATE_ASC;
case "title_asc":
return SearchSortOption.TITLE_ASC;
case "title_desc":
return SearchSortOption.TITLE_DESC;
case "feed_asc":
return SearchSortOption.FEED_ASC;
case "feed_desc":
return SearchSortOption.FEED_DESC;
default:
return SearchSortOption.RELEVANCE;
}
}
/**
* Check if any filters are set
*/
public bool has_filters() {
return this.date_from != null ||
this.date_to != null ||
(this.feed_ids != null && this.feed_ids.length > 0) ||
(this.authors != null && this.authors.length > 0) ||
this.content_type != null;
}
/**
* Serialize to JSON string
*/
public string to_json_string() {
var sb = new StringBuilder();
sb.append("{");
var first = true;
if (this.date_from != null) {
sb.append("\"dateFrom\":\"");
sb.append(this.date_from);
sb.append("\"");
first = false;
}
if (this.date_to != null) {
if (!first) sb.append(",");
sb.append("\"dateTo\":\"");
sb.append(this.date_to);
sb.append("\"");
first = false;
}
if (this.feed_ids != null && this.feed_ids.length > 0) {
if (!first) sb.append(",");
sb.append("\"feedIds\":[");
for (var i = 0; i < this.feed_ids.length; i++) {
if (i > 0) sb.append(",");
sb.append("\"");
sb.append(this.feed_ids[i]);
sb.append("\"");
}
sb.append("]");
first = false;
}
if (this.authors != null && this.authors.length > 0) {
if (!first) sb.append(",");
sb.append("\"authors\":[");
for (var i = 0; i < this.authors.length; i++) {
if (i > 0) sb.append(",");
sb.append("\"");
sb.append(this.authors[i]);
sb.append("\"");
}
sb.append("]");
first = false;
}
if (this.content_type != null) {
if (!first) sb.append(",");
sb.append("\"contentType\":\"");
sb.append(this.get_content_type_string());
sb.append("\"");
}
sb.append("}");
return sb.str;
}
/**
* Deserialize from JSON string
*/
public static SearchFilters? from_json_string(string json_string) {
var parser = new Json.Parser();
try {
if (!parser.load_from_data(json_string)) {
return null;
}
} catch (Error e) {
warning("Failed to parse JSON: %s", e.message);
return null;
}
return from_json_node(parser.get_root());
}
/**
* Deserialize from Json.Node
*/
public static SearchFilters? from_json_node(Json.Node node) {
if (node.get_node_type() != Json.NodeType.OBJECT) {
return null;
}
var obj = node.get_object();
var filters = SearchFilters();
if (obj.has_member("dateFrom")) {
filters.date_from = obj.get_string_member("dateFrom");
}
if (obj.has_member("dateTo")) {
filters.date_to = obj.get_string_member("dateTo");
}
if (obj.has_member("feedIds")) {
var array = obj.get_array_member("feedIds");
var feed_ids = new string[array.get_length()];
for (var i = 0; i < array.get_length(); i++) {
feed_ids[i] = array.get_string_element(i);
}
filters.feed_ids = feed_ids;
}
if (obj.has_member("authors")) {
var array = obj.get_array_member("authors");
var authors = new string[array.get_length()];
for (var i = 0; i < array.get_length(); i++) {
authors[i] = array.get_string_element(i);
}
filters.authors = authors;
}
if (obj.has_member("contentType")) {
filters.content_type = content_type_from_string(obj.get_string_member("contentType"));
}
return filters;
}
/**
* Equality comparison
*/
public bool equals(SearchFilters other) {
return this.date_from == other.date_from &&
this.date_to == other.date_to &&
this.feeds_equal(other.feed_ids) &&
this.authors_equal(other.authors) &&
this.content_type == other.content_type;
}
/**
* Helper for feed_ids comparison
*/
private bool feeds_equal(string[]? other) {
if (this.feed_ids == null && other == null) return true;
if (this.feed_ids == null || other == null) return false;
if (this.feed_ids.length != other.length) {
return false;
}
for (var i = 0; i < this.feed_ids.length; i++) {
if (this.feed_ids[i] != other[i]) {
return false;
}
}
return true;
}
/**
* Helper for authors comparison
*/
private bool authors_equal(string[]? other) {
if (this.authors == null && other == null) return true;
if (this.authors == null || other == null) return false;
if (this.authors.length != other.length) {
return false;
}
for (var i = 0; i < this.authors.length; i++) {
if (this.authors[i] != other[i]) {
return false;
}
}
return true;
}
}
/**
* SearchQuery - Represents a complete search query
*/
public struct RSSuper.SearchQuery {
public string query { get; set; }
public int page { get; set; }
public int page_size { get; set; }
public string filters_json { get; set; }
public SearchSortOption sort { get; set; }
/**
* Default constructor
*/
public SearchQuery(string query, int page = 1, int page_size = 20,
string? filters_json = null, SearchSortOption sort = SearchSortOption.RELEVANCE) {
this.query = query;
this.page = page;
this.page_size = page_size;
this.filters_json = filters_json;
this.sort = sort;
}
/**
* Get filters as struct
*/
public SearchFilters? get_filters() {
if (this.filters_json == null || this.filters_json.length == 0) {
return null;
}
return SearchFilters.from_json_string(this.filters_json);
}
/**
* Set filters from struct
*/
public void set_filters(SearchFilters? filters) {
if (filters == null) {
this.filters_json = "";
} else {
this.filters_json = filters.to_json_string();
}
}
/**
* Serialize to JSON string
*/
public string to_json_string() {
var sb = new StringBuilder();
sb.append("{");
sb.append("\"query\":\"");
sb.append(this.query);
sb.append("\"");
sb.append(",\"page\":%d".printf(this.page));
sb.append(",\"pageSize\":%d".printf(this.page_size));
if (this.filters_json != null && this.filters_json.length > 0) {
sb.append(",\"filters\":");
sb.append(this.filters_json);
}
sb.append(",\"sort\":\"");
sb.append(SearchFilters.sort_option_to_string(this.sort));
sb.append("\"");
sb.append("}");
return sb.str;
}
/**
* Deserialize from JSON string
*/
public static SearchQuery? from_json_string(string json_string) {
var parser = new Json.Parser();
try {
if (!parser.load_from_data(json_string)) {
return null;
}
} catch (Error e) {
warning("Failed to parse JSON: %s", e.message);
return null;
}
return from_json_node(parser.get_root());
}
/**
* Deserialize from Json.Node
*/
public static SearchQuery? from_json_node(Json.Node node) {
if (node.get_node_type() != Json.NodeType.OBJECT) {
return null;
}
var obj = node.get_object();
if (!obj.has_member("query")) {
return null;
}
var query = SearchQuery(obj.get_string_member("query"));
if (obj.has_member("page")) {
query.page = (int)obj.get_int_member("page");
}
if (obj.has_member("pageSize")) {
query.page_size = (int)obj.get_int_member("pageSize");
}
if (obj.has_member("filters")) {
var generator = new Json.Generator();
generator.set_root(obj.get_member("filters"));
query.filters_json = generator.to_data(null);
}
if (obj.has_member("sort")) {
query.sort = SearchFilters.sort_option_from_string(obj.get_string_member("sort"));
}
return query;
}
/**
* Equality comparison
*/
public bool equals(SearchQuery other) {
return this.query == other.query &&
this.page == other.page &&
this.page_size == other.page_size &&
this.filters_json == other.filters_json &&
this.sort == other.sort;
}
}

View File

@@ -0,0 +1,208 @@
/*
* SearchResult.vala
*
* Represents a search result item from the feed database.
* Following GNOME HIG naming conventions and Vala/GObject patterns.
*/
/**
* SearchResultType - Type of search result
*/
public enum RSSuper.SearchResultType {
ARTICLE,
FEED
}
/**
* SearchResult - Represents a single search result
*/
public class RSSuper.SearchResult : Object {
public string id { get; set; }
public SearchResultType result_type { get; set; }
public string title { get; set; }
public string? snippet { get; set; }
public string? link { get; set; }
public string? feed_title { get; set; }
public string? published { get; set; }
public double score { get; set; }
/**
* Default constructor
*/
public SearchResult() {
this.id = "";
this.result_type = SearchResultType.ARTICLE;
this.title = "";
this.score = 0.0;
}
/**
* Constructor with initial values
*/
public SearchResult.with_values(string id, SearchResultType type, string title,
string? snippet = null, string? link = null,
string? feed_title = null, string? published = null,
double score = 0.0) {
this.id = id;
this.result_type = type;
this.title = title;
this.snippet = snippet;
this.link = link;
this.feed_title = feed_title;
this.published = published;
this.score = score;
}
/**
* Get type as string
*/
public string get_type_string() {
switch (this.result_type) {
case SearchResultType.ARTICLE:
return "article";
case SearchResultType.FEED:
return "feed";
default:
return "unknown";
}
}
/**
* Parse type from string
*/
public static SearchResultType type_from_string(string str) {
switch (str) {
case "article":
return SearchResultType.ARTICLE;
case "feed":
return SearchResultType.FEED;
default:
return SearchResultType.ARTICLE;
}
}
/**
* Serialize to JSON string
*/
public string to_json_string() {
var sb = new StringBuilder();
sb.append("{");
sb.append("\"id\":\"");
sb.append(this.id);
sb.append("\",\"type\":\"");
sb.append(this.get_type_string());
sb.append("\",\"title\":\"");
sb.append(this.title);
sb.append("\"");
if (this.snippet != null) {
sb.append(",\"snippet\":\"");
sb.append(this.snippet);
sb.append("\"");
}
if (this.link != null) {
sb.append(",\"link\":\"");
sb.append(this.link);
sb.append("\"");
}
if (this.feed_title != null) {
sb.append(",\"feedTitle\":\"");
sb.append(this.feed_title);
sb.append("\"");
}
if (this.published != null) {
sb.append(",\"published\":\"");
sb.append(this.published);
sb.append("\"");
}
if (this.score != 0.0) {
sb.append(",\"score\":%f".printf(this.score));
}
sb.append("}");
return sb.str;
}
/**
* Deserialize from JSON string
*/
public static SearchResult? from_json_string(string json_string) {
var parser = new Json.Parser();
try {
if (!parser.load_from_data(json_string)) {
return null;
}
} catch (Error e) {
warning("Failed to parse JSON: %s", e.message);
return null;
}
return from_json_node(parser.get_root());
}
/**
* Deserialize from Json.Node
*/
public static SearchResult? from_json_node(Json.Node node) {
if (node.get_node_type() != Json.NodeType.OBJECT) {
return null;
}
var obj = node.get_object();
if (!obj.has_member("id") || !obj.has_member("type") || !obj.has_member("title")) {
return null;
}
var result = new SearchResult();
result.id = obj.get_string_member("id");
result.result_type = SearchResult.type_from_string(obj.get_string_member("type"));
result.title = obj.get_string_member("title");
if (obj.has_member("snippet")) {
result.snippet = obj.get_string_member("snippet");
}
if (obj.has_member("link")) {
result.link = obj.get_string_member("link");
}
if (obj.has_member("feedTitle")) {
result.feed_title = obj.get_string_member("feedTitle");
}
if (obj.has_member("published")) {
result.published = obj.get_string_member("published");
}
if (obj.has_member("score")) {
result.score = obj.get_double_member("score");
}
return result;
}
/**
* Equality comparison
*/
public bool equals(SearchResult? other) {
if (other == null) {
return false;
}
return this.id == other.id &&
this.result_type == other.result_type &&
this.title == other.title &&
this.snippet == other.snippet &&
this.link == other.link &&
this.feed_title == other.feed_title &&
this.published == other.published &&
this.score == other.score;
}
/**
* Get a human-readable summary
*/
public string get_summary() {
if (this.feed_title != null) {
return "[%s] %s - %s".printf(this.get_type_string(), this.feed_title, this.title);
}
return "[%s] %s".printf(this.get_type_string(), this.title);
}
}

View File

@@ -0,0 +1,288 @@
/*
* DatabaseTests.vala
*
* Unit tests for database layer.
*/
public class RSSuper.DatabaseTests : TestCase {
private Database? db;
private string test_db_path;
public override void setUp() {
base.setUp();
test_db_path = "/tmp/rssuper_test_%d.db".printf((int)Time.get_current_time());
try {
db = new Database(test_db_path);
} catch (DatabaseError e) {
warn("Failed to create test database: %s", e.message);
}
}
public override void tearDown() {
base.tearDown();
if (db != null) {
db.close();
db = null;
}
// Clean up test database
var file = File.new_for_path(test_db_path);
if (file.query_exists()) {
try {
file.delete();
} catch (DatabaseError e) {
warn("Failed to delete test database: %s", e.message);
}
}
// Clean up WAL file
var wal_file = File.new_for_path(test_db_path + "-wal");
if (wal_file.query_exists()) {
try {
wal_file.delete();
} catch (DatabaseError e) {
warn("Failed to delete WAL file: %s", e.message);
}
}
}
public void test_subscription_crud() {
if (db == null) return;
var store = new SubscriptionStore(db);
// Create test subscription
var subscription = new FeedSubscription.with_values(
"sub_1",
"https://example.com/feed.xml",
"Example Feed",
60,
"Technology",
true,
"2024-01-01T00:00:00Z",
"2024-01-01T00:00:00Z"
);
// Test add
store.add(subscription);
assert_not_null(store.get_by_id("sub_1"));
// Test get
var retrieved = store.get_by_id("sub_1");
assert_not_null(retrieved);
assert_equal("Example Feed", retrieved.title);
assert_equal("https://example.com/feed.xml", retrieved.url);
// Test update
retrieved.title = "Updated Feed";
store.update(retrieved);
var updated = store.get_by_id("sub_1");
assert_equal("Updated Feed", updated.title);
// Test delete
store.delete("sub_1");
var deleted = store.get_by_id("sub_1");
assert_null(deleted);
}
public void test_subscription_list() {
if (db == null) return;
var store = new SubscriptionStore(db);
// Add multiple subscriptions
var sub1 = new FeedSubscription.with_values("sub_1", "https://feed1.com", "Feed 1");
var sub2 = new FeedSubscription.with_values("sub_2", "https://feed2.com", "Feed 2");
var sub3 = new FeedSubscription.with_values("sub_3", "https://feed3.com", "Feed 3", 60, null, false);
store.add(sub1);
store.add(sub2);
store.add(sub3);
// Test get_all
var all = store.get_all();
assert_equal(3, all.length);
// Test get_enabled
var enabled = store.get_enabled();
assert_equal(2, enabled.length);
}
public void test_feed_item_crud() {
if (db == null) return;
var sub_store = new SubscriptionStore(db);
var item_store = new FeedItemStore(db);
// Create subscription first
var subscription = new FeedSubscription.with_values(
"sub_1", "https://example.com/feed.xml", "Example Feed"
);
sub_store.add(subscription);
// Create test item
var item = new FeedItem.with_values(
"item_1",
"Test Article",
"https://example.com/article",
"This is a test description",
"Full content of the article",
"John Doe",
"2024-01-01T12:00:00Z",
"2024-01-01T12:00:00Z",
{"Technology", "News"},
null, null, null, null,
"Example Feed"
);
// Test add
item_store.add(item);
var retrieved = item_store.get_by_id("item_1");
assert_not_null(retrieved);
assert_equal("Test Article", retrieved.title);
// Test get by subscription
var items = item_store.get_by_subscription("sub_1");
assert_equal(1, items.length);
// Test mark as read
item_store.mark_as_read("item_1");
// Test delete
item_store.delete("item_1");
var deleted = item_store.get_by_id("item_1");
assert_null(deleted);
}
public void test_feed_item_batch() {
if (db == null) return;
var sub_store = new SubscriptionStore(db);
var item_store = new FeedItemStore(db);
// Create subscription
var subscription = new FeedSubscription.with_values(
"sub_1", "https://example.com/feed.xml", "Example Feed"
);
sub_store.add(subscription);
// Create multiple items
var items = new FeedItem[5];
for (var i = 0; i < 5; i++) {
items[i] = new FeedItem.with_values(
"item_%d".printf(i),
"Article %d".printf(i),
"https://example.com/article%d".printf(i),
"Description %d".printf(i),
null,
"Author %d".printf(i),
"2024-01-%02dT12:00:00Z".printf(i + 1),
null,
null,
null, null, null, null,
"Example Feed"
);
}
// Test batch insert
item_store.add_batch(items);
var all = item_store.get_by_subscription("sub_1");
assert_equal(5, all.length);
}
public void test_search_history() {
if (db == null) return;
var store = new SearchHistoryStore(db);
// Create test queries
var query1 = SearchQuery("test query", 1, 20, null, SearchSortOption.RELEVANCE);
var query2 = SearchQuery("another search", 1, 10, null, SearchSortOption.DATE_DESC);
// Test record
store.record_search(query1, 15);
store.record_search(query2, 8);
// Test get_history
var history = store.get_history();
assert_equal(2, history.length);
assert_equal("another search", history[0].query); // Most recent first
// Test get_recent
var recent = store.get_recent();
assert_equal(2, recent.length);
}
public void test_fts_search() {
if (db == null) return;
var sub_store = new SubscriptionStore(db);
var item_store = new FeedItemStore(db);
// Create subscription
var subscription = new FeedSubscription.with_values(
"sub_1", "https://example.com/feed.xml", "Example Feed"
);
sub_store.add(subscription);
// Add items with searchable content
var item1 = new FeedItem.with_values(
"item_1",
"Swift Programming Guide",
"https://example.com/swift",
"Learn Swift programming language basics",
"A comprehensive guide to Swift",
"Apple Developer",
"2024-01-01T12:00:00Z",
null,
null,
null, null, null, null,
"Example Feed"
);
var item2 = new FeedItem.with_values(
"item_2",
"Python for Data Science",
"https://example.com/python",
"Data analysis with Python and pandas",
"Complete Python data science tutorial",
"Data Team",
"2024-01-02T12:00:00Z",
null,
null,
null, null, null, null,
"Example Feed"
);
item_store.add(item1);
item_store.add(item2);
// Test FTS search
var results = item_store.search("swift");
assert_equal(1, results.length);
assert_equal("Swift Programming Guide", results[0].title);
results = item_store.search("python");
assert_equal(1, results.length);
assert_equal("Python for Data Science", results[0].title);
}
public static int main(string[] args) {
Test.init(ref args);
var suite = Test.create_suite();
var test_case = new DatabaseTests();
Test.add_func("/database/subscription_crud", test_case.test_subscription_crud);
Test.add_func("/database/subscription_list", test_case.test_subscription_list);
Test.add_func("/database/feed_item_crud", test_case.test_feed_item_crud);
Test.add_func("/database/feed_item_batch", test_case.test_feed_item_batch);
Test.add_func("/database/search_history", test_case.test_search_history);
Test.add_func("/database/fts_search", test_case.test_fts_search);
return Test.run();
}
}

View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "native-route"
include(":android")

View File

@@ -9,13 +9,13 @@ Status legend: [ ] todo, [~] in-progress, [x] done
- [x] 02 — Design shared data models for all platforms → `02-design-shared-data-models.md`
## Phase 2: Data Models (Per Platform)
- [ ] 03 — Implement iOS data models (Swift) → `03-implement-ios-data-models.md`
- [ ] 04 — Implement Android data models (Kotlin) → `04-implement-android-data-models.md`
- [ ] 05 — Implement Linux data models (C/Vala) → `05-implement-linux-data-models.md`
- [x] 03 — Implement iOS data models (Swift) → `03-implement-ios-data-models.md`
- [x] 04 — Implement Android data models (Kotlin) → `04-implement-android-data-models.md`
- [x] 05 — Implement Linux data models (C/Vala) → `05-implement-linux-data-models.md`
## Phase 3: Database Layer (Per Platform)
- [ ] 06 — Implement iOS database layer (Core Data/GRDB) → `06-implement-ios-database-layer.md`
- [ ] 07 — Implement Android database layer (Room) → `07-implement-android-database-layer.md`
- [x] 06 — Implement iOS database layer (Core Data/GRDB) → `06-implement-ios-database-layer.md`
- [x] 07 — Implement Android database layer (Room) → `07-implement-android-database-layer.md`
- [ ] 08 — Implement Linux database layer (SQLite) → `08-implement-linux-database-layer.md`
## Phase 4: Feed Parsing (Per Platform)