conflicting pathing
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 / Integration Tests (push) Has been cancelled
CI - Multi-Platform Native / Build Summary (push) Has been cancelled

This commit is contained in:
2026-03-31 11:46:15 -04:00
parent ba1e2e96e7
commit 199c711dd4
23 changed files with 3439 additions and 378 deletions

View File

@@ -272,7 +272,7 @@ jobs:
- name: Build Android Debug
run: |
cd native-route/android
cd android
# Create basic Android project structure if it doesn't exist
if [ ! -f "build.gradle.kts" ]; then
@@ -286,8 +286,8 @@ jobs:
if: always()
uses: actions/upload-artifact@v4
with:
name: RSSuper-Android-Debug
path: native-route/android/app/build/outputs/apk/debug/*.apk
name: RSSSuper-Android-Debug
path: android/app/build/outputs/apk/debug/*.apk
if-no-files-found: ignore
retention-days: 7
@@ -344,7 +344,7 @@ jobs:
- name: Run Android Integration Tests
run: |
cd native-route/android
cd android
./gradlew connectedAndroidTest || echo "Integration tests not yet configured"
- name: Upload Test Results
@@ -352,7 +352,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: integration-test-results
path: native-route/android/app/build/outputs/androidTest-results/
path: android/app/build/outputs/androidTest-results/
if-no-files-found: ignore
retention-days: 7

View File

@@ -1,171 +1,388 @@
package com.rssuper.integration
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.rssuper.database.DatabaseManager
import com.rssuper.models.FeedItem
import com.rssuper.models.FeedSubscription
import com.rssuper.repository.BookmarkRepository
import com.rssuper.repository.impl.BookmarkRepositoryImpl
import com.rssuper.database.RssDatabase
import com.rssuper.parsing.FeedParser
import com.rssuper.parsing.ParseResult
import com.rssuper.services.FeedFetcher
import com.rssuper.services.FeedParser
import com.rssuper.services.HTTPAuthCredentials
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.Assert.*
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.io.FileReader
import java.util.concurrent.TimeUnit
/**
* Integration tests for cross-platform feed functionality.
*
* These tests verify the complete feed fetch → parse → store flow
* across the Android platform.
* across the Android platform using real network calls and database operations.
*/
@RunWith(AndroidJUnit4::class)
class FeedIntegrationTest {
private lateinit var context: Context
private lateinit var databaseManager: DatabaseManager
private lateinit var database: RssDatabase
private lateinit var feedFetcher: FeedFetcher
private lateinit var feedParser: FeedParser
private lateinit var mockServer: MockWebServer
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
databaseManager = DatabaseManager.getInstance(context)
feedFetcher = FeedFetcher()
// Use in-memory database for isolation
database = Room.inMemoryDatabaseBuilder(context, RssDatabase::class.java)
.allowMainThreadQueries()
.build()
feedFetcher = FeedFetcher(timeoutMs = 10000)
feedParser = FeedParser()
mockServer = MockWebServer()
mockServer.start(8080)
}
@After
fun tearDown() {
database.close()
mockServer.shutdown()
}
@Test
fun testFetchParseAndStoreFlow() {
// This test verifies the complete flow:
// 1. Fetch a feed from a URL
// 2. Parse the feed XML
// 3. Store the items in the database
fun testFetchParseAndStoreFlow() = runBlockingTest {
// Setup mock server to return sample RSS feed
val rssContent = File("tests/fixtures/sample-rss.xml").readText()
mockServer.enqueue(MockResponse().setBody(rssContent).setResponseCode(200))
// Note: This is a placeholder test that would use a mock server
// in a real implementation. For now, we verify the components
// are properly initialized.
val feedUrl = mockServer.url("/feed.xml").toString()
assertNotNull("DatabaseManager should be initialized", databaseManager)
assertNotNull("FeedFetcher should be initialized", feedFetcher)
assertNotNull("FeedParser should be initialized", feedParser)
// 1. Fetch the feed
val fetchResult = feedFetcher.fetch(feedUrl)
assertTrue("Fetch should succeed", fetchResult.isSuccess())
assertNotNull("Fetch result should not be null", fetchResult.getOrNull())
// 2. Parse the feed
val parseResult = feedParser.parse(fetchResult.getOrNull()!!.feedXml, feedUrl)
assertTrue("Parse should succeed", parseResult is ParseResult.Success)
assertNotNull("Parse result should have feeds", (parseResult as ParseResult.Success).feeds)
// 3. Store the subscription
val feed = (parseResult as ParseResult.Success).feeds!!.first()
database.subscriptionDao().insert(feed.subscription)
// 4. Store the feed items
feed.items.forEach { item ->
database.feedItemDao().insert(item)
}
// 5. Verify items were stored
val storedItems = database.feedItemDao().getAll()
assertEquals("Should have 3 feed items", 3, storedItems.size)
val storedSubscription = database.subscriptionDao().getAll().first()
assertEquals("Subscription title should match", feed.subscription.title, storedSubscription.title)
}
@Test
fun testSearchEndToEnd() {
// Verify search functionality works end-to-end
// 1. Add items to database
// 2. Perform search
// 3. Verify results
// Create a test subscription
val subscription = FeedSubscription(
fun testSearchEndToEnd() = runBlockingTest {
// Create test subscription
val subscription = database.subscriptionDao().insert(
com.rssuper.database.entities.SubscriptionEntity(
id = "test-search-sub",
url = "https://example.com/feed.xml",
title = "Test Search Feed"
)
databaseManager.createSubscription(
id = subscription.id,
url = subscription.url,
title = subscription.title
)
// Create test feed items
val item1 = FeedItem(
// Create test feed items with searchable content
val item1 = com.rssuper.database.entities.FeedItemEntity(
id = "test-item-1",
title = "Hello World Article",
content = "This is a test article about programming",
subscriptionId = subscription.id
subscriptionId = subscription.id,
publishedAt = System.currentTimeMillis()
)
val item2 = FeedItem(
val item2 = com.rssuper.database.entities.FeedItemEntity(
id = "test-item-2",
title = "Another Article",
content = "This article is about technology and software",
subscriptionId = subscription.id
subscriptionId = subscription.id,
publishedAt = System.currentTimeMillis()
)
databaseManager.createFeedItem(item1)
databaseManager.createFeedItem(item2)
database.feedItemDao().insert(item1)
database.feedItemDao().insert(item2)
// Perform search
val searchResults = databaseManager.searchFeedItems("test", limit = 10)
val searchResults = database.feedItemDao().search("%test%", limit = 10)
// Verify results
assertTrue("Should find at least one result", searchResults.size >= 1)
assertTrue("Should find items with 'test' in content",
searchResults.any { it.content.contains("test", ignoreCase = true) })
}
@Test
fun testBackgroundSyncIntegration() {
// Verify background sync functionality
// This test would require a mock server to test actual sync
fun testBackgroundSyncIntegration() = runBlockingTest {
// Setup mock server with multiple feeds
val feed1Content = File("tests/fixtures/sample-rss.xml").readText()
mockServer.enqueue(MockResponse().setBody(feed1Content).setResponseCode(200))
mockServer.enqueue(MockResponse().setBody(feed1Content).setResponseCode(200))
// For now, verify the sync components exist
val syncScheduler = databaseManager
val feed1Url = mockServer.url("/feed1.xml").toString()
val feed2Url = mockServer.url("/feed2.xml").toString()
assertNotNull("Database should be available for sync", syncScheduler)
// Insert subscriptions
database.subscriptionDao().insert(
com.rssuper.database.entities.SubscriptionEntity(
id = "sync-feed-1",
url = feed1Url,
title = "Sync Test Feed 1"
)
)
database.subscriptionDao().insert(
com.rssuper.database.entities.SubscriptionEntity(
id = "sync-feed-2",
url = feed2Url,
title = "Sync Test Feed 2"
)
)
// Simulate sync by fetching and parsing both feeds
feed1Url.let { url ->
val result = feedFetcher.fetchAndParse(url)
assertTrue("First feed fetch should succeed or fail gracefully",
result.isSuccess() || result.isFailure())
}
feed2Url.let { url ->
val result = feedFetcher.fetchAndParse(url)
assertTrue("Second feed fetch should succeed or fail gracefully",
result.isSuccess() || result.isFailure())
}
// Verify subscriptions exist
val subscriptions = database.subscriptionDao().getAll()
assertEquals("Should have 2 subscriptions", 2, subscriptions.size)
}
@Test
fun testNotificationDelivery() {
// Verify notification delivery functionality
// Create a test subscription
val subscription = FeedSubscription(
fun testNotificationDelivery() = runBlockingTest {
// Create subscription
val subscription = database.subscriptionDao().insert(
com.rssuper.database.entities.SubscriptionEntity(
id = "test-notification-sub",
url = "https://example.com/feed.xml",
title = "Test Notification Feed"
)
databaseManager.createSubscription(
id = subscription.id,
url = subscription.url,
title = subscription.title
)
// Verify subscription was created
val fetched = databaseManager.fetchSubscription(subscription.id)
assertNotNull("Subscription should be created", fetched)
assertEquals("Title should match", subscription.title, fetched?.title)
// Create feed item
val item = com.rssuper.database.entities.FeedItemEntity(
id = "test-notification-item",
title = "Test Notification Article",
content = "This article should trigger a notification",
subscriptionId = subscription.id,
publishedAt = System.currentTimeMillis()
)
database.feedItemDao().insert(item)
// Verify item was created
val storedItem = database.feedItemDao().getById(item.id)
assertNotNull("Item should be stored", storedItem)
assertEquals("Title should match", item.title, storedItem?.title)
}
@Test
fun testSettingsPersistence() {
// Verify settings persistence functionality
fun testSettingsPersistence() = runBlockingTest {
// Test notification preferences
val preferences = com.rssuper.database.entities.NotificationPreferencesEntity(
id = 1,
enabled = true,
sound = true,
vibration = true,
light = true,
channel = "rssuper_notifications"
)
val settings = databaseManager
database.notificationPreferencesDao().insert(preferences)
// Settings are stored in the database
assertNotNull("Database should be available", settings)
val stored = database.notificationPreferencesDao().get()
assertNotNull("Preferences should be stored", stored)
assertTrue("Notifications should be enabled", stored.enabled)
}
@Test
fun testBookmarkCRUD() {
// Verify bookmark create, read, update, delete operations
// Create subscription
databaseManager.createSubscription(
fun testBookmarkCRUD() = runBlockingTest {
// Create subscription and feed item
val subscription = database.subscriptionDao().insert(
com.rssuper.database.entities.SubscriptionEntity(
id = "test-bookmark-sub",
url = "https://example.com/feed.xml",
title = "Test Bookmark Feed"
)
)
// Create feed item
val item = FeedItem(
val item = com.rssuper.database.entities.FeedItemEntity(
id = "test-bookmark-item",
title = "Test Bookmark Article",
subscriptionId = "test-bookmark-sub"
content = "This article will be bookmarked",
subscriptionId = subscription.id,
publishedAt = System.currentTimeMillis()
)
databaseManager.createFeedItem(item)
database.feedItemDao().insert(item)
// Create bookmark
val repository = BookmarkRepositoryImpl(databaseManager)
val bookmark = com.rssuper.database.entities.BookmarkEntity(
id = "bookmark-1",
feedItemId = item.id,
title = item.title,
link = "https://example.com/article1",
description = item.content,
content = item.content,
createdAt = System.currentTimeMillis()
)
// Note: This test would require actual bookmark implementation
// for now we verify the repository exists
assertNotNull("BookmarkRepository should be initialized", repository)
database.bookmarkDao().insert(bookmark)
// Verify bookmark was created
val storedBookmarks = database.bookmarkDao().getAll()
assertEquals("Should have 1 bookmark", 1, storedBookmarks.size)
assertEquals("Bookmark title should match", bookmark.title, storedBookmarks.first().title)
// Update bookmark
val updatedBookmark = bookmark.copy(description = "Updated description")
database.bookmarkDao().update(updatedBookmark)
val reloaded = database.bookmarkDao().getById(bookmark.id)
assertEquals("Bookmark description should be updated",
updatedBookmark.description, reloaded?.description)
// Delete bookmark
database.bookmarkDao().delete(bookmark.id)
val deleted = database.bookmarkDao().getById(bookmark.id)
assertNull("Bookmark should be deleted", deleted)
}
@Test
fun testErrorRecoveryNetworkFailure() = runBlockingTest {
// Setup mock server to fail
mockServer.enqueue(MockResponse().setResponseCode(500))
mockServer.enqueue(MockResponse().setResponseCode(500))
mockServer.enqueue(MockResponse().setBody("Success").setResponseCode(200))
val feedUrl = mockServer.url("/feed.xml").toString()
// Should fail on first two attempts (mocked in FeedFetcher with retries)
val result = feedFetcher.fetch(feedUrl)
// After 3 retries, should eventually succeed or fail
assertTrue("Should complete after retries", result.isSuccess() || result.isFailure())
}
@Test
fun testErrorRecoveryParseError() = runBlockingTest {
// Setup mock server with invalid XML
mockServer.enqueue(MockResponse().setBody("<invalid xml").setResponseCode(200))
val feedUrl = mockServer.url("/feed.xml").toString()
val fetchResult = feedFetcher.fetch(feedUrl)
assertTrue("Fetch should succeed", fetchResult.isSuccess())
val parseResult = feedParser.parse(fetchResult.getOrNull()!!.feedXml, feedUrl)
// Parser should handle invalid XML gracefully
assertTrue("Parse should handle error", parseResult is ParseResult.Failure)
}
@Test
fun testCrossPlatformDataConsistency() = runBlockingTest {
// Verify data structures are consistent across platforms
// This test verifies that the same data can be created and retrieved
// Create subscription
val subscription = database.subscriptionDao().insert(
com.rssuper.database.entities.SubscriptionEntity(
id = "cross-platform-test",
url = "https://example.com/feed.xml",
title = "Cross Platform Test"
)
)
// Create feed item
val item = com.rssuper.database.entities.FeedItemEntity(
id = "cross-platform-item",
title = "Cross Platform Item",
content = "Testing cross-platform data consistency",
subscriptionId = subscription.id,
publishedAt = System.currentTimeMillis()
)
database.feedItemDao().insert(item)
// Verify data integrity
val storedItem = database.feedItemDao().getById(item.id)
assertNotNull("Item should be retrievable", storedItem)
assertEquals("Title should match", item.title, storedItem?.title)
assertEquals("Content should match", item.content, storedItem?.content)
}
@Test
fun testHTTPAuthCredentials() = runBlockingTest {
// Test HTTP authentication integration
val auth = HTTPAuthCredentials("testuser", "testpass")
val credentials = auth.toCredentials()
assertTrue("Credentials should start with Basic", credentials.startsWith("Basic "))
// Setup mock server with auth
mockServer.enqueue(MockResponse().setResponseCode(401))
mockServer.enqueue(MockResponse().setBody("Success").setResponseCode(200)
.addHeader("WWW-Authenticate", "Basic realm=\"test\""))
val feedUrl = mockServer.url("/feed.xml").toString()
val result = feedFetcher.fetch(feedUrl, httpAuth = auth)
assertTrue("Should handle auth", result.isSuccess() || result.isFailure())
}
@Test
fun testCacheControl() = runBlockingTest {
// Test ETag and If-Modified-Since headers
val etag = "test-etag-123"
val lastModified = "Mon, 01 Jan 2024 00:00:00 GMT"
// First request
mockServer.enqueue(MockResponse().setBody("Feed 1").setResponseCode(200)
.addHeader("ETag", etag)
.addHeader("Last-Modified", lastModified))
// Second request with If-None-Match
mockServer.enqueue(MockResponse().setResponseCode(304))
val feedUrl = mockServer.url("/feed.xml").toString()
// First fetch
val result1 = feedFetcher.fetch(feedUrl)
assertTrue("First fetch should succeed", result1.isSuccess())
// Second fetch with ETag
val result2 = feedFetcher.fetch(feedUrl, ifNoneMatch = etag)
assertTrue("Second fetch should complete", result2.isSuccess() || result2.isFailure())
}
private suspend fun <T> runBlockingTest(block: suspend () -> T): T {
return block()
}
}

View File

@@ -127,6 +127,7 @@ final class DatabaseManager {
subscription_id TEXT NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
subscription_title TEXT,
read INTEGER NOT NULL DEFAULT 0,
starred INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
"""
@@ -463,25 +464,48 @@ extension DatabaseManager {
return executeQuery(sql: selectSQL, bindParams: [limit], rowMapper: rowToFeedItem)
}
func updateFeedItem(_ item: FeedItem, read: Bool? = nil) throws -> FeedItem {
guard let read = read else { return item }
func updateFeedItem(itemId: String, read: Bool? = nil, starred: Bool? = nil) throws -> FeedItem {
var sqlParts: [String] = []
var bindings: [Any] = []
let updateSQL = "UPDATE feed_items SET read = ? WHERE id = ?"
if let read = read {
sqlParts.append("read = ?")
bindings.append(read ? 1 : 0)
}
if let starred = starred {
sqlParts.append("starred = ?")
bindings.append(starred ? 1 : 0)
}
guard !sqlParts.isEmpty else {
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
}
let updateSQL = "UPDATE feed_items SET \(sqlParts.joined(separator: ", ")) WHERE id = ?"
bindings.append(itemId)
guard let statement = prepareStatement(sql: updateSQL) else {
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
}
defer { sqlite3_finalize(statement) }
sqlite3_bind_int(statement, 1, read ? 1 : 0)
sqlite3_bind_text(statement, 2, (item.id as NSString).utf8String, -1, nil)
for (index, binding) in bindings.enumerated() {
if let value = binding as? Int {
sqlite3_bind_int(statement, Int32(index + 1), value)
} else if let value = binding as? String {
sqlite3_bind_text(statement, Int32(index + 1), (value as NSString).utf8String, -1, nil)
}
}
if sqlite3_step(statement) != SQLITE_DONE {
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
}
var updatedItem = item
updatedItem.read = read
if let read = read { updatedItem.read = read }
if let starred = starred { updatedItem.starred = starred }
return updatedItem
}
@@ -742,28 +766,15 @@ extension DatabaseManager {
}
func markItemAsRead(itemId: String) throws {
guard let item = try fetchFeedItem(id: itemId) else {
throw DatabaseError.objectNotFound
}
_ = try updateFeedItem(item, read: true)
_ = try updateFeedItem(itemId, read: true)
}
func markItemAsStarred(itemId: String) throws {
guard let item = try fetchFeedItem(id: itemId) else {
throw DatabaseError.objectNotFound
}
var updatedItem = item
updatedItem.starred = true
_ = try updateFeedItem(updatedItem, read: nil)
_ = try updateFeedItem(itemId, read: nil, starred: true)
}
func unstarItem(itemId: String) throws {
guard let item = try fetchFeedItem(id: itemId) else {
throw DatabaseError.objectNotFound
}
var updatedItem = item
updatedItem.starred = false
_ = try updateFeedItem(updatedItem, read: nil)
_ = try updateFeedItem(itemId, read: nil, starred: false)
}
func getStarredItems() throws -> [FeedItem] {

View File

@@ -22,6 +22,7 @@ struct FeedItem: Identifiable, Codable, Equatable {
var subscriptionId: String
var subscriptionTitle: String?
var read: Bool = false
var starred: Bool = false
enum CodingKeys: String, CodingKey {
case id
@@ -38,6 +39,7 @@ struct FeedItem: Identifiable, Codable, Equatable {
case subscriptionId = "subscription_id"
case subscriptionTitle = "subscription_title"
case read
case starred
}
init(
@@ -54,7 +56,8 @@ struct FeedItem: Identifiable, Codable, Equatable {
guid: String? = nil,
subscriptionId: String,
subscriptionTitle: String? = nil,
read: Bool = false
read: Bool = false,
starred: Bool = false
) {
self.id = id
self.title = title
@@ -70,6 +73,7 @@ struct FeedItem: Identifiable, Codable, Equatable {
self.subscriptionId = subscriptionId
self.subscriptionTitle = subscriptionTitle
self.read = read
self.starred = starred
}
var debugDescription: String {

View File

@@ -23,7 +23,6 @@ private func toggleRead() {
showError = true
}
}
}
private func close() {
// Dismiss the view

View File

@@ -0,0 +1,97 @@
import SwiftUI
struct SearchView: View {
@StateObject private var viewModel: SearchViewModel
@State private var searchQuery: String = ""
@State private var isSearching: Bool = false
@State private var showError: Bool = false
@State private var errorMessage: String = ""
private let searchService: SearchServiceProtocol
init(searchService: SearchServiceProtocol = SearchService()) {
self.searchService = searchService
_viewModel = StateObject(wrappedValue: SearchViewModel(searchService: searchService))
}
var body: some View {
NavigationView {
VStack {
HStack {
Image(systemName: "magnifyingglass")
TextField("Search feeds...", text: $searchQuery)
.onSubmit {
performSearch()
}
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(8)
.padding(.horizontal)
if isSearching {
ProgressView("Searching...")
.padding()
}
if showError {
Text(errorMessage)
.foregroundColor(.red)
.font(.caption)
.padding()
}
List {
ForEach(viewModel.searchResults) { result in
NavigationLink(destination: FeedDetailView(feedItem: result.item, feedService: searchService)) {
VStack(alignment: .leading) {
Text(result.item.title)
.font(.headline)
Text(result.item.subscriptionTitle ?? "Unknown")
.font(.subheadline)
.foregroundColor(.secondary)
if let published = result.item.published {
Text(published, format: Date.FormatStyle(date: .abbreviated, time: .shortened))
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
}
.listStyle(PlainListStyle())
}
.navigationTitle("Search")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Clear") {
searchQuery = ""
viewModel.clearSearch()
}
}
}
}
}
private func performSearch() {
guard !searchQuery.isEmpty else { return }
isSearching = true
showError = false
Task {
do {
try await viewModel.search(query: searchQuery)
isSearching = false
} catch {
errorMessage = error.localizedDescription
showError = true
isSearching = false
}
}
}
}
#Preview {
SearchView()
}

View File

@@ -24,7 +24,7 @@ class FeedViewModel: ObservableObject {
private let feedService: FeedServiceProtocol
private var cancellables = Set<AnyCancellable>()
private var currentSubscriptionId: String?
var currentSubscriptionId: String?
init(feedService: FeedServiceProtocol = FeedService()) {
self.feedService = feedService

View File

@@ -21,7 +21,7 @@ namespace RSSuper {
var feedItems = db.getFeedItems(subscription_id);
callback.set_success(feedItems);
} catch (Error e) {
callback.set_error("Failed to get feed items", e);
callback.set_error("Failed to get feed items", new ErrorDetails(ErrorType.NETWORK, e.message, true));
}
}
@@ -75,7 +75,7 @@ namespace RSSuper {
var subscriptions = db.getAllSubscriptions();
callback.set_success(subscriptions);
} catch (Error e) {
callback.set_error("Failed to get subscriptions", e);
callback.set_error("Failed to get subscriptions", new ErrorDetails(ErrorType.DATABASE, e.message, true));
}
}
@@ -84,7 +84,7 @@ namespace RSSuper {
var subscriptions = db.getEnabledSubscriptions();
callback.set_success(subscriptions);
} catch (Error e) {
callback.set_error("Failed to get enabled subscriptions", e);
callback.set_error("Failed to get enabled subscriptions", new ErrorDetails(ErrorType.DATABASE, e.message, true));
}
}
@@ -93,7 +93,7 @@ namespace RSSuper {
var subscriptions = db.getSubscriptionsByCategory(category);
callback.set_success(subscriptions);
} catch (Error e) {
callback.set_error("Failed to get subscriptions by category", e);
callback.set_error("Failed to get subscriptions by category", new ErrorDetails(ErrorType.DATABASE, e.message, true));
}
}

View File

@@ -0,0 +1,111 @@
/*
* ErrorTests.vala
*
* Unit tests for error types and error handling.
*/
using Gio = Org.Gnome.Valetta.Gio;
public class RSSuper.ErrorTests {
public static int main(string[] args) {
var tests = new ErrorTests();
tests.test_error_type_enum();
tests.test_error_details_creation();
tests.test_error_details_properties();
tests.test_error_details_comparison();
print("All error tests passed!\n");
return 0;
}
public void test_error_type_enum() {
assert(ErrorType.NETWORK == ErrorType.NETWORK);
assert(ErrorType.DATABASE == ErrorType.DATABASE);
assert(ErrorType.PARSING == ErrorType.PARSING);
assert(ErrorType.AUTH == ErrorType.AUTH);
assert(ErrorType.UNKNOWN == ErrorType.UNKNOWN);
print("PASS: test_error_type_enum\n");
}
public void test_error_details_creation() {
// Test default constructor
var error1 = new ErrorDetails(ErrorType.NETWORK, "Connection failed", true);
assert(error1.type == ErrorType.NETWORK);
assert(error1.message == "Connection failed");
assert(error1.retryable == true);
// Test with retryable=false
var error2 = new ErrorDetails(ErrorType.DATABASE, "Table locked", false);
assert(error2.type == ErrorType.DATABASE);
assert(error2.message == "Table locked");
assert(error2.retryable == false);
// Test with null message
var error3 = new ErrorDetails(ErrorType.PARSING, null, true);
assert(error3.type == ErrorType.PARSING);
assert(error3.message == null);
assert(error3.retryable == true);
print("PASS: test_error_details_creation\n");
}
public void test_error_details_properties() {
var error = new ErrorDetails(ErrorType.DATABASE, "Query timeout", true);
// Test property getters
assert(error.type == ErrorType.DATABASE);
assert(error.message == "Query timeout");
assert(error.retryable == true);
// Test property setters
error.type = ErrorType.PARSING;
error.message = "Syntax error";
error.retryable = false;
assert(error.type == ErrorType.PARSING);
assert(error.message == "Syntax error");
assert(error.retryable == false);
print("PASS: test_error_details_properties\n");
}
public void test_error_details_comparison() {
var error1 = new ErrorDetails(ErrorType.NETWORK, "Timeout", true);
var error2 = new ErrorDetails(ErrorType.NETWORK, "Timeout", true);
var error3 = new ErrorDetails(ErrorType.DATABASE, "Timeout", true);
// Same type, message, retryable - equal
assert(error1.type == error2.type);
assert(error1.message == error2.message);
assert(error1.retryable == error2.retryable);
// Different type - not equal
assert(error1.type != error3.type);
// Different retryable - not equal
var error4 = new ErrorDetails(ErrorType.NETWORK, "Timeout", false);
assert(error1.retryable != error4.retryable);
print("PASS: test_error_details_comparison\n");
}
public void test_error_from_gio_error() {
// Simulate Gio.Error
var error = new Gio.Error();
error.set_code(Gio.Error.Code.NOT_FOUND);
error.set_domain("gio");
error.set_message("File not found");
// Convert to ErrorDetails
var details = new ErrorDetails(ErrorType.DATABASE, error.message, true);
assert(details.type == ErrorType.DATABASE);
assert(details.message == "File not found");
assert(details.retryable == true);
print("PASS: test_error_from_gio_error\n");
}
}

View File

@@ -1,247 +1,423 @@
/*
* RepositoryTests.vala
*
* Unit tests for repository layer.
* Unit tests for feed and subscription repositories.
*/
using Gio = Org.Gnome.Valetta.Gio;
public class RSSuper.RepositoryTests {
public static int main(string[] args) {
var tests = new RepositoryTests();
tests.test_bookmark_repository_create();
tests.test_bookmark_repository_read();
tests.test_bookmark_repository_update();
tests.test_bookmark_repository_delete();
tests.test_bookmark_repository_tags();
tests.test_bookmark_repository_by_feed_item();
tests.test_feed_repository_get_items();
tests.test_feed_repository_get_item_by_id();
tests.test_feed_repository_insert_item();
tests.test_feed_repository_insert_items();
tests.test_feed_repository_update_item();
tests.test_feed_repository_mark_as_read();
tests.test_feed_repository_mark_as_starred();
tests.test_feed_repository_delete_item();
tests.test_feed_repository_get_unread_count();
tests.test_subscription_repository_get_all();
tests.test_subscription_repository_get_enabled();
tests.test_subscription_repository_get_by_category();
tests.test_subscription_repository_get_by_id();
tests.test_subscription_repository_get_by_url();
tests.test_subscription_repository_insert();
tests.test_subscription_repository_update();
tests.test_subscription_repository_delete();
tests.test_subscription_repository_set_enabled();
tests.test_subscription_repository_set_error();
tests.test_subscription_repository_update_timestamps();
print("All repository tests passed!\n");
return 0;
}
public void test_bookmark_repository_create() {
// Create a test database
public void test_feed_repository_get_items() {
var db = new Database(":memory:");
var repo = new FeedRepositoryImpl(db);
// Create bookmark repository
var repo = new BookmarkRepositoryImpl(db);
var state = new State<FeedItem[]>();
repo.get_feed_items(null, (s) => {
state.set_success(db.getFeedItems(null));
});
// Create a test bookmark
var bookmark = Bookmark.new_internal(
id: "test-bookmark-1",
feed_item_id: "test-item-1",
created_at: Time.now()
);
assert(state.is_loading() == true);
assert(state.is_success() == false);
assert(state.is_error() == false);
// Test creation
var result = repo.add(bookmark);
if (result.is_error()) {
printerr("FAIL: Bookmark creation failed: %s\n", result.error.message);
return;
print("PASS: test_feed_repository_get_items\n");
}
print("PASS: test_bookmark_repository_create\n");
}
public void test_bookmark_repository_read() {
// Create a test database
public void test_feed_repository_get_item_by_id() {
var db = new Database(":memory:");
var repo = new FeedRepositoryImpl(db);
// Create bookmark repository
var repo = new BookmarkRepositoryImpl(db);
// Create a test bookmark
var bookmark = Bookmark.new_internal(
id: "test-bookmark-2",
feed_item_id: "test-item-2",
created_at: Time.now()
var item = db.create_feed_item(
id: "test-item-1",
title: "Test Item",
url: "https://example.com/article/1"
);
var create_result = repo.add(bookmark);
if (create_result.is_error()) {
printerr("FAIL: Could not create bookmark: %s\n", create_result.error.message);
return;
var result = repo.get_feed_item_by_id("test-item-1");
assert(result != null);
assert(result.id == "test-item-1");
assert(result.title == "Test Item");
print("PASS: test_feed_repository_get_item_by_id\n");
}
// Test reading
var read_result = repo.get_by_id("test-bookmark-2");
if (read_result.is_error()) {
printerr("FAIL: Bookmark read failed: %s\n", read_result.error.message);
return;
}
var saved = read_result.value;
if (saved.id != "test-bookmark-2") {
printerr("FAIL: Expected id 'test-bookmark-2', got '%s'\n", saved.id);
return;
}
print("PASS: test_bookmark_repository_read\n");
}
public void test_bookmark_repository_update() {
// Create a test database
public void test_feed_repository_insert_item() {
var db = new Database(":memory:");
var repo = new FeedRepositoryImpl(db);
// Create bookmark repository
var repo = new BookmarkRepositoryImpl(db);
// Create a test bookmark
var bookmark = Bookmark.new_internal(
id: "test-bookmark-3",
feed_item_id: "test-item-3",
created_at: Time.now()
var item = FeedItem.new(
id: "test-item-2",
title: "New Item",
url: "https://example.com/article/2",
published_at: Time.now()
);
var create_result = repo.add(bookmark);
if (create_result.is_error()) {
printerr("FAIL: Could not create bookmark: %s\n", create_result.error.message);
return;
var result = repo.insert_feed_item(item);
assert(result.is_error() == false);
var retrieved = repo.get_feed_item_by_id("test-item-2");
assert(retrieved != null);
assert(retrieved.id == "test-item-2");
print("PASS: test_feed_repository_insert_item\n");
}
// Update the bookmark
bookmark.tags = ["important", "read-later"];
var update_result = repo.update(bookmark);
if (update_result.is_error()) {
printerr("FAIL: Bookmark update failed: %s\n", update_result.error.message);
return;
}
// Verify update
var read_result = repo.get_by_id("test-bookmark-3");
if (read_result.is_error()) {
printerr("FAIL: Could not read bookmark: %s\n", read_result.error.message);
return;
}
var saved = read_result.value;
if (saved.tags.length != 2) {
printerr("FAIL: Expected 2 tags, got %d\n", saved.tags.length);
return;
}
print("PASS: test_bookmark_repository_update\n");
}
public void test_bookmark_repository_delete() {
// Create a test database
public void test_feed_repository_insert_items() {
var db = new Database(":memory:");
var repo = new FeedRepositoryImpl(db);
// Create bookmark repository
var repo = new BookmarkRepositoryImpl(db);
var items = new FeedItem[2];
// Create a test bookmark
var bookmark = Bookmark.new_internal(
id: "test-bookmark-4",
feed_item_id: "test-item-4",
created_at: Time.now()
items[0] = FeedItem.new(
id: "test-item-3",
title: "Item 1",
url: "https://example.com/article/3",
published_at: Time.now()
);
var create_result = repo.add(bookmark);
if (create_result.is_error()) {
printerr("FAIL: Could not create bookmark: %s\n", create_result.error.message);
return;
items[1] = FeedItem.new(
id: "test-item-4",
title: "Item 2",
url: "https://example.com/article/4",
published_at: Time.now()
);
var result = repo.insert_feed_items(items);
assert(result.is_error() == false);
var all_items = repo.get_feed_items(null);
assert(all_items.length == 2);
print("PASS: test_feed_repository_insert_items\n");
}
// Delete the bookmark
var delete_result = repo.remove("test-bookmark-4");
if (delete_result.is_error()) {
printerr("FAIL: Bookmark deletion failed: %s\n", delete_result.error.message);
return;
}
// Verify deletion
var read_result = repo.get_by_id("test-bookmark-4");
if (!read_result.is_error()) {
printerr("FAIL: Bookmark should have been deleted\n");
return;
}
print("PASS: test_bookmark_repository_delete\n");
}
public void test_bookmark_repository_tags() {
// Create a test database
public void test_feed_repository_update_item() {
var db = new Database(":memory:");
var repo = new FeedRepositoryImpl(db);
// Create bookmark repository
var repo = new BookmarkRepositoryImpl(db);
// Create multiple bookmarks with different tags
var bookmark1 = Bookmark.new_internal(
id: "test-bookmark-5",
feed_item_id: "test-item-5",
created_at: Time.now()
var item = db.create_feed_item(
id: "test-item-5",
title: "Original Title",
url: "https://example.com/article/5"
);
bookmark1.tags = ["important"];
repo.add(bookmark1);
var bookmark2 = Bookmark.new_internal(
id: "test-bookmark-6",
feed_item_id: "test-item-6",
created_at: Time.now()
);
bookmark2.tags = ["read-later"];
repo.add(bookmark2);
item.title = "Updated Title";
// Test tag-based query
var by_tag_result = repo.get_by_tag("important");
var result = repo.update_feed_item(item);
if (by_tag_result.is_error()) {
printerr("FAIL: Tag query failed: %s\n", by_tag_result.error.message);
return;
assert(result.is_error() == false);
var updated = repo.get_feed_item_by_id("test-item-5");
assert(updated != null);
assert(updated.title == "Updated Title");
print("PASS: test_feed_repository_update_item\n");
}
var bookmarks = by_tag_result.value;
if (bookmarks.length != 1) {
printerr("FAIL: Expected 1 bookmark with tag 'important', got %d\n", bookmarks.length);
return;
}
print("PASS: test_bookmark_repository_tags\n");
}
public void test_bookmark_repository_by_feed_item() {
// Create a test database
public void test_feed_repository_mark_as_read() {
var db = new Database(":memory:");
var repo = new FeedRepositoryImpl(db);
// Create bookmark repository
var repo = new BookmarkRepositoryImpl(db);
// Create multiple bookmarks for the same feed item
var bookmark1 = Bookmark.new_internal(
id: "test-bookmark-7",
feed_item_id: "test-item-7",
created_at: Time.now()
var item = db.create_feed_item(
id: "test-item-6",
title: "Read Item",
url: "https://example.com/article/6"
);
repo.add(bookmark1);
var bookmark2 = Bookmark.new_internal(
id: "test-bookmark-8",
feed_item_id: "test-item-7",
created_at: Time.now()
var result = repo.mark_as_read("test-item-6", true);
assert(result.is_error() == false);
var unread = repo.get_unread_count(null);
assert(unread == 0);
print("PASS: test_feed_repository_mark_as_read\n");
}
public void test_feed_repository_mark_as_starred() {
var db = new Database(":memory:");
var repo = new FeedRepositoryImpl(db);
var item = db.create_feed_item(
id: "test-item-7",
title: "Starred Item",
url: "https://example.com/article/7"
);
repo.add(bookmark2);
// Test feed item-based query
var by_item_result = repo.get_by_feed_item("test-item-7");
var result = repo.mark_as_starred("test-item-7", true);
if (by_item_result.is_error()) {
printerr("FAIL: Feed item query failed: %s\n", by_item_result.error.message);
return;
assert(result.is_error() == false);
print("PASS: test_feed_repository_mark_as_starred\n");
}
var bookmarks = by_item_result.value;
if (bookmarks.length != 2) {
printerr("FAIL: Expected 2 bookmarks for feed item, got %d\n", bookmarks.length);
return;
public void test_feed_repository_delete_item() {
var db = new Database(":memory:");
var repo = new FeedRepositoryImpl(db);
var item = db.create_feed_item(
id: "test-item-8",
title: "Delete Item",
url: "https://example.com/article/8"
);
var result = repo.delete_feed_item("test-item-8");
assert(result.is_error() == false);
var deleted = repo.get_feed_item_by_id("test-item-8");
assert(deleted == null);
print("PASS: test_feed_repository_delete_item\n");
}
print("PASS: test_bookmark_repository_by_feed_item\n");
public void test_feed_repository_get_unread_count() {
var db = new Database(":memory:");
var repo = new FeedRepositoryImpl(db);
var count = repo.get_unread_count(null);
assert(count == 0);
print("PASS: test_feed_repository_get_unread_count\n");
}
public void test_subscription_repository_get_all() {
var db = new Database(":memory:");
var repo = new SubscriptionRepositoryImpl(db);
var state = new State<FeedSubscription[]>();
repo.get_all_subscriptions((s) => {
state.set_success(db.getAllSubscriptions());
});
assert(state.is_loading() == true);
assert(state.is_success() == false);
print("PASS: test_subscription_repository_get_all\n");
}
public void test_subscription_repository_get_enabled() {
var db = new Database(":memory:");
var repo = new SubscriptionRepositoryImpl(db);
var state = new State<FeedSubscription[]>();
repo.get_enabled_subscriptions((s) => {
state.set_success(db.getEnabledSubscriptions());
});
assert(state.is_loading() == true);
assert(state.is_success() == false);
print("PASS: test_subscription_repository_get_enabled\n");
}
public void test_subscription_repository_get_by_category() {
var db = new Database(":memory:");
var repo = new SubscriptionRepositoryImpl(db);
var state = new State<FeedSubscription[]>();
repo.get_subscriptions_by_category("technology", (s) => {
state.set_success(db.getSubscriptionsByCategory("technology"));
});
assert(state.is_loading() == true);
assert(state.is_success() == false);
print("PASS: test_subscription_repository_get_by_category\n");
}
public void test_subscription_repository_get_by_id() {
var db = new Database(":memory:");
var repo = new SubscriptionRepositoryImpl(db);
var subscription = db.create_subscription(
id: "test-sub-1",
url: "https://example.com/feed.xml",
title: "Test Subscription"
);
var result = repo.get_subscription_by_id("test-sub-1");
assert(result != null);
assert(result.id == "test-sub-1");
print("PASS: test_subscription_repository_get_by_id\n");
}
public void test_subscription_repository_get_by_url() {
var db = new Database(":memory:");
var repo = new SubscriptionRepositoryImpl(db);
var subscription = db.create_subscription(
id: "test-sub-2",
url: "https://example.com/feed.xml",
title: "Test Subscription"
);
var result = repo.get_subscription_by_url("https://example.com/feed.xml");
assert(result != null);
assert(result.url == "https://example.com/feed.xml");
print("PASS: test_subscription_repository_get_by_url\n");
}
public void test_subscription_repository_insert() {
var db = new Database(":memory:");
var repo = new SubscriptionRepositoryImpl(db);
var subscription = FeedSubscription.new(
id: "test-sub-3",
url: "https://example.com/feed.xml",
title: "New Subscription",
enabled: true
);
var result = repo.insert_subscription(subscription);
assert(result.is_error() == false);
var retrieved = repo.get_subscription_by_id("test-sub-3");
assert(retrieved != null);
assert(retrieved.id == "test-sub-3");
print("PASS: test_subscription_repository_insert\n");
}
public void test_subscription_repository_update() {
var db = new Database(":memory:");
var repo = new SubscriptionRepositoryImpl(db);
var subscription = db.create_subscription(
id: "test-sub-4",
url: "https://example.com/feed.xml",
title: "Original Title"
);
subscription.title = "Updated Title";
var result = repo.update_subscription(subscription);
assert(result.is_error() == false);
var updated = repo.get_subscription_by_id("test-sub-4");
assert(updated != null);
assert(updated.title == "Updated Title");
print("PASS: test_subscription_repository_update\n");
}
public void test_subscription_repository_delete() {
var db = new Database(":memory:");
var repo = new SubscriptionRepositoryImpl(db);
var subscription = db.create_subscription(
id: "test-sub-5",
url: "https://example.com/feed.xml",
title: "Delete Subscription"
);
var result = repo.delete_subscription("test-sub-5");
assert(result.is_error() == false);
var deleted = repo.get_subscription_by_id("test-sub-5");
assert(deleted == null);
print("PASS: test_subscription_repository_delete\n");
}
public void test_subscription_repository_set_enabled() {
var db = new Database(":memory:");
var repo = new SubscriptionRepositoryImpl(db);
var subscription = db.create_subscription(
id: "test-sub-6",
url: "https://example.com/feed.xml",
title: "Toggle Subscription"
);
var result = repo.set_enabled("test-sub-6", false);
assert(result.is_error() == false);
var updated = repo.get_subscription_by_id("test-sub-6");
assert(updated != null);
assert(updated.enabled == false);
print("PASS: test_subscription_repository_set_enabled\n");
}
public void test_subscription_repository_set_error() {
var db = new Database(":memory:");
var repo = new SubscriptionRepositoryImpl(db);
var subscription = db.create_subscription(
id: "test-sub-7",
url: "https://example.com/feed.xml",
title: "Error Subscription"
);
var result = repo.set_error("test-sub-7", "Connection failed");
assert(result.is_error() == false);
print("PASS: test_subscription_repository_set_error\n");
}
public void test_subscription_repository_update_timestamps() {
var db = new Database(":memory:");
var repo = new SubscriptionRepositoryImpl(db);
var subscription = db.create_subscription(
id: "test-sub-8",
url: "https://example.com/feed.xml",
title: "Timestamp Test"
);
var last_fetched = Time.now().unix_timestamp;
var next_fetch = Time.now().unix_timestamp + 3600;
var result = repo.update_last_fetched_at("test-sub-8", last_fetched);
var result2 = repo.update_next_fetch_at("test-sub-8", next_fetch);
assert(result.is_error() == false);
assert(result2.is_error() == false);
print("PASS: test_subscription_repository_update_timestamps\n");
}
}

View File

@@ -0,0 +1,185 @@
/*
* StateTests.vala
*
* Unit tests for state management types.
*/
using Gio = Org.Gnome.Valetta.Gio;
public class RSSuper.StateTests {
public static int main(string[] args) {
var tests = new StateTests();
tests.test_state_enum_values();
tests.test_state_class_initialization();
tests.test_state_setters();
tests.test_state_getters();
tests.test_state_comparison();
tests.test_state_signal_emission();
print("All state tests passed!\n");
return 0;
}
public void test_state_enum_values() {
assert(State.IDLE == State.IDLE);
assert(State.LOADING == State.LOADING);
assert(State.SUCCESS == State.SUCCESS);
assert(State.ERROR == State.ERROR);
print("PASS: test_state_enum_values\n");
}
public void test_state_class_initialization() {
var state = new State<string>();
assert(state.get_state() == State.IDLE);
assert(state.is_idle());
assert(!state.is_loading());
assert(!state.is_success());
assert(!state.is_error());
print("PASS: test_state_class_initialization\n");
}
public void test_state_setters() {
var state = new State<string>();
// Test set_idle
state.set_idle();
assert(state.get_state() == State.IDLE);
assert(state.is_idle());
// Test set_loading
state.set_loading();
assert(state.get_state() == State.LOADING);
assert(state.is_loading());
// Test set_success
string data = "test data";
state.set_success(data);
assert(state.get_state() == State.SUCCESS);
assert(state.is_success());
assert(state.get_data() == data);
// Test set_error
state.set_error("test error");
assert(state.get_state() == State.ERROR);
assert(state.is_error());
assert(state.get_message() == "test error");
print("PASS: test_state_setters\n");
}
public void test_state_getters() {
var state = new State<int>();
// Test initial values
assert(state.get_state() == State.IDLE);
assert(state.get_data() == null);
assert(state.get_message() == null);
assert(state.get_error() == null);
// Test after set_success
state.set_success(42);
assert(state.get_state() == State.SUCCESS);
assert(state.get_data() == 42);
assert(state.get_message() == null);
assert(state.get_error() == null);
// Test after set_error
state.set_error("database error");
assert(state.get_state() == State.ERROR);
assert(state.get_data() == null);
assert(state.get_message() == "database error");
assert(state.get_error() != null);
print("PASS: test_state_getters\n");
}
public void test_state_comparison() {
var state1 = new State<string>();
var state2 = new State<string>();
// Initially equal
assert(state1.get_state() == state2.get_state());
// After different states, not equal
state1.set_success("value1");
state2.set_error("error");
assert(state1.get_state() != state2.get_state());
// Same state, different data
var state3 = new State<string>();
state3.set_success("value2");
assert(state3.get_state() == state1.get_state());
assert(state3.get_data() != state1.get_data());
print("PASS: test_state_comparison\n");
}
public void test_state_signal_emission() {
var state = new State<string>();
// Track signal emissions
int state_changed_count = 0;
int data_changed_count = 0;
state.connect_signal("state_changed", (sender, signal) => {
state_changed_count++;
});
state.connect_signal("data_changed", (sender, signal) => {
data_changed_count++;
});
// Initial state - no signals
assert(state_changed_count == 0);
assert(data_changed_count == 0);
// set_loading emits state_changed
state.set_loading();
assert(state_changed_count == 1);
assert(data_changed_count == 0);
// set_success emits both signals
state.set_success("test");
assert(state_changed_count == 2);
assert(data_changed_count == 1);
// set_error emits state_changed only
state.set_error("error");
assert(state_changed_count == 3);
assert(data_changed_count == 1);
print("PASS: test_state_signal_emission\n");
}
public void test_generic_state_t() {
// Test State<int>
var intState = new State<int>();
intState.set_success(123);
assert(intState.get_data() == 123);
assert(intState.is_success());
// Test State<bool>
var boolState = new State<bool>();
boolState.set_success(true);
assert(boolState.get_data() == true);
assert(boolState.is_success());
// Test State<string>
var stringState = new State<string>();
stringState.set_success("hello");
assert(stringState.get_data() == "hello");
assert(stringState.is_success());
// Test State<object>
var objectState = new State<object>();
objectState.set_success("test");
assert(objectState.get_data() == "test");
print("PASS: test_generic_state_t\n");
}
}

View File

@@ -1,123 +1,242 @@
/*
* ViewModelTests.vala
*
* Unit tests for view models.
* Unit tests for feed and subscription view models.
*/
using Gio = Org.Gnome.Valetta.Gio;
public class RSSuper.ViewModelTests {
public static int main(string[] args) {
var tests = new ViewModelTests();
tests.test_feed_view_model_state();
tests.test_feed_view_model_initialization();
tests.test_feed_view_model_loading();
tests.test_feed_view_model_success();
tests.test_feed_view_model_error();
tests.test_subscription_view_model_state();
tests.test_feed_view_model_mark_as_read();
tests.test_feed_view_model_mark_as_starred();
tests.test_feed_view_model_refresh();
tests.test_subscription_view_model_initialization();
tests.test_subscription_view_model_loading();
tests.test_subscription_view_model_set_enabled();
tests.test_subscription_view_model_set_error();
tests.test_subscription_view_model_update_timestamps();
tests.test_subscription_view_model_refresh();
print("All view model tests passed!\n");
return 0;
}
public void test_feed_view_model_state() {
// Create a test database
public void test_feed_view_model_initialization() {
var db = new Database(":memory:");
var repo = new FeedRepositoryImpl(db);
var model = new FeedViewModel(repo);
// Create feed view model
var model = new FeedViewModel(db);
assert(model.feedState.get_state() == State.IDLE);
assert(model.unreadCountState.get_state() == State.IDLE);
// Test initial state
assert(model.feed_state == FeedState.idle);
print("PASS: test_feed_view_model_state\n");
print("PASS: test_feed_view_model_initialization\n");
}
public void test_feed_view_model_loading() {
// Create a test database
var db = new Database(":memory:");
var repo = new FeedRepositoryImpl(db);
var model = new FeedViewModel(repo);
// Create feed view model
var model = new FeedViewModel(db);
model.load_feed_items("test-subscription");
// Test loading state
model.load_feed_items("test-subscription-id");
assert(model.feed_state is FeedState.loading);
assert(model.feedState.is_loading() == true);
assert(model.feedState.is_success() == false);
assert(model.feedState.is_error() == false);
print("PASS: test_feed_view_model_loading\n");
}
public void test_feed_view_model_success() {
// Create a test database
var db = new Database(":memory:");
var repo = new FeedRepositoryImpl(db);
var model = new FeedViewModel(repo);
// Create subscription
db.create_subscription(
id: "test-sub",
url: "https://example.com/feed.xml",
title: "Test Feed"
);
// Mock success state
var items = db.getFeedItems("test-subscription");
model.feedState.set_success(items);
// Create feed view model
var model = new FeedViewModel(db);
// Test success state (mocked for unit test)
// In a real test, we would mock the database or use a test database
var items = new FeedItem[0];
model.feed_state = FeedState.success(items);
assert(model.feed_state is FeedState.success);
var success_state = (FeedState.success) model.feed_state;
assert(success_state.items.length == 0);
assert(model.feedState.is_success() == true);
assert(model.feedState.get_data().length > 0);
print("PASS: test_feed_view_model_success\n");
}
public void test_feed_view_model_error() {
// Create a test database
var db = new Database(":memory:");
var repo = new FeedRepositoryImpl(db);
var model = new FeedViewModel(repo);
// Create feed view model
var model = new FeedViewModel(db);
// Mock error state
model.feedState.set_error("Connection failed");
// Test error state
model.feed_state = FeedState.error("Test error");
assert(model.feed_state is FeedState.error);
var error_state = (FeedState.error) model.feed_state;
assert(error_state.message == "Test error");
assert(model.feedState.is_error() == true);
assert(model.feedState.get_message() == "Connection failed");
print("PASS: test_feed_view_model_error\n");
}
public void test_subscription_view_model_state() {
// Create a test database
public void test_feed_view_model_mark_as_read() {
var db = new Database(":memory:");
var repo = new FeedRepositoryImpl(db);
var model = new FeedViewModel(repo);
// Create subscription view model
var model = new SubscriptionViewModel(db);
model.mark_as_read("test-item-1", true);
// Test initial state
assert(model.subscription_state is SubscriptionState.idle);
assert(model.unreadCountState.is_loading() == true);
print("PASS: test_subscription_view_model_state\n");
print("PASS: test_feed_view_model_mark_as_read\n");
}
public void test_feed_view_model_mark_as_starred() {
var db = new Database(":memory:");
var repo = new FeedRepositoryImpl(db);
var model = new FeedViewModel(repo);
model.mark_as_starred("test-item-2", true);
assert(model.feedState.is_loading() == true);
print("PASS: test_feed_view_model_mark_as_starred\n");
}
public void test_feed_view_model_refresh() {
var db = new Database(":memory:");
var repo = new FeedRepositoryImpl(db);
var model = new FeedViewModel(repo);
model.refresh("test-subscription");
assert(model.feedState.is_loading() == true);
assert(model.unreadCountState.is_loading() == true);
print("PASS: test_feed_view_model_refresh\n");
}
public void test_subscription_view_model_initialization() {
var db = new Database(":memory:");
var repo = new SubscriptionRepositoryImpl(db);
var model = new SubscriptionViewModel(repo);
assert(model.subscriptionsState.get_state() == State.IDLE);
assert(model.enabledSubscriptionsState.get_state() == State.IDLE);
print("PASS: test_subscription_view_model_initialization\n");
}
public void test_subscription_view_model_loading() {
// Create a test database
var db = new Database(":memory:");
var repo = new SubscriptionRepositoryImpl(db);
var model = new SubscriptionViewModel(repo);
// Create subscription view model
var model = new SubscriptionViewModel(db);
model.load_all_subscriptions();
// Test loading state
model.load_subscriptions();
assert(model.subscription_state is SubscriptionState.loading);
assert(model.subscriptionsState.is_loading() == true);
assert(model.subscriptionsState.is_success() == false);
assert(model.subscriptionsState.is_error() == false);
print("PASS: test_subscription_view_model_loading\n");
}
public void test_subscription_view_model_set_enabled() {
var db = new Database(":memory:");
var repo = new SubscriptionRepositoryImpl(db);
var model = new SubscriptionViewModel(repo);
model.set_enabled("test-sub-1", false);
assert(model.enabledSubscriptionsState.is_loading() == true);
print("PASS: test_subscription_view_model_set_enabled\n");
}
public void test_subscription_view_model_set_error() {
var db = new Database(":memory:");
var repo = new SubscriptionRepositoryImpl(db);
var model = new SubscriptionViewModel(repo);
model.set_error("test-sub-2", "Connection failed");
assert(model.subscriptionsState.is_error() == true);
assert(model.subscriptionsState.get_message() == "Connection failed");
print("PASS: test_subscription_view_model_set_error\n");
}
public void test_subscription_view_model_update_timestamps() {
var db = new Database(":memory:");
var repo = new SubscriptionRepositoryImpl(db);
var model = new SubscriptionViewModel(repo);
var last_fetched = Time.now().unix_timestamp;
var next_fetch = Time.now().unix_timestamp + 3600;
model.update_last_fetched_at("test-sub-3", last_fetched);
model.update_next_fetch_at("test-sub-3", next_fetch);
assert(model.subscriptionsState.is_loading() == true);
print("PASS: test_subscription_view_model_update_timestamps\n");
}
public void test_subscription_view_model_refresh() {
var db = new Database(":memory:");
var repo = new SubscriptionRepositoryImpl(db);
var model = new SubscriptionViewModel(repo);
model.refresh();
assert(model.subscriptionsState.is_loading() == true);
assert(model.enabledSubscriptionsState.is_loading() == true);
print("PASS: test_subscription_view_model_refresh\n");
}
public void test_feed_view_model_signal_propagation() {
var db = new Database(":memory:");
var repo = new FeedRepositoryImpl(db);
var model = new FeedViewModel(repo);
// Track signal emissions
int stateChangedCount = 0;
model.feedState.connect_signal("state_changed", (sender, signal) => {
stateChangedCount++;
});
// Load items - should emit state_changed
model.load_feed_items("test-sub");
// Verify signal was emitted during loading
assert(stateChangedCount >= 1);
print("PASS: test_feed_view_model_signal_propagation\n");
}
public void test_subscription_view_model_signal_propagation() {
var db = new Database(":memory:");
var repo = new SubscriptionRepositoryImpl(db);
var model = new SubscriptionViewModel(repo);
// Track signal emissions
int stateChangedCount = 0;
model.subscriptionsState.connect_signal("state_changed", (sender, signal) => {
stateChangedCount++;
});
// Load subscriptions - should emit state_changed
model.load_all_subscriptions();
// Verify signal was emitted during loading
assert(stateChangedCount >= 1);
print("PASS: test_subscription_view_model_signal_propagation\n");
}
}

View File

@@ -31,7 +31,7 @@ namespace RSSuper {
public void load_feed_items(string? subscription_id = null) {
feedState.set_loading();
repository.get_feed_items(subscription_id, (state) => {
feedState = state;
feedState.set_success(state.get_data());
});
}
@@ -41,7 +41,7 @@ namespace RSSuper {
var count = repository.get_unread_count(subscription_id);
unreadCountState.set_success(count);
} catch (Error e) {
unreadCountState.set_error("Failed to load unread count", e);
unreadCountState.set_error("Failed to load unread count", new ErrorDetails(ErrorType.DATABASE, e.message, true));
}
}
@@ -50,7 +50,7 @@ namespace RSSuper {
repository.mark_as_read(id, is_read);
load_unread_count();
} catch (Error e) {
unreadCountState.set_error("Failed to update read state", e);
unreadCountState.set_error("Failed to update read state", new ErrorDetails(ErrorType.DATABASE, e.message, true));
}
}
@@ -58,7 +58,7 @@ namespace RSSuper {
try {
repository.mark_as_starred(id, is_starred);
} catch (Error e) {
feedState.set_error("Failed to update starred state", e);
feedState.set_error("Failed to update starred state", new ErrorDetails(ErrorType.DATABASE, e.message, true));
}
}

View File

@@ -31,14 +31,14 @@ namespace RSSuper {
public void load_all_subscriptions() {
subscriptionsState.set_loading();
repository.get_all_subscriptions((state) => {
subscriptionsState = state;
subscriptionsState.set_success(state.get_data());
});
}
public void load_enabled_subscriptions() {
enabledSubscriptionsState.set_loading();
repository.get_enabled_subscriptions((state) => {
enabledSubscriptionsState = state;
enabledSubscriptionsState.set_success(state.get_data());
});
}
@@ -47,7 +47,7 @@ namespace RSSuper {
repository.set_enabled(id, enabled);
load_enabled_subscriptions();
} catch (Error e) {
enabledSubscriptionsState.set_error("Failed to update subscription enabled state", e);
enabledSubscriptionsState.set_error("Failed to update subscription enabled state", new ErrorDetails(ErrorType.DATABASE, e.message, true));
}
}
@@ -55,7 +55,7 @@ namespace RSSuper {
try {
repository.set_error(id, error);
} catch (Error e) {
subscriptionsState.set_error("Failed to set subscription error", e);
subscriptionsState.set_error("Failed to set subscription error", new ErrorDetails(ErrorType.DATABASE, e.message, true));
}
}
@@ -63,7 +63,7 @@ namespace RSSuper {
try {
repository.update_last_fetched_at(id, last_fetched_at);
} catch (Error e) {
subscriptionsState.set_error("Failed to update last fetched time", e);
subscriptionsState.set_error("Failed to update last fetched time", new ErrorDetails(ErrorType.DATABASE, e.message, true));
}
}
@@ -71,7 +71,7 @@ namespace RSSuper {
try {
repository.update_next_fetch_at(id, next_fetch_at);
} catch (Error e) {
subscriptionsState.set_error("Failed to update next fetch time", e);
subscriptionsState.set_error("Failed to update next fetch time", new ErrorDetails(ErrorType.DATABASE, e.message, true));
}
}

View File

@@ -0,0 +1,201 @@
<?xml version="1.0" encoding="UTF-8"?>
<EntityDescription>
<Name>FeedItem</Name>
<Attributes>
<AttributeDescription>
<Name>id</Name>
<Type>NSUUID</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>subscriptionId</Name>
<Type>NSString</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>title</Name>
<Type>NSString</Type>
<Required>true</Required>
<Searchable>true</Searchable>
<FTSSearchable>true</FTSSearchable>
</AttributeDescription>
<AttributeDescription>
<Name>link</Name>
<Type>NSString</Type>
<Required>false</Required>
<Searchable>true</Searchable>
<FTSSearchable>true</FTSSearchable>
</AttributeDescription>
<AttributeDescription>
<Name>description</Name>
<Type>NSString</Type>
<Required>false</Required>
<Searchable>true</Searchable>
<FTSSearchable>true</FTSSearchable>
</AttributeDescription>
<AttributeDescription>
<Name>content</Name>
<Type>NSString</Type>
<Required>false</Required>
<Searchable>true</Searchable>
<FTSSearchable>true</FTSSearchable>
</AttributeDescription>
<AttributeDescription>
<Name>author</Name>
<Type>NSString</Type>
<Required>false</Required>
<Searchable>true</Searchable>
<FTSSearchable>true</FTSSearchable>
</AttributeDescription>
<AttributeDescription>
<Name>published</Name>
<Type>NSString</Type>
<Required>false</Required>
</AttributeDescription>
<AttributeDescription>
<Name>updated</Name>
<Type>NSString</Type>
<Required>false</Required>
</AttributeDescription>
<AttributeDescription>
<Name>categories</Name>
<Type>NSString</Type>
<Required>false</Required>
</AttributeDescription>
<AttributeDescription>
<Name>enclosureUrl</Name>
<Type>NSString</Type>
<Required>false</Required>
</AttributeDescription>
<AttributeDescription>
<Name>enclosureType</Name>
<Type>NSString</Type>
<Required>false</Required>
</AttributeDescription>
<AttributeDescription>
<Name>enclosureLength</Name>
<Type>NSNumber</Type>
<Required>false</Required>
</AttributeDescription>
<AttributeDescription>
<Name>guid</Name>
<Type>NSString</Type>
<Required>false</Required>
</AttributeDescription>
<AttributeDescription>
<Name>isRead</Name>
<Type>NSNumber</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>isStarred</Name>
<Type>NSNumber</Type>
<Required>true</Required>
</AttributeDescription>
</Attributes>
<Relationships>
<RelationshipDescription>
<Name>subscription</Name>
<SourceEntity>FeedItem</SourceEntity>
<DestinationEntity>FeedSubscription</DestinationEntity>
<IsOptional>false</IsOptional>
<IsNullable>true</IsNullable>
</RelationshipDescription>
</Relationships>
</EntityDescription>
<EntityDescription>
<Name>FeedSubscription</Name>
<Attributes>
<AttributeDescription>
<Name>id</Name>
<Type>NSUUID</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>url</Name>
<Type>NSString</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>title</Name>
<Type>NSString</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>enabled</Name>
<Type>NSNumber</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>lastFetchedAt</Name>
<Type>NSNumber</Type>
<Required>false</Required>
</AttributeDescription>
<AttributeDescription>
<Name>nextFetchAt</Name>
<Type>NSNumber</Type>
<Required>false</Required>
</AttributeDescription>
<AttributeDescription>
<Name>error</Name>
<Type>NSString</Type>
<Required>false</Required>
</AttributeDescription>
</Attributes>
<Relationships>
<RelationshipDescription>
<Name>feedItems</Name>
<SourceEntity>FeedSubscription</SourceEntity>
<DestinationEntity>FeedItem</DestinationEntity>
<IsOptional>true</IsOptional>
<IsNullable>true</IsNullable>
</RelationshipDescription>
</Relationships>
</EntityDescription>
<EntityDescription>
<Name>SearchHistoryEntry</Name>
<Attributes>
<AttributeDescription>
<Name>id</Name>
<Type>NSNumber</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>query</Name>
<Type>NSString</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>filtersJson</Name>
<Type>NSString</Type>
<Required>false</Required>
</AttributeDescription>
<AttributeDescription>
<Name>sortOption</Name>
<Type>NSString</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>page</Name>
<Type>NSNumber</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>pageSize</Name>
<Type>NSNumber</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>resultCount</Name>
<Type>NSNumber</Type>
<Required>true</Required>
</AttributeDescription>
<AttributeDescription>
<Name>createdAt</Name>
<Type>NSDate</Type>
<Required>true</Required>
</AttributeDescription>
</Attributes>
</EntityDescription>

View File

@@ -0,0 +1,98 @@
/*
* SearchFilters.swift
*
* Search filter model for iOS search service.
*/
import Foundation
/// Search filter configuration
class SearchFilters: Codable {
/// Date range filter
let dateFrom: Date?
/// Date range filter
let dateTo: Date?
/// Feed ID filter
let feedIds: [String]?
/// Author filter
let author: String?
/// Category filter
let category: String?
/// Enclosure type filter
let enclosureType: String?
/// Enclosure length filter
let enclosureLength: Double?
/// Is read filter
let isRead: Bool?
/// Is starred filter
let isStarred: Bool?
/// Initialize search filters
init(
dateFrom: Date? = nil,
dateTo: Date? = nil,
feedIds: [String]? = nil,
author: String? = nil,
category: String? = nil,
enclosureType: String? = nil,
enclosureLength: Double? = nil,
isRead: Bool? = nil,
isStarred: Bool? = nil
) {
self.dateFrom = dateFrom
self.dateTo = dateTo
self.feedIds = feedIds
self.author = author
self.category = category
self.enclosureType = enclosureType
self.enclosureLength = enclosureLength
self.isRead = isRead
self.isStarred = isStarred
}
/// Initialize with values
init(
dateFrom: Date?,
dateTo: Date?,
feedIds: [String]?,
author: String?,
category: String?,
enclosureType: String?,
enclosureLength: Double?,
isRead: Bool?,
isStarred: Bool?
) {
self.dateFrom = dateFrom
self.dateTo = dateTo
self.feedIds = feedIds
self.author = author
self.category = category
self.enclosureType = enclosureType
self.enclosureLength = enclosureLength
self.isRead = isRead
self.isStarred = isStarred
}
}
/// Search filter to string converter
extension SearchFilters {
func filtersToJSON() -> String {
try? JSONEncoder().encode(self).data(using: .utf8)?.description ?? ""
}
init?(json: String) {
guard let data = json.data(using: .utf8),
let decoded = try? JSONDecoder().decode(SearchFilters.self, from: data) else {
return nil
}
self = decoded
}
}

View File

@@ -0,0 +1,212 @@
/*
* SearchQuery.swift
*
* Search query model for iOS search service.
*/
import Foundation
/// Search query parameters
class SearchQuery: Codable {
/// The search query string
let query: String
/// Current page number (0-indexed)
let page: Int
/// Items per page
let pageSize: Int
/// Optional filters
let filters: [SearchFilter]?
/// Sort option
let sortOrder: SearchSortOption
/// Timestamp when query was made
let createdAt: Date
/// Human-readable description
var description: String {
guard !query.isEmpty else { return "Search" }
return query
}
/// JSON representation
var jsonRepresentation: String {
try? JSONEncoder().encode(self).data(using: .utf8)?.description ?? ""
}
/// Initialize a search query
init(
query: String,
page: Int = 0,
pageSize: Int = 50,
filters: [SearchFilter]? = nil,
sortOrder: SearchSortOption = .relevance
) {
self.query = query
self.page = page
self.pageSize = pageSize
self.filters = filters
self.sortOrder = sortOrder
self.createdAt = Date()
}
/// Initialize with values
init(
query: String,
page: Int,
pageSize: Int,
filters: [SearchFilter]?,
sortOrder: SearchSortOption
) {
self.query = query
self.page = page
self.pageSize = pageSize
self.filters = filters
self.sortOrder = sortOrder
self.createdAt = Date()
}
}
/// Search filter options
enum SearchFilter: String, Codable, CaseIterable {
case dateRange
case feedID
case author
case category
case enclosureType
case enclosureLength
case isRead
case isStarred
case publishedDateRange
case title
}
/// Search sort options
enum SearchSortOption: String, Codable, CaseIterable {
case relevance
case publishedDate
case updatedDate
case title
case feedTitle
case author
}
/// Search sort option converter
extension SearchSortOption {
static func sortOptionToKey(_ option: SearchSortOption) -> String {
switch option {
case .relevance: return "relevance"
case .publishedDate: return "publishedDate"
case .updatedDate: return "updatedDate"
case .title: return "title"
case .feedTitle: return "feedTitle"
case .author: return "author"
}
}
static func sortOptionFromKey(_ key: String) -> SearchSortOption {
switch key {
case "relevance": return .relevance
case "publishedDate": return .publishedDate
case "updatedDate": return .updatedDate
case "title": return .title
case "feedTitle": return .feedTitle
case "author": return .author
default: return .relevance
}
}
}
/// Search filter configuration
class SearchFilters: Codable {
/// Date range filter
let dateFrom: Date?
/// Date range filter
let dateTo: Date?
/// Feed ID filter
let feedIds: [String]?
/// Author filter
let author: String?
/// Category filter
let category: String?
/// Enclosure type filter
let enclosureType: String?
/// Enclosure length filter
let enclosureLength: Double?
/// Is read filter
let isRead: Bool?
/// Is starred filter
let isStarred: Bool?
/// Initialize search filters
init(
dateFrom: Date? = nil,
dateTo: Date? = nil,
feedIds: [String]? = nil,
author: String? = nil,
category: String? = nil,
enclosureType: String? = nil,
enclosureLength: Double? = nil,
isRead: Bool? = nil,
isStarred: Bool? = nil
) {
self.dateFrom = dateFrom
self.dateTo = dateTo
self.feedIds = feedIds
self.author = author
self.category = category
self.enclosureType = enclosureType
self.enclosureLength = enclosureLength
self.isRead = isRead
self.isStarred = isStarred
}
/// Initialize with values
init(
dateFrom: Date?,
dateTo: Date?,
feedIds: [String]?,
author: String?,
category: String?,
enclosureType: String?,
enclosureLength: Double?,
isRead: Bool?,
isStarred: Bool?
) {
self.dateFrom = dateFrom
self.dateTo = dateTo
self.feedIds = feedIds
self.author = author
self.category = category
self.enclosureType = enclosureType
self.enclosureLength = enclosureLength
self.isRead = isRead
self.isStarred = isStarred
}
}
/// Search filter to string converter
extension SearchFilters {
func filtersToJSON() -> String {
try? JSONEncoder().encode(self).data(using: .utf8)?.description ?? ""
}
init?(json: String) {
guard let data = json.data(using: .utf8),
let decoded = try? JSONDecoder().decode(SearchFilters.self, from: data) else {
return nil
}
self = decoded
}
}

View File

@@ -0,0 +1,331 @@
/*
* SearchResult.swift
*
* Search result model for iOS search service.
*/
import Foundation
/// Search result type
enum SearchResultType: String, Codable, CaseIterable {
case article
case feed
case notification
case bookmark
}
/// Search result highlight configuration
struct SearchResultHighlight: Codable {
/// The original text
let original: String
/// Highlighted text
let highlighted: String
/// Indices of highlighted ranges
let ranges: [(start: Int, end: Int)]
/// Matched terms
let matchedTerms: [String]
private enum CodingKeys: String, CodingKey {
case original, highlighted, ranges, matchedTerms
}
}
/// Search result item
class SearchResult: Codable, Equatable {
/// Unique identifier
var id: String?
/// Type of search result
var type: SearchResultType
/// Main title
var title: String?
/// Description
var description: String?
/// Full content
var content: String?
/// Link URL
var link: String?
/// Feed title (for feed results)
var feedTitle: String?
/// Published date
var published: String?
/// Updated date
var updated: String?
/// Author
var author: String?
/// Categories
var categories: [String]?
/// Enclosure URL
var enclosureUrl: String?
/// Enclosure type
var enclosureType: String?
/// Enclosure length
var enclosureLength: Double?
/// Search relevance score (0.0 to 1.0)
var score: Double = 0.0
/// Highlighted text
var highlightedText: String? {
guard let content = content else { return nil }
return highlightText(content, query: nil) // Highlight all text
}
/// Initialize with values
init(
id: String?,
type: SearchResultType,
title: String?,
description: String?,
content: String?,
link: String?,
feedTitle: String?,
published: String?,
updated: String? = nil,
author: String? = nil,
categories: [String]? = nil,
enclosureUrl: String? = nil,
enclosureType: String? = nil,
enclosureLength: Double? = nil,
score: Double = 0.0,
highlightedText: String? = nil
) {
self.id = id
self.type = type
self.title = title
self.description = description
self.content = content
self.link = link
self.feedTitle = feedTitle
self.published = published
self.updated = updated
self.author = author
self.categories = categories
self.enclosureUrl = enclosureUrl
self.enclosureType = enclosureType
self.enclosureLength = enclosureLength
self.score = score
self.highlightedText = highlightedText
}
/// Initialize with values (without highlightedText)
init(
id: String?,
type: SearchResultType,
title: String?,
description: String?,
content: String?,
link: String?,
feedTitle: String?,
published: String?,
updated: String? = nil,
author: String? = nil,
categories: [String]? = nil,
enclosureUrl: String? = nil,
enclosureType: String? = nil,
enclosureLength: Double? = nil,
score: Double = 0.0
) {
self.id = id
self.type = type
self.title = title
self.description = description
self.content = content
self.link = link
self.feedTitle = feedTitle
self.published = published
self.updated = updated
self.author = author
self.categories = categories
self.enclosureUrl = enclosureUrl
self.enclosureType = enclosureType
self.enclosureLength = enclosureLength
self.score = score
}
/// Initialize with values (for Core Data)
init(
id: String?,
type: SearchResultType,
title: String?,
description: String?,
content: String?,
link: String?,
feedTitle: String?,
published: String?,
updated: String? = nil,
author: String? = nil,
categories: [String]? = nil,
enclosureUrl: String? = nil,
enclosureType: String? = nil,
enclosureLength: Double? = nil,
score: Double = 0.0
) {
self.id = id
self.type = type
self.title = title
self.description = description
self.content = content
self.link = link
self.feedTitle = feedTitle
self.published = published
self.updated = updated
self.author = author
self.categories = categories
self.enclosureUrl = enclosureUrl
self.enclosureType = enclosureType
self.enclosureLength = enclosureLength
self.score = score
}
/// Initialize with values (for GRDB)
init(
id: String?,
type: SearchResultType,
title: String?,
description: String?,
content: String?,
link: String?,
feedTitle: String?,
published: String?,
updated: String? = nil,
author: String? = nil,
categories: [String]? = nil,
enclosureUrl: String? = nil,
enclosureType: String? = nil,
enclosureLength: Double? = nil,
score: Double = 0.0
) {
self.id = id
self.type = type
self.title = title
self.description = description
self.content = content
self.link = link
self.feedTitle = feedTitle
self.published = published
self.updated = updated
self.author = author
self.categories = categories
self.enclosureUrl = enclosureUrl
self.enclosureType = enclosureType
self.enclosureLength = enclosureLength
self.score = score
}
/// Highlight text with query
func highlightText(_ text: String, query: String?) -> String? {
var highlighted = text
if let query = query, !query.isEmpty {
let queryWords = query.components(separatedBy: .whitespaces)
for word in queryWords {
let word = word.trimmingCharacters(in: .whitespaces)
guard !word.isEmpty else { continue }
let lowerWord = word.lowercased()
let regex = try? NSRegularExpression(pattern: String(regexEscape(word)), options: [.caseInsensitive])
if let regex = regex {
let ranges = regex.matches(in: text, range: NSRange(text.startIndex..., in: text))
for match in ranges {
if let range = Range(match.range, in: text) {
// Replace with HTML span
highlighted = highlightText(replacing: text[range], with: "<mark>\u{00AB}\(text[range])\u{00BB}</mark>") ?? highlighted
}
}
}
}
}
return highlighted
}
/// Highlight text with ranges
func highlightText(text: String, ranges: [(start: Int, end: Int)]) -> String? {
var result = text
// Sort ranges by start position (descending) to process from end
let sortedRanges = ranges.sorted { $0.start > $1.start }
for range in sortedRanges {
if let range = Range(range, in: text) {
result = result.replacingCharacters(in: range, with: "<mark>\u{00AB}\(text[range])\u{00BB}</mark>") ?? result
}
}
return result
}
/// Initialize with values (simple version)
init(
id: String?,
type: SearchResultType,
title: String?,
description: String?,
content: String?,
link: String?,
feedTitle: String?,
published: String?,
updated: String? = nil,
author: String? = nil,
categories: [String]? = nil,
enclosureUrl: String? = nil,
enclosureType: String? = nil,
enclosureLength: Double? = nil,
score: Double = 0.0,
published: Date? = nil
) {
self.id = id
self.type = type
self.title = title
self.description = description
self.content = content
self.link = link
self.feedTitle = feedTitle
self.published = published.map { $0.iso8601 }
self.updated = updated.map { $0.iso8601 }
self.author = author
self.categories = categories
self.enclosureUrl = enclosureUrl
self.enclosureType = enclosureType
self.enclosureLength = enclosureLength
self.score = score
}
}
/// Equality check
func == (lhs: SearchResult, rhs: SearchResult) -> Bool {
lhs.id == rhs.id &&
lhs.type == rhs.type &&
lhs.title == rhs.title &&
lhs.description == rhs.description &&
lhs.content == rhs.content &&
lhs.link == rhs.link &&
lhs.feedTitle == rhs.feedTitle &&
lhs.published == rhs.published &&
lhs.updated == rhs.updated &&
lhs.author == rhs.author &&
lhs.categories == rhs.categories &&
lhs.enclosureUrl == rhs.enclosureUrl &&
lhs.enclosureType == rhs.enclosureType &&
lhs.enclosureLength == rhs.enclosureLength &&
lhs.score == rhs.score
}

View File

@@ -0,0 +1,572 @@
/*
* CoreDataDatabase.swift
*
* Core Data database wrapper with FTS support.
*/
import Foundation
import CoreData
/// Core Data stack
class CoreDataStack: NSObject {
static let shared = CoreDataStack()
private let persistentContainer: NSPersistentContainer
private init() {
persistentContainer = NSPersistentContainer(name: "RSSuper")
persistentContainer.loadPersistentStores {
($0, _ ) in
return NSPersistentStoreFault()
}
}
var managedObjectContext: NSManagedObjectContext {
return persistentContainer.viewContext
}
func saveContext() async throws {
try await managedObjectContext.save()
}
func performTask(_ task: @escaping (NSManagedObjectContext) async throws -> Void) async throws {
try await task(managedObjectContext)
try await saveContext()
}
}
/// CoreDataDatabase - Core Data wrapper with FTS support
class CoreDataDatabase: NSObject {
private let stack: CoreDataStack
/// Create a new core data database
init() {
self.stack = CoreDataStack.shared
super.init()
}
/// Perform a task on the context
func performTask(_ task: @escaping (NSManagedObjectContext) async throws -> Void) async throws {
try await task(stack.managedObjectContext)
try await stack.saveContext()
}
}
/// CoreDataFeedItemStore - Feed item store with FTS
class CoreDataFeedItemStore: NSObject {
private let db: CoreDataDatabase
init(db: CoreDataDatabase) {
self.db = db
super.init()
}
/// Insert a feed item
func insertFeedItem(_ item: FeedItem) async throws {
try await db.performTask { context in
let managedObject = FeedItem(context: context)
managedObject.id = item.id
managedObject.subscriptionId = item.subscriptionId
managedObject.title = item.title
managedObject.link = item.link
managedObject.description = item.description
managedObject.content = item.content
managedObject.author = item.author
managedObject.published = item.published.map { $0.iso8601 }
managedObject.updated = item.updated.map { $0.iso8601 }
managedObject.categories = item.categories?.joined(separator: ",")
managedObject.enclosureUrl = item.enclosureUrl
managedObject.enclosureType = item.enclosureType
managedObject.enclosureLength = item.enclosureLength
managedObject.guid = item.guid
managedObject.isRead = item.isRead
managedObject.isStarred = item.isStarred
// Update FTS index
try await updateFTS(context: context, feedItemId: item.id, title: item.title, link: item.link, description: item.description, content: item.content)
}
}
/// Insert multiple feed items
func insertFeedItems(_ items: [FeedItem]) async throws {
try await db.performTask { context in
for item in items {
let managedObject = FeedItem(context: context)
managedObject.id = item.id
managedObject.subscriptionId = item.subscriptionId
managedObject.title = item.title
managedObject.link = item.link
managedObject.description = item.description
managedObject.content = item.content
managedObject.author = item.author
managedObject.published = item.published.map { $0.iso8601 }
managedObject.updated = item.updated.map { $0.iso8601 }
managedObject.categories = item.categories?.joined(separator: ",")
managedObject.enclosureUrl = item.enclosureUrl
managedObject.enclosureType = item.enclosureType
managedObject.enclosureLength = item.enclosureLength
managedObject.guid = item.guid
managedObject.isRead = item.isRead
managedObject.isStarred = item.isStarred
// Update FTS index
try await updateFTS(context: context, feedItemId: item.id, title: item.title, link: item.link, description: item.description, content: item.content)
}
}
}
/// Get feed items by subscription ID
func getFeedItems(_ subscriptionId: String?) async throws -> [FeedItem] {
let results: [FeedItem] = try await db.performTask { context in
var items: [FeedItem] = []
let predicate = NSPredicate(format: "subscriptionId == %@", subscriptionId ?? "")
let fetchRequest = NSFetchRequest<FeedItem>(entityName: "FeedItem")
fetchRequest.predicate = predicate
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)]
fetchRequest.limit = 1000
do {
let managedObjects = try context.fetch(fetchRequest)
for managedObject in managedObjects {
items.append(managedObjectToItem(managedObject))
}
} catch {
print("Failed to fetch feed items: \(error.localizedDescription)")
}
return items
}
return results
}
/// Get feed item by ID
func getFeedItemById(_ id: String) async throws -> FeedItem? {
let result: FeedItem? = try await db.performTask { context in
let fetchRequest = NSFetchRequest<FeedItem>(entityName: "FeedItem")
fetchRequest.predicate = NSPredicate(format: "id == %@", id)
do {
let managedObjects = try context.fetch(fetchRequest)
return managedObjects.first.map { managedObjectToItem($0) }
} catch {
print("Failed to fetch feed item: \(error.localizedDescription)")
return nil
}
}
return result
}
/// Delete feed item by ID
func deleteFeedItem(_ id: String) async throws {
try await db.performTask { context in
let fetchRequest = NSFetchRequest<FeedItem>(entityName: "FeedItem")
fetchRequest.predicate = NSPredicate(format: "id == %@", id)
do {
let managedObjects = try context.fetch(fetchRequest)
for managedObject in managedObjects {
context.delete(managedObject)
}
} catch {
print("Failed to delete feed item: \(error.localizedDescription)")
}
}
}
/// Delete feed items by subscription ID
func deleteFeedItems(_ subscriptionId: String) async throws {
try await db.performTask { context in
let fetchRequest = NSFetchRequest<FeedItem>(entityName: "FeedItem")
fetchRequest.predicate = NSPredicate(format: "subscriptionId == %@", subscriptionId)
do {
let managedObjects = try context.fetch(fetchRequest)
for managedObject in managedObjects {
context.delete(managedObject)
}
} catch {
print("Failed to delete feed items: \(error.localizedDescription)")
}
}
}
/// Clean up old feed items
func cleanupOldItems(keepCount: Int = 100) async throws {
try await db.performTask { context in
let fetchRequest = NSFetchRequest<FeedItem>(entityName: "FeedItem")
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)]
fetchRequest.limit = keepCount
do {
let managedObjects = try context.fetch(fetchRequest)
for managedObject in managedObjects {
context.delete(managedObject)
}
} catch {
print("Failed to cleanup old feed items: \(error.localizedDescription)")
}
}
}
/// Update FTS index for a feed item
private func updateFTS(context: NSManagedObjectContext, feedItemId: String, title: String?, link: String?, description: String?, content: String?) async throws {
try await db.performTask { context in
let feedItem = FeedItem(context: context)
// Update text attributes for FTS
feedItem.title = title
feedItem.link = link
feedItem.description = description
feedItem.content = content
// Trigger FTS update
do {
try context.performSyncBlock()
} catch {
print("FTS update failed: \(error.localizedDescription)")
}
}
}
}
/// CoreDataSearchHistoryStore - Search history store
class CoreDataSearchHistoryStore: NSObject {
private let db: CoreDataDatabase
init(db: CoreDataDatabase) {
self.db = db
super.init()
}
/// Record a search query
func recordSearchHistory(query: SearchQuery, resultCount: Int) async throws -> Int {
try await db.performTask { context in
let historyEntry = SearchHistoryEntry(context: context)
historyEntry.query = query
historyEntry.resultCount = resultCount
historyEntry.createdAt = Date()
// Save and trigger FTS update
try context.save()
try context.performSyncBlock()
return resultCount
}
}
/// Get search history
func getSearchHistory(limit: Int = 50) async throws -> [SearchQuery] {
let results: [SearchQuery] = try await db.performTask { context in
var queries: [SearchQuery] = []
let fetchRequest = NSFetchRequest<SearchHistoryEntry>(entityName: "SearchHistoryEntry")
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
fetchRequest.limit = UInt32(limit)
do {
let managedObjects = try context.fetch(fetchRequest)
for managedObject in managedObjects {
queries.append(managedObjectToQuery(managedObject))
}
} catch {
print("Failed to fetch search history: \(error.localizedDescription)")
}
return queries
}
return results
}
/// Get recent searches (last 24 hours)
func getRecentSearches(limit: Int = 20) async throws -> [SearchQuery] {
let results: [SearchQuery] = try await db.performTask { context in
var queries: [SearchQuery] = []
let now = Date()
let yesterday = Calendar.current.startOfDay(in: now)
let threshold = yesterday.timeIntervalSince1970
let fetchRequest = NSFetchRequest<SearchHistoryEntry>(entityName: "SearchHistoryEntry")
fetchRequest.predicate = NSPredicate(format: "createdAt >= %f", threshold)
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
fetchRequest.limit = UInt32(limit)
do {
let managedObjects = try context.fetch(fetchRequest)
for managedObject in managedObjects {
queries.append(managedObjectToQuery(managedObject))
}
} catch {
print("Failed to fetch recent searches: \(error.localizedDescription)")
}
return queries
}
return results
}
/// Delete a search history entry by ID
func deleteSearchHistoryEntry(id: Int) async throws {
try await db.performTask { context in
let fetchRequest = NSFetchRequest<SearchHistoryEntry>(entityName: "SearchHistoryEntry")
fetchRequest.predicate = NSPredicate(format: "id == %d", id)
do {
let managedObjects = try context.fetch(fetchRequest)
for managedObject in managedObjects {
context.delete(managedObject)
}
} catch {
print("Failed to delete search history entry: \(error.localizedDescription)")
}
}
}
/// Clear all search history
func clearSearchHistory() async throws {
try await db.performTask { context in
let fetchRequest = NSFetchRequest<SearchHistoryEntry>(entityName: "SearchHistoryEntry")
do {
let managedObjects = try context.fetch(fetchRequest)
for managedObject in managedObjects {
context.delete(managedObject)
}
} catch {
print("Failed to clear search history: \(error.localizedDescription)")
}
}
}
/// Clean up old search history entries
func cleanupOldSearchHistory(limit: Int = 100) async throws {
try await db.performTask { context in
let fetchRequest = NSFetchRequest<SearchHistoryEntry>(entityName: "SearchHistoryEntry")
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
fetchRequest.limit = UInt32(limit)
do {
let managedObjects = try context.fetch(fetchRequest)
for managedObject in managedObjects {
context.delete(managedObject)
}
} catch {
print("Failed to cleanup old search history: \(error.localizedDescription)")
}
}
}
}
/// CoreDataFullTextSearch - FTS5 search implementation
class CoreDataFullTextSearch: NSObject {
private let db: CoreDataDatabase
init(db: CoreDataDatabase) {
self.db = db
super.init()
}
/// Search using FTS5
func search(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
let fullTextSearch = CoreDataFullTextSearch(db: db)
// Perform FTS search
var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit)
// Rank results by relevance
results = try fullTextSearch.rankResults(query: query, results: results)
return results
}
/// Search using FTS5 with custom limit
func searchFTS(query: String, filters: SearchFilters? = nil, limit: Int) async throws -> [SearchResult] {
let fullTextSearch = CoreDataFullTextSearch(db: db)
// Perform FTS search
var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit)
// Rank results by relevance
results = try fullTextSearch.rankResults(query: query, results: results)
return results
}
/// Search with fuzzy matching
func searchFuzzy(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
let fullTextSearch = CoreDataFullTextSearch(db: db)
// For FTS5, we can use the boolean mode with fuzzy operators
// FTS5 supports prefix matching and phrase queries
// Convert query to FTS5 boolean format
let ftsQuery = fullTextSearch.buildFTSQuery(query)
// Perform FTS search
var results = try await fullTextSearch.searchFTS(query: ftsQuery, filters: filters, limit: limit)
// Rank results by relevance
results = try fullTextSearch.rankResults(query: query, results: results)
return results
}
/// Search with highlighting
func searchWithHighlight(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
let fullTextSearch = CoreDataFullTextSearch(db: db)
// Perform FTS search
var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit)
// Rank results by relevance
results = try fullTextSearch.rankResults(query: query, results: results)
// Apply highlighting
results.forEach { result in
result.highlightedText = fullTextSearch.highlightText(result.content ?? "", query: query)
}
return results
}
/// Build FTS5 query from user input
/// Supports fuzzy matching with prefix operators
func buildFTSQuery(_ query: String) -> String {
var sb = StringBuilder()
let words = query.components(separatedBy: .whitespaces)
for (index, word) in words.enumerated() {
let word = word.trimmingCharacters(in: .whitespaces)
if word.isEmpty { continue }
if index > 0 { sb.append(" AND ") }
// Use * for prefix matching in FTS5
sb.append("\"")
sb.append(word)
sb.append("*")
sb.append("\"")
}
return sb.str
}
/// Highlight text with query
func highlightText(_ text: String, query: String) -> String? {
var highlighted = text
if !query.isEmpty {
let queryWords = query.components(separatedBy: .whitespaces)
for word in queryWords {
let word = word.trimmingCharacters(in: .whitespaces)
guard !word.isEmpty else { continue }
let lowerWord = word.lowercased()
let regex = try? NSRegularExpression(pattern: String(regexEscape(word)), options: [.caseInsensitive])
if let regex = regex {
let ranges = regex.matches(in: text, range: NSRange(text.startIndex..., in: text))
for match in ranges {
if let range = Range(match.range, in: text) {
highlighted = highlightText(replacing: text[range], with: "<mark>\u{00AB}\(text[range])\u{00BB}</mark>") ?? highlighted
}
}
}
}
}
return highlighted
}
/// Highlight text with ranges
func highlightText(text: String, ranges: [(start: Int, end: Int)]) -> String? {
var result = text
// Sort ranges by start position (descending) to process from end
let sortedRanges = ranges.sorted { $0.start > $1.start }
for range in sortedRanges {
if let range = Range(range, in: text) {
result = result.replacingCharacters(in: range, with: "<mark>\u{00AB}\(text[range])\u{00BB}</mark>") ?? result
}
}
return result
}
/// Rank search results by relevance
func rankResults(query: String, results: [SearchResult]) async throws -> [SearchResult] {
let queryWords = query.components(separatedBy: .whitespaces)
var ranked: [SearchResult?] = results.map { $0 }
for result in ranked {
guard let result = result else { continue }
var score = result.score
// Boost score for exact title matches
if let title = result.title {
for word in queryWords {
let word = word.trimmingCharacters(in: .whitespaces)
if !word.isEmpty && title.lowercased().contains(word.lowercased()) {
score += 0.5
}
}
}
// Boost score for feed title matches
if let feedTitle = result.feedTitle {
for word in queryWords {
let word = word.trimmingCharacters(in: .whitespaces)
if !word.isEmpty && feedTitle.lowercased().contains(word.lowercased()) {
score += 0.3
}
}
}
result.score = score
ranked.append(result)
}
// Sort by score (descending)
ranked.sort { $0?.score ?? 0 > $1?.score ?? 0 }
return ranked.compactMap { $0 }
}
}
/// CoreDataFeedItemStore extension for FTS search
extend(CoreDataFeedItemStore) {
/// Search using FTS5
func searchFTS(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
let fullTextSearch = CoreDataFullTextSearch(db: db)
// Perform FTS search
var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit)
// Rank results by relevance
results = try fullTextSearch.rankResults(query: query, results: results)
return results
}
}
/// CoreDataSearchHistoryStore extension
extend(CoreDataSearchHistoryStore) {
/// Record a search query
func recordSearch(_ query: SearchQuery, resultCount: Int = 0) async throws -> Int {
try await recordSearchHistory(query: query, resultCount: resultCount)
searchRecorded?(query, resultCount)
// Clean up old entries if needed
try await cleanupOldEntries(limit: maxEntries)
return resultCount
}
}

View File

@@ -0,0 +1,190 @@
/*
* FeedItemStore.swift
*
* CRUD operations for feed items with FTS search support.
*/
import Foundation
import CoreData
/// FeedItemStore - Manages feed item persistence with FTS search
class FeedItemStore: NSObject {
private let db: CoreDataDatabase
/// Signal emitted when an item is added
var itemAdded: ((FeedItem) -> Void)?
/// Signal emitted when an item is updated
var itemUpdated: ((FeedItem) -> Void)?
/// Signal emitted when an item is deleted
var itemDeleted: ((String) -> Void)?
/// Create a new feed item store
init(db: CoreDataDatabase) {
self.db = db
super.init()
}
/// Add a new feed item
func add(_ item: FeedItem) async throws -> FeedItem {
try await db.insertFeedItem(item)
itemAdded?(item)
return item
}
/// Add multiple items in a batch
func addBatch(_ items: [FeedItem]) async throws {
try await db.insertFeedItems(items)
}
/// Get an item by ID
func get_BY_ID(_ id: String) async throws -> FeedItem? {
return try await db.getFeedItemById(id)
}
/// Get items by subscription ID
func get_BY_SUBSCRIPTION(_ subscriptionId: String) async throws -> [FeedItem] {
return try await db.getFeedItems(subscriptionId)
}
/// Get all items
func get_ALL() async throws -> [FeedItem] {
return try await db.getFeedItems(nil)
}
/// Search items using FTS
func search(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
return try await searchFTS(query: query, filters: filters, limit: limit)
}
/// Search items using FTS with custom limit
func searchFTS(query: String, filters: SearchFilters? = nil, limit: Int) async throws -> [SearchResult] {
let fullTextSearch = FullTextSearch(db: db)
// Perform FTS search
var results = try await fullTextSearch.search(query: query, filters: filters, limit: limit)
// Rank results by relevance
results = try rankResults(query: query, results: results)
return results
}
/// Apply search filters to a search result
func applyFilters(_ result: SearchResult, filters: SearchFilters) -> Bool {
// Date filters
if let dateFrom = filters.dateFrom, result.published != nil {
let published = result.published.map { Date(string: $0) } ?? Date.distantPast
if published < dateFrom {
return false
}
}
if let dateTo = filters.dateTo, result.published != nil {
let published = result.published.map { Date(string: $0) } ?? Date.distantFuture
if published > dateTo {
return false
}
}
// Feed ID filters
if let feedIds = filters.feedIds, !feedIds.isEmpty {
// For now, we can't filter by feedId without additional lookup
// This would require joining with feed_subscriptions
}
// Author filters - not directly supported in current schema
// Would require adding author to FTS index
// Content type filters - not directly supported
// Would require adding enclosure_type to FTS index
return true
}
/// Rank search results by relevance
func rankResults(query: String, results: [SearchResult]) async throws -> [SearchResult] {
let queryWords = query.components(separatedBy: .whitespaces)
var ranked: [SearchResult?] = results.map { $0 }
for result in ranked {
guard let result = result else { continue }
var score = result.score
// Boost score for exact title matches
if let title = result.title {
for word in queryWords {
let word = word.trimmingCharacters(in: .whitespaces)
if !word.isEmpty && title.lowercased().contains(word.lowercased()) {
score += 0.5
}
}
}
// Boost score for feed title matches
if let feedTitle = result.feedTitle {
for word in queryWords {
let word = word.trimmingCharacters(in: .whitespaces)
if !word.isEmpty && feedTitle.lowercased().contains(word.lowercased()) {
score += 0.3
}
}
}
result.score = score
ranked.append(result)
}
// Sort by score (descending)
ranked.sort { $0?.score ?? 0 > $1?.score ?? 0 }
return ranked.compactMap { $0 }
}
/// Mark an item as read
func markAsRead(_ id: String) async throws {
try await db.markFeedItemAsRead(id)
}
/// Mark an item as unread
func markAsUnread(_ id: String) async throws {
try await db.markFeedItemAsUnread(id)
}
/// Mark an item as starred
func markAsStarred(_ id: String) async throws {
try await db.markFeedItemAsStarred(id)
}
/// Unmark an item from starred
func unmarkStarred(_ id: String) async throws {
try await db.unmarkFeedItemAsStarred(id)
}
/// Get unread items
func get_UNREAD() async throws -> [FeedItem] {
return try await db.getFeedItems(nil).filter { $0.isRead == false }
}
/// Get starred items
func get_STARRED() async throws -> [FeedItem] {
return try await db.getFeedItems(nil).filter { $0.isStarred == true }
}
/// Delete an item by ID
func delete(_ id: String) async throws {
try await db.deleteFeedItem(id)
itemDeleted?(id)
}
/// Delete items by subscription ID
func deleteBySubscription(_ subscriptionId: String) async throws {
try await db.deleteFeedItems(subscriptionId)
}
/// Delete old items (keep last N items per subscription)
func cleanupOldItems(keepCount: Int = 100) async throws {
try await db.cleanupOldItems(keepCount: keepCount)
}
}

View File

@@ -0,0 +1,221 @@
/*
* FullTextSearch.swift
*
* Full-Text Search implementation using Core Data FTS5.
*/
import Foundation
import CoreData
/// FullTextSearch - FTS5 search implementation for Core Data
class FullTextSearch: NSObject {
private let db: CoreDataDatabase
/// Create a new full text search
init(db: CoreDataDatabase) {
self.db = db
super.init()
}
/// Search using FTS5
func search(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
let fullTextSearch = FullTextSearch(db: db)
// Perform FTS search
var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit)
// Rank results by relevance
results = try fullTextSearch.rankResults(query: query, results: results)
return results
}
/// Search using FTS5 with custom limit
func searchFTS(query: String, filters: SearchFilters? = nil, limit: Int) async throws -> [SearchResult] {
let fullTextSearch = FullTextSearch(db: db)
// Perform FTS search
var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit)
// Rank results by relevance
results = try fullTextSearch.rankResults(query: query, results: results)
return results
}
/// Search with fuzzy matching
func searchFuzzy(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
let fullTextSearch = FullTextSearch(db: db)
// For FTS5, we can use the boolean mode with fuzzy operators
// FTS5 supports prefix matching and phrase queries
// Convert query to FTS5 boolean format
let ftsQuery = fullTextSearch.buildFTSQuery(query)
// Perform FTS search
var results = try await fullTextSearch.searchFTS(query: ftsQuery, filters: filters, limit: limit)
// Rank results by relevance
results = try fullTextSearch.rankResults(query: query, results: results)
return results
}
/// Build FTS5 query from user input
/// Supports fuzzy matching with prefix operators
func buildFTSQuery(_ query: String) -> String {
var sb = StringBuilder()
let words = query.components(separatedBy: .whitespaces)
for (index, word) in words.enumerated() {
let word = word.trimmingCharacters(in: .whitespaces)
if word.isEmpty { continue }
if index > 0 { sb.append(" AND ") }
// Use * for prefix matching in FTS5
// This allows matching partial words
sb.append("\"")
sb.append(word)
sb.append("*")
sb.append("\"")
}
return sb.str
}
/// Search with highlighting
func searchWithHighlight(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
let fullTextSearch = FullTextSearch(db: db)
// Perform FTS search
var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit)
// Rank results by relevance
results = try fullTextSearch.rankResults(query: query, results: results)
// Apply highlighting
results.forEach { result in
result.highlightedText = fullTextSearch.highlightText(result.content ?? "", query: query)
}
return results
}
/// Highlight text with query
func highlightText(_ text: String, query: String) -> String? {
var highlighted = text
if !query.isEmpty {
let queryWords = query.components(separatedBy: .whitespaces)
for word in queryWords {
let word = word.trimmingCharacters(in: .whitespaces)
guard !word.isEmpty else { continue }
let lowerWord = word.lowercased()
let regex = try? NSRegularExpression(pattern: String(regexEscape(word)), options: [.caseInsensitive])
if let regex = regex {
let ranges = regex.matches(in: text, range: NSRange(text.startIndex..., in: text))
for match in ranges {
if let range = Range(match.range, in: text) {
// Replace with HTML span
highlighted = highlightText(replacing: text[range], with: "<mark>\u{00AB}\(text[range])\u{00BB}</mark>") ?? highlighted
}
}
}
}
}
return highlighted
}
/// Highlight text with ranges
func highlightText(text: String, ranges: [(start: Int, end: Int)]) -> String? {
var result = text
// Sort ranges by start position (descending) to process from end
let sortedRanges = ranges.sorted { $0.start > $1.start }
for range in sortedRanges {
if let range = Range(range, in: text) {
result = result.replacingCharacters(in: range, with: "<mark>\u{00AB}\(text[range])\u{00BB}</mark>") ?? result
}
}
return result
}
/// Rank search results by relevance
func rankResults(query: String, results: [SearchResult]) async throws -> [SearchResult] {
let queryWords = query.components(separatedBy: .whitespaces)
var ranked: [SearchResult?] = results.map { $0 }
for result in ranked {
guard let result = result else { continue }
var score = result.score
// Boost score for exact title matches
if let title = result.title {
for word in queryWords {
let word = word.trimmingCharacters(in: .whitespaces)
if !word.isEmpty && title.lowercased().contains(word.lowercased()) {
score += 0.5
}
}
}
// Boost score for feed title matches
if let feedTitle = result.feedTitle {
for word in queryWords {
let word = word.trimmingCharacters(in: .whitespaces)
if !word.isEmpty && feedTitle.lowercased().contains(word.lowercased()) {
score += 0.3
}
}
}
result.score = score
ranked.append(result)
}
// Sort by score (descending)
ranked.sort { $0?.score ?? 0 > $1?.score ?? 0 }
return ranked.compactMap { $0 }
}
}
/// StringBuilder helper
class StringBuilder {
var str: String = ""
mutating func append(_ value: String) {
str.append(value)
}
mutating func append(_ value: Int) {
str.append(String(value))
}
}
/// Regex escape helper
func regexEscape(_ string: String) -> String {
return string.replacingOccurrences(of: ".", with: ".")
.replacingOccurrences(of: "+", with: "+")
.replacingOccurrences(of: "?", with: "?")
.replacingOccurrences(of: "*", with: "*")
.replacingOccurrences(of: "^", with: "^")
.replacingOccurrences(of: "$", with: "$")
.replacingOccurrences(of: "(", with: "(")
.replacingOccurrences(of: ")", with: ")")
.replacingOccurrences(of: "[", with: "[")
.replacingOccurrences(of: "]", with: "]")
.replacingOccurrences(of: "{", with: "{")
.replacingOccurrences(of: "}", with: "}")
.replacingOccurrences(of: "|", with: "|")
.replacingOccurrences(of: "\\", with: "\\\\")
}

View File

@@ -0,0 +1,65 @@
/*
* SearchHistoryStore.swift
*
* CRUD operations for search history.
*/
import Foundation
import CoreData
/// SearchHistoryStore - Manages search history persistence
class SearchHistoryStore: NSObject {
private let db: CoreDataDatabase
/// Maximum number of history entries to keep
var maxEntries: Int = 100
/// Signal emitted when a search is recorded
var searchRecorded: ((SearchQuery, Int) -> Void)?
/// Signal emitted when history is cleared
var historyCleared: (() -> Void)?
/// Create a new search history store
init(db: CoreDataDatabase) {
self.db = db
super.init()
}
/// Record a search query
func recordSearch(_ query: SearchQuery, resultCount: Int = 0) async throws -> Int {
try await db.recordSearchHistory(query: query, resultCount: resultCount)
searchRecorded?(query, resultCount)
// Clean up old entries if needed
try await cleanupOldEntries()
return resultCount
}
/// Get search history
func getHistory(limit: Int = 50) async throws -> [SearchQuery] {
return try await db.getSearchHistory(limit: limit)
}
/// Get recent searches (last 24 hours)
func getRecent(limit: Int = 20) async throws -> [SearchQuery] {
return try await db.getRecentSearches(limit: limit)
}
/// Delete a search history entry by ID
func deleteHistoryEntry(id: Int) async throws {
try await db.deleteSearchHistoryEntry(id: id)
}
/// Clear all search history
func clearHistory() async throws {
try await db.clearSearchHistory()
historyCleared?()
}
/// Clear old search history entries
private func cleanupOldEntries() async throws {
try await db.cleanupOldSearchHistory(limit: maxEntries)
}
}

View File

@@ -0,0 +1,252 @@
/*
* SearchService.swift
*
* Full-text search service with history tracking and fuzzy matching.
*/
import Foundation
import Combine
import CoreData
/// SearchService - Manages search operations with history tracking
class SearchService: NSObject {
private let db: CoreDataDatabase
private let historyStore: SearchHistoryStore
/// Maximum number of results to return
var maxResults: Int = 50
/// Maximum number of history entries to keep
var maxHistory: Int = 100
/// Search results publisher
private let resultsPublisher = CurrentValueSubject<SearchResult?, Never>(nil)
/// Search history publisher
private let historyPublisher = CurrentValueSubject<SearchHistoryEntry?, Never>(nil)
/// Signals
var searchPerformed: ((SearchQuery, SearchResult) -> Void)?
var searchRecorded: ((SearchQuery, Int) -> Void)?
var historyCleared: (() -> Void)?
/// Create a new search service
init(db: CoreDataDatabase) {
self.db = db
self.historyStore = SearchHistoryStore(db: db)
self.historyStore.maxEntries = maxHistory
// Connect to history store signals
historyStore.searchRecorded { query, count in
self.searchRecorded?(query, count)
self.historyPublisher.send(query)
}
historyStore.historyCleared { [weak self] in
self?.historyCleared?()
}
}
/// Perform a search
func search(_ query: String, filters: SearchFilters? = nil) async throws -> [SearchResult] {
let itemStore = FeedItemStore(db: db)
// Perform FTS search
var results = try await itemStore.searchFTS(query: query, filters: filters, limit: maxResults)
// Rank results by relevance
results = try rankResults(query: query, results: results)
// Record in history
let searchQuery = SearchQuery(
query: query,
page: 0,
pageSize: maxResults,
filters: filters,
sortOrder: .relevance
)
try await historyStore.recordSearch(searchQuery, resultCount: results.count)
searchPerformed?(searchQuery, results.first!)
resultsPublisher.send(results.first)
return results
}
/// Perform a search with custom page size
func searchWithPage(_ query: String, page: Int, pageSize: Int, filters: SearchFilters? = nil) async throws -> [SearchResult] {
let itemStore = FeedItemStore(db: db)
var results = try await itemStore.searchFTS(query: query, filters: filters, limit: pageSize)
// Rank results by relevance
results = try rankResults(query: query, results: results)
// Record in history
let searchQuery = SearchQuery(
query: query,
page: page,
pageSize: pageSize,
filters: filters,
sortOrder: .relevance
)
try await historyStore.recordSearch(searchQuery, resultCount: results.count)
searchPerformed?(searchQuery, results.first!)
resultsPublisher.send(results.first)
return results
}
/// Get search history
func getHistory(limit: Int = 50) async throws -> [SearchQuery] {
return try await historyStore.getHistory(limit: limit)
}
/// Get recent searches (last 24 hours)
func getRecent() async throws -> [SearchQuery] {
return try await historyStore.getRecent(limit: 20)
}
/// Delete a search history entry by ID
func deleteHistoryEntry(id: Int) async throws {
try await historyStore.deleteHistoryEntry(id: id)
}
/// Clear all search history
func clearHistory() async throws {
try await historyStore.clearHistory()
historyCleared?()
}
/// Get search suggestions based on recent queries
func getSuggestions(_ prefix: String, limit: Int = 10) async throws -> [String] {
let history = try await historyStore.getHistory(limit: limit * 2)
var suggestions: Set<String> = []
for entry in history {
if entry.query.hasPrefix(prefix) && entry.query != prefix {
suggestions.insert(entry.query)
if suggestions.count >= limit {
break
}
}
}
return Array(suggestions)
}
/// Get search suggestions from current results
func getResultSuggestions(_ results: [SearchResult], field: String) -> [String] {
var suggestions: Set<String> = []
var resultList: [String] = []
for result in results {
switch field {
case "title":
if let title = result.title, !title.isEmpty {
suggestions.insert(title)
}
case "feed":
if let feedTitle = result.feedTitle, !feedTitle.isEmpty {
suggestions.insert(feedTitle)
}
default:
break
}
}
var iter = suggestions.iterator()
var key: String?
while (key = iter.nextValue()) {
resultList.append(key!)
}
return resultList
}
/// Rank search results by relevance
func rankResults(query: String, results: [SearchResult]) async throws -> [SearchResult] {
let queryWords = query.components(separatedBy: .whitespaces)
var ranked: [SearchResult?] = results.map { $0 }
for result in ranked {
guard let result = result else { continue }
var score = result.score
// Boost score for exact title matches
if let title = result.title {
for word in queryWords {
let word = word.trimmingCharacters(in: .whitespaces)
if !word.isEmpty && title.lowercased().contains(word.lowercased()) {
score += 0.5
}
}
}
// Boost score for feed title matches
if let feedTitle = result.feedTitle {
for word in queryWords {
let word = word.trimmingCharacters(in: .whitespaces)
if !word.isEmpty && feedTitle.lowercased().contains(word.lowercased()) {
score += 0.3
}
}
}
result.score = score
ranked.append(result)
}
// Sort by score (descending)
ranked.sort { $0?.score ?? 0 > $1?.score ?? 0 }
return ranked.compactMap { $0 }
}
/// Search suggestions from recent queries
var suggestionsSubject: Published<[String]> {
return Published(
publisher: Publishers.CombineLatest(
Publishers.Everything($0.suggestionsSubject),
Publishers.Everything($0.historyPublisher)
) { suggestions, history in
var result: [String] = suggestions
for query in history {
result += query.query.components(separatedBy: "\n")
}
return result.sorted()
}
)
}
}
/// Search history entry
class SearchHistoryEntry: Codable, Equatable {
let query: SearchQuery
let resultCount: Int
let createdAt: Date
var description: String {
guard !query.query.isEmpty else { return "Search" }
return query.query
}
init(query: SearchQuery, resultCount: Int = 0, createdAt: Date = Date()) {
self.query = query
self.resultCount = resultCount
self.createdAt = createdAt
}
init(query: SearchQuery, resultCount: Int) {
self.query = query
self.resultCount = resultCount
self.createdAt = Date()
}
}
extension SearchHistoryEntry: Equatable {
static func == (lhs: SearchHistoryEntry, rhs: SearchHistoryEntry) -> Bool {
lhs.query == rhs.query && lhs.resultCount == rhs.resultCount
}
}