conflicting pathing
Some checks failed
CI - Multi-Platform Native / Build iOS (RSSuper) (push) Has been cancelled
CI - Multi-Platform Native / Build macOS (push) Has been cancelled
CI - Multi-Platform Native / Build Android (push) Has been cancelled
CI - Multi-Platform Native / Build Linux (push) Has been cancelled
CI - Multi-Platform Native / Integration Tests (push) Has been cancelled
CI - Multi-Platform Native / Build Summary (push) Has been cancelled
Some checks failed
CI - Multi-Platform Native / Build iOS (RSSuper) (push) Has been cancelled
CI - Multi-Platform Native / Build macOS (push) Has been cancelled
CI - Multi-Platform Native / Build Android (push) Has been cancelled
CI - Multi-Platform Native / Build Linux (push) Has been cancelled
CI - Multi-Platform Native / Integration Tests (push) Has been cancelled
CI - Multi-Platform Native / Build Summary (push) Has been cancelled
This commit is contained in:
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -272,7 +272,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Android Debug
|
- name: Build Android Debug
|
||||||
run: |
|
run: |
|
||||||
cd native-route/android
|
cd android
|
||||||
|
|
||||||
# Create basic Android project structure if it doesn't exist
|
# Create basic Android project structure if it doesn't exist
|
||||||
if [ ! -f "build.gradle.kts" ]; then
|
if [ ! -f "build.gradle.kts" ]; then
|
||||||
@@ -286,8 +286,8 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: RSSuper-Android-Debug
|
name: RSSSuper-Android-Debug
|
||||||
path: native-route/android/app/build/outputs/apk/debug/*.apk
|
path: android/app/build/outputs/apk/debug/*.apk
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
@@ -344,7 +344,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Android Integration Tests
|
- name: Run Android Integration Tests
|
||||||
run: |
|
run: |
|
||||||
cd native-route/android
|
cd android
|
||||||
./gradlew connectedAndroidTest || echo "Integration tests not yet configured"
|
./gradlew connectedAndroidTest || echo "Integration tests not yet configured"
|
||||||
|
|
||||||
- name: Upload Test Results
|
- name: Upload Test Results
|
||||||
@@ -352,7 +352,7 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: integration-test-results
|
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
|
if-no-files-found: ignore
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
|||||||
@@ -1,171 +1,388 @@
|
|||||||
package com.rssuper.integration
|
package com.rssuper.integration
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.rssuper.database.DatabaseManager
|
import com.rssuper.database.RssDatabase
|
||||||
import com.rssuper.models.FeedItem
|
import com.rssuper.parsing.FeedParser
|
||||||
import com.rssuper.models.FeedSubscription
|
import com.rssuper.parsing.ParseResult
|
||||||
import com.rssuper.repository.BookmarkRepository
|
|
||||||
import com.rssuper.repository.impl.BookmarkRepositoryImpl
|
|
||||||
import com.rssuper.services.FeedFetcher
|
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.Assert.*
|
||||||
|
import org.junit.After
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
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.
|
* Integration tests for cross-platform feed functionality.
|
||||||
*
|
*
|
||||||
* These tests verify the complete feed fetch → parse → store flow
|
* 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)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class FeedIntegrationTest {
|
class FeedIntegrationTest {
|
||||||
|
|
||||||
private lateinit var context: Context
|
private lateinit var context: Context
|
||||||
private lateinit var databaseManager: DatabaseManager
|
private lateinit var database: RssDatabase
|
||||||
private lateinit var feedFetcher: FeedFetcher
|
private lateinit var feedFetcher: FeedFetcher
|
||||||
private lateinit var feedParser: FeedParser
|
private lateinit var feedParser: FeedParser
|
||||||
|
private lateinit var mockServer: MockWebServer
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
context = ApplicationProvider.getApplicationContext()
|
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()
|
feedParser = FeedParser()
|
||||||
|
mockServer = MockWebServer()
|
||||||
|
mockServer.start(8080)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
database.close()
|
||||||
|
mockServer.shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testFetchParseAndStoreFlow() {
|
fun testFetchParseAndStoreFlow() = runBlockingTest {
|
||||||
// This test verifies the complete flow:
|
// Setup mock server to return sample RSS feed
|
||||||
// 1. Fetch a feed from a URL
|
val rssContent = File("tests/fixtures/sample-rss.xml").readText()
|
||||||
// 2. Parse the feed XML
|
mockServer.enqueue(MockResponse().setBody(rssContent).setResponseCode(200))
|
||||||
// 3. Store the items in the database
|
|
||||||
|
|
||||||
// Note: This is a placeholder test that would use a mock server
|
val feedUrl = mockServer.url("/feed.xml").toString()
|
||||||
// in a real implementation. For now, we verify the components
|
|
||||||
// are properly initialized.
|
|
||||||
|
|
||||||
assertNotNull("DatabaseManager should be initialized", databaseManager)
|
// 1. Fetch the feed
|
||||||
assertNotNull("FeedFetcher should be initialized", feedFetcher)
|
val fetchResult = feedFetcher.fetch(feedUrl)
|
||||||
assertNotNull("FeedParser should be initialized", feedParser)
|
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
|
@Test
|
||||||
fun testSearchEndToEnd() {
|
fun testSearchEndToEnd() = runBlockingTest {
|
||||||
// Verify search functionality works end-to-end
|
// Create test subscription
|
||||||
// 1. Add items to database
|
val subscription = database.subscriptionDao().insert(
|
||||||
// 2. Perform search
|
com.rssuper.database.entities.SubscriptionEntity(
|
||||||
// 3. Verify results
|
id = "test-search-sub",
|
||||||
|
url = "https://example.com/feed.xml",
|
||||||
// Create a test subscription
|
title = "Test Search Feed"
|
||||||
val subscription = FeedSubscription(
|
)
|
||||||
id = "test-search-sub",
|
|
||||||
url = "https://example.com/feed.xml",
|
|
||||||
title = "Test Search Feed"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
databaseManager.createSubscription(
|
// Create test feed items with searchable content
|
||||||
id = subscription.id,
|
val item1 = com.rssuper.database.entities.FeedItemEntity(
|
||||||
url = subscription.url,
|
|
||||||
title = subscription.title
|
|
||||||
)
|
|
||||||
|
|
||||||
// Create test feed items
|
|
||||||
val item1 = FeedItem(
|
|
||||||
id = "test-item-1",
|
id = "test-item-1",
|
||||||
title = "Hello World Article",
|
title = "Hello World Article",
|
||||||
content = "This is a test article about programming",
|
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",
|
id = "test-item-2",
|
||||||
title = "Another Article",
|
title = "Another Article",
|
||||||
content = "This article is about technology and software",
|
content = "This article is about technology and software",
|
||||||
subscriptionId = subscription.id
|
subscriptionId = subscription.id,
|
||||||
|
publishedAt = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
|
|
||||||
databaseManager.createFeedItem(item1)
|
database.feedItemDao().insert(item1)
|
||||||
databaseManager.createFeedItem(item2)
|
database.feedItemDao().insert(item2)
|
||||||
|
|
||||||
// Perform search
|
// Perform search
|
||||||
val searchResults = databaseManager.searchFeedItems("test", limit = 10)
|
val searchResults = database.feedItemDao().search("%test%", limit = 10)
|
||||||
|
|
||||||
// Verify results
|
// Verify results
|
||||||
assertTrue("Should find at least one result", searchResults.size >= 1)
|
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
|
@Test
|
||||||
fun testBackgroundSyncIntegration() {
|
fun testBackgroundSyncIntegration() = runBlockingTest {
|
||||||
// Verify background sync functionality
|
// Setup mock server with multiple feeds
|
||||||
// This test would require a mock server to test actual sync
|
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 feed1Url = mockServer.url("/feed1.xml").toString()
|
||||||
val syncScheduler = databaseManager
|
val feed2Url = mockServer.url("/feed2.xml").toString()
|
||||||
|
|
||||||
assertNotNull("Database should be available for sync", syncScheduler)
|
// Insert subscriptions
|
||||||
}
|
database.subscriptionDao().insert(
|
||||||
|
com.rssuper.database.entities.SubscriptionEntity(
|
||||||
@Test
|
id = "sync-feed-1",
|
||||||
fun testNotificationDelivery() {
|
url = feed1Url,
|
||||||
// Verify notification delivery functionality
|
title = "Sync Test Feed 1"
|
||||||
|
)
|
||||||
// Create a test subscription
|
|
||||||
val subscription = FeedSubscription(
|
|
||||||
id = "test-notification-sub",
|
|
||||||
url = "https://example.com/feed.xml",
|
|
||||||
title = "Test Notification Feed"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
databaseManager.createSubscription(
|
database.subscriptionDao().insert(
|
||||||
id = subscription.id,
|
com.rssuper.database.entities.SubscriptionEntity(
|
||||||
url = subscription.url,
|
id = "sync-feed-2",
|
||||||
title = subscription.title
|
url = feed2Url,
|
||||||
|
title = "Sync Test Feed 2"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Verify subscription was created
|
// Simulate sync by fetching and parsing both feeds
|
||||||
val fetched = databaseManager.fetchSubscription(subscription.id)
|
feed1Url.let { url ->
|
||||||
assertNotNull("Subscription should be created", fetched)
|
val result = feedFetcher.fetchAndParse(url)
|
||||||
assertEquals("Title should match", subscription.title, fetched?.title)
|
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
|
@Test
|
||||||
fun testSettingsPersistence() {
|
fun testNotificationDelivery() = runBlockingTest {
|
||||||
// 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
|
|
||||||
|
|
||||||
// Create subscription
|
// Create subscription
|
||||||
databaseManager.createSubscription(
|
val subscription = database.subscriptionDao().insert(
|
||||||
id = "test-bookmark-sub",
|
com.rssuper.database.entities.SubscriptionEntity(
|
||||||
url = "https://example.com/feed.xml",
|
id = "test-notification-sub",
|
||||||
title = "Test Bookmark Feed"
|
url = "https://example.com/feed.xml",
|
||||||
|
title = "Test Notification Feed"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create feed item
|
// 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",
|
id = "test-bookmark-item",
|
||||||
title = "Test Bookmark Article",
|
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
|
// 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
|
database.bookmarkDao().insert(bookmark)
|
||||||
// for now we verify the repository exists
|
|
||||||
assertNotNull("BookmarkRepository should be initialized", repository)
|
// Verify bookmark was created
|
||||||
|
val storedBookmarks = database.bookmarkDao().getAll()
|
||||||
|
assertEquals("Should have 1 bookmark", 1, storedBookmarks.size)
|
||||||
|
assertEquals("Bookmark title should match", bookmark.title, storedBookmarks.first().title)
|
||||||
|
|
||||||
|
// Update bookmark
|
||||||
|
val updatedBookmark = bookmark.copy(description = "Updated description")
|
||||||
|
database.bookmarkDao().update(updatedBookmark)
|
||||||
|
|
||||||
|
val reloaded = database.bookmarkDao().getById(bookmark.id)
|
||||||
|
assertEquals("Bookmark description should be updated",
|
||||||
|
updatedBookmark.description, reloaded?.description)
|
||||||
|
|
||||||
|
// Delete bookmark
|
||||||
|
database.bookmarkDao().delete(bookmark.id)
|
||||||
|
|
||||||
|
val deleted = database.bookmarkDao().getById(bookmark.id)
|
||||||
|
assertNull("Bookmark should be deleted", deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testErrorRecoveryNetworkFailure() = runBlockingTest {
|
||||||
|
// Setup mock server to fail
|
||||||
|
mockServer.enqueue(MockResponse().setResponseCode(500))
|
||||||
|
mockServer.enqueue(MockResponse().setResponseCode(500))
|
||||||
|
mockServer.enqueue(MockResponse().setBody("Success").setResponseCode(200))
|
||||||
|
|
||||||
|
val feedUrl = mockServer.url("/feed.xml").toString()
|
||||||
|
|
||||||
|
// Should fail on first two attempts (mocked in FeedFetcher with retries)
|
||||||
|
val result = feedFetcher.fetch(feedUrl)
|
||||||
|
|
||||||
|
// After 3 retries, should eventually succeed or fail
|
||||||
|
assertTrue("Should complete after retries", result.isSuccess() || result.isFailure())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testErrorRecoveryParseError() = runBlockingTest {
|
||||||
|
// Setup mock server with invalid XML
|
||||||
|
mockServer.enqueue(MockResponse().setBody("<invalid xml").setResponseCode(200))
|
||||||
|
|
||||||
|
val feedUrl = mockServer.url("/feed.xml").toString()
|
||||||
|
|
||||||
|
val fetchResult = feedFetcher.fetch(feedUrl)
|
||||||
|
assertTrue("Fetch should succeed", fetchResult.isSuccess())
|
||||||
|
|
||||||
|
val parseResult = feedParser.parse(fetchResult.getOrNull()!!.feedXml, feedUrl)
|
||||||
|
// Parser should handle invalid XML gracefully
|
||||||
|
assertTrue("Parse should handle error", parseResult is ParseResult.Failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCrossPlatformDataConsistency() = runBlockingTest {
|
||||||
|
// Verify data structures are consistent across platforms
|
||||||
|
// This test verifies that the same data can be created and retrieved
|
||||||
|
|
||||||
|
// Create subscription
|
||||||
|
val subscription = database.subscriptionDao().insert(
|
||||||
|
com.rssuper.database.entities.SubscriptionEntity(
|
||||||
|
id = "cross-platform-test",
|
||||||
|
url = "https://example.com/feed.xml",
|
||||||
|
title = "Cross Platform Test"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create feed item
|
||||||
|
val item = com.rssuper.database.entities.FeedItemEntity(
|
||||||
|
id = "cross-platform-item",
|
||||||
|
title = "Cross Platform Item",
|
||||||
|
content = "Testing cross-platform data consistency",
|
||||||
|
subscriptionId = subscription.id,
|
||||||
|
publishedAt = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
database.feedItemDao().insert(item)
|
||||||
|
|
||||||
|
// Verify data integrity
|
||||||
|
val storedItem = database.feedItemDao().getById(item.id)
|
||||||
|
assertNotNull("Item should be retrievable", storedItem)
|
||||||
|
assertEquals("Title should match", item.title, storedItem?.title)
|
||||||
|
assertEquals("Content should match", item.content, storedItem?.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testHTTPAuthCredentials() = runBlockingTest {
|
||||||
|
// Test HTTP authentication integration
|
||||||
|
val auth = HTTPAuthCredentials("testuser", "testpass")
|
||||||
|
val credentials = auth.toCredentials()
|
||||||
|
|
||||||
|
assertTrue("Credentials should start with Basic", credentials.startsWith("Basic "))
|
||||||
|
|
||||||
|
// Setup mock server with auth
|
||||||
|
mockServer.enqueue(MockResponse().setResponseCode(401))
|
||||||
|
mockServer.enqueue(MockResponse().setBody("Success").setResponseCode(200)
|
||||||
|
.addHeader("WWW-Authenticate", "Basic realm=\"test\""))
|
||||||
|
|
||||||
|
val feedUrl = mockServer.url("/feed.xml").toString()
|
||||||
|
|
||||||
|
val result = feedFetcher.fetch(feedUrl, httpAuth = auth)
|
||||||
|
assertTrue("Should handle auth", result.isSuccess() || result.isFailure())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCacheControl() = runBlockingTest {
|
||||||
|
// Test ETag and If-Modified-Since headers
|
||||||
|
val etag = "test-etag-123"
|
||||||
|
val lastModified = "Mon, 01 Jan 2024 00:00:00 GMT"
|
||||||
|
|
||||||
|
// First request
|
||||||
|
mockServer.enqueue(MockResponse().setBody("Feed 1").setResponseCode(200)
|
||||||
|
.addHeader("ETag", etag)
|
||||||
|
.addHeader("Last-Modified", lastModified))
|
||||||
|
|
||||||
|
// Second request with If-None-Match
|
||||||
|
mockServer.enqueue(MockResponse().setResponseCode(304))
|
||||||
|
|
||||||
|
val feedUrl = mockServer.url("/feed.xml").toString()
|
||||||
|
|
||||||
|
// First fetch
|
||||||
|
val result1 = feedFetcher.fetch(feedUrl)
|
||||||
|
assertTrue("First fetch should succeed", result1.isSuccess())
|
||||||
|
|
||||||
|
// Second fetch with ETag
|
||||||
|
val result2 = feedFetcher.fetch(feedUrl, ifNoneMatch = etag)
|
||||||
|
assertTrue("Second fetch should complete", result2.isSuccess() || result2.isFailure())
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> runBlockingTest(block: suspend () -> T): T {
|
||||||
|
return block()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ final class DatabaseManager {
|
|||||||
subscription_id TEXT NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
|
subscription_id TEXT NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
|
||||||
subscription_title TEXT,
|
subscription_title TEXT,
|
||||||
read INTEGER NOT NULL DEFAULT 0,
|
read INTEGER NOT NULL DEFAULT 0,
|
||||||
|
starred INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
@@ -463,25 +464,48 @@ extension DatabaseManager {
|
|||||||
return executeQuery(sql: selectSQL, bindParams: [limit], rowMapper: rowToFeedItem)
|
return executeQuery(sql: selectSQL, bindParams: [limit], rowMapper: rowToFeedItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateFeedItem(_ item: FeedItem, read: Bool? = nil) throws -> FeedItem {
|
func updateFeedItem(itemId: String, read: Bool? = nil, starred: Bool? = nil) throws -> FeedItem {
|
||||||
guard let read = read else { return item }
|
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 {
|
guard let statement = prepareStatement(sql: updateSQL) else {
|
||||||
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
|
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer { sqlite3_finalize(statement) }
|
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 {
|
if sqlite3_step(statement) != SQLITE_DONE {
|
||||||
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
|
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
var updatedItem = item
|
var updatedItem = item
|
||||||
updatedItem.read = read
|
if let read = read { updatedItem.read = read }
|
||||||
|
if let starred = starred { updatedItem.starred = starred }
|
||||||
return updatedItem
|
return updatedItem
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,28 +766,15 @@ extension DatabaseManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func markItemAsRead(itemId: String) throws {
|
func markItemAsRead(itemId: String) throws {
|
||||||
guard let item = try fetchFeedItem(id: itemId) else {
|
_ = try updateFeedItem(itemId, read: true)
|
||||||
throw DatabaseError.objectNotFound
|
|
||||||
}
|
|
||||||
_ = try updateFeedItem(item, read: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func markItemAsStarred(itemId: String) throws {
|
func markItemAsStarred(itemId: String) throws {
|
||||||
guard let item = try fetchFeedItem(id: itemId) else {
|
_ = try updateFeedItem(itemId, read: nil, starred: true)
|
||||||
throw DatabaseError.objectNotFound
|
|
||||||
}
|
|
||||||
var updatedItem = item
|
|
||||||
updatedItem.starred = true
|
|
||||||
_ = try updateFeedItem(updatedItem, read: nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func unstarItem(itemId: String) throws {
|
func unstarItem(itemId: String) throws {
|
||||||
guard let item = try fetchFeedItem(id: itemId) else {
|
_ = try updateFeedItem(itemId, read: nil, starred: false)
|
||||||
throw DatabaseError.objectNotFound
|
|
||||||
}
|
|
||||||
var updatedItem = item
|
|
||||||
updatedItem.starred = false
|
|
||||||
_ = try updateFeedItem(updatedItem, read: nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getStarredItems() throws -> [FeedItem] {
|
func getStarredItems() throws -> [FeedItem] {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ struct FeedItem: Identifiable, Codable, Equatable {
|
|||||||
var subscriptionId: String
|
var subscriptionId: String
|
||||||
var subscriptionTitle: String?
|
var subscriptionTitle: String?
|
||||||
var read: Bool = false
|
var read: Bool = false
|
||||||
|
var starred: Bool = false
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
@@ -38,6 +39,7 @@ struct FeedItem: Identifiable, Codable, Equatable {
|
|||||||
case subscriptionId = "subscription_id"
|
case subscriptionId = "subscription_id"
|
||||||
case subscriptionTitle = "subscription_title"
|
case subscriptionTitle = "subscription_title"
|
||||||
case read
|
case read
|
||||||
|
case starred
|
||||||
}
|
}
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -54,7 +56,8 @@ struct FeedItem: Identifiable, Codable, Equatable {
|
|||||||
guid: String? = nil,
|
guid: String? = nil,
|
||||||
subscriptionId: String,
|
subscriptionId: String,
|
||||||
subscriptionTitle: String? = nil,
|
subscriptionTitle: String? = nil,
|
||||||
read: Bool = false
|
read: Bool = false,
|
||||||
|
starred: Bool = false
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
@@ -70,6 +73,7 @@ struct FeedItem: Identifiable, Codable, Equatable {
|
|||||||
self.subscriptionId = subscriptionId
|
self.subscriptionId = subscriptionId
|
||||||
self.subscriptionTitle = subscriptionTitle
|
self.subscriptionTitle = subscriptionTitle
|
||||||
self.read = read
|
self.read = read
|
||||||
|
self.starred = starred
|
||||||
}
|
}
|
||||||
|
|
||||||
var debugDescription: String {
|
var debugDescription: String {
|
||||||
|
|||||||
@@ -16,14 +16,13 @@ struct FeedDetailView: View {
|
|||||||
feedItem.read
|
feedItem.read
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggleRead() {
|
private func toggleRead() {
|
||||||
let success = feedService.markItemAsRead(itemId: feedItem.id)
|
let success = feedService.markItemAsRead(itemId: feedItem.id)
|
||||||
if !success {
|
if !success {
|
||||||
errorMessage = "Failed to update read status"
|
errorMessage = "Failed to update read status"
|
||||||
showError = true
|
showError = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private func close() {
|
private func close() {
|
||||||
// Dismiss the view
|
// Dismiss the view
|
||||||
|
|||||||
97
iOS/RSSuper/UI/SearchView.swift
Normal file
97
iOS/RSSuper/UI/SearchView.swift
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SearchView: View {
|
||||||
|
@StateObject private var viewModel: SearchViewModel
|
||||||
|
@State private var searchQuery: String = ""
|
||||||
|
@State private var isSearching: Bool = false
|
||||||
|
@State private var showError: Bool = false
|
||||||
|
@State private var errorMessage: String = ""
|
||||||
|
|
||||||
|
private let searchService: SearchServiceProtocol
|
||||||
|
|
||||||
|
init(searchService: SearchServiceProtocol = SearchService()) {
|
||||||
|
self.searchService = searchService
|
||||||
|
_viewModel = StateObject(wrappedValue: SearchViewModel(searchService: searchService))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
VStack {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
TextField("Search feeds...", text: $searchQuery)
|
||||||
|
.onSubmit {
|
||||||
|
performSearch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
if isSearching {
|
||||||
|
ProgressView("Searching...")
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
if showError {
|
||||||
|
Text(errorMessage)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.font(.caption)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
List {
|
||||||
|
ForEach(viewModel.searchResults) { result in
|
||||||
|
NavigationLink(destination: FeedDetailView(feedItem: result.item, feedService: searchService)) {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(result.item.title)
|
||||||
|
.font(.headline)
|
||||||
|
Text(result.item.subscriptionTitle ?? "Unknown")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
if let published = result.item.published {
|
||||||
|
Text(published, format: Date.FormatStyle(date: .abbreviated, time: .shortened))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(PlainListStyle())
|
||||||
|
}
|
||||||
|
.navigationTitle("Search")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Clear") {
|
||||||
|
searchQuery = ""
|
||||||
|
viewModel.clearSearch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performSearch() {
|
||||||
|
guard !searchQuery.isEmpty else { return }
|
||||||
|
|
||||||
|
isSearching = true
|
||||||
|
showError = false
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await viewModel.search(query: searchQuery)
|
||||||
|
isSearching = false
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
showError = true
|
||||||
|
isSearching = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SearchView()
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ class FeedViewModel: ObservableObject {
|
|||||||
|
|
||||||
private let feedService: FeedServiceProtocol
|
private let feedService: FeedServiceProtocol
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var currentSubscriptionId: String?
|
var currentSubscriptionId: String?
|
||||||
|
|
||||||
init(feedService: FeedServiceProtocol = FeedService()) {
|
init(feedService: FeedServiceProtocol = FeedService()) {
|
||||||
self.feedService = feedService
|
self.feedService = feedService
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ namespace RSSuper {
|
|||||||
var feedItems = db.getFeedItems(subscription_id);
|
var feedItems = db.getFeedItems(subscription_id);
|
||||||
callback.set_success(feedItems);
|
callback.set_success(feedItems);
|
||||||
} catch (Error e) {
|
} 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();
|
var subscriptions = db.getAllSubscriptions();
|
||||||
callback.set_success(subscriptions);
|
callback.set_success(subscriptions);
|
||||||
} catch (Error e) {
|
} 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();
|
var subscriptions = db.getEnabledSubscriptions();
|
||||||
callback.set_success(subscriptions);
|
callback.set_success(subscriptions);
|
||||||
} catch (Error e) {
|
} 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);
|
var subscriptions = db.getSubscriptionsByCategory(category);
|
||||||
callback.set_success(subscriptions);
|
callback.set_success(subscriptions);
|
||||||
} catch (Error e) {
|
} 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
111
linux/src/tests/error-tests.vala
Normal file
111
linux/src/tests/error-tests.vala
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/*
|
||||||
|
* ErrorTests.vala
|
||||||
|
*
|
||||||
|
* Unit tests for error types and error handling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
using Gio = Org.Gnome.Valetta.Gio;
|
||||||
|
|
||||||
|
public class RSSuper.ErrorTests {
|
||||||
|
|
||||||
|
public static int main(string[] args) {
|
||||||
|
var tests = new ErrorTests();
|
||||||
|
|
||||||
|
tests.test_error_type_enum();
|
||||||
|
tests.test_error_details_creation();
|
||||||
|
tests.test_error_details_properties();
|
||||||
|
tests.test_error_details_comparison();
|
||||||
|
|
||||||
|
print("All error tests passed!\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_error_type_enum() {
|
||||||
|
assert(ErrorType.NETWORK == ErrorType.NETWORK);
|
||||||
|
assert(ErrorType.DATABASE == ErrorType.DATABASE);
|
||||||
|
assert(ErrorType.PARSING == ErrorType.PARSING);
|
||||||
|
assert(ErrorType.AUTH == ErrorType.AUTH);
|
||||||
|
assert(ErrorType.UNKNOWN == ErrorType.UNKNOWN);
|
||||||
|
|
||||||
|
print("PASS: test_error_type_enum\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_error_details_creation() {
|
||||||
|
// Test default constructor
|
||||||
|
var error1 = new ErrorDetails(ErrorType.NETWORK, "Connection failed", true);
|
||||||
|
assert(error1.type == ErrorType.NETWORK);
|
||||||
|
assert(error1.message == "Connection failed");
|
||||||
|
assert(error1.retryable == true);
|
||||||
|
|
||||||
|
// Test with retryable=false
|
||||||
|
var error2 = new ErrorDetails(ErrorType.DATABASE, "Table locked", false);
|
||||||
|
assert(error2.type == ErrorType.DATABASE);
|
||||||
|
assert(error2.message == "Table locked");
|
||||||
|
assert(error2.retryable == false);
|
||||||
|
|
||||||
|
// Test with null message
|
||||||
|
var error3 = new ErrorDetails(ErrorType.PARSING, null, true);
|
||||||
|
assert(error3.type == ErrorType.PARSING);
|
||||||
|
assert(error3.message == null);
|
||||||
|
assert(error3.retryable == true);
|
||||||
|
|
||||||
|
print("PASS: test_error_details_creation\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_error_details_properties() {
|
||||||
|
var error = new ErrorDetails(ErrorType.DATABASE, "Query timeout", true);
|
||||||
|
|
||||||
|
// Test property getters
|
||||||
|
assert(error.type == ErrorType.DATABASE);
|
||||||
|
assert(error.message == "Query timeout");
|
||||||
|
assert(error.retryable == true);
|
||||||
|
|
||||||
|
// Test property setters
|
||||||
|
error.type = ErrorType.PARSING;
|
||||||
|
error.message = "Syntax error";
|
||||||
|
error.retryable = false;
|
||||||
|
|
||||||
|
assert(error.type == ErrorType.PARSING);
|
||||||
|
assert(error.message == "Syntax error");
|
||||||
|
assert(error.retryable == false);
|
||||||
|
|
||||||
|
print("PASS: test_error_details_properties\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_error_details_comparison() {
|
||||||
|
var error1 = new ErrorDetails(ErrorType.NETWORK, "Timeout", true);
|
||||||
|
var error2 = new ErrorDetails(ErrorType.NETWORK, "Timeout", true);
|
||||||
|
var error3 = new ErrorDetails(ErrorType.DATABASE, "Timeout", true);
|
||||||
|
|
||||||
|
// Same type, message, retryable - equal
|
||||||
|
assert(error1.type == error2.type);
|
||||||
|
assert(error1.message == error2.message);
|
||||||
|
assert(error1.retryable == error2.retryable);
|
||||||
|
|
||||||
|
// Different type - not equal
|
||||||
|
assert(error1.type != error3.type);
|
||||||
|
|
||||||
|
// Different retryable - not equal
|
||||||
|
var error4 = new ErrorDetails(ErrorType.NETWORK, "Timeout", false);
|
||||||
|
assert(error1.retryable != error4.retryable);
|
||||||
|
|
||||||
|
print("PASS: test_error_details_comparison\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_error_from_gio_error() {
|
||||||
|
// Simulate Gio.Error
|
||||||
|
var error = new Gio.Error();
|
||||||
|
error.set_code(Gio.Error.Code.NOT_FOUND);
|
||||||
|
error.set_domain("gio");
|
||||||
|
error.set_message("File not found");
|
||||||
|
|
||||||
|
// Convert to ErrorDetails
|
||||||
|
var details = new ErrorDetails(ErrorType.DATABASE, error.message, true);
|
||||||
|
|
||||||
|
assert(details.type == ErrorType.DATABASE);
|
||||||
|
assert(details.message == "File not found");
|
||||||
|
assert(details.retryable == true);
|
||||||
|
|
||||||
|
print("PASS: test_error_from_gio_error\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,247 +1,423 @@
|
|||||||
/*
|
/*
|
||||||
* RepositoryTests.vala
|
* 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 class RSSuper.RepositoryTests {
|
||||||
|
|
||||||
public static int main(string[] args) {
|
public static int main(string[] args) {
|
||||||
var tests = new RepositoryTests();
|
var tests = new RepositoryTests();
|
||||||
|
|
||||||
tests.test_bookmark_repository_create();
|
tests.test_feed_repository_get_items();
|
||||||
tests.test_bookmark_repository_read();
|
tests.test_feed_repository_get_item_by_id();
|
||||||
tests.test_bookmark_repository_update();
|
tests.test_feed_repository_insert_item();
|
||||||
tests.test_bookmark_repository_delete();
|
tests.test_feed_repository_insert_items();
|
||||||
tests.test_bookmark_repository_tags();
|
tests.test_feed_repository_update_item();
|
||||||
tests.test_bookmark_repository_by_feed_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");
|
print("All repository tests passed!\n");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void test_bookmark_repository_create() {
|
public void test_feed_repository_get_items() {
|
||||||
// Create a test database
|
|
||||||
var db = new Database(":memory:");
|
var db = new Database(":memory:");
|
||||||
|
var repo = new FeedRepositoryImpl(db);
|
||||||
|
|
||||||
// Create bookmark repository
|
var state = new State<FeedItem[]>();
|
||||||
var repo = new BookmarkRepositoryImpl(db);
|
repo.get_feed_items(null, (s) => {
|
||||||
|
state.set_success(db.getFeedItems(null));
|
||||||
|
});
|
||||||
|
|
||||||
// Create a test bookmark
|
assert(state.is_loading() == true);
|
||||||
var bookmark = Bookmark.new_internal(
|
assert(state.is_success() == false);
|
||||||
id: "test-bookmark-1",
|
assert(state.is_error() == false);
|
||||||
feed_item_id: "test-item-1",
|
|
||||||
created_at: Time.now()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Test creation
|
print("PASS: test_feed_repository_get_items\n");
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void test_bookmark_repository_read() {
|
public void test_feed_repository_get_item_by_id() {
|
||||||
// Create a test database
|
|
||||||
var db = new Database(":memory:");
|
var db = new Database(":memory:");
|
||||||
|
var repo = new FeedRepositoryImpl(db);
|
||||||
|
|
||||||
// Create bookmark repository
|
var item = db.create_feed_item(
|
||||||
var repo = new BookmarkRepositoryImpl(db);
|
id: "test-item-1",
|
||||||
|
title: "Test Item",
|
||||||
// Create a test bookmark
|
url: "https://example.com/article/1"
|
||||||
var bookmark = Bookmark.new_internal(
|
|
||||||
id: "test-bookmark-2",
|
|
||||||
feed_item_id: "test-item-2",
|
|
||||||
created_at: Time.now()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
var create_result = repo.add(bookmark);
|
var result = repo.get_feed_item_by_id("test-item-1");
|
||||||
if (create_result.is_error()) {
|
|
||||||
printerr("FAIL: Could not create bookmark: %s\n", create_result.error.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test reading
|
assert(result != null);
|
||||||
var read_result = repo.get_by_id("test-bookmark-2");
|
assert(result.id == "test-item-1");
|
||||||
|
assert(result.title == "Test Item");
|
||||||
|
|
||||||
if (read_result.is_error()) {
|
print("PASS: test_feed_repository_get_item_by_id\n");
|
||||||
printerr("FAIL: Bookmark read failed: %s\n", read_result.error.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var saved = read_result.value;
|
|
||||||
if (saved.id != "test-bookmark-2") {
|
|
||||||
printerr("FAIL: Expected id 'test-bookmark-2', got '%s'\n", saved.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
print("PASS: test_bookmark_repository_read\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void test_bookmark_repository_update() {
|
public void test_feed_repository_insert_item() {
|
||||||
// Create a test database
|
|
||||||
var db = new Database(":memory:");
|
var db = new Database(":memory:");
|
||||||
|
var repo = new FeedRepositoryImpl(db);
|
||||||
|
|
||||||
// Create bookmark repository
|
var item = FeedItem.new(
|
||||||
var repo = new BookmarkRepositoryImpl(db);
|
id: "test-item-2",
|
||||||
|
title: "New Item",
|
||||||
// Create a test bookmark
|
url: "https://example.com/article/2",
|
||||||
var bookmark = Bookmark.new_internal(
|
published_at: Time.now()
|
||||||
id: "test-bookmark-3",
|
|
||||||
feed_item_id: "test-item-3",
|
|
||||||
created_at: Time.now()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
var create_result = repo.add(bookmark);
|
var result = repo.insert_feed_item(item);
|
||||||
if (create_result.is_error()) {
|
|
||||||
printerr("FAIL: Could not create bookmark: %s\n", create_result.error.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the bookmark
|
assert(result.is_error() == false);
|
||||||
bookmark.tags = ["important", "read-later"];
|
|
||||||
var update_result = repo.update(bookmark);
|
|
||||||
|
|
||||||
if (update_result.is_error()) {
|
var retrieved = repo.get_feed_item_by_id("test-item-2");
|
||||||
printerr("FAIL: Bookmark update failed: %s\n", update_result.error.message);
|
assert(retrieved != null);
|
||||||
return;
|
assert(retrieved.id == "test-item-2");
|
||||||
}
|
|
||||||
|
|
||||||
// Verify update
|
print("PASS: test_feed_repository_insert_item\n");
|
||||||
var read_result = repo.get_by_id("test-bookmark-3");
|
|
||||||
if (read_result.is_error()) {
|
|
||||||
printerr("FAIL: Could not read bookmark: %s\n", read_result.error.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var saved = read_result.value;
|
|
||||||
if (saved.tags.length != 2) {
|
|
||||||
printerr("FAIL: Expected 2 tags, got %d\n", saved.tags.length);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
print("PASS: test_bookmark_repository_update\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void test_bookmark_repository_delete() {
|
public void test_feed_repository_insert_items() {
|
||||||
// Create a test database
|
|
||||||
var db = new Database(":memory:");
|
var db = new Database(":memory:");
|
||||||
|
var repo = new FeedRepositoryImpl(db);
|
||||||
|
|
||||||
// Create bookmark repository
|
var items = new FeedItem[2];
|
||||||
var repo = new BookmarkRepositoryImpl(db);
|
|
||||||
|
|
||||||
// Create a test bookmark
|
items[0] = FeedItem.new(
|
||||||
var bookmark = Bookmark.new_internal(
|
id: "test-item-3",
|
||||||
id: "test-bookmark-4",
|
title: "Item 1",
|
||||||
feed_item_id: "test-item-4",
|
url: "https://example.com/article/3",
|
||||||
created_at: Time.now()
|
published_at: Time.now()
|
||||||
);
|
);
|
||||||
|
|
||||||
var create_result = repo.add(bookmark);
|
items[1] = FeedItem.new(
|
||||||
if (create_result.is_error()) {
|
id: "test-item-4",
|
||||||
printerr("FAIL: Could not create bookmark: %s\n", create_result.error.message);
|
title: "Item 2",
|
||||||
return;
|
url: "https://example.com/article/4",
|
||||||
}
|
published_at: Time.now()
|
||||||
|
);
|
||||||
|
|
||||||
// Delete the bookmark
|
var result = repo.insert_feed_items(items);
|
||||||
var delete_result = repo.remove("test-bookmark-4");
|
|
||||||
|
|
||||||
if (delete_result.is_error()) {
|
assert(result.is_error() == false);
|
||||||
printerr("FAIL: Bookmark deletion failed: %s\n", delete_result.error.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify deletion
|
var all_items = repo.get_feed_items(null);
|
||||||
var read_result = repo.get_by_id("test-bookmark-4");
|
assert(all_items.length == 2);
|
||||||
if (!read_result.is_error()) {
|
|
||||||
printerr("FAIL: Bookmark should have been deleted\n");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
print("PASS: test_bookmark_repository_delete\n");
|
print("PASS: test_feed_repository_insert_items\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void test_bookmark_repository_tags() {
|
public void test_feed_repository_update_item() {
|
||||||
// Create a test database
|
|
||||||
var db = new Database(":memory:");
|
var db = new Database(":memory:");
|
||||||
|
var repo = new FeedRepositoryImpl(db);
|
||||||
|
|
||||||
// Create bookmark repository
|
var item = db.create_feed_item(
|
||||||
var repo = new BookmarkRepositoryImpl(db);
|
id: "test-item-5",
|
||||||
|
title: "Original Title",
|
||||||
// Create multiple bookmarks with different tags
|
url: "https://example.com/article/5"
|
||||||
var bookmark1 = Bookmark.new_internal(
|
|
||||||
id: "test-bookmark-5",
|
|
||||||
feed_item_id: "test-item-5",
|
|
||||||
created_at: Time.now()
|
|
||||||
);
|
);
|
||||||
bookmark1.tags = ["important"];
|
|
||||||
repo.add(bookmark1);
|
|
||||||
|
|
||||||
var bookmark2 = Bookmark.new_internal(
|
item.title = "Updated Title";
|
||||||
id: "test-bookmark-6",
|
|
||||||
feed_item_id: "test-item-6",
|
|
||||||
created_at: Time.now()
|
|
||||||
);
|
|
||||||
bookmark2.tags = ["read-later"];
|
|
||||||
repo.add(bookmark2);
|
|
||||||
|
|
||||||
// Test tag-based query
|
var result = repo.update_feed_item(item);
|
||||||
var by_tag_result = repo.get_by_tag("important");
|
|
||||||
|
|
||||||
if (by_tag_result.is_error()) {
|
assert(result.is_error() == false);
|
||||||
printerr("FAIL: Tag query failed: %s\n", by_tag_result.error.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var bookmarks = by_tag_result.value;
|
var updated = repo.get_feed_item_by_id("test-item-5");
|
||||||
if (bookmarks.length != 1) {
|
assert(updated != null);
|
||||||
printerr("FAIL: Expected 1 bookmark with tag 'important', got %d\n", bookmarks.length);
|
assert(updated.title == "Updated Title");
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
print("PASS: test_bookmark_repository_tags\n");
|
print("PASS: test_feed_repository_update_item\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void test_bookmark_repository_by_feed_item() {
|
public void test_feed_repository_mark_as_read() {
|
||||||
// Create a test database
|
|
||||||
var db = new Database(":memory:");
|
var db = new Database(":memory:");
|
||||||
|
var repo = new FeedRepositoryImpl(db);
|
||||||
|
|
||||||
// Create bookmark repository
|
var item = db.create_feed_item(
|
||||||
var repo = new BookmarkRepositoryImpl(db);
|
id: "test-item-6",
|
||||||
|
title: "Read Item",
|
||||||
// Create multiple bookmarks for the same feed item
|
url: "https://example.com/article/6"
|
||||||
var bookmark1 = Bookmark.new_internal(
|
|
||||||
id: "test-bookmark-7",
|
|
||||||
feed_item_id: "test-item-7",
|
|
||||||
created_at: Time.now()
|
|
||||||
);
|
);
|
||||||
repo.add(bookmark1);
|
|
||||||
|
|
||||||
var bookmark2 = Bookmark.new_internal(
|
var result = repo.mark_as_read("test-item-6", true);
|
||||||
id: "test-bookmark-8",
|
|
||||||
feed_item_id: "test-item-7",
|
assert(result.is_error() == false);
|
||||||
created_at: Time.now()
|
|
||||||
|
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 result = repo.mark_as_starred("test-item-7", true);
|
||||||
var by_item_result = repo.get_by_feed_item("test-item-7");
|
|
||||||
|
|
||||||
if (by_item_result.is_error()) {
|
assert(result.is_error() == false);
|
||||||
printerr("FAIL: Feed item query failed: %s\n", by_item_result.error.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var bookmarks = by_item_result.value;
|
print("PASS: test_feed_repository_mark_as_starred\n");
|
||||||
if (bookmarks.length != 2) {
|
}
|
||||||
printerr("FAIL: Expected 2 bookmarks for feed item, got %d\n", bookmarks.length);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
print("PASS: test_bookmark_repository_by_feed_item\n");
|
public void test_feed_repository_delete_item() {
|
||||||
|
var db = new Database(":memory:");
|
||||||
|
var repo = new FeedRepositoryImpl(db);
|
||||||
|
|
||||||
|
var item = db.create_feed_item(
|
||||||
|
id: "test-item-8",
|
||||||
|
title: "Delete Item",
|
||||||
|
url: "https://example.com/article/8"
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = repo.delete_feed_item("test-item-8");
|
||||||
|
|
||||||
|
assert(result.is_error() == false);
|
||||||
|
|
||||||
|
var deleted = repo.get_feed_item_by_id("test-item-8");
|
||||||
|
assert(deleted == null);
|
||||||
|
|
||||||
|
print("PASS: test_feed_repository_delete_item\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_feed_repository_get_unread_count() {
|
||||||
|
var db = new Database(":memory:");
|
||||||
|
var repo = new FeedRepositoryImpl(db);
|
||||||
|
|
||||||
|
var count = repo.get_unread_count(null);
|
||||||
|
|
||||||
|
assert(count == 0);
|
||||||
|
|
||||||
|
print("PASS: test_feed_repository_get_unread_count\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_subscription_repository_get_all() {
|
||||||
|
var db = new Database(":memory:");
|
||||||
|
var repo = new SubscriptionRepositoryImpl(db);
|
||||||
|
|
||||||
|
var state = new State<FeedSubscription[]>();
|
||||||
|
repo.get_all_subscriptions((s) => {
|
||||||
|
state.set_success(db.getAllSubscriptions());
|
||||||
|
});
|
||||||
|
|
||||||
|
assert(state.is_loading() == true);
|
||||||
|
assert(state.is_success() == false);
|
||||||
|
|
||||||
|
print("PASS: test_subscription_repository_get_all\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_subscription_repository_get_enabled() {
|
||||||
|
var db = new Database(":memory:");
|
||||||
|
var repo = new SubscriptionRepositoryImpl(db);
|
||||||
|
|
||||||
|
var state = new State<FeedSubscription[]>();
|
||||||
|
repo.get_enabled_subscriptions((s) => {
|
||||||
|
state.set_success(db.getEnabledSubscriptions());
|
||||||
|
});
|
||||||
|
|
||||||
|
assert(state.is_loading() == true);
|
||||||
|
assert(state.is_success() == false);
|
||||||
|
|
||||||
|
print("PASS: test_subscription_repository_get_enabled\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_subscription_repository_get_by_category() {
|
||||||
|
var db = new Database(":memory:");
|
||||||
|
var repo = new SubscriptionRepositoryImpl(db);
|
||||||
|
|
||||||
|
var state = new State<FeedSubscription[]>();
|
||||||
|
repo.get_subscriptions_by_category("technology", (s) => {
|
||||||
|
state.set_success(db.getSubscriptionsByCategory("technology"));
|
||||||
|
});
|
||||||
|
|
||||||
|
assert(state.is_loading() == true);
|
||||||
|
assert(state.is_success() == false);
|
||||||
|
|
||||||
|
print("PASS: test_subscription_repository_get_by_category\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_subscription_repository_get_by_id() {
|
||||||
|
var db = new Database(":memory:");
|
||||||
|
var repo = new SubscriptionRepositoryImpl(db);
|
||||||
|
|
||||||
|
var subscription = db.create_subscription(
|
||||||
|
id: "test-sub-1",
|
||||||
|
url: "https://example.com/feed.xml",
|
||||||
|
title: "Test Subscription"
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = repo.get_subscription_by_id("test-sub-1");
|
||||||
|
|
||||||
|
assert(result != null);
|
||||||
|
assert(result.id == "test-sub-1");
|
||||||
|
|
||||||
|
print("PASS: test_subscription_repository_get_by_id\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_subscription_repository_get_by_url() {
|
||||||
|
var db = new Database(":memory:");
|
||||||
|
var repo = new SubscriptionRepositoryImpl(db);
|
||||||
|
|
||||||
|
var subscription = db.create_subscription(
|
||||||
|
id: "test-sub-2",
|
||||||
|
url: "https://example.com/feed.xml",
|
||||||
|
title: "Test Subscription"
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = repo.get_subscription_by_url("https://example.com/feed.xml");
|
||||||
|
|
||||||
|
assert(result != null);
|
||||||
|
assert(result.url == "https://example.com/feed.xml");
|
||||||
|
|
||||||
|
print("PASS: test_subscription_repository_get_by_url\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_subscription_repository_insert() {
|
||||||
|
var db = new Database(":memory:");
|
||||||
|
var repo = new SubscriptionRepositoryImpl(db);
|
||||||
|
|
||||||
|
var subscription = FeedSubscription.new(
|
||||||
|
id: "test-sub-3",
|
||||||
|
url: "https://example.com/feed.xml",
|
||||||
|
title: "New Subscription",
|
||||||
|
enabled: true
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = repo.insert_subscription(subscription);
|
||||||
|
|
||||||
|
assert(result.is_error() == false);
|
||||||
|
|
||||||
|
var retrieved = repo.get_subscription_by_id("test-sub-3");
|
||||||
|
assert(retrieved != null);
|
||||||
|
assert(retrieved.id == "test-sub-3");
|
||||||
|
|
||||||
|
print("PASS: test_subscription_repository_insert\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_subscription_repository_update() {
|
||||||
|
var db = new Database(":memory:");
|
||||||
|
var repo = new SubscriptionRepositoryImpl(db);
|
||||||
|
|
||||||
|
var subscription = db.create_subscription(
|
||||||
|
id: "test-sub-4",
|
||||||
|
url: "https://example.com/feed.xml",
|
||||||
|
title: "Original Title"
|
||||||
|
);
|
||||||
|
|
||||||
|
subscription.title = "Updated Title";
|
||||||
|
|
||||||
|
var result = repo.update_subscription(subscription);
|
||||||
|
|
||||||
|
assert(result.is_error() == false);
|
||||||
|
|
||||||
|
var updated = repo.get_subscription_by_id("test-sub-4");
|
||||||
|
assert(updated != null);
|
||||||
|
assert(updated.title == "Updated Title");
|
||||||
|
|
||||||
|
print("PASS: test_subscription_repository_update\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_subscription_repository_delete() {
|
||||||
|
var db = new Database(":memory:");
|
||||||
|
var repo = new SubscriptionRepositoryImpl(db);
|
||||||
|
|
||||||
|
var subscription = db.create_subscription(
|
||||||
|
id: "test-sub-5",
|
||||||
|
url: "https://example.com/feed.xml",
|
||||||
|
title: "Delete Subscription"
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = repo.delete_subscription("test-sub-5");
|
||||||
|
|
||||||
|
assert(result.is_error() == false);
|
||||||
|
|
||||||
|
var deleted = repo.get_subscription_by_id("test-sub-5");
|
||||||
|
assert(deleted == null);
|
||||||
|
|
||||||
|
print("PASS: test_subscription_repository_delete\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_subscription_repository_set_enabled() {
|
||||||
|
var db = new Database(":memory:");
|
||||||
|
var repo = new SubscriptionRepositoryImpl(db);
|
||||||
|
|
||||||
|
var subscription = db.create_subscription(
|
||||||
|
id: "test-sub-6",
|
||||||
|
url: "https://example.com/feed.xml",
|
||||||
|
title: "Toggle Subscription"
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = repo.set_enabled("test-sub-6", false);
|
||||||
|
|
||||||
|
assert(result.is_error() == false);
|
||||||
|
|
||||||
|
var updated = repo.get_subscription_by_id("test-sub-6");
|
||||||
|
assert(updated != null);
|
||||||
|
assert(updated.enabled == false);
|
||||||
|
|
||||||
|
print("PASS: test_subscription_repository_set_enabled\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_subscription_repository_set_error() {
|
||||||
|
var db = new Database(":memory:");
|
||||||
|
var repo = new SubscriptionRepositoryImpl(db);
|
||||||
|
|
||||||
|
var subscription = db.create_subscription(
|
||||||
|
id: "test-sub-7",
|
||||||
|
url: "https://example.com/feed.xml",
|
||||||
|
title: "Error Subscription"
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = repo.set_error("test-sub-7", "Connection failed");
|
||||||
|
|
||||||
|
assert(result.is_error() == false);
|
||||||
|
|
||||||
|
print("PASS: test_subscription_repository_set_error\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_subscription_repository_update_timestamps() {
|
||||||
|
var db = new Database(":memory:");
|
||||||
|
var repo = new SubscriptionRepositoryImpl(db);
|
||||||
|
|
||||||
|
var subscription = db.create_subscription(
|
||||||
|
id: "test-sub-8",
|
||||||
|
url: "https://example.com/feed.xml",
|
||||||
|
title: "Timestamp Test"
|
||||||
|
);
|
||||||
|
|
||||||
|
var last_fetched = Time.now().unix_timestamp;
|
||||||
|
var next_fetch = Time.now().unix_timestamp + 3600;
|
||||||
|
|
||||||
|
var result = repo.update_last_fetched_at("test-sub-8", last_fetched);
|
||||||
|
var result2 = repo.update_next_fetch_at("test-sub-8", next_fetch);
|
||||||
|
|
||||||
|
assert(result.is_error() == false);
|
||||||
|
assert(result2.is_error() == false);
|
||||||
|
|
||||||
|
print("PASS: test_subscription_repository_update_timestamps\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
185
linux/src/tests/state-tests.vala
Normal file
185
linux/src/tests/state-tests.vala
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
/*
|
||||||
|
* StateTests.vala
|
||||||
|
*
|
||||||
|
* Unit tests for state management types.
|
||||||
|
*/
|
||||||
|
|
||||||
|
using Gio = Org.Gnome.Valetta.Gio;
|
||||||
|
|
||||||
|
public class RSSuper.StateTests {
|
||||||
|
|
||||||
|
public static int main(string[] args) {
|
||||||
|
var tests = new StateTests();
|
||||||
|
|
||||||
|
tests.test_state_enum_values();
|
||||||
|
tests.test_state_class_initialization();
|
||||||
|
tests.test_state_setters();
|
||||||
|
tests.test_state_getters();
|
||||||
|
tests.test_state_comparison();
|
||||||
|
tests.test_state_signal_emission();
|
||||||
|
|
||||||
|
print("All state tests passed!\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_state_enum_values() {
|
||||||
|
assert(State.IDLE == State.IDLE);
|
||||||
|
assert(State.LOADING == State.LOADING);
|
||||||
|
assert(State.SUCCESS == State.SUCCESS);
|
||||||
|
assert(State.ERROR == State.ERROR);
|
||||||
|
|
||||||
|
print("PASS: test_state_enum_values\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_state_class_initialization() {
|
||||||
|
var state = new State<string>();
|
||||||
|
|
||||||
|
assert(state.get_state() == State.IDLE);
|
||||||
|
assert(state.is_idle());
|
||||||
|
assert(!state.is_loading());
|
||||||
|
assert(!state.is_success());
|
||||||
|
assert(!state.is_error());
|
||||||
|
|
||||||
|
print("PASS: test_state_class_initialization\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_state_setters() {
|
||||||
|
var state = new State<string>();
|
||||||
|
|
||||||
|
// Test set_idle
|
||||||
|
state.set_idle();
|
||||||
|
assert(state.get_state() == State.IDLE);
|
||||||
|
assert(state.is_idle());
|
||||||
|
|
||||||
|
// Test set_loading
|
||||||
|
state.set_loading();
|
||||||
|
assert(state.get_state() == State.LOADING);
|
||||||
|
assert(state.is_loading());
|
||||||
|
|
||||||
|
// Test set_success
|
||||||
|
string data = "test data";
|
||||||
|
state.set_success(data);
|
||||||
|
assert(state.get_state() == State.SUCCESS);
|
||||||
|
assert(state.is_success());
|
||||||
|
assert(state.get_data() == data);
|
||||||
|
|
||||||
|
// Test set_error
|
||||||
|
state.set_error("test error");
|
||||||
|
assert(state.get_state() == State.ERROR);
|
||||||
|
assert(state.is_error());
|
||||||
|
assert(state.get_message() == "test error");
|
||||||
|
|
||||||
|
print("PASS: test_state_setters\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_state_getters() {
|
||||||
|
var state = new State<int>();
|
||||||
|
|
||||||
|
// Test initial values
|
||||||
|
assert(state.get_state() == State.IDLE);
|
||||||
|
assert(state.get_data() == null);
|
||||||
|
assert(state.get_message() == null);
|
||||||
|
assert(state.get_error() == null);
|
||||||
|
|
||||||
|
// Test after set_success
|
||||||
|
state.set_success(42);
|
||||||
|
assert(state.get_state() == State.SUCCESS);
|
||||||
|
assert(state.get_data() == 42);
|
||||||
|
assert(state.get_message() == null);
|
||||||
|
assert(state.get_error() == null);
|
||||||
|
|
||||||
|
// Test after set_error
|
||||||
|
state.set_error("database error");
|
||||||
|
assert(state.get_state() == State.ERROR);
|
||||||
|
assert(state.get_data() == null);
|
||||||
|
assert(state.get_message() == "database error");
|
||||||
|
assert(state.get_error() != null);
|
||||||
|
|
||||||
|
print("PASS: test_state_getters\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_state_comparison() {
|
||||||
|
var state1 = new State<string>();
|
||||||
|
var state2 = new State<string>();
|
||||||
|
|
||||||
|
// Initially equal
|
||||||
|
assert(state1.get_state() == state2.get_state());
|
||||||
|
|
||||||
|
// After different states, not equal
|
||||||
|
state1.set_success("value1");
|
||||||
|
state2.set_error("error");
|
||||||
|
assert(state1.get_state() != state2.get_state());
|
||||||
|
|
||||||
|
// Same state, different data
|
||||||
|
var state3 = new State<string>();
|
||||||
|
state3.set_success("value2");
|
||||||
|
assert(state3.get_state() == state1.get_state());
|
||||||
|
assert(state3.get_data() != state1.get_data());
|
||||||
|
|
||||||
|
print("PASS: test_state_comparison\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_state_signal_emission() {
|
||||||
|
var state = new State<string>();
|
||||||
|
|
||||||
|
// Track signal emissions
|
||||||
|
int state_changed_count = 0;
|
||||||
|
int data_changed_count = 0;
|
||||||
|
|
||||||
|
state.connect_signal("state_changed", (sender, signal) => {
|
||||||
|
state_changed_count++;
|
||||||
|
});
|
||||||
|
|
||||||
|
state.connect_signal("data_changed", (sender, signal) => {
|
||||||
|
data_changed_count++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial state - no signals
|
||||||
|
assert(state_changed_count == 0);
|
||||||
|
assert(data_changed_count == 0);
|
||||||
|
|
||||||
|
// set_loading emits state_changed
|
||||||
|
state.set_loading();
|
||||||
|
assert(state_changed_count == 1);
|
||||||
|
assert(data_changed_count == 0);
|
||||||
|
|
||||||
|
// set_success emits both signals
|
||||||
|
state.set_success("test");
|
||||||
|
assert(state_changed_count == 2);
|
||||||
|
assert(data_changed_count == 1);
|
||||||
|
|
||||||
|
// set_error emits state_changed only
|
||||||
|
state.set_error("error");
|
||||||
|
assert(state_changed_count == 3);
|
||||||
|
assert(data_changed_count == 1);
|
||||||
|
|
||||||
|
print("PASS: test_state_signal_emission\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void test_generic_state_t() {
|
||||||
|
// Test State<int>
|
||||||
|
var intState = new State<int>();
|
||||||
|
intState.set_success(123);
|
||||||
|
assert(intState.get_data() == 123);
|
||||||
|
assert(intState.is_success());
|
||||||
|
|
||||||
|
// Test State<bool>
|
||||||
|
var boolState = new State<bool>();
|
||||||
|
boolState.set_success(true);
|
||||||
|
assert(boolState.get_data() == true);
|
||||||
|
assert(boolState.is_success());
|
||||||
|
|
||||||
|
// Test State<string>
|
||||||
|
var stringState = new State<string>();
|
||||||
|
stringState.set_success("hello");
|
||||||
|
assert(stringState.get_data() == "hello");
|
||||||
|
assert(stringState.is_success());
|
||||||
|
|
||||||
|
// Test State<object>
|
||||||
|
var objectState = new State<object>();
|
||||||
|
objectState.set_success("test");
|
||||||
|
assert(objectState.get_data() == "test");
|
||||||
|
|
||||||
|
print("PASS: test_generic_state_t\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,123 +1,242 @@
|
|||||||
/*
|
/*
|
||||||
* ViewModelTests.vala
|
* 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 class RSSuper.ViewModelTests {
|
||||||
|
|
||||||
public static int main(string[] args) {
|
public static int main(string[] args) {
|
||||||
var tests = new ViewModelTests();
|
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_loading();
|
||||||
tests.test_feed_view_model_success();
|
tests.test_feed_view_model_success();
|
||||||
tests.test_feed_view_model_error();
|
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_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");
|
print("All view model tests passed!\n");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void test_feed_view_model_state() {
|
public void test_feed_view_model_initialization() {
|
||||||
// Create a test database
|
|
||||||
var db = new Database(":memory:");
|
var db = new Database(":memory:");
|
||||||
|
var repo = new FeedRepositoryImpl(db);
|
||||||
|
var model = new FeedViewModel(repo);
|
||||||
|
|
||||||
// Create feed view model
|
assert(model.feedState.get_state() == State.IDLE);
|
||||||
var model = new FeedViewModel(db);
|
assert(model.unreadCountState.get_state() == State.IDLE);
|
||||||
|
|
||||||
// Test initial state
|
print("PASS: test_feed_view_model_initialization\n");
|
||||||
assert(model.feed_state == FeedState.idle);
|
|
||||||
|
|
||||||
print("PASS: test_feed_view_model_state\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void test_feed_view_model_loading() {
|
public void test_feed_view_model_loading() {
|
||||||
// Create a test database
|
|
||||||
var db = new Database(":memory:");
|
var db = new Database(":memory:");
|
||||||
|
var repo = new FeedRepositoryImpl(db);
|
||||||
|
var model = new FeedViewModel(repo);
|
||||||
|
|
||||||
// Create feed view model
|
model.load_feed_items("test-subscription");
|
||||||
var model = new FeedViewModel(db);
|
|
||||||
|
|
||||||
// Test loading state
|
assert(model.feedState.is_loading() == true);
|
||||||
model.load_feed_items("test-subscription-id");
|
assert(model.feedState.is_success() == false);
|
||||||
|
assert(model.feedState.is_error() == false);
|
||||||
assert(model.feed_state is FeedState.loading);
|
|
||||||
|
|
||||||
print("PASS: test_feed_view_model_loading\n");
|
print("PASS: test_feed_view_model_loading\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void test_feed_view_model_success() {
|
public void test_feed_view_model_success() {
|
||||||
// Create a test database
|
|
||||||
var db = new Database(":memory:");
|
var db = new Database(":memory:");
|
||||||
|
var repo = new FeedRepositoryImpl(db);
|
||||||
|
var model = new FeedViewModel(repo);
|
||||||
|
|
||||||
// Create subscription
|
// Mock success state
|
||||||
db.create_subscription(
|
var items = db.getFeedItems("test-subscription");
|
||||||
id: "test-sub",
|
model.feedState.set_success(items);
|
||||||
url: "https://example.com/feed.xml",
|
|
||||||
title: "Test Feed"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create feed view model
|
assert(model.feedState.is_success() == true);
|
||||||
var model = new FeedViewModel(db);
|
assert(model.feedState.get_data().length > 0);
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
print("PASS: test_feed_view_model_success\n");
|
print("PASS: test_feed_view_model_success\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void test_feed_view_model_error() {
|
public void test_feed_view_model_error() {
|
||||||
// Create a test database
|
|
||||||
var db = new Database(":memory:");
|
var db = new Database(":memory:");
|
||||||
|
var repo = new FeedRepositoryImpl(db);
|
||||||
|
var model = new FeedViewModel(repo);
|
||||||
|
|
||||||
// Create feed view model
|
// Mock error state
|
||||||
var model = new FeedViewModel(db);
|
model.feedState.set_error("Connection failed");
|
||||||
|
|
||||||
// Test error state
|
assert(model.feedState.is_error() == true);
|
||||||
model.feed_state = FeedState.error("Test error");
|
assert(model.feedState.get_message() == "Connection failed");
|
||||||
|
|
||||||
assert(model.feed_state is FeedState.error);
|
|
||||||
|
|
||||||
var error_state = (FeedState.error) model.feed_state;
|
|
||||||
assert(error_state.message == "Test error");
|
|
||||||
|
|
||||||
print("PASS: test_feed_view_model_error\n");
|
print("PASS: test_feed_view_model_error\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void test_subscription_view_model_state() {
|
public void test_feed_view_model_mark_as_read() {
|
||||||
// Create a test database
|
|
||||||
var db = new Database(":memory:");
|
var db = new Database(":memory:");
|
||||||
|
var repo = new FeedRepositoryImpl(db);
|
||||||
|
var model = new FeedViewModel(repo);
|
||||||
|
|
||||||
// Create subscription view model
|
model.mark_as_read("test-item-1", true);
|
||||||
var model = new SubscriptionViewModel(db);
|
|
||||||
|
|
||||||
// Test initial state
|
assert(model.unreadCountState.is_loading() == true);
|
||||||
assert(model.subscription_state is SubscriptionState.idle);
|
|
||||||
|
|
||||||
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() {
|
public void test_subscription_view_model_loading() {
|
||||||
// Create a test database
|
|
||||||
var db = new Database(":memory:");
|
var db = new Database(":memory:");
|
||||||
|
var repo = new SubscriptionRepositoryImpl(db);
|
||||||
|
var model = new SubscriptionViewModel(repo);
|
||||||
|
|
||||||
// Create subscription view model
|
model.load_all_subscriptions();
|
||||||
var model = new SubscriptionViewModel(db);
|
|
||||||
|
|
||||||
// Test loading state
|
assert(model.subscriptionsState.is_loading() == true);
|
||||||
model.load_subscriptions();
|
assert(model.subscriptionsState.is_success() == false);
|
||||||
|
assert(model.subscriptionsState.is_error() == false);
|
||||||
assert(model.subscription_state is SubscriptionState.loading);
|
|
||||||
|
|
||||||
print("PASS: test_subscription_view_model_loading\n");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ namespace RSSuper {
|
|||||||
public void load_feed_items(string? subscription_id = null) {
|
public void load_feed_items(string? subscription_id = null) {
|
||||||
feedState.set_loading();
|
feedState.set_loading();
|
||||||
repository.get_feed_items(subscription_id, (state) => {
|
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);
|
var count = repository.get_unread_count(subscription_id);
|
||||||
unreadCountState.set_success(count);
|
unreadCountState.set_success(count);
|
||||||
} catch (Error e) {
|
} 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);
|
repository.mark_as_read(id, is_read);
|
||||||
load_unread_count();
|
load_unread_count();
|
||||||
} catch (Error e) {
|
} 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 {
|
try {
|
||||||
repository.mark_as_starred(id, is_starred);
|
repository.mark_as_starred(id, is_starred);
|
||||||
} catch (Error e) {
|
} 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ namespace RSSuper {
|
|||||||
public void load_all_subscriptions() {
|
public void load_all_subscriptions() {
|
||||||
subscriptionsState.set_loading();
|
subscriptionsState.set_loading();
|
||||||
repository.get_all_subscriptions((state) => {
|
repository.get_all_subscriptions((state) => {
|
||||||
subscriptionsState = state;
|
subscriptionsState.set_success(state.get_data());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void load_enabled_subscriptions() {
|
public void load_enabled_subscriptions() {
|
||||||
enabledSubscriptionsState.set_loading();
|
enabledSubscriptionsState.set_loading();
|
||||||
repository.get_enabled_subscriptions((state) => {
|
repository.get_enabled_subscriptions((state) => {
|
||||||
enabledSubscriptionsState = state;
|
enabledSubscriptionsState.set_success(state.get_data());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ namespace RSSuper {
|
|||||||
repository.set_enabled(id, enabled);
|
repository.set_enabled(id, enabled);
|
||||||
load_enabled_subscriptions();
|
load_enabled_subscriptions();
|
||||||
} catch (Error e) {
|
} 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 {
|
try {
|
||||||
repository.set_error(id, error);
|
repository.set_error(id, error);
|
||||||
} catch (Error e) {
|
} 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 {
|
try {
|
||||||
repository.update_last_fetched_at(id, last_fetched_at);
|
repository.update_last_fetched_at(id, last_fetched_at);
|
||||||
} catch (Error e) {
|
} 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 {
|
try {
|
||||||
repository.update_next_fetch_at(id, next_fetch_at);
|
repository.update_next_fetch_at(id, next_fetch_at);
|
||||||
} catch (Error e) {
|
} 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
201
native-route/ios/RSSuper/CoreData/CoreDataModel.ent
Normal file
201
native-route/ios/RSSuper/CoreData/CoreDataModel.ent
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<EntityDescription>
|
||||||
|
<Name>FeedItem</Name>
|
||||||
|
<Attributes>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>id</Name>
|
||||||
|
<Type>NSUUID</Type>
|
||||||
|
<Required>true</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>subscriptionId</Name>
|
||||||
|
<Type>NSString</Type>
|
||||||
|
<Required>true</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>title</Name>
|
||||||
|
<Type>NSString</Type>
|
||||||
|
<Required>true</Required>
|
||||||
|
<Searchable>true</Searchable>
|
||||||
|
<FTSSearchable>true</FTSSearchable>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>link</Name>
|
||||||
|
<Type>NSString</Type>
|
||||||
|
<Required>false</Required>
|
||||||
|
<Searchable>true</Searchable>
|
||||||
|
<FTSSearchable>true</FTSSearchable>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>description</Name>
|
||||||
|
<Type>NSString</Type>
|
||||||
|
<Required>false</Required>
|
||||||
|
<Searchable>true</Searchable>
|
||||||
|
<FTSSearchable>true</FTSSearchable>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>content</Name>
|
||||||
|
<Type>NSString</Type>
|
||||||
|
<Required>false</Required>
|
||||||
|
<Searchable>true</Searchable>
|
||||||
|
<FTSSearchable>true</FTSSearchable>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>author</Name>
|
||||||
|
<Type>NSString</Type>
|
||||||
|
<Required>false</Required>
|
||||||
|
<Searchable>true</Searchable>
|
||||||
|
<FTSSearchable>true</FTSSearchable>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>published</Name>
|
||||||
|
<Type>NSString</Type>
|
||||||
|
<Required>false</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>updated</Name>
|
||||||
|
<Type>NSString</Type>
|
||||||
|
<Required>false</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>categories</Name>
|
||||||
|
<Type>NSString</Type>
|
||||||
|
<Required>false</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>enclosureUrl</Name>
|
||||||
|
<Type>NSString</Type>
|
||||||
|
<Required>false</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>enclosureType</Name>
|
||||||
|
<Type>NSString</Type>
|
||||||
|
<Required>false</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>enclosureLength</Name>
|
||||||
|
<Type>NSNumber</Type>
|
||||||
|
<Required>false</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>guid</Name>
|
||||||
|
<Type>NSString</Type>
|
||||||
|
<Required>false</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>isRead</Name>
|
||||||
|
<Type>NSNumber</Type>
|
||||||
|
<Required>true</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>isStarred</Name>
|
||||||
|
<Type>NSNumber</Type>
|
||||||
|
<Required>true</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
</Attributes>
|
||||||
|
<Relationships>
|
||||||
|
<RelationshipDescription>
|
||||||
|
<Name>subscription</Name>
|
||||||
|
<SourceEntity>FeedItem</SourceEntity>
|
||||||
|
<DestinationEntity>FeedSubscription</DestinationEntity>
|
||||||
|
<IsOptional>false</IsOptional>
|
||||||
|
<IsNullable>true</IsNullable>
|
||||||
|
</RelationshipDescription>
|
||||||
|
</Relationships>
|
||||||
|
</EntityDescription>
|
||||||
|
|
||||||
|
<EntityDescription>
|
||||||
|
<Name>FeedSubscription</Name>
|
||||||
|
<Attributes>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>id</Name>
|
||||||
|
<Type>NSUUID</Type>
|
||||||
|
<Required>true</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>url</Name>
|
||||||
|
<Type>NSString</Type>
|
||||||
|
<Required>true</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>title</Name>
|
||||||
|
<Type>NSString</Type>
|
||||||
|
<Required>true</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>enabled</Name>
|
||||||
|
<Type>NSNumber</Type>
|
||||||
|
<Required>true</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>lastFetchedAt</Name>
|
||||||
|
<Type>NSNumber</Type>
|
||||||
|
<Required>false</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>nextFetchAt</Name>
|
||||||
|
<Type>NSNumber</Type>
|
||||||
|
<Required>false</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>error</Name>
|
||||||
|
<Type>NSString</Type>
|
||||||
|
<Required>false</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
</Attributes>
|
||||||
|
<Relationships>
|
||||||
|
<RelationshipDescription>
|
||||||
|
<Name>feedItems</Name>
|
||||||
|
<SourceEntity>FeedSubscription</SourceEntity>
|
||||||
|
<DestinationEntity>FeedItem</DestinationEntity>
|
||||||
|
<IsOptional>true</IsOptional>
|
||||||
|
<IsNullable>true</IsNullable>
|
||||||
|
</RelationshipDescription>
|
||||||
|
</Relationships>
|
||||||
|
</EntityDescription>
|
||||||
|
|
||||||
|
<EntityDescription>
|
||||||
|
<Name>SearchHistoryEntry</Name>
|
||||||
|
<Attributes>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>id</Name>
|
||||||
|
<Type>NSNumber</Type>
|
||||||
|
<Required>true</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>query</Name>
|
||||||
|
<Type>NSString</Type>
|
||||||
|
<Required>true</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>filtersJson</Name>
|
||||||
|
<Type>NSString</Type>
|
||||||
|
<Required>false</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>sortOption</Name>
|
||||||
|
<Type>NSString</Type>
|
||||||
|
<Required>true</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>page</Name>
|
||||||
|
<Type>NSNumber</Type>
|
||||||
|
<Required>true</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>pageSize</Name>
|
||||||
|
<Type>NSNumber</Type>
|
||||||
|
<Required>true</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>resultCount</Name>
|
||||||
|
<Type>NSNumber</Type>
|
||||||
|
<Required>true</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
<AttributeDescription>
|
||||||
|
<Name>createdAt</Name>
|
||||||
|
<Type>NSDate</Type>
|
||||||
|
<Required>true</Required>
|
||||||
|
</AttributeDescription>
|
||||||
|
</Attributes>
|
||||||
|
</EntityDescription>
|
||||||
98
native-route/ios/RSSuper/Models/SearchFilters.swift
Normal file
98
native-route/ios/RSSuper/Models/SearchFilters.swift
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/*
|
||||||
|
* SearchFilters.swift
|
||||||
|
*
|
||||||
|
* Search filter model for iOS search service.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Search filter configuration
|
||||||
|
class SearchFilters: Codable {
|
||||||
|
/// Date range filter
|
||||||
|
let dateFrom: Date?
|
||||||
|
|
||||||
|
/// Date range filter
|
||||||
|
let dateTo: Date?
|
||||||
|
|
||||||
|
/// Feed ID filter
|
||||||
|
let feedIds: [String]?
|
||||||
|
|
||||||
|
/// Author filter
|
||||||
|
let author: String?
|
||||||
|
|
||||||
|
/// Category filter
|
||||||
|
let category: String?
|
||||||
|
|
||||||
|
/// Enclosure type filter
|
||||||
|
let enclosureType: String?
|
||||||
|
|
||||||
|
/// Enclosure length filter
|
||||||
|
let enclosureLength: Double?
|
||||||
|
|
||||||
|
/// Is read filter
|
||||||
|
let isRead: Bool?
|
||||||
|
|
||||||
|
/// Is starred filter
|
||||||
|
let isStarred: Bool?
|
||||||
|
|
||||||
|
/// Initialize search filters
|
||||||
|
init(
|
||||||
|
dateFrom: Date? = nil,
|
||||||
|
dateTo: Date? = nil,
|
||||||
|
feedIds: [String]? = nil,
|
||||||
|
author: String? = nil,
|
||||||
|
category: String? = nil,
|
||||||
|
enclosureType: String? = nil,
|
||||||
|
enclosureLength: Double? = nil,
|
||||||
|
isRead: Bool? = nil,
|
||||||
|
isStarred: Bool? = nil
|
||||||
|
) {
|
||||||
|
self.dateFrom = dateFrom
|
||||||
|
self.dateTo = dateTo
|
||||||
|
self.feedIds = feedIds
|
||||||
|
self.author = author
|
||||||
|
self.category = category
|
||||||
|
self.enclosureType = enclosureType
|
||||||
|
self.enclosureLength = enclosureLength
|
||||||
|
self.isRead = isRead
|
||||||
|
self.isStarred = isStarred
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize with values
|
||||||
|
init(
|
||||||
|
dateFrom: Date?,
|
||||||
|
dateTo: Date?,
|
||||||
|
feedIds: [String]?,
|
||||||
|
author: String?,
|
||||||
|
category: String?,
|
||||||
|
enclosureType: String?,
|
||||||
|
enclosureLength: Double?,
|
||||||
|
isRead: Bool?,
|
||||||
|
isStarred: Bool?
|
||||||
|
) {
|
||||||
|
self.dateFrom = dateFrom
|
||||||
|
self.dateTo = dateTo
|
||||||
|
self.feedIds = feedIds
|
||||||
|
self.author = author
|
||||||
|
self.category = category
|
||||||
|
self.enclosureType = enclosureType
|
||||||
|
self.enclosureLength = enclosureLength
|
||||||
|
self.isRead = isRead
|
||||||
|
self.isStarred = isStarred
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search filter to string converter
|
||||||
|
extension SearchFilters {
|
||||||
|
func filtersToJSON() -> String {
|
||||||
|
try? JSONEncoder().encode(self).data(using: .utf8)?.description ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(json: String) {
|
||||||
|
guard let data = json.data(using: .utf8),
|
||||||
|
let decoded = try? JSONDecoder().decode(SearchFilters.self, from: data) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self = decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
212
native-route/ios/RSSuper/Models/SearchQuery.swift
Normal file
212
native-route/ios/RSSuper/Models/SearchQuery.swift
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/*
|
||||||
|
* SearchQuery.swift
|
||||||
|
*
|
||||||
|
* Search query model for iOS search service.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Search query parameters
|
||||||
|
class SearchQuery: Codable {
|
||||||
|
/// The search query string
|
||||||
|
let query: String
|
||||||
|
|
||||||
|
/// Current page number (0-indexed)
|
||||||
|
let page: Int
|
||||||
|
|
||||||
|
/// Items per page
|
||||||
|
let pageSize: Int
|
||||||
|
|
||||||
|
/// Optional filters
|
||||||
|
let filters: [SearchFilter]?
|
||||||
|
|
||||||
|
/// Sort option
|
||||||
|
let sortOrder: SearchSortOption
|
||||||
|
|
||||||
|
/// Timestamp when query was made
|
||||||
|
let createdAt: Date
|
||||||
|
|
||||||
|
/// Human-readable description
|
||||||
|
var description: String {
|
||||||
|
guard !query.isEmpty else { return "Search" }
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON representation
|
||||||
|
var jsonRepresentation: String {
|
||||||
|
try? JSONEncoder().encode(self).data(using: .utf8)?.description ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize a search query
|
||||||
|
init(
|
||||||
|
query: String,
|
||||||
|
page: Int = 0,
|
||||||
|
pageSize: Int = 50,
|
||||||
|
filters: [SearchFilter]? = nil,
|
||||||
|
sortOrder: SearchSortOption = .relevance
|
||||||
|
) {
|
||||||
|
self.query = query
|
||||||
|
self.page = page
|
||||||
|
self.pageSize = pageSize
|
||||||
|
self.filters = filters
|
||||||
|
self.sortOrder = sortOrder
|
||||||
|
self.createdAt = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize with values
|
||||||
|
init(
|
||||||
|
query: String,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
filters: [SearchFilter]?,
|
||||||
|
sortOrder: SearchSortOption
|
||||||
|
) {
|
||||||
|
self.query = query
|
||||||
|
self.page = page
|
||||||
|
self.pageSize = pageSize
|
||||||
|
self.filters = filters
|
||||||
|
self.sortOrder = sortOrder
|
||||||
|
self.createdAt = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search filter options
|
||||||
|
enum SearchFilter: String, Codable, CaseIterable {
|
||||||
|
case dateRange
|
||||||
|
case feedID
|
||||||
|
case author
|
||||||
|
case category
|
||||||
|
case enclosureType
|
||||||
|
case enclosureLength
|
||||||
|
case isRead
|
||||||
|
case isStarred
|
||||||
|
case publishedDateRange
|
||||||
|
case title
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search sort options
|
||||||
|
enum SearchSortOption: String, Codable, CaseIterable {
|
||||||
|
case relevance
|
||||||
|
case publishedDate
|
||||||
|
case updatedDate
|
||||||
|
case title
|
||||||
|
case feedTitle
|
||||||
|
case author
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search sort option converter
|
||||||
|
extension SearchSortOption {
|
||||||
|
static func sortOptionToKey(_ option: SearchSortOption) -> String {
|
||||||
|
switch option {
|
||||||
|
case .relevance: return "relevance"
|
||||||
|
case .publishedDate: return "publishedDate"
|
||||||
|
case .updatedDate: return "updatedDate"
|
||||||
|
case .title: return "title"
|
||||||
|
case .feedTitle: return "feedTitle"
|
||||||
|
case .author: return "author"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func sortOptionFromKey(_ key: String) -> SearchSortOption {
|
||||||
|
switch key {
|
||||||
|
case "relevance": return .relevance
|
||||||
|
case "publishedDate": return .publishedDate
|
||||||
|
case "updatedDate": return .updatedDate
|
||||||
|
case "title": return .title
|
||||||
|
case "feedTitle": return .feedTitle
|
||||||
|
case "author": return .author
|
||||||
|
default: return .relevance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search filter configuration
|
||||||
|
class SearchFilters: Codable {
|
||||||
|
/// Date range filter
|
||||||
|
let dateFrom: Date?
|
||||||
|
|
||||||
|
/// Date range filter
|
||||||
|
let dateTo: Date?
|
||||||
|
|
||||||
|
/// Feed ID filter
|
||||||
|
let feedIds: [String]?
|
||||||
|
|
||||||
|
/// Author filter
|
||||||
|
let author: String?
|
||||||
|
|
||||||
|
/// Category filter
|
||||||
|
let category: String?
|
||||||
|
|
||||||
|
/// Enclosure type filter
|
||||||
|
let enclosureType: String?
|
||||||
|
|
||||||
|
/// Enclosure length filter
|
||||||
|
let enclosureLength: Double?
|
||||||
|
|
||||||
|
/// Is read filter
|
||||||
|
let isRead: Bool?
|
||||||
|
|
||||||
|
/// Is starred filter
|
||||||
|
let isStarred: Bool?
|
||||||
|
|
||||||
|
/// Initialize search filters
|
||||||
|
init(
|
||||||
|
dateFrom: Date? = nil,
|
||||||
|
dateTo: Date? = nil,
|
||||||
|
feedIds: [String]? = nil,
|
||||||
|
author: String? = nil,
|
||||||
|
category: String? = nil,
|
||||||
|
enclosureType: String? = nil,
|
||||||
|
enclosureLength: Double? = nil,
|
||||||
|
isRead: Bool? = nil,
|
||||||
|
isStarred: Bool? = nil
|
||||||
|
) {
|
||||||
|
self.dateFrom = dateFrom
|
||||||
|
self.dateTo = dateTo
|
||||||
|
self.feedIds = feedIds
|
||||||
|
self.author = author
|
||||||
|
self.category = category
|
||||||
|
self.enclosureType = enclosureType
|
||||||
|
self.enclosureLength = enclosureLength
|
||||||
|
self.isRead = isRead
|
||||||
|
self.isStarred = isStarred
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize with values
|
||||||
|
init(
|
||||||
|
dateFrom: Date?,
|
||||||
|
dateTo: Date?,
|
||||||
|
feedIds: [String]?,
|
||||||
|
author: String?,
|
||||||
|
category: String?,
|
||||||
|
enclosureType: String?,
|
||||||
|
enclosureLength: Double?,
|
||||||
|
isRead: Bool?,
|
||||||
|
isStarred: Bool?
|
||||||
|
) {
|
||||||
|
self.dateFrom = dateFrom
|
||||||
|
self.dateTo = dateTo
|
||||||
|
self.feedIds = feedIds
|
||||||
|
self.author = author
|
||||||
|
self.category = category
|
||||||
|
self.enclosureType = enclosureType
|
||||||
|
self.enclosureLength = enclosureLength
|
||||||
|
self.isRead = isRead
|
||||||
|
self.isStarred = isStarred
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search filter to string converter
|
||||||
|
extension SearchFilters {
|
||||||
|
func filtersToJSON() -> String {
|
||||||
|
try? JSONEncoder().encode(self).data(using: .utf8)?.description ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(json: String) {
|
||||||
|
guard let data = json.data(using: .utf8),
|
||||||
|
let decoded = try? JSONDecoder().decode(SearchFilters.self, from: data) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self = decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
331
native-route/ios/RSSuper/Models/SearchResult.swift
Normal file
331
native-route/ios/RSSuper/Models/SearchResult.swift
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
/*
|
||||||
|
* SearchResult.swift
|
||||||
|
*
|
||||||
|
* Search result model for iOS search service.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Search result type
|
||||||
|
enum SearchResultType: String, Codable, CaseIterable {
|
||||||
|
case article
|
||||||
|
case feed
|
||||||
|
case notification
|
||||||
|
case bookmark
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search result highlight configuration
|
||||||
|
struct SearchResultHighlight: Codable {
|
||||||
|
/// The original text
|
||||||
|
let original: String
|
||||||
|
|
||||||
|
/// Highlighted text
|
||||||
|
let highlighted: String
|
||||||
|
|
||||||
|
/// Indices of highlighted ranges
|
||||||
|
let ranges: [(start: Int, end: Int)]
|
||||||
|
|
||||||
|
/// Matched terms
|
||||||
|
let matchedTerms: [String]
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case original, highlighted, ranges, matchedTerms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search result item
|
||||||
|
class SearchResult: Codable, Equatable {
|
||||||
|
/// Unique identifier
|
||||||
|
var id: String?
|
||||||
|
|
||||||
|
/// Type of search result
|
||||||
|
var type: SearchResultType
|
||||||
|
|
||||||
|
/// Main title
|
||||||
|
var title: String?
|
||||||
|
|
||||||
|
/// Description
|
||||||
|
var description: String?
|
||||||
|
|
||||||
|
/// Full content
|
||||||
|
var content: String?
|
||||||
|
|
||||||
|
/// Link URL
|
||||||
|
var link: String?
|
||||||
|
|
||||||
|
/// Feed title (for feed results)
|
||||||
|
var feedTitle: String?
|
||||||
|
|
||||||
|
/// Published date
|
||||||
|
var published: String?
|
||||||
|
|
||||||
|
/// Updated date
|
||||||
|
var updated: String?
|
||||||
|
|
||||||
|
/// Author
|
||||||
|
var author: String?
|
||||||
|
|
||||||
|
/// Categories
|
||||||
|
var categories: [String]?
|
||||||
|
|
||||||
|
/// Enclosure URL
|
||||||
|
var enclosureUrl: String?
|
||||||
|
|
||||||
|
/// Enclosure type
|
||||||
|
var enclosureType: String?
|
||||||
|
|
||||||
|
/// Enclosure length
|
||||||
|
var enclosureLength: Double?
|
||||||
|
|
||||||
|
/// Search relevance score (0.0 to 1.0)
|
||||||
|
var score: Double = 0.0
|
||||||
|
|
||||||
|
/// Highlighted text
|
||||||
|
var highlightedText: String? {
|
||||||
|
guard let content = content else { return nil }
|
||||||
|
return highlightText(content, query: nil) // Highlight all text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize with values
|
||||||
|
init(
|
||||||
|
id: String?,
|
||||||
|
type: SearchResultType,
|
||||||
|
title: String?,
|
||||||
|
description: String?,
|
||||||
|
content: String?,
|
||||||
|
link: String?,
|
||||||
|
feedTitle: String?,
|
||||||
|
published: String?,
|
||||||
|
updated: String? = nil,
|
||||||
|
author: String? = nil,
|
||||||
|
categories: [String]? = nil,
|
||||||
|
enclosureUrl: String? = nil,
|
||||||
|
enclosureType: String? = nil,
|
||||||
|
enclosureLength: Double? = nil,
|
||||||
|
score: Double = 0.0,
|
||||||
|
highlightedText: String? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.type = type
|
||||||
|
self.title = title
|
||||||
|
self.description = description
|
||||||
|
self.content = content
|
||||||
|
self.link = link
|
||||||
|
self.feedTitle = feedTitle
|
||||||
|
self.published = published
|
||||||
|
self.updated = updated
|
||||||
|
self.author = author
|
||||||
|
self.categories = categories
|
||||||
|
self.enclosureUrl = enclosureUrl
|
||||||
|
self.enclosureType = enclosureType
|
||||||
|
self.enclosureLength = enclosureLength
|
||||||
|
self.score = score
|
||||||
|
self.highlightedText = highlightedText
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize with values (without highlightedText)
|
||||||
|
init(
|
||||||
|
id: String?,
|
||||||
|
type: SearchResultType,
|
||||||
|
title: String?,
|
||||||
|
description: String?,
|
||||||
|
content: String?,
|
||||||
|
link: String?,
|
||||||
|
feedTitle: String?,
|
||||||
|
published: String?,
|
||||||
|
updated: String? = nil,
|
||||||
|
author: String? = nil,
|
||||||
|
categories: [String]? = nil,
|
||||||
|
enclosureUrl: String? = nil,
|
||||||
|
enclosureType: String? = nil,
|
||||||
|
enclosureLength: Double? = nil,
|
||||||
|
score: Double = 0.0
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.type = type
|
||||||
|
self.title = title
|
||||||
|
self.description = description
|
||||||
|
self.content = content
|
||||||
|
self.link = link
|
||||||
|
self.feedTitle = feedTitle
|
||||||
|
self.published = published
|
||||||
|
self.updated = updated
|
||||||
|
self.author = author
|
||||||
|
self.categories = categories
|
||||||
|
self.enclosureUrl = enclosureUrl
|
||||||
|
self.enclosureType = enclosureType
|
||||||
|
self.enclosureLength = enclosureLength
|
||||||
|
self.score = score
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize with values (for Core Data)
|
||||||
|
init(
|
||||||
|
id: String?,
|
||||||
|
type: SearchResultType,
|
||||||
|
title: String?,
|
||||||
|
description: String?,
|
||||||
|
content: String?,
|
||||||
|
link: String?,
|
||||||
|
feedTitle: String?,
|
||||||
|
published: String?,
|
||||||
|
updated: String? = nil,
|
||||||
|
author: String? = nil,
|
||||||
|
categories: [String]? = nil,
|
||||||
|
enclosureUrl: String? = nil,
|
||||||
|
enclosureType: String? = nil,
|
||||||
|
enclosureLength: Double? = nil,
|
||||||
|
score: Double = 0.0
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.type = type
|
||||||
|
self.title = title
|
||||||
|
self.description = description
|
||||||
|
self.content = content
|
||||||
|
self.link = link
|
||||||
|
self.feedTitle = feedTitle
|
||||||
|
self.published = published
|
||||||
|
self.updated = updated
|
||||||
|
self.author = author
|
||||||
|
self.categories = categories
|
||||||
|
self.enclosureUrl = enclosureUrl
|
||||||
|
self.enclosureType = enclosureType
|
||||||
|
self.enclosureLength = enclosureLength
|
||||||
|
self.score = score
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize with values (for GRDB)
|
||||||
|
init(
|
||||||
|
id: String?,
|
||||||
|
type: SearchResultType,
|
||||||
|
title: String?,
|
||||||
|
description: String?,
|
||||||
|
content: String?,
|
||||||
|
link: String?,
|
||||||
|
feedTitle: String?,
|
||||||
|
published: String?,
|
||||||
|
updated: String? = nil,
|
||||||
|
author: String? = nil,
|
||||||
|
categories: [String]? = nil,
|
||||||
|
enclosureUrl: String? = nil,
|
||||||
|
enclosureType: String? = nil,
|
||||||
|
enclosureLength: Double? = nil,
|
||||||
|
score: Double = 0.0
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.type = type
|
||||||
|
self.title = title
|
||||||
|
self.description = description
|
||||||
|
self.content = content
|
||||||
|
self.link = link
|
||||||
|
self.feedTitle = feedTitle
|
||||||
|
self.published = published
|
||||||
|
self.updated = updated
|
||||||
|
self.author = author
|
||||||
|
self.categories = categories
|
||||||
|
self.enclosureUrl = enclosureUrl
|
||||||
|
self.enclosureType = enclosureType
|
||||||
|
self.enclosureLength = enclosureLength
|
||||||
|
self.score = score
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highlight text with query
|
||||||
|
func highlightText(_ text: String, query: String?) -> String? {
|
||||||
|
var highlighted = text
|
||||||
|
|
||||||
|
if let query = query, !query.isEmpty {
|
||||||
|
let queryWords = query.components(separatedBy: .whitespaces)
|
||||||
|
|
||||||
|
for word in queryWords {
|
||||||
|
let word = word.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !word.isEmpty else { continue }
|
||||||
|
|
||||||
|
let lowerWord = word.lowercased()
|
||||||
|
let regex = try? NSRegularExpression(pattern: String(regexEscape(word)), options: [.caseInsensitive])
|
||||||
|
|
||||||
|
if let regex = regex {
|
||||||
|
let ranges = regex.matches(in: text, range: NSRange(text.startIndex..., in: text))
|
||||||
|
|
||||||
|
for match in ranges {
|
||||||
|
if let range = Range(match.range, in: text) {
|
||||||
|
// Replace with HTML span
|
||||||
|
highlighted = highlightText(replacing: text[range], with: "<mark>\u{00AB}\(text[range])\u{00BB}</mark>") ?? highlighted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return highlighted
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highlight text with ranges
|
||||||
|
func highlightText(text: String, ranges: [(start: Int, end: Int)]) -> String? {
|
||||||
|
var result = text
|
||||||
|
|
||||||
|
// Sort ranges by start position (descending) to process from end
|
||||||
|
let sortedRanges = ranges.sorted { $0.start > $1.start }
|
||||||
|
|
||||||
|
for range in sortedRanges {
|
||||||
|
if let range = Range(range, in: text) {
|
||||||
|
result = result.replacingCharacters(in: range, with: "<mark>\u{00AB}\(text[range])\u{00BB}</mark>") ?? result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize with values (simple version)
|
||||||
|
init(
|
||||||
|
id: String?,
|
||||||
|
type: SearchResultType,
|
||||||
|
title: String?,
|
||||||
|
description: String?,
|
||||||
|
content: String?,
|
||||||
|
link: String?,
|
||||||
|
feedTitle: String?,
|
||||||
|
published: String?,
|
||||||
|
updated: String? = nil,
|
||||||
|
author: String? = nil,
|
||||||
|
categories: [String]? = nil,
|
||||||
|
enclosureUrl: String? = nil,
|
||||||
|
enclosureType: String? = nil,
|
||||||
|
enclosureLength: Double? = nil,
|
||||||
|
score: Double = 0.0,
|
||||||
|
published: Date? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.type = type
|
||||||
|
self.title = title
|
||||||
|
self.description = description
|
||||||
|
self.content = content
|
||||||
|
self.link = link
|
||||||
|
self.feedTitle = feedTitle
|
||||||
|
self.published = published.map { $0.iso8601 }
|
||||||
|
self.updated = updated.map { $0.iso8601 }
|
||||||
|
self.author = author
|
||||||
|
self.categories = categories
|
||||||
|
self.enclosureUrl = enclosureUrl
|
||||||
|
self.enclosureType = enclosureType
|
||||||
|
self.enclosureLength = enclosureLength
|
||||||
|
self.score = score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Equality check
|
||||||
|
func == (lhs: SearchResult, rhs: SearchResult) -> Bool {
|
||||||
|
lhs.id == rhs.id &&
|
||||||
|
lhs.type == rhs.type &&
|
||||||
|
lhs.title == rhs.title &&
|
||||||
|
lhs.description == rhs.description &&
|
||||||
|
lhs.content == rhs.content &&
|
||||||
|
lhs.link == rhs.link &&
|
||||||
|
lhs.feedTitle == rhs.feedTitle &&
|
||||||
|
lhs.published == rhs.published &&
|
||||||
|
lhs.updated == rhs.updated &&
|
||||||
|
lhs.author == rhs.author &&
|
||||||
|
lhs.categories == rhs.categories &&
|
||||||
|
lhs.enclosureUrl == rhs.enclosureUrl &&
|
||||||
|
lhs.enclosureType == rhs.enclosureType &&
|
||||||
|
lhs.enclosureLength == rhs.enclosureLength &&
|
||||||
|
lhs.score == rhs.score
|
||||||
|
}
|
||||||
572
native-route/ios/RSSuper/Services/CoreDataDatabase.swift
Normal file
572
native-route/ios/RSSuper/Services/CoreDataDatabase.swift
Normal file
@@ -0,0 +1,572 @@
|
|||||||
|
/*
|
||||||
|
* CoreDataDatabase.swift
|
||||||
|
*
|
||||||
|
* Core Data database wrapper with FTS support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
/// Core Data stack
|
||||||
|
class CoreDataStack: NSObject {
|
||||||
|
static let shared = CoreDataStack()
|
||||||
|
|
||||||
|
private let persistentContainer: NSPersistentContainer
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
persistentContainer = NSPersistentContainer(name: "RSSuper")
|
||||||
|
persistentContainer.loadPersistentStores {
|
||||||
|
($0, _ ) in
|
||||||
|
return NSPersistentStoreFault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var managedObjectContext: NSManagedObjectContext {
|
||||||
|
return persistentContainer.viewContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveContext() async throws {
|
||||||
|
try await managedObjectContext.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func performTask(_ task: @escaping (NSManagedObjectContext) async throws -> Void) async throws {
|
||||||
|
try await task(managedObjectContext)
|
||||||
|
try await saveContext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CoreDataDatabase - Core Data wrapper with FTS support
|
||||||
|
class CoreDataDatabase: NSObject {
|
||||||
|
private let stack: CoreDataStack
|
||||||
|
|
||||||
|
/// Create a new core data database
|
||||||
|
init() {
|
||||||
|
self.stack = CoreDataStack.shared
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform a task on the context
|
||||||
|
func performTask(_ task: @escaping (NSManagedObjectContext) async throws -> Void) async throws {
|
||||||
|
try await task(stack.managedObjectContext)
|
||||||
|
try await stack.saveContext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CoreDataFeedItemStore - Feed item store with FTS
|
||||||
|
class CoreDataFeedItemStore: NSObject {
|
||||||
|
private let db: CoreDataDatabase
|
||||||
|
|
||||||
|
init(db: CoreDataDatabase) {
|
||||||
|
self.db = db
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a feed item
|
||||||
|
func insertFeedItem(_ item: FeedItem) async throws {
|
||||||
|
try await db.performTask { context in
|
||||||
|
let managedObject = FeedItem(context: context)
|
||||||
|
managedObject.id = item.id
|
||||||
|
managedObject.subscriptionId = item.subscriptionId
|
||||||
|
managedObject.title = item.title
|
||||||
|
managedObject.link = item.link
|
||||||
|
managedObject.description = item.description
|
||||||
|
managedObject.content = item.content
|
||||||
|
managedObject.author = item.author
|
||||||
|
managedObject.published = item.published.map { $0.iso8601 }
|
||||||
|
managedObject.updated = item.updated.map { $0.iso8601 }
|
||||||
|
managedObject.categories = item.categories?.joined(separator: ",")
|
||||||
|
managedObject.enclosureUrl = item.enclosureUrl
|
||||||
|
managedObject.enclosureType = item.enclosureType
|
||||||
|
managedObject.enclosureLength = item.enclosureLength
|
||||||
|
managedObject.guid = item.guid
|
||||||
|
managedObject.isRead = item.isRead
|
||||||
|
managedObject.isStarred = item.isStarred
|
||||||
|
|
||||||
|
// Update FTS index
|
||||||
|
try await updateFTS(context: context, feedItemId: item.id, title: item.title, link: item.link, description: item.description, content: item.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert multiple feed items
|
||||||
|
func insertFeedItems(_ items: [FeedItem]) async throws {
|
||||||
|
try await db.performTask { context in
|
||||||
|
for item in items {
|
||||||
|
let managedObject = FeedItem(context: context)
|
||||||
|
managedObject.id = item.id
|
||||||
|
managedObject.subscriptionId = item.subscriptionId
|
||||||
|
managedObject.title = item.title
|
||||||
|
managedObject.link = item.link
|
||||||
|
managedObject.description = item.description
|
||||||
|
managedObject.content = item.content
|
||||||
|
managedObject.author = item.author
|
||||||
|
managedObject.published = item.published.map { $0.iso8601 }
|
||||||
|
managedObject.updated = item.updated.map { $0.iso8601 }
|
||||||
|
managedObject.categories = item.categories?.joined(separator: ",")
|
||||||
|
managedObject.enclosureUrl = item.enclosureUrl
|
||||||
|
managedObject.enclosureType = item.enclosureType
|
||||||
|
managedObject.enclosureLength = item.enclosureLength
|
||||||
|
managedObject.guid = item.guid
|
||||||
|
managedObject.isRead = item.isRead
|
||||||
|
managedObject.isStarred = item.isStarred
|
||||||
|
|
||||||
|
// Update FTS index
|
||||||
|
try await updateFTS(context: context, feedItemId: item.id, title: item.title, link: item.link, description: item.description, content: item.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get feed items by subscription ID
|
||||||
|
func getFeedItems(_ subscriptionId: String?) async throws -> [FeedItem] {
|
||||||
|
let results: [FeedItem] = try await db.performTask { context in
|
||||||
|
var items: [FeedItem] = []
|
||||||
|
|
||||||
|
let predicate = NSPredicate(format: "subscriptionId == %@", subscriptionId ?? "")
|
||||||
|
let fetchRequest = NSFetchRequest<FeedItem>(entityName: "FeedItem")
|
||||||
|
fetchRequest.predicate = predicate
|
||||||
|
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)]
|
||||||
|
fetchRequest.limit = 1000
|
||||||
|
|
||||||
|
do {
|
||||||
|
let managedObjects = try context.fetch(fetchRequest)
|
||||||
|
for managedObject in managedObjects {
|
||||||
|
items.append(managedObjectToItem(managedObject))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to fetch feed items: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get feed item by ID
|
||||||
|
func getFeedItemById(_ id: String) async throws -> FeedItem? {
|
||||||
|
let result: FeedItem? = try await db.performTask { context in
|
||||||
|
let fetchRequest = NSFetchRequest<FeedItem>(entityName: "FeedItem")
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "id == %@", id)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let managedObjects = try context.fetch(fetchRequest)
|
||||||
|
return managedObjects.first.map { managedObjectToItem($0) }
|
||||||
|
} catch {
|
||||||
|
print("Failed to fetch feed item: \(error.localizedDescription)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete feed item by ID
|
||||||
|
func deleteFeedItem(_ id: String) async throws {
|
||||||
|
try await db.performTask { context in
|
||||||
|
let fetchRequest = NSFetchRequest<FeedItem>(entityName: "FeedItem")
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "id == %@", id)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let managedObjects = try context.fetch(fetchRequest)
|
||||||
|
for managedObject in managedObjects {
|
||||||
|
context.delete(managedObject)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to delete feed item: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete feed items by subscription ID
|
||||||
|
func deleteFeedItems(_ subscriptionId: String) async throws {
|
||||||
|
try await db.performTask { context in
|
||||||
|
let fetchRequest = NSFetchRequest<FeedItem>(entityName: "FeedItem")
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "subscriptionId == %@", subscriptionId)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let managedObjects = try context.fetch(fetchRequest)
|
||||||
|
for managedObject in managedObjects {
|
||||||
|
context.delete(managedObject)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to delete feed items: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up old feed items
|
||||||
|
func cleanupOldItems(keepCount: Int = 100) async throws {
|
||||||
|
try await db.performTask { context in
|
||||||
|
let fetchRequest = NSFetchRequest<FeedItem>(entityName: "FeedItem")
|
||||||
|
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)]
|
||||||
|
fetchRequest.limit = keepCount
|
||||||
|
|
||||||
|
do {
|
||||||
|
let managedObjects = try context.fetch(fetchRequest)
|
||||||
|
for managedObject in managedObjects {
|
||||||
|
context.delete(managedObject)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to cleanup old feed items: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update FTS index for a feed item
|
||||||
|
private func updateFTS(context: NSManagedObjectContext, feedItemId: String, title: String?, link: String?, description: String?, content: String?) async throws {
|
||||||
|
try await db.performTask { context in
|
||||||
|
let feedItem = FeedItem(context: context)
|
||||||
|
|
||||||
|
// Update text attributes for FTS
|
||||||
|
feedItem.title = title
|
||||||
|
feedItem.link = link
|
||||||
|
feedItem.description = description
|
||||||
|
feedItem.content = content
|
||||||
|
|
||||||
|
// Trigger FTS update
|
||||||
|
do {
|
||||||
|
try context.performSyncBlock()
|
||||||
|
} catch {
|
||||||
|
print("FTS update failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CoreDataSearchHistoryStore - Search history store
|
||||||
|
class CoreDataSearchHistoryStore: NSObject {
|
||||||
|
private let db: CoreDataDatabase
|
||||||
|
|
||||||
|
init(db: CoreDataDatabase) {
|
||||||
|
self.db = db
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a search query
|
||||||
|
func recordSearchHistory(query: SearchQuery, resultCount: Int) async throws -> Int {
|
||||||
|
try await db.performTask { context in
|
||||||
|
let historyEntry = SearchHistoryEntry(context: context)
|
||||||
|
historyEntry.query = query
|
||||||
|
historyEntry.resultCount = resultCount
|
||||||
|
historyEntry.createdAt = Date()
|
||||||
|
|
||||||
|
// Save and trigger FTS update
|
||||||
|
try context.save()
|
||||||
|
try context.performSyncBlock()
|
||||||
|
|
||||||
|
return resultCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get search history
|
||||||
|
func getSearchHistory(limit: Int = 50) async throws -> [SearchQuery] {
|
||||||
|
let results: [SearchQuery] = try await db.performTask { context in
|
||||||
|
var queries: [SearchQuery] = []
|
||||||
|
|
||||||
|
let fetchRequest = NSFetchRequest<SearchHistoryEntry>(entityName: "SearchHistoryEntry")
|
||||||
|
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
|
||||||
|
fetchRequest.limit = UInt32(limit)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let managedObjects = try context.fetch(fetchRequest)
|
||||||
|
for managedObject in managedObjects {
|
||||||
|
queries.append(managedObjectToQuery(managedObject))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to fetch search history: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return queries
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get recent searches (last 24 hours)
|
||||||
|
func getRecentSearches(limit: Int = 20) async throws -> [SearchQuery] {
|
||||||
|
let results: [SearchQuery] = try await db.performTask { context in
|
||||||
|
var queries: [SearchQuery] = []
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
let yesterday = Calendar.current.startOfDay(in: now)
|
||||||
|
let threshold = yesterday.timeIntervalSince1970
|
||||||
|
|
||||||
|
let fetchRequest = NSFetchRequest<SearchHistoryEntry>(entityName: "SearchHistoryEntry")
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "createdAt >= %f", threshold)
|
||||||
|
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
|
||||||
|
fetchRequest.limit = UInt32(limit)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let managedObjects = try context.fetch(fetchRequest)
|
||||||
|
for managedObject in managedObjects {
|
||||||
|
queries.append(managedObjectToQuery(managedObject))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to fetch recent searches: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return queries
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a search history entry by ID
|
||||||
|
func deleteSearchHistoryEntry(id: Int) async throws {
|
||||||
|
try await db.performTask { context in
|
||||||
|
let fetchRequest = NSFetchRequest<SearchHistoryEntry>(entityName: "SearchHistoryEntry")
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "id == %d", id)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let managedObjects = try context.fetch(fetchRequest)
|
||||||
|
for managedObject in managedObjects {
|
||||||
|
context.delete(managedObject)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to delete search history entry: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all search history
|
||||||
|
func clearSearchHistory() async throws {
|
||||||
|
try await db.performTask { context in
|
||||||
|
let fetchRequest = NSFetchRequest<SearchHistoryEntry>(entityName: "SearchHistoryEntry")
|
||||||
|
|
||||||
|
do {
|
||||||
|
let managedObjects = try context.fetch(fetchRequest)
|
||||||
|
for managedObject in managedObjects {
|
||||||
|
context.delete(managedObject)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to clear search history: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up old search history entries
|
||||||
|
func cleanupOldSearchHistory(limit: Int = 100) async throws {
|
||||||
|
try await db.performTask { context in
|
||||||
|
let fetchRequest = NSFetchRequest<SearchHistoryEntry>(entityName: "SearchHistoryEntry")
|
||||||
|
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]
|
||||||
|
fetchRequest.limit = UInt32(limit)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let managedObjects = try context.fetch(fetchRequest)
|
||||||
|
for managedObject in managedObjects {
|
||||||
|
context.delete(managedObject)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to cleanup old search history: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CoreDataFullTextSearch - FTS5 search implementation
|
||||||
|
class CoreDataFullTextSearch: NSObject {
|
||||||
|
private let db: CoreDataDatabase
|
||||||
|
|
||||||
|
init(db: CoreDataDatabase) {
|
||||||
|
self.db = db
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search using FTS5
|
||||||
|
func search(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
|
||||||
|
let fullTextSearch = CoreDataFullTextSearch(db: db)
|
||||||
|
|
||||||
|
// Perform FTS search
|
||||||
|
var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit)
|
||||||
|
|
||||||
|
// Rank results by relevance
|
||||||
|
results = try fullTextSearch.rankResults(query: query, results: results)
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search using FTS5 with custom limit
|
||||||
|
func searchFTS(query: String, filters: SearchFilters? = nil, limit: Int) async throws -> [SearchResult] {
|
||||||
|
let fullTextSearch = CoreDataFullTextSearch(db: db)
|
||||||
|
|
||||||
|
// Perform FTS search
|
||||||
|
var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit)
|
||||||
|
|
||||||
|
// Rank results by relevance
|
||||||
|
results = try fullTextSearch.rankResults(query: query, results: results)
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search with fuzzy matching
|
||||||
|
func searchFuzzy(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
|
||||||
|
let fullTextSearch = CoreDataFullTextSearch(db: db)
|
||||||
|
|
||||||
|
// For FTS5, we can use the boolean mode with fuzzy operators
|
||||||
|
// FTS5 supports prefix matching and phrase queries
|
||||||
|
|
||||||
|
// Convert query to FTS5 boolean format
|
||||||
|
let ftsQuery = fullTextSearch.buildFTSQuery(query)
|
||||||
|
|
||||||
|
// Perform FTS search
|
||||||
|
var results = try await fullTextSearch.searchFTS(query: ftsQuery, filters: filters, limit: limit)
|
||||||
|
|
||||||
|
// Rank results by relevance
|
||||||
|
results = try fullTextSearch.rankResults(query: query, results: results)
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search with highlighting
|
||||||
|
func searchWithHighlight(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
|
||||||
|
let fullTextSearch = CoreDataFullTextSearch(db: db)
|
||||||
|
|
||||||
|
// Perform FTS search
|
||||||
|
var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit)
|
||||||
|
|
||||||
|
// Rank results by relevance
|
||||||
|
results = try fullTextSearch.rankResults(query: query, results: results)
|
||||||
|
|
||||||
|
// Apply highlighting
|
||||||
|
results.forEach { result in
|
||||||
|
result.highlightedText = fullTextSearch.highlightText(result.content ?? "", query: query)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build FTS5 query from user input
|
||||||
|
/// Supports fuzzy matching with prefix operators
|
||||||
|
func buildFTSQuery(_ query: String) -> String {
|
||||||
|
var sb = StringBuilder()
|
||||||
|
let words = query.components(separatedBy: .whitespaces)
|
||||||
|
|
||||||
|
for (index, word) in words.enumerated() {
|
||||||
|
let word = word.trimmingCharacters(in: .whitespaces)
|
||||||
|
if word.isEmpty { continue }
|
||||||
|
|
||||||
|
if index > 0 { sb.append(" AND ") }
|
||||||
|
|
||||||
|
// Use * for prefix matching in FTS5
|
||||||
|
sb.append("\"")
|
||||||
|
sb.append(word)
|
||||||
|
sb.append("*")
|
||||||
|
sb.append("\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.str
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highlight text with query
|
||||||
|
func highlightText(_ text: String, query: String) -> String? {
|
||||||
|
var highlighted = text
|
||||||
|
|
||||||
|
if !query.isEmpty {
|
||||||
|
let queryWords = query.components(separatedBy: .whitespaces)
|
||||||
|
|
||||||
|
for word in queryWords {
|
||||||
|
let word = word.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !word.isEmpty else { continue }
|
||||||
|
|
||||||
|
let lowerWord = word.lowercased()
|
||||||
|
let regex = try? NSRegularExpression(pattern: String(regexEscape(word)), options: [.caseInsensitive])
|
||||||
|
|
||||||
|
if let regex = regex {
|
||||||
|
let ranges = regex.matches(in: text, range: NSRange(text.startIndex..., in: text))
|
||||||
|
|
||||||
|
for match in ranges {
|
||||||
|
if let range = Range(match.range, in: text) {
|
||||||
|
highlighted = highlightText(replacing: text[range], with: "<mark>\u{00AB}\(text[range])\u{00BB}</mark>") ?? highlighted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return highlighted
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highlight text with ranges
|
||||||
|
func highlightText(text: String, ranges: [(start: Int, end: Int)]) -> String? {
|
||||||
|
var result = text
|
||||||
|
|
||||||
|
// Sort ranges by start position (descending) to process from end
|
||||||
|
let sortedRanges = ranges.sorted { $0.start > $1.start }
|
||||||
|
|
||||||
|
for range in sortedRanges {
|
||||||
|
if let range = Range(range, in: text) {
|
||||||
|
result = result.replacingCharacters(in: range, with: "<mark>\u{00AB}\(text[range])\u{00BB}</mark>") ?? result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rank search results by relevance
|
||||||
|
func rankResults(query: String, results: [SearchResult]) async throws -> [SearchResult] {
|
||||||
|
let queryWords = query.components(separatedBy: .whitespaces)
|
||||||
|
var ranked: [SearchResult?] = results.map { $0 }
|
||||||
|
|
||||||
|
for result in ranked {
|
||||||
|
guard let result = result else { continue }
|
||||||
|
var score = result.score
|
||||||
|
|
||||||
|
// Boost score for exact title matches
|
||||||
|
if let title = result.title {
|
||||||
|
for word in queryWords {
|
||||||
|
let word = word.trimmingCharacters(in: .whitespaces)
|
||||||
|
if !word.isEmpty && title.lowercased().contains(word.lowercased()) {
|
||||||
|
score += 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boost score for feed title matches
|
||||||
|
if let feedTitle = result.feedTitle {
|
||||||
|
for word in queryWords {
|
||||||
|
let word = word.trimmingCharacters(in: .whitespaces)
|
||||||
|
if !word.isEmpty && feedTitle.lowercased().contains(word.lowercased()) {
|
||||||
|
score += 0.3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.score = score
|
||||||
|
ranked.append(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score (descending)
|
||||||
|
ranked.sort { $0?.score ?? 0 > $1?.score ?? 0 }
|
||||||
|
|
||||||
|
return ranked.compactMap { $0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CoreDataFeedItemStore extension for FTS search
|
||||||
|
extend(CoreDataFeedItemStore) {
|
||||||
|
/// Search using FTS5
|
||||||
|
func searchFTS(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
|
||||||
|
let fullTextSearch = CoreDataFullTextSearch(db: db)
|
||||||
|
|
||||||
|
// Perform FTS search
|
||||||
|
var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit)
|
||||||
|
|
||||||
|
// Rank results by relevance
|
||||||
|
results = try fullTextSearch.rankResults(query: query, results: results)
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CoreDataSearchHistoryStore extension
|
||||||
|
extend(CoreDataSearchHistoryStore) {
|
||||||
|
/// Record a search query
|
||||||
|
func recordSearch(_ query: SearchQuery, resultCount: Int = 0) async throws -> Int {
|
||||||
|
try await recordSearchHistory(query: query, resultCount: resultCount)
|
||||||
|
searchRecorded?(query, resultCount)
|
||||||
|
|
||||||
|
// Clean up old entries if needed
|
||||||
|
try await cleanupOldEntries(limit: maxEntries)
|
||||||
|
|
||||||
|
return resultCount
|
||||||
|
}
|
||||||
|
}
|
||||||
190
native-route/ios/RSSuper/Services/FeedItemStore.swift
Normal file
190
native-route/ios/RSSuper/Services/FeedItemStore.swift
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/*
|
||||||
|
* FeedItemStore.swift
|
||||||
|
*
|
||||||
|
* CRUD operations for feed items with FTS search support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
/// FeedItemStore - Manages feed item persistence with FTS search
|
||||||
|
class FeedItemStore: NSObject {
|
||||||
|
private let db: CoreDataDatabase
|
||||||
|
|
||||||
|
/// Signal emitted when an item is added
|
||||||
|
var itemAdded: ((FeedItem) -> Void)?
|
||||||
|
|
||||||
|
/// Signal emitted when an item is updated
|
||||||
|
var itemUpdated: ((FeedItem) -> Void)?
|
||||||
|
|
||||||
|
/// Signal emitted when an item is deleted
|
||||||
|
var itemDeleted: ((String) -> Void)?
|
||||||
|
|
||||||
|
/// Create a new feed item store
|
||||||
|
init(db: CoreDataDatabase) {
|
||||||
|
self.db = db
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a new feed item
|
||||||
|
func add(_ item: FeedItem) async throws -> FeedItem {
|
||||||
|
try await db.insertFeedItem(item)
|
||||||
|
itemAdded?(item)
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add multiple items in a batch
|
||||||
|
func addBatch(_ items: [FeedItem]) async throws {
|
||||||
|
try await db.insertFeedItems(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get an item by ID
|
||||||
|
func get_BY_ID(_ id: String) async throws -> FeedItem? {
|
||||||
|
return try await db.getFeedItemById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get items by subscription ID
|
||||||
|
func get_BY_SUBSCRIPTION(_ subscriptionId: String) async throws -> [FeedItem] {
|
||||||
|
return try await db.getFeedItems(subscriptionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all items
|
||||||
|
func get_ALL() async throws -> [FeedItem] {
|
||||||
|
return try await db.getFeedItems(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search items using FTS
|
||||||
|
func search(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
|
||||||
|
return try await searchFTS(query: query, filters: filters, limit: limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search items using FTS with custom limit
|
||||||
|
func searchFTS(query: String, filters: SearchFilters? = nil, limit: Int) async throws -> [SearchResult] {
|
||||||
|
let fullTextSearch = FullTextSearch(db: db)
|
||||||
|
|
||||||
|
// Perform FTS search
|
||||||
|
var results = try await fullTextSearch.search(query: query, filters: filters, limit: limit)
|
||||||
|
|
||||||
|
// Rank results by relevance
|
||||||
|
results = try rankResults(query: query, results: results)
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply search filters to a search result
|
||||||
|
func applyFilters(_ result: SearchResult, filters: SearchFilters) -> Bool {
|
||||||
|
// Date filters
|
||||||
|
if let dateFrom = filters.dateFrom, result.published != nil {
|
||||||
|
let published = result.published.map { Date(string: $0) } ?? Date.distantPast
|
||||||
|
if published < dateFrom {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let dateTo = filters.dateTo, result.published != nil {
|
||||||
|
let published = result.published.map { Date(string: $0) } ?? Date.distantFuture
|
||||||
|
if published > dateTo {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed ID filters
|
||||||
|
if let feedIds = filters.feedIds, !feedIds.isEmpty {
|
||||||
|
// For now, we can't filter by feedId without additional lookup
|
||||||
|
// This would require joining with feed_subscriptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// Author filters - not directly supported in current schema
|
||||||
|
// Would require adding author to FTS index
|
||||||
|
|
||||||
|
// Content type filters - not directly supported
|
||||||
|
// Would require adding enclosure_type to FTS index
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rank search results by relevance
|
||||||
|
func rankResults(query: String, results: [SearchResult]) async throws -> [SearchResult] {
|
||||||
|
let queryWords = query.components(separatedBy: .whitespaces)
|
||||||
|
var ranked: [SearchResult?] = results.map { $0 }
|
||||||
|
|
||||||
|
for result in ranked {
|
||||||
|
guard let result = result else { continue }
|
||||||
|
var score = result.score
|
||||||
|
|
||||||
|
// Boost score for exact title matches
|
||||||
|
if let title = result.title {
|
||||||
|
for word in queryWords {
|
||||||
|
let word = word.trimmingCharacters(in: .whitespaces)
|
||||||
|
if !word.isEmpty && title.lowercased().contains(word.lowercased()) {
|
||||||
|
score += 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boost score for feed title matches
|
||||||
|
if let feedTitle = result.feedTitle {
|
||||||
|
for word in queryWords {
|
||||||
|
let word = word.trimmingCharacters(in: .whitespaces)
|
||||||
|
if !word.isEmpty && feedTitle.lowercased().contains(word.lowercased()) {
|
||||||
|
score += 0.3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.score = score
|
||||||
|
ranked.append(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score (descending)
|
||||||
|
ranked.sort { $0?.score ?? 0 > $1?.score ?? 0 }
|
||||||
|
|
||||||
|
return ranked.compactMap { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark an item as read
|
||||||
|
func markAsRead(_ id: String) async throws {
|
||||||
|
try await db.markFeedItemAsRead(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark an item as unread
|
||||||
|
func markAsUnread(_ id: String) async throws {
|
||||||
|
try await db.markFeedItemAsUnread(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark an item as starred
|
||||||
|
func markAsStarred(_ id: String) async throws {
|
||||||
|
try await db.markFeedItemAsStarred(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unmark an item from starred
|
||||||
|
func unmarkStarred(_ id: String) async throws {
|
||||||
|
try await db.unmarkFeedItemAsStarred(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get unread items
|
||||||
|
func get_UNREAD() async throws -> [FeedItem] {
|
||||||
|
return try await db.getFeedItems(nil).filter { $0.isRead == false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get starred items
|
||||||
|
func get_STARRED() async throws -> [FeedItem] {
|
||||||
|
return try await db.getFeedItems(nil).filter { $0.isStarred == true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an item by ID
|
||||||
|
func delete(_ id: String) async throws {
|
||||||
|
try await db.deleteFeedItem(id)
|
||||||
|
itemDeleted?(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete items by subscription ID
|
||||||
|
func deleteBySubscription(_ subscriptionId: String) async throws {
|
||||||
|
try await db.deleteFeedItems(subscriptionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete old items (keep last N items per subscription)
|
||||||
|
func cleanupOldItems(keepCount: Int = 100) async throws {
|
||||||
|
try await db.cleanupOldItems(keepCount: keepCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
221
native-route/ios/RSSuper/Services/FullTextSearch.swift
Normal file
221
native-route/ios/RSSuper/Services/FullTextSearch.swift
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
/*
|
||||||
|
* FullTextSearch.swift
|
||||||
|
*
|
||||||
|
* Full-Text Search implementation using Core Data FTS5.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
/// FullTextSearch - FTS5 search implementation for Core Data
|
||||||
|
class FullTextSearch: NSObject {
|
||||||
|
private let db: CoreDataDatabase
|
||||||
|
|
||||||
|
/// Create a new full text search
|
||||||
|
init(db: CoreDataDatabase) {
|
||||||
|
self.db = db
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search using FTS5
|
||||||
|
func search(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
|
||||||
|
let fullTextSearch = FullTextSearch(db: db)
|
||||||
|
|
||||||
|
// Perform FTS search
|
||||||
|
var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit)
|
||||||
|
|
||||||
|
// Rank results by relevance
|
||||||
|
results = try fullTextSearch.rankResults(query: query, results: results)
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search using FTS5 with custom limit
|
||||||
|
func searchFTS(query: String, filters: SearchFilters? = nil, limit: Int) async throws -> [SearchResult] {
|
||||||
|
let fullTextSearch = FullTextSearch(db: db)
|
||||||
|
|
||||||
|
// Perform FTS search
|
||||||
|
var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit)
|
||||||
|
|
||||||
|
// Rank results by relevance
|
||||||
|
results = try fullTextSearch.rankResults(query: query, results: results)
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search with fuzzy matching
|
||||||
|
func searchFuzzy(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
|
||||||
|
let fullTextSearch = FullTextSearch(db: db)
|
||||||
|
|
||||||
|
// For FTS5, we can use the boolean mode with fuzzy operators
|
||||||
|
// FTS5 supports prefix matching and phrase queries
|
||||||
|
|
||||||
|
// Convert query to FTS5 boolean format
|
||||||
|
let ftsQuery = fullTextSearch.buildFTSQuery(query)
|
||||||
|
|
||||||
|
// Perform FTS search
|
||||||
|
var results = try await fullTextSearch.searchFTS(query: ftsQuery, filters: filters, limit: limit)
|
||||||
|
|
||||||
|
// Rank results by relevance
|
||||||
|
results = try fullTextSearch.rankResults(query: query, results: results)
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build FTS5 query from user input
|
||||||
|
/// Supports fuzzy matching with prefix operators
|
||||||
|
func buildFTSQuery(_ query: String) -> String {
|
||||||
|
var sb = StringBuilder()
|
||||||
|
let words = query.components(separatedBy: .whitespaces)
|
||||||
|
|
||||||
|
for (index, word) in words.enumerated() {
|
||||||
|
let word = word.trimmingCharacters(in: .whitespaces)
|
||||||
|
if word.isEmpty { continue }
|
||||||
|
|
||||||
|
if index > 0 { sb.append(" AND ") }
|
||||||
|
|
||||||
|
// Use * for prefix matching in FTS5
|
||||||
|
// This allows matching partial words
|
||||||
|
sb.append("\"")
|
||||||
|
sb.append(word)
|
||||||
|
sb.append("*")
|
||||||
|
sb.append("\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.str
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search with highlighting
|
||||||
|
func searchWithHighlight(_ query: String, filters: SearchFilters? = nil, limit: Int = 50) async throws -> [SearchResult] {
|
||||||
|
let fullTextSearch = FullTextSearch(db: db)
|
||||||
|
|
||||||
|
// Perform FTS search
|
||||||
|
var results = try await fullTextSearch.searchFTS(query: query, filters: filters, limit: limit)
|
||||||
|
|
||||||
|
// Rank results by relevance
|
||||||
|
results = try fullTextSearch.rankResults(query: query, results: results)
|
||||||
|
|
||||||
|
// Apply highlighting
|
||||||
|
results.forEach { result in
|
||||||
|
result.highlightedText = fullTextSearch.highlightText(result.content ?? "", query: query)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highlight text with query
|
||||||
|
func highlightText(_ text: String, query: String) -> String? {
|
||||||
|
var highlighted = text
|
||||||
|
|
||||||
|
if !query.isEmpty {
|
||||||
|
let queryWords = query.components(separatedBy: .whitespaces)
|
||||||
|
|
||||||
|
for word in queryWords {
|
||||||
|
let word = word.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !word.isEmpty else { continue }
|
||||||
|
|
||||||
|
let lowerWord = word.lowercased()
|
||||||
|
let regex = try? NSRegularExpression(pattern: String(regexEscape(word)), options: [.caseInsensitive])
|
||||||
|
|
||||||
|
if let regex = regex {
|
||||||
|
let ranges = regex.matches(in: text, range: NSRange(text.startIndex..., in: text))
|
||||||
|
|
||||||
|
for match in ranges {
|
||||||
|
if let range = Range(match.range, in: text) {
|
||||||
|
// Replace with HTML span
|
||||||
|
highlighted = highlightText(replacing: text[range], with: "<mark>\u{00AB}\(text[range])\u{00BB}</mark>") ?? highlighted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return highlighted
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highlight text with ranges
|
||||||
|
func highlightText(text: String, ranges: [(start: Int, end: Int)]) -> String? {
|
||||||
|
var result = text
|
||||||
|
|
||||||
|
// Sort ranges by start position (descending) to process from end
|
||||||
|
let sortedRanges = ranges.sorted { $0.start > $1.start }
|
||||||
|
|
||||||
|
for range in sortedRanges {
|
||||||
|
if let range = Range(range, in: text) {
|
||||||
|
result = result.replacingCharacters(in: range, with: "<mark>\u{00AB}\(text[range])\u{00BB}</mark>") ?? result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rank search results by relevance
|
||||||
|
func rankResults(query: String, results: [SearchResult]) async throws -> [SearchResult] {
|
||||||
|
let queryWords = query.components(separatedBy: .whitespaces)
|
||||||
|
var ranked: [SearchResult?] = results.map { $0 }
|
||||||
|
|
||||||
|
for result in ranked {
|
||||||
|
guard let result = result else { continue }
|
||||||
|
var score = result.score
|
||||||
|
|
||||||
|
// Boost score for exact title matches
|
||||||
|
if let title = result.title {
|
||||||
|
for word in queryWords {
|
||||||
|
let word = word.trimmingCharacters(in: .whitespaces)
|
||||||
|
if !word.isEmpty && title.lowercased().contains(word.lowercased()) {
|
||||||
|
score += 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boost score for feed title matches
|
||||||
|
if let feedTitle = result.feedTitle {
|
||||||
|
for word in queryWords {
|
||||||
|
let word = word.trimmingCharacters(in: .whitespaces)
|
||||||
|
if !word.isEmpty && feedTitle.lowercased().contains(word.lowercased()) {
|
||||||
|
score += 0.3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.score = score
|
||||||
|
ranked.append(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score (descending)
|
||||||
|
ranked.sort { $0?.score ?? 0 > $1?.score ?? 0 }
|
||||||
|
|
||||||
|
return ranked.compactMap { $0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// StringBuilder helper
|
||||||
|
class StringBuilder {
|
||||||
|
var str: String = ""
|
||||||
|
|
||||||
|
mutating func append(_ value: String) {
|
||||||
|
str.append(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func append(_ value: Int) {
|
||||||
|
str.append(String(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regex escape helper
|
||||||
|
func regexEscape(_ string: String) -> String {
|
||||||
|
return string.replacingOccurrences(of: ".", with: ".")
|
||||||
|
.replacingOccurrences(of: "+", with: "+")
|
||||||
|
.replacingOccurrences(of: "?", with: "?")
|
||||||
|
.replacingOccurrences(of: "*", with: "*")
|
||||||
|
.replacingOccurrences(of: "^", with: "^")
|
||||||
|
.replacingOccurrences(of: "$", with: "$")
|
||||||
|
.replacingOccurrences(of: "(", with: "(")
|
||||||
|
.replacingOccurrences(of: ")", with: ")")
|
||||||
|
.replacingOccurrences(of: "[", with: "[")
|
||||||
|
.replacingOccurrences(of: "]", with: "]")
|
||||||
|
.replacingOccurrences(of: "{", with: "{")
|
||||||
|
.replacingOccurrences(of: "}", with: "}")
|
||||||
|
.replacingOccurrences(of: "|", with: "|")
|
||||||
|
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||||
|
}
|
||||||
65
native-route/ios/RSSuper/Services/SearchHistoryStore.swift
Normal file
65
native-route/ios/RSSuper/Services/SearchHistoryStore.swift
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/*
|
||||||
|
* SearchHistoryStore.swift
|
||||||
|
*
|
||||||
|
* CRUD operations for search history.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
/// SearchHistoryStore - Manages search history persistence
|
||||||
|
class SearchHistoryStore: NSObject {
|
||||||
|
private let db: CoreDataDatabase
|
||||||
|
|
||||||
|
/// Maximum number of history entries to keep
|
||||||
|
var maxEntries: Int = 100
|
||||||
|
|
||||||
|
/// Signal emitted when a search is recorded
|
||||||
|
var searchRecorded: ((SearchQuery, Int) -> Void)?
|
||||||
|
|
||||||
|
/// Signal emitted when history is cleared
|
||||||
|
var historyCleared: (() -> Void)?
|
||||||
|
|
||||||
|
/// Create a new search history store
|
||||||
|
init(db: CoreDataDatabase) {
|
||||||
|
self.db = db
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a search query
|
||||||
|
func recordSearch(_ query: SearchQuery, resultCount: Int = 0) async throws -> Int {
|
||||||
|
try await db.recordSearchHistory(query: query, resultCount: resultCount)
|
||||||
|
searchRecorded?(query, resultCount)
|
||||||
|
|
||||||
|
// Clean up old entries if needed
|
||||||
|
try await cleanupOldEntries()
|
||||||
|
|
||||||
|
return resultCount
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get search history
|
||||||
|
func getHistory(limit: Int = 50) async throws -> [SearchQuery] {
|
||||||
|
return try await db.getSearchHistory(limit: limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get recent searches (last 24 hours)
|
||||||
|
func getRecent(limit: Int = 20) async throws -> [SearchQuery] {
|
||||||
|
return try await db.getRecentSearches(limit: limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a search history entry by ID
|
||||||
|
func deleteHistoryEntry(id: Int) async throws {
|
||||||
|
try await db.deleteSearchHistoryEntry(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all search history
|
||||||
|
func clearHistory() async throws {
|
||||||
|
try await db.clearSearchHistory()
|
||||||
|
historyCleared?()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear old search history entries
|
||||||
|
private func cleanupOldEntries() async throws {
|
||||||
|
try await db.cleanupOldSearchHistory(limit: maxEntries)
|
||||||
|
}
|
||||||
|
}
|
||||||
252
native-route/ios/RSSuper/Services/SearchService.swift
Normal file
252
native-route/ios/RSSuper/Services/SearchService.swift
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
/*
|
||||||
|
* SearchService.swift
|
||||||
|
*
|
||||||
|
* Full-text search service with history tracking and fuzzy matching.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
/// SearchService - Manages search operations with history tracking
|
||||||
|
class SearchService: NSObject {
|
||||||
|
private let db: CoreDataDatabase
|
||||||
|
private let historyStore: SearchHistoryStore
|
||||||
|
|
||||||
|
/// Maximum number of results to return
|
||||||
|
var maxResults: Int = 50
|
||||||
|
|
||||||
|
/// Maximum number of history entries to keep
|
||||||
|
var maxHistory: Int = 100
|
||||||
|
|
||||||
|
/// Search results publisher
|
||||||
|
private let resultsPublisher = CurrentValueSubject<SearchResult?, Never>(nil)
|
||||||
|
|
||||||
|
/// Search history publisher
|
||||||
|
private let historyPublisher = CurrentValueSubject<SearchHistoryEntry?, Never>(nil)
|
||||||
|
|
||||||
|
/// Signals
|
||||||
|
var searchPerformed: ((SearchQuery, SearchResult) -> Void)?
|
||||||
|
var searchRecorded: ((SearchQuery, Int) -> Void)?
|
||||||
|
var historyCleared: (() -> Void)?
|
||||||
|
|
||||||
|
/// Create a new search service
|
||||||
|
init(db: CoreDataDatabase) {
|
||||||
|
self.db = db
|
||||||
|
self.historyStore = SearchHistoryStore(db: db)
|
||||||
|
self.historyStore.maxEntries = maxHistory
|
||||||
|
|
||||||
|
// Connect to history store signals
|
||||||
|
historyStore.searchRecorded { query, count in
|
||||||
|
self.searchRecorded?(query, count)
|
||||||
|
self.historyPublisher.send(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
historyStore.historyCleared { [weak self] in
|
||||||
|
self?.historyCleared?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform a search
|
||||||
|
func search(_ query: String, filters: SearchFilters? = nil) async throws -> [SearchResult] {
|
||||||
|
let itemStore = FeedItemStore(db: db)
|
||||||
|
|
||||||
|
// Perform FTS search
|
||||||
|
var results = try await itemStore.searchFTS(query: query, filters: filters, limit: maxResults)
|
||||||
|
|
||||||
|
// Rank results by relevance
|
||||||
|
results = try rankResults(query: query, results: results)
|
||||||
|
|
||||||
|
// Record in history
|
||||||
|
let searchQuery = SearchQuery(
|
||||||
|
query: query,
|
||||||
|
page: 0,
|
||||||
|
pageSize: maxResults,
|
||||||
|
filters: filters,
|
||||||
|
sortOrder: .relevance
|
||||||
|
)
|
||||||
|
try await historyStore.recordSearch(searchQuery, resultCount: results.count)
|
||||||
|
|
||||||
|
searchPerformed?(searchQuery, results.first!)
|
||||||
|
resultsPublisher.send(results.first)
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform a search with custom page size
|
||||||
|
func searchWithPage(_ query: String, page: Int, pageSize: Int, filters: SearchFilters? = nil) async throws -> [SearchResult] {
|
||||||
|
let itemStore = FeedItemStore(db: db)
|
||||||
|
|
||||||
|
var results = try await itemStore.searchFTS(query: query, filters: filters, limit: pageSize)
|
||||||
|
|
||||||
|
// Rank results by relevance
|
||||||
|
results = try rankResults(query: query, results: results)
|
||||||
|
|
||||||
|
// Record in history
|
||||||
|
let searchQuery = SearchQuery(
|
||||||
|
query: query,
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize,
|
||||||
|
filters: filters,
|
||||||
|
sortOrder: .relevance
|
||||||
|
)
|
||||||
|
try await historyStore.recordSearch(searchQuery, resultCount: results.count)
|
||||||
|
|
||||||
|
searchPerformed?(searchQuery, results.first!)
|
||||||
|
resultsPublisher.send(results.first)
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get search history
|
||||||
|
func getHistory(limit: Int = 50) async throws -> [SearchQuery] {
|
||||||
|
return try await historyStore.getHistory(limit: limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get recent searches (last 24 hours)
|
||||||
|
func getRecent() async throws -> [SearchQuery] {
|
||||||
|
return try await historyStore.getRecent(limit: 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a search history entry by ID
|
||||||
|
func deleteHistoryEntry(id: Int) async throws {
|
||||||
|
try await historyStore.deleteHistoryEntry(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all search history
|
||||||
|
func clearHistory() async throws {
|
||||||
|
try await historyStore.clearHistory()
|
||||||
|
historyCleared?()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get search suggestions based on recent queries
|
||||||
|
func getSuggestions(_ prefix: String, limit: Int = 10) async throws -> [String] {
|
||||||
|
let history = try await historyStore.getHistory(limit: limit * 2)
|
||||||
|
var suggestions: Set<String> = []
|
||||||
|
|
||||||
|
for entry in history {
|
||||||
|
if entry.query.hasPrefix(prefix) && entry.query != prefix {
|
||||||
|
suggestions.insert(entry.query)
|
||||||
|
if suggestions.count >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array(suggestions)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get search suggestions from current results
|
||||||
|
func getResultSuggestions(_ results: [SearchResult], field: String) -> [String] {
|
||||||
|
var suggestions: Set<String> = []
|
||||||
|
var resultList: [String] = []
|
||||||
|
|
||||||
|
for result in results {
|
||||||
|
switch field {
|
||||||
|
case "title":
|
||||||
|
if let title = result.title, !title.isEmpty {
|
||||||
|
suggestions.insert(title)
|
||||||
|
}
|
||||||
|
case "feed":
|
||||||
|
if let feedTitle = result.feedTitle, !feedTitle.isEmpty {
|
||||||
|
suggestions.insert(feedTitle)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var iter = suggestions.iterator()
|
||||||
|
var key: String?
|
||||||
|
while (key = iter.nextValue()) {
|
||||||
|
resultList.append(key!)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultList
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rank search results by relevance
|
||||||
|
func rankResults(query: String, results: [SearchResult]) async throws -> [SearchResult] {
|
||||||
|
let queryWords = query.components(separatedBy: .whitespaces)
|
||||||
|
var ranked: [SearchResult?] = results.map { $0 }
|
||||||
|
|
||||||
|
for result in ranked {
|
||||||
|
guard let result = result else { continue }
|
||||||
|
var score = result.score
|
||||||
|
|
||||||
|
// Boost score for exact title matches
|
||||||
|
if let title = result.title {
|
||||||
|
for word in queryWords {
|
||||||
|
let word = word.trimmingCharacters(in: .whitespaces)
|
||||||
|
if !word.isEmpty && title.lowercased().contains(word.lowercased()) {
|
||||||
|
score += 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boost score for feed title matches
|
||||||
|
if let feedTitle = result.feedTitle {
|
||||||
|
for word in queryWords {
|
||||||
|
let word = word.trimmingCharacters(in: .whitespaces)
|
||||||
|
if !word.isEmpty && feedTitle.lowercased().contains(word.lowercased()) {
|
||||||
|
score += 0.3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.score = score
|
||||||
|
ranked.append(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score (descending)
|
||||||
|
ranked.sort { $0?.score ?? 0 > $1?.score ?? 0 }
|
||||||
|
|
||||||
|
return ranked.compactMap { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search suggestions from recent queries
|
||||||
|
var suggestionsSubject: Published<[String]> {
|
||||||
|
return Published(
|
||||||
|
publisher: Publishers.CombineLatest(
|
||||||
|
Publishers.Everything($0.suggestionsSubject),
|
||||||
|
Publishers.Everything($0.historyPublisher)
|
||||||
|
) { suggestions, history in
|
||||||
|
var result: [String] = suggestions
|
||||||
|
for query in history {
|
||||||
|
result += query.query.components(separatedBy: "\n")
|
||||||
|
}
|
||||||
|
return result.sorted()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search history entry
|
||||||
|
class SearchHistoryEntry: Codable, Equatable {
|
||||||
|
let query: SearchQuery
|
||||||
|
let resultCount: Int
|
||||||
|
let createdAt: Date
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
guard !query.query.isEmpty else { return "Search" }
|
||||||
|
return query.query
|
||||||
|
}
|
||||||
|
|
||||||
|
init(query: SearchQuery, resultCount: Int = 0, createdAt: Date = Date()) {
|
||||||
|
self.query = query
|
||||||
|
self.resultCount = resultCount
|
||||||
|
self.createdAt = createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
init(query: SearchQuery, resultCount: Int) {
|
||||||
|
self.query = query
|
||||||
|
self.resultCount = resultCount
|
||||||
|
self.createdAt = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SearchHistoryEntry: Equatable {
|
||||||
|
static func == (lhs: SearchHistoryEntry, rhs: SearchHistoryEntry) -> Bool {
|
||||||
|
lhs.query == rhs.query && lhs.resultCount == rhs.resultCount
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user