Skip to content

Commit 9665795

Browse files
committed
Merge branch 'main' of github.com:prof18/feed-flow
2 parents 6c821f1 + 2e894f7 commit 9665795

File tree

7 files changed

+253
-5
lines changed

7 files changed

+253
-5
lines changed

database/src/commonMain/kotlin/com/prof18/feedflow/database/DatabaseHelper.kt

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,7 +511,7 @@ class DatabaseHelper(
511511
): Flow<List<Search>> =
512512
dbRef.feedSearchQueries
513513
.search(
514-
query = searchQuery,
514+
query = searchQuery.toFtsPrefixQuery(),
515515
feedSourceId = feedFilter?.getFeedSourceId(),
516516
feedSourceCategoryId = feedFilter?.getCategoryId(),
517517
isUncategorized = feedFilter?.getIsUncategorized(),
@@ -523,6 +523,36 @@ class DatabaseHelper(
523523
.mapToList(backgroundDispatcher)
524524
.flowOn(backgroundDispatcher)
525525

526+
private fun String.toFtsPrefixQuery(): String {
527+
val terms = mutableListOf<String>()
528+
val token = StringBuilder()
529+
var tokenContainsLettersOrDigits = false
530+
531+
fun flushToken() {
532+
if (tokenContainsLettersOrDigits && token.isNotEmpty()) {
533+
terms.add(token.toString())
534+
}
535+
token.clear()
536+
tokenContainsLettersOrDigits = false
537+
}
538+
539+
for (char in this) {
540+
when {
541+
char.isLetterOrDigit() -> {
542+
token.append(char)
543+
tokenContainsLettersOrDigits = true
544+
}
545+
546+
char.isWhitespace() || char == '-' -> flushToken()
547+
char != '"' && token.isNotEmpty() -> token.append(char)
548+
else -> flushToken()
549+
}
550+
}
551+
flushToken()
552+
553+
return terms.joinToString(separator = " ") { "$it*" }
554+
}
555+
526556
suspend fun getLastChangeTimestamp(tableName: DatabaseTables): Long? = withContext(backgroundDispatcher) {
527557
dbRef.syncMetadataQueries.selectLastChangeTimestamp(tableName.tableName)
528558
.executeAsOneOrNull()?.last_change_timestamp

database/src/commonMain/sqldelight/com/prof18/feedflow/db/FeedSearch.sq

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS feed_search USING FTS4(
44
url_hash TEXT NOT NULL,
55
title TEXT,
66
subtitle TEXT,
7-
tokenize="unicode61"
7+
tokenize=unicode61 "tokenchars=+#"
88
);
99

1010
CREATE TRIGGER IF NOT EXISTS populate_feed_search
@@ -33,7 +33,7 @@ SELECT
3333
FROM (
3434
SELECT *
3535
FROM feed_search
36-
WHERE feed_search MATCH :query || '*'
36+
WHERE feed_search MATCH :query
3737
) AS searched_feeds
3838
INNER JOIN feed_item
3939
ON searched_feeds.url_hash = feed_item.url_hash
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
DROP TRIGGER IF EXISTS populate_feed_search;
2+
DROP TRIGGER IF EXISTS delete_feed_search;
3+
DROP TABLE IF EXISTS feed_search;
4+
5+
CREATE VIRTUAL TABLE feed_search USING FTS4(
6+
url_hash TEXT NOT NULL,
7+
title TEXT,
8+
subtitle TEXT,
9+
tokenize=unicode61 "tokenchars=+#"
10+
);
11+
12+
CREATE TRIGGER populate_feed_search
13+
AFTER INSERT ON feed_item
14+
BEGIN INSERT OR IGNORE INTO feed_search(url_hash, title, subtitle) VALUES (new.url_hash, new.title, new.subtitle);
15+
END;
16+
17+
CREATE TRIGGER delete_feed_search
18+
BEFORE DELETE ON feed_item
19+
BEGIN DELETE FROM feed_search WHERE url_hash = old.url_hash;
20+
END;
21+
22+
INSERT OR IGNORE INTO feed_search(url_hash, title, subtitle)
23+
SELECT url_hash, title, subtitle
24+
FROM feed_item;
Binary file not shown.

shared/src/commonMain/kotlin/com/prof18/feedflow/shared/domain/feed/FeedSourcesRepository.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,16 @@ internal class FeedSourcesRepository(
5353
private val knownUrlSuffix = listOf(
5454
"",
5555
"feed",
56+
"feed/",
57+
"feed.rss",
5658
"rss",
57-
"atom.xml",
58-
"feed.xml",
59+
"rss/",
5960
"rss.xml",
61+
"feed.xml",
6062
"index.xml",
63+
"index.rss",
64+
"atom",
65+
"atom.xml",
6166
"atom.json",
6267
"feed.json",
6368
"rss.json",
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package com.prof18.feedflow.shared.domain.feed
2+
3+
import com.prof18.feedflow.core.model.FeedSourceCategory
4+
import com.prof18.feedflow.core.model.SyncAccounts
5+
import com.prof18.feedflow.database.DatabaseHelper
6+
import com.prof18.feedflow.feedsync.networkcore.NetworkSettings
7+
import com.prof18.feedflow.shared.domain.model.FeedAddedState
8+
import com.prof18.feedflow.shared.test.KoinTestBase
9+
import com.prof18.feedflow.shared.test.TestDispatcherProvider.testDispatcher
10+
import com.prof18.feedflow.shared.test.koin.TestModules
11+
import com.prof18.rssparser.model.RssChannel
12+
import kotlinx.coroutines.test.advanceUntilIdle
13+
import kotlinx.coroutines.test.runTest
14+
import org.koin.core.module.Module
15+
import org.koin.dsl.module
16+
import org.koin.test.inject
17+
import kotlin.test.Test
18+
import kotlin.test.assertEquals
19+
import kotlin.test.assertIs
20+
21+
class FeedSourcesRepositoryLocalSuffixLookupTest : KoinTestBase() {
22+
23+
private val feedSourcesRepository: FeedSourcesRepository by inject()
24+
private val databaseHelper: DatabaseHelper by inject()
25+
private val fakeRssParserWrapper: FakeRssParserWrapper by inject()
26+
27+
override fun getTestModules(): List<Module> = TestModules.createTestModules() + module {
28+
single { FakeRssParserWrapper() }
29+
single<RssParserWrapper> { get<FakeRssParserWrapper>() }
30+
}
31+
32+
@Test
33+
fun `addFeedSource discovers feed dot rss suffix`() = runTest(testDispatcher) {
34+
setupLocalAccount()
35+
fakeRssParserWrapper.reset(supportedUrl = "https://example.com/feed.rss")
36+
37+
val result = feedSourcesRepository.addFeedSource(
38+
feedUrl = "https://example.com",
39+
categoryName = FeedSourceCategory(id = "local-tech", title = "Tech"),
40+
isNotificationEnabled = false,
41+
)
42+
advanceUntilIdle()
43+
44+
assertIs<FeedAddedState.FeedAdded>(result)
45+
assertEquals("https://example.com/feed.rss", databaseHelper.getFeedSources().single().url)
46+
assertEquals("https://example.com/feed.rss", fakeRssParserWrapper.requestedUrls.last())
47+
}
48+
49+
@Test
50+
fun `addFeedSource discovers index dot rss suffix`() = runTest(testDispatcher) {
51+
setupLocalAccount()
52+
fakeRssParserWrapper.reset(supportedUrl = "https://example.com/index.rss")
53+
54+
val result = feedSourcesRepository.addFeedSource(
55+
feedUrl = "https://example.com",
56+
categoryName = null,
57+
isNotificationEnabled = false,
58+
)
59+
advanceUntilIdle()
60+
61+
assertIs<FeedAddedState.FeedAdded>(result)
62+
assertEquals("https://example.com/index.rss", databaseHelper.getFeedSources().single().url)
63+
assertEquals("https://example.com/index.rss", fakeRssParserWrapper.requestedUrls.last())
64+
}
65+
66+
private fun setupLocalAccount() {
67+
val settings: NetworkSettings = getKoin().get()
68+
settings.setSyncAccountType(SyncAccounts.LOCAL)
69+
}
70+
71+
private class FakeRssParserWrapper : RssParserWrapper {
72+
private var supportedUrl: String? = null
73+
val requestedUrls = mutableListOf<String>()
74+
75+
override suspend fun getRssChannel(url: String): RssChannel {
76+
requestedUrls.add(url)
77+
check(url == supportedUrl) { "Unsupported url: $url" }
78+
return RssChannel(
79+
title = "Example Feed",
80+
link = "https://example.com",
81+
description = null,
82+
image = null,
83+
lastBuildDate = null,
84+
updatePeriod = null,
85+
items = emptyList(),
86+
itunesChannelData = null,
87+
youtubeChannelData = null,
88+
)
89+
}
90+
91+
fun reset(supportedUrl: String) {
92+
this.supportedUrl = supportedUrl
93+
requestedUrls.clear()
94+
}
95+
}
96+
}

shared/src/commonTest/kotlin/com/prof18/feedflow/shared/presentation/SearchViewModelTest.kt

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,99 @@ class SearchViewModelTest : KoinTestBase() {
129129
viewModel.searchState.value shouldBe SearchState.NoDataFound(searchQuery = "missing")
130130
}
131131

132+
@Test
133+
fun `search treats hyphen as separator for FTS queries`() = runTest(testDispatcher) {
134+
val viewModel = getViewModel()
135+
val databaseHelper = getDatabaseHelper()
136+
137+
val feedSource = createFeedSource(id = "source-hyphen", title = "Source Hyphen")
138+
insertFeedSources(databaseHelper, feedSource)
139+
140+
val hyphenatedItem = createFeedItem(
141+
id = "item-hyphenated",
142+
title = "Mini-Solaranlage guide",
143+
feedSource = feedSource,
144+
pubDateMillis = 2000,
145+
)
146+
val controlItem = createFeedItem(
147+
id = "item-control",
148+
title = "Miniature lights",
149+
feedSource = feedSource,
150+
pubDateMillis = 1000,
151+
)
152+
databaseHelper.insertFeedItems(listOf(hyphenatedItem, controlItem), lastSyncTimestamp = 0)
153+
154+
viewModel.updateSearchQuery("Mini-Solar")
155+
156+
advanceTimeBy(500.milliseconds)
157+
advanceUntilIdle()
158+
159+
val state = viewModel.searchState.value as SearchState.DataFound
160+
state.items.map { it.id } shouldBe listOf(hyphenatedItem.id)
161+
}
162+
163+
@Test
164+
fun `search preserves plus symbols for FTS queries`() = runTest(testDispatcher) {
165+
val viewModel = getViewModel()
166+
val databaseHelper = getDatabaseHelper()
167+
168+
val feedSource = createFeedSource(id = "source-symbols", title = "Source Symbols")
169+
insertFeedSources(databaseHelper, feedSource)
170+
171+
val cppItem = createFeedItem(
172+
id = "item-cpp",
173+
title = "C++ Memory model explained",
174+
feedSource = feedSource,
175+
pubDateMillis = 2000,
176+
)
177+
val controlItem = createFeedItem(
178+
id = "item-control-symbols",
179+
title = "Cats weekly roundup",
180+
feedSource = feedSource,
181+
pubDateMillis = 1000,
182+
)
183+
databaseHelper.insertFeedItems(listOf(cppItem, controlItem), lastSyncTimestamp = 0)
184+
185+
viewModel.updateSearchQuery("C++")
186+
187+
advanceTimeBy(500.milliseconds)
188+
advanceUntilIdle()
189+
190+
val state = viewModel.searchState.value as SearchState.DataFound
191+
state.items.map { it.id } shouldBe listOf(cppItem.id)
192+
}
193+
194+
@Test
195+
fun `search preserves hash symbols for FTS queries`() = runTest(testDispatcher) {
196+
val viewModel = getViewModel()
197+
val databaseHelper = getDatabaseHelper()
198+
199+
val feedSource = createFeedSource(id = "source-hash-symbols", title = "Source Hash Symbols")
200+
insertFeedSources(databaseHelper, feedSource)
201+
202+
val cSharpItem = createFeedItem(
203+
id = "item-csharp",
204+
title = "C# Pattern matching tips",
205+
feedSource = feedSource,
206+
pubDateMillis = 2000,
207+
)
208+
val controlItem = createFeedItem(
209+
id = "item-control-hash-symbols",
210+
title = "Cats weekly roundup",
211+
feedSource = feedSource,
212+
pubDateMillis = 1000,
213+
)
214+
databaseHelper.insertFeedItems(listOf(cSharpItem, controlItem), lastSyncTimestamp = 0)
215+
216+
viewModel.updateSearchQuery("C#")
217+
218+
advanceTimeBy(500.milliseconds)
219+
advanceUntilIdle()
220+
221+
val state = viewModel.searchState.value as SearchState.DataFound
222+
state.items.map { it.id } shouldBe listOf(cSharpItem.id)
223+
}
224+
132225
@Test
133226
fun `updateSearchFilter re-runs search for read and bookmarked items`() = runTest(testDispatcher) {
134227
val viewModel = getViewModel()

0 commit comments

Comments
 (0)