diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d4fec8..a04104e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/android/src/androidTest/java/com/rssuper/integration/FeedIntegrationTest.kt b/android/src/androidTest/java/com/rssuper/integration/FeedIntegrationTest.kt index 3650dbc..7404b80 100644 --- a/android/src/androidTest/java/com/rssuper/integration/FeedIntegrationTest.kt +++ b/android/src/androidTest/java/com/rssuper/integration/FeedIntegrationTest.kt @@ -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( - id = "test-search-sub", - url = "https://example.com/feed.xml", - title = "Test Search Feed" + 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) - } - - @Test - fun testNotificationDelivery() { - // Verify notification delivery functionality - - // Create a test subscription - val subscription = FeedSubscription( - id = "test-notification-sub", - url = "https://example.com/feed.xml", - title = "Test Notification Feed" + // Insert subscriptions + database.subscriptionDao().insert( + com.rssuper.database.entities.SubscriptionEntity( + id = "sync-feed-1", + url = feed1Url, + title = "Sync Test Feed 1" + ) ) - databaseManager.createSubscription( - id = subscription.id, - url = subscription.url, - title = subscription.title + database.subscriptionDao().insert( + com.rssuper.database.entities.SubscriptionEntity( + id = "sync-feed-2", + url = feed2Url, + title = "Sync Test Feed 2" + ) ) - // Verify subscription was created - val fetched = databaseManager.fetchSubscription(subscription.id) - assertNotNull("Subscription should be created", fetched) - assertEquals("Title should match", subscription.title, fetched?.title) + // 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 testSettingsPersistence() { - // Verify settings persistence functionality - - val settings = databaseManager - - // Settings are stored in the database - assertNotNull("Database should be available", settings) - } - - @Test - fun testBookmarkCRUD() { - // Verify bookmark create, read, update, delete operations - + fun testNotificationDelivery() = runBlockingTest { // Create subscription - databaseManager.createSubscription( - id = "test-bookmark-sub", - url = "https://example.com/feed.xml", - title = "Test Bookmark Feed" + val subscription = database.subscriptionDao().insert( + com.rssuper.database.entities.SubscriptionEntity( + id = "test-notification-sub", + url = "https://example.com/feed.xml", + title = "Test Notification Feed" + ) ) // Create feed item - val item = FeedItem( + 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() = runBlockingTest { + // Test notification preferences + val preferences = com.rssuper.database.entities.NotificationPreferencesEntity( + id = 1, + enabled = true, + sound = true, + vibration = true, + light = true, + channel = "rssuper_notifications" + ) + + database.notificationPreferencesDao().insert(preferences) + + val stored = database.notificationPreferencesDao().get() + assertNotNull("Preferences should be stored", stored) + assertTrue("Notifications should be enabled", stored.enabled) + } + + @Test + 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" + ) + ) + + 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(" runBlockingTest(block: suspend () -> T): T { + return block() } } diff --git a/iOS/RSSuper/Database/DatabaseManager.swift b/iOS/RSSuper/Database/DatabaseManager.swift index 91d34b5..d575285 100644 --- a/iOS/RSSuper/Database/DatabaseManager.swift +++ b/iOS/RSSuper/Database/DatabaseManager.swift @@ -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] { diff --git a/iOS/RSSuper/Models/FeedItem.swift b/iOS/RSSuper/Models/FeedItem.swift index 40eb273..69b26e2 100644 --- a/iOS/RSSuper/Models/FeedItem.swift +++ b/iOS/RSSuper/Models/FeedItem.swift @@ -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 { diff --git a/iOS/RSSuper/UI/FeedDetailView.swift b/iOS/RSSuper/UI/FeedDetailView.swift index 437e62c..569563b 100644 --- a/iOS/RSSuper/UI/FeedDetailView.swift +++ b/iOS/RSSuper/UI/FeedDetailView.swift @@ -16,14 +16,13 @@ struct FeedDetailView: View { feedItem.read } -private func toggleRead() { + private func toggleRead() { let success = feedService.markItemAsRead(itemId: feedItem.id) if !success { errorMessage = "Failed to update read status" showError = true } } - } private func close() { // Dismiss the view diff --git a/iOS/RSSuper/UI/SearchView.swift b/iOS/RSSuper/UI/SearchView.swift new file mode 100644 index 0000000..59a604c --- /dev/null +++ b/iOS/RSSuper/UI/SearchView.swift @@ -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() +} diff --git a/iOS/RSSuper/ViewModels/FeedViewModel.swift b/iOS/RSSuper/ViewModels/FeedViewModel.swift index b4f7ab2..4287146 100644 --- a/iOS/RSSuper/ViewModels/FeedViewModel.swift +++ b/iOS/RSSuper/ViewModels/FeedViewModel.swift @@ -24,7 +24,7 @@ class FeedViewModel: ObservableObject { private let feedService: FeedServiceProtocol private var cancellables = Set() - private var currentSubscriptionId: String? + var currentSubscriptionId: String? init(feedService: FeedServiceProtocol = FeedService()) { self.feedService = feedService diff --git a/linux/src/repository/RepositoriesImpl.vala b/linux/src/repository/RepositoriesImpl.vala index c840631..bde6966 100644 --- a/linux/src/repository/RepositoriesImpl.vala +++ b/linux/src/repository/RepositoriesImpl.vala @@ -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)); } } diff --git a/linux/src/tests/error-tests.vala b/linux/src/tests/error-tests.vala new file mode 100644 index 0000000..08432ae --- /dev/null +++ b/linux/src/tests/error-tests.vala @@ -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"); + } +} diff --git a/linux/src/tests/repository-tests.vala b/linux/src/tests/repository-tests.vala index ff4692a..5bb787e 100644 --- a/linux/src/tests/repository-tests.vala +++ b/linux/src/tests/repository-tests.vala @@ -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(); + 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_bookmark_repository_create\n"); + print("PASS: test_feed_repository_get_items\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"); - // Test reading - var read_result = repo.get_by_id("test-bookmark-2"); + assert(result != null); + assert(result.id == "test-item-1"); + assert(result.title == "Test Item"); - 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"); + print("PASS: test_feed_repository_get_item_by_id\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); - // Update the bookmark - bookmark.tags = ["important", "read-later"]; - var update_result = repo.update(bookmark); + assert(result.is_error() == false); - if (update_result.is_error()) { - printerr("FAIL: Bookmark update failed: %s\n", update_result.error.message); - return; - } + var retrieved = repo.get_feed_item_by_id("test-item-2"); + assert(retrieved != null); + assert(retrieved.id == "test-item-2"); - // 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"); + print("PASS: test_feed_repository_insert_item\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() + ); - // Delete the bookmark - var delete_result = repo.remove("test-bookmark-4"); + var result = repo.insert_feed_items(items); - if (delete_result.is_error()) { - printerr("FAIL: Bookmark deletion failed: %s\n", delete_result.error.message); - return; - } + assert(result.is_error() == false); - // 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; - } + var all_items = repo.get_feed_items(null); + assert(all_items.length == 2); - print("PASS: test_bookmark_repository_delete\n"); + print("PASS: test_feed_repository_insert_items\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 bookmarks = by_tag_result.value; - if (bookmarks.length != 1) { - printerr("FAIL: Expected 1 bookmark with tag 'important', got %d\n", bookmarks.length); - return; - } + var updated = repo.get_feed_item_by_id("test-item-5"); + assert(updated != null); + assert(updated.title == "Updated Title"); - print("PASS: test_bookmark_repository_tags\n"); + print("PASS: test_feed_repository_update_item\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); - var bookmarks = by_item_result.value; - if (bookmarks.length != 2) { - printerr("FAIL: Expected 2 bookmarks for feed item, got %d\n", bookmarks.length); - return; - } + print("PASS: test_feed_repository_mark_as_starred\n"); + } + + public void test_feed_repository_delete_item() { + var db = new Database(":memory:"); + var repo = new FeedRepositoryImpl(db); - print("PASS: test_bookmark_repository_by_feed_item\n"); + 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"); + } + + 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(); + 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(); + 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(); + 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"); } } diff --git a/linux/src/tests/state-tests.vala b/linux/src/tests/state-tests.vala new file mode 100644 index 0000000..98f9516 --- /dev/null +++ b/linux/src/tests/state-tests.vala @@ -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(); + + 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(); + + // 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(); + + // 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(); + var state2 = new State(); + + // 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(); + 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(); + + // 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 + var intState = new State(); + intState.set_success(123); + assert(intState.get_data() == 123); + assert(intState.is_success()); + + // Test State + var boolState = new State(); + boolState.set_success(true); + assert(boolState.get_data() == true); + assert(boolState.is_success()); + + // Test State + var stringState = new State(); + stringState.set_success("hello"); + assert(stringState.get_data() == "hello"); + assert(stringState.is_success()); + + // Test State + var objectState = new State(); + objectState.set_success("test"); + assert(objectState.get_data() == "test"); + + print("PASS: test_generic_state_t\n"); + } +} diff --git a/linux/src/tests/viewmodel-tests.vala b/linux/src/tests/viewmodel-tests.vala index 131b730..923a34d 100644 --- a/linux/src/tests/viewmodel-tests.vala +++ b/linux/src/tests/viewmodel-tests.vala @@ -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"); + } } diff --git a/linux/src/viewmodel/FeedViewModel.vala b/linux/src/viewmodel/FeedViewModel.vala index 8a98903..294868c 100644 --- a/linux/src/viewmodel/FeedViewModel.vala +++ b/linux/src/viewmodel/FeedViewModel.vala @@ -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)); } } diff --git a/linux/src/viewmodel/SubscriptionViewModel.vala b/linux/src/viewmodel/SubscriptionViewModel.vala index ec755b1..2309115 100644 --- a/linux/src/viewmodel/SubscriptionViewModel.vala +++ b/linux/src/viewmodel/SubscriptionViewModel.vala @@ -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)); } } diff --git a/native-route/ios/RSSuper/CoreData/CoreDataModel.ent b/native-route/ios/RSSuper/CoreData/CoreDataModel.ent new file mode 100644 index 0000000..89eff3b --- /dev/null +++ b/native-route/ios/RSSuper/CoreData/CoreDataModel.ent @@ -0,0 +1,201 @@ + + + FeedItem + + + id + NSUUID + true + + + subscriptionId + NSString + true + + + title + NSString + true + true + true + + + link + NSString + false + true + true + + + description + NSString + false + true + true + + + content + NSString + false + true + true + + + author + NSString + false + true + true + + + published + NSString + false + + + updated + NSString + false + + + categories + NSString + false + + + enclosureUrl + NSString + false + + + enclosureType + NSString + false + + + enclosureLength + NSNumber + false + + + guid + NSString + false + + + isRead + NSNumber + true + + + isStarred + NSNumber + true + + + + + subscription + FeedItem + FeedSubscription + false + true + + + + + + FeedSubscription + + + id + NSUUID + true + + + url + NSString + true + + + title + NSString + true + + + enabled + NSNumber + true + + + lastFetchedAt + NSNumber + false + + + nextFetchAt + NSNumber + false + + + error + NSString + false + + + + + feedItems + FeedSubscription + FeedItem + true + true + + + + + + SearchHistoryEntry + + + id + NSNumber + true + + + query + NSString + true + + + filtersJson + NSString + false + + + sortOption + NSString + true + + + page + NSNumber + true + + + pageSize + NSNumber + true + + + resultCount + NSNumber + true + + + createdAt + NSDate + true + + + diff --git a/native-route/ios/RSSuper/Models/SearchFilters.swift b/native-route/ios/RSSuper/Models/SearchFilters.swift new file mode 100644 index 0000000..318f6c4 --- /dev/null +++ b/native-route/ios/RSSuper/Models/SearchFilters.swift @@ -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 + } +} diff --git a/native-route/ios/RSSuper/Models/SearchQuery.swift b/native-route/ios/RSSuper/Models/SearchQuery.swift new file mode 100644 index 0000000..78f4003 --- /dev/null +++ b/native-route/ios/RSSuper/Models/SearchQuery.swift @@ -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 + } +} diff --git a/native-route/ios/RSSuper/Models/SearchResult.swift b/native-route/ios/RSSuper/Models/SearchResult.swift new file mode 100644 index 0000000..d2d20f1 --- /dev/null +++ b/native-route/ios/RSSuper/Models/SearchResult.swift @@ -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: "\u{00AB}\(text[range])\u{00BB}") ?? 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: "\u{00AB}\(text[range])\u{00BB}") ?? 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 +} diff --git a/native-route/ios/RSSuper/Services/CoreDataDatabase.swift b/native-route/ios/RSSuper/Services/CoreDataDatabase.swift new file mode 100644 index 0000000..bfa7a4c --- /dev/null +++ b/native-route/ios/RSSuper/Services/CoreDataDatabase.swift @@ -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(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(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(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(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(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(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(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(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(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(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: "\u{00AB}\(text[range])\u{00BB}") ?? 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: "\u{00AB}\(text[range])\u{00BB}") ?? 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 + } +} diff --git a/native-route/ios/RSSuper/Services/FeedItemStore.swift b/native-route/ios/RSSuper/Services/FeedItemStore.swift new file mode 100644 index 0000000..f764177 --- /dev/null +++ b/native-route/ios/RSSuper/Services/FeedItemStore.swift @@ -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) + } +} diff --git a/native-route/ios/RSSuper/Services/FullTextSearch.swift b/native-route/ios/RSSuper/Services/FullTextSearch.swift new file mode 100644 index 0000000..0a174d1 --- /dev/null +++ b/native-route/ios/RSSuper/Services/FullTextSearch.swift @@ -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: "\u{00AB}\(text[range])\u{00BB}") ?? 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: "\u{00AB}\(text[range])\u{00BB}") ?? 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: "\\\\") +} diff --git a/native-route/ios/RSSuper/Services/SearchHistoryStore.swift b/native-route/ios/RSSuper/Services/SearchHistoryStore.swift new file mode 100644 index 0000000..b93df65 --- /dev/null +++ b/native-route/ios/RSSuper/Services/SearchHistoryStore.swift @@ -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) + } +} diff --git a/native-route/ios/RSSuper/Services/SearchService.swift b/native-route/ios/RSSuper/Services/SearchService.swift new file mode 100644 index 0000000..a0902ef --- /dev/null +++ b/native-route/ios/RSSuper/Services/SearchService.swift @@ -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(nil) + + /// Search history publisher + private let historyPublisher = CurrentValueSubject(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 = [] + + 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 = [] + 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 + } +}