From fe4ea80b46a147874bb0101adf45a14f7af9526e Mon Sep 17 00:00:00 2001 From: Josiah Campbell <9521010+jocmp@users.noreply.github.com> Date: Sun, 4 Feb 2024 15:04:59 -0600 Subject: [PATCH] Prevent duplicate feeds Remove existing feeds before re-adding them via the importer or the Add Feed screen. --- app/build.gradle.kts | 1 + .../com/jocmp/basilreader/MainActivity.kt | 5 +- .../basilreader/ui/accounts/AccountModule.kt | 3 + .../ui/articles/AccountViewModel.kt | 3 +- .../basilreader/ui/articles/AddFeedScreen.kt | 7 +- .../basilreader/ui/articles/AddFeedView.kt | 132 +++++++++++------- .../ui/articles/ArticleScaffold.kt | 9 +- .../basilreader/ui/articles/ArticleScreen.kt | 2 +- .../basilreader/ui/articles/ArticleView.kt | 12 +- .../basilreader/ui/articles/ArticlesModule.kt | 7 +- .../src/main/java/com/jocmp/basil/Account.kt | 61 +++----- .../java/com/jocmp/basil/AccountManager.kt | 2 +- .../main/java/com/jocmp/basil/AddFeedForm.kt | 5 +- .../main/java/com/jocmp/basil/FeedSearch.kt | 31 ++++ .../java/com/jocmp/basil/opml/OPMLImporter.kt | 16 ++- .../jocmp/basil/persistence/FeedRecords.kt | 2 +- .../com/jocmp/basil/shared/OptionalURL.kt | 3 + .../test/java/com/jocmp/basil/AccountTest.kt | 34 ++--- .../com/jocmp/basil/opml/OPMLImporterTest.kt | 21 +++ .../resources/multiple_matching_feeds.xml | 24 ++++ 20 files changed, 255 insertions(+), 125 deletions(-) create mode 100644 basil/src/main/java/com/jocmp/basil/FeedSearch.kt create mode 100644 basil/src/test/resources/multiple_matching_feeds.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3677f856..1ba28025 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -53,6 +53,7 @@ android { dependencies { implementation("androidx.webkit:webkit:1.10.0") + implementation(project(":feedfinder")) val sqldelightVersion = libs.versions.sqldelight.get() val pagingVersion = libs.versions.androidx.paging.get() diff --git a/app/src/main/java/com/jocmp/basilreader/MainActivity.kt b/app/src/main/java/com/jocmp/basilreader/MainActivity.kt index ac251d59..ffd63854 100644 --- a/app/src/main/java/com/jocmp/basilreader/MainActivity.kt +++ b/app/src/main/java/com/jocmp/basilreader/MainActivity.kt @@ -24,11 +24,12 @@ class MainActivity : ComponentActivity() { } private fun startDestination(): String { + val accountManager = get() val appPreferences = get() - val accountID = appPreferences.accountID.get() + val account = accountManager.findByID(appPreferences.accountID.get()) - return if (accountID.isBlank()) { + return if (account == null) { "accounts" } else { "articles" diff --git a/app/src/main/java/com/jocmp/basilreader/ui/accounts/AccountModule.kt b/app/src/main/java/com/jocmp/basilreader/ui/accounts/AccountModule.kt index 2a65a26c..642f0bd4 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/accounts/AccountModule.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/accounts/AccountModule.kt @@ -1,5 +1,8 @@ package com.jocmp.basilreader.ui.accounts +import com.jocmp.basil.FeedSearch +import com.jocmp.feedfinder.DefaultFeedFinder +import com.jocmp.feedfinder.FeedFinder import org.koin.android.ext.koin.androidContext import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/AccountViewModel.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/AccountViewModel.kt index d9aef86a..946c4608 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/AccountViewModel.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/AccountViewModel.kt @@ -15,6 +15,7 @@ import com.jocmp.basil.Article import com.jocmp.basil.ArticleFilter import com.jocmp.basil.ArticleStatus import com.jocmp.basil.Feed +import com.jocmp.basil.FeedSearch import com.jocmp.basil.Folder import com.jocmp.basil.buildPager import com.jocmp.basil.unreadCounts @@ -176,7 +177,7 @@ class AccountViewModel( onFailure: (message: String) -> Unit ) { viewModelScope.launch { - return@launch account.addFeed(entry).fold( + account.addFeed(entry).fold( onSuccess = { feed -> selectFeed(feed.id) onSuccess() diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/AddFeedScreen.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/AddFeedScreen.kt index 911e43e0..b8b1ebbc 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/AddFeedScreen.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/AddFeedScreen.kt @@ -1,20 +1,20 @@ package com.jocmp.basilreader.ui.articles import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier +import com.jocmp.basil.FeedSearch import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject @Composable fun AddFeedScreen( viewModel: AccountViewModel = koinViewModel(), + feedSearch: FeedSearch = koinInject(), onSubmit: () -> Unit, onCancel: () -> Unit ) { @@ -35,6 +35,7 @@ fun AddFeedScreen( } ) }, + searchFeeds = { feedSearch.search(it).getOrNull() }, onCancel = onCancel ) SnackbarHost(hostState = snackbarState) diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/AddFeedView.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/AddFeedView.kt index 44285abc..4404ffe3 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/AddFeedView.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/AddFeedView.kt @@ -15,40 +15,64 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.toMutableStateMap import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.jocmp.basil.AddFeedForm +import com.jocmp.basil.FeedSearch.SearchResult import com.jocmp.basil.Folder +import com.jocmp.basil.shared.orEmpty import com.jocmp.basilreader.R import com.jocmp.basilreader.ui.components.TextField +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @Composable fun AddFeedView( folders: List, onSubmit: (feed: AddFeedForm) -> Unit, - onCancel: () -> Unit + onCancel: () -> Unit, + searchFeeds: suspend (url: String) -> SearchResult?, ) { - val (url, setURL) = remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + val (queryURL, setQueryURL) = remember { mutableStateOf("") } + val (searchResult, setSearchResult) = remember { mutableStateOf(null) } val (name, setName) = remember { mutableStateOf("") } val (addedFolder, setAddedFolder) = remember { mutableStateOf("") } val switchFolders = remember { folders.map { it.title to false }.toMutableStateMap() } + val url = searchResult?.url.orEmpty - fun submitFeed() { + val search = { + scope.launch(Dispatchers.IO) { + val result = searchFeeds(queryURL) + if (result != null) { + if (result.name.isNotBlank()) { + setName(result.name) + } + setSearchResult(result) + } + } + } + + val submitFeed = { val existingFolderNames = switchFolders.filter { it.value }.keys val folderNames = collectFolders(existingFolderNames, addedFolder) - onSubmit( - AddFeedForm( - url = url, - name = name, - folderTitles = folderNames + if (searchResult != null) { + onSubmit( + AddFeedForm( + url = searchResult.url, + siteURL = searchResult.siteURL, + name = name, + folderTitles = folderNames + ) ) - ) + } } Card( @@ -56,53 +80,60 @@ fun AddFeedView( ) { Column(Modifier.padding(16.dp)) { TextField( - value = url, - onValueChange = setURL, + value = queryURL, + onValueChange = setQueryURL, + readOnly = searchResult != null, label = { Text(stringResource(id = R.string.add_feed_url_title)) }, - supportingText = { - Text(stringResource(R.string.required_placeholder)) - } ) - TextField( - value = name, - onValueChange = setName, - label = { - Text(stringResource(id = R.string.add_feed_name_title)) + if (url.isBlank()) { + Button(onClick = { search() }) { + Text("Search") } - ) - TextField( - value = addedFolder, - onValueChange = setAddedFolder, - placeholder = { - Text(stringResource(id = R.string.add_feed_new_folder_title)) - } - ) - Column(modifier = Modifier.padding(horizontal = 16.dp)) { - switchFolders.forEach { (folderTitle, checked) -> - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Text(folderTitle) - Switch( - checked = checked, - onCheckedChange = { value -> switchFolders[folderTitle] = value } - ) + } else { + TextField( + value = name, + onValueChange = setName, + label = { + Text(stringResource(id = R.string.add_feed_name_title)) + }, + supportingText = { + Text(url) + } + ) + TextField( + value = addedFolder, + onValueChange = setAddedFolder, + placeholder = { + Text(stringResource(id = R.string.add_feed_new_folder_title)) + } + ) + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + switchFolders.forEach { (folderTitle, checked) -> + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text(folderTitle) + Switch( + checked = checked, + onCheckedChange = { value -> switchFolders[folderTitle] = value } + ) + } } } - } - Row( - horizontalArrangement = Arrangement.End, - modifier = Modifier.fillMaxWidth() - ) { - TextButton(onClick = onCancel) { - Text(stringResource(R.string.feed_form_cancel)) - } - Button(onClick = { submitFeed() }) { - Text(stringResource(R.string.add_feed_submit)) + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth() + ) { + TextButton(onClick = onCancel) { + Text(stringResource(R.string.feed_form_cancel)) + } + Button(onClick = { submitFeed() }) { + Text(stringResource(R.string.add_feed_submit)) + } } } } @@ -128,6 +159,7 @@ fun AddFeedViewPreview() { AddFeedView( folders = listOf(Folder(title = "Tech")), onSubmit = {}, - onCancel = {} + onCancel = {}, + searchFeeds = { null } ) } diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScaffold.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScaffold.kt index ca4a6edd..78815c70 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScaffold.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScaffold.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.AnimatedPane import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.ListDetailPaneScaffold import androidx.compose.material3.adaptive.ListDetailPaneScaffoldRole @@ -57,10 +58,14 @@ fun ArticleScaffold( ListDetailPaneScaffold( scaffoldState = listDetailState, listPane = { - listPane() + AnimatedPane(Modifier) { + listPane() + } }, detailPane = { - detailPane() + AnimatedPane(Modifier) { + detailPane() + } } ) } diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScreen.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScreen.kt index 4a6ba032..5fe819bc 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScreen.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScreen.kt @@ -49,7 +49,7 @@ fun ArticleScreen( val scaffoldState = calculateListDetailPaneScaffoldState( currentPaneDestination = destination, - scaffoldDirective = calculateArticleDirective() + scaffoldDirective = calculateArticleDirective(), ) val context = LocalContext.current diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleView.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleView.kt index 46e0c22f..a5188899 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleView.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleView.kt @@ -30,6 +30,7 @@ fun ArticleView( onToggleRead: () -> Unit, onToggleStar: () -> Unit ) { + if (article != null) { ArticleLoadedView( article = article, @@ -42,6 +43,7 @@ fun ArticleView( EmptyView() } + BackHandler(article != null) { onBackPressed() } @@ -71,10 +73,16 @@ fun ArticleLoadedView( topBar = { Row { IconButton(onClick = { onToggleRead() }) { - Icon(painterResource(id = readIcon), contentDescription = stringResource(R.string.article_view_mark_as_read)) + Icon( + painterResource(id = readIcon), + contentDescription = stringResource(R.string.article_view_mark_as_read) + ) } IconButton(onClick = { onToggleStar() }) { - Icon(painterResource(id = starIcon), contentDescription = stringResource(R.string.article_view_bookmark)) + Icon( + painterResource(id = starIcon), + contentDescription = stringResource(R.string.article_view_bookmark) + ) } } } diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticlesModule.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticlesModule.kt index d18e87cf..fa9d438c 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticlesModule.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticlesModule.kt @@ -1,15 +1,20 @@ package com.jocmp.basilreader.ui.articles +import com.jocmp.basil.FeedSearch +import com.jocmp.feedfinder.DefaultFeedFinder +import com.jocmp.feedfinder.FeedFinder import org.koin.android.ext.koin.androidContext import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.core.scope.get import org.koin.dsl.module internal val articlesModule = module { + single { DefaultFeedFinder() } + single { FeedSearch(get()) } viewModel { AccountViewModel( accountManager = get(), - appPreferences = get() + appPreferences = get(), ) } viewModel { diff --git a/basil/src/main/java/com/jocmp/basil/Account.kt b/basil/src/main/java/com/jocmp/basil/Account.kt index 2bfcafb8..721609c9 100644 --- a/basil/src/main/java/com/jocmp/basil/Account.kt +++ b/basil/src/main/java/com/jocmp/basil/Account.kt @@ -12,6 +12,7 @@ import com.jocmp.basil.opml.asFolder import com.jocmp.basil.persistence.ArticleRecords import com.jocmp.basil.persistence.FeedRecords import com.jocmp.basil.shared.nowUTCInSeconds +import com.jocmp.basil.shared.orEmpty import com.jocmp.basil.shared.upsert import com.jocmp.feedfinder.DefaultFeedFinder import com.jocmp.feedfinder.FeedFinder @@ -23,7 +24,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.InputStream import java.net.URI -import java.net.URL data class Account( val id: String, @@ -100,49 +100,30 @@ data class Account( suspend fun removeFeed(feedID: String) { feedRecords.removeFeed(feedID = feedID) - - feeds.removeIf { it.id == feedID } - - folders.forEach { folder -> - folder.feeds.removeIf { it.id == feedID } - } - - folders.removeIf { it.feeds.isEmpty() } + removeFeedFromOPML(feedID) saveOPMLFile() } suspend fun addFeed(form: AddFeedForm): Result { - val result = feedFinder.find(url = form.url) - - return if (result.isSuccess) { - saveNewFeed(form, result.getOrNull()!!) - } else { - Result.failure(result.exceptionOrNull()!!) - } + return saveNewFeed(form) } - private suspend fun saveNewFeed( - form: AddFeedForm, - foundFeeds: List - ): Result { - val found = foundFeeds.first() - + private suspend fun saveNewFeed(form: AddFeedForm): Result { val externalID = delegate - .createFeed(feedURL = found.feedURL) + .createFeed(feedURL = form.url) .getOrNull() ?: return Result.failure(Throwable("Could not create external feed")) - val record = feedRecords.findOrCreate( - feedURL = found.feedURL.toString(), - externalID = externalID - ) + val record = feedRecords.findOrCreate(feedURL = form.url.toString()) + + removeFeedFromOPML(record.id.toString()) val feed = Feed( id = record.id.toString(), externalID = externalID, - name = entryNameOrDefault(form.name, found.name), - feedURL = found.feedURL.toString(), - siteURL = entrySiteURL(found.siteURL) + name = entryNameOrDefault(form.name), + feedURL = form.url.toString(), + siteURL = form.siteURL.orEmpty, ) if (form.folderTitles.isEmpty()) { @@ -272,6 +253,16 @@ data class Account( OPMLImporter(this).import(inputStream) } + private fun removeFeedFromOPML(feedID: String) { + feeds.removeIf { it.id == feedID } + + folders.forEach { folder -> + folder.feeds.removeIf { it.id == feedID } + } + + folders.removeIf { it.feeds.isEmpty() } + } + private fun updateArticles(feed: Feed, items: List) { items.forEach { item -> val publishedAt = item.publishedAt?.toEpochSecond() @@ -312,19 +303,11 @@ data class Account( }.awaitAll() } - private fun entrySiteURL(url: URL?): String { - return url?.toString() ?: "" - } - - private fun entryNameOrDefault(entryName: String, feedName: String = ""): String { + private fun entryNameOrDefault(entryName: String): String { if (entryName.isNotBlank()) { return entryName } - if (feedName.isNotBlank()) { - return feedName - } - return DEFAULT_TITLE } diff --git a/basil/src/main/java/com/jocmp/basil/AccountManager.kt b/basil/src/main/java/com/jocmp/basil/AccountManager.kt index 1ac7875e..b6756363 100644 --- a/basil/src/main/java/com/jocmp/basil/AccountManager.kt +++ b/basil/src/main/java/com/jocmp/basil/AccountManager.kt @@ -71,7 +71,7 @@ class AccountManager( } private fun findAccountFile(id: String): File? { - return accountFolder().listFiles(AccountFileFilter(id))?.first() + return accountFolder().listFiles(AccountFileFilter(id))?.firstOrNull() } companion object { diff --git a/basil/src/main/java/com/jocmp/basil/AddFeedForm.kt b/basil/src/main/java/com/jocmp/basil/AddFeedForm.kt index 3217e8bf..10374f87 100644 --- a/basil/src/main/java/com/jocmp/basil/AddFeedForm.kt +++ b/basil/src/main/java/com/jocmp/basil/AddFeedForm.kt @@ -1,7 +1,10 @@ package com.jocmp.basil +import java.net.URL + data class AddFeedForm( - val url: String, + val url: URL, val name: String = "", + val siteURL: URL? = null, val folderTitles: List = emptyList() ) diff --git a/basil/src/main/java/com/jocmp/basil/FeedSearch.kt b/basil/src/main/java/com/jocmp/basil/FeedSearch.kt new file mode 100644 index 00000000..86e62127 --- /dev/null +++ b/basil/src/main/java/com/jocmp/basil/FeedSearch.kt @@ -0,0 +1,31 @@ +package com.jocmp.basil + +import com.jocmp.feedfinder.FeedFinder +import java.net.URL + +class FeedSearch(private val feedFinder: FeedFinder) { + suspend fun search(url: String): Result { + return feedFinder.find(url = url).fold( + onSuccess = { + val feed = it.first() + + Result.success( + SearchResult( + url = feed.feedURL, + siteURL = feed.siteURL, + name = feed.name, + ) + ) + }, + onFailure = { + Result.failure(it) + } + ) + } + + data class SearchResult( + val url: URL, + val name: String = "", + val siteURL: URL? = null, + ) +} diff --git a/basil/src/main/java/com/jocmp/basil/opml/OPMLImporter.kt b/basil/src/main/java/com/jocmp/basil/opml/OPMLImporter.kt index 8761549c..549b4924 100644 --- a/basil/src/main/java/com/jocmp/basil/opml/OPMLImporter.kt +++ b/basil/src/main/java/com/jocmp/basil/opml/OPMLImporter.kt @@ -6,6 +6,7 @@ import com.jocmp.basil.OPMLFile import java.io.BufferedReader import java.io.InputStream import java.net.URI +import java.net.URL /** * 1. Load file via `OPMLFile` @@ -18,8 +19,15 @@ internal class OPMLImporter(private val account: Account) { val entries = findEntries(outlines) - entries.forEach { - account.addFeed(it) + val groupedForms = entries.groupBy { it.url }.toMap() + + groupedForms.forEach { (feedURL, forms) -> + val folderTitles = forms.flatMap { it.folderTitles }.distinct() + val name = forms.first().name + + val form = AddFeedForm(url = feedURL, name = name, folderTitles = folderTitles) + + account.addFeed(form) } } @@ -31,7 +39,7 @@ internal class OPMLImporter(private val account: Account) { val feed = outline.feed if (!feed.xmlUrl.isNullOrBlank()) { - feedEntries.add(AddFeedForm(url = feed.xmlUrl, name = feed.title ?: "")) + feedEntries.add(AddFeedForm(url = URL(feed.xmlUrl), name = feed.title ?: "")) } } else if (outline is Outline.FolderOutline) { val feeds = flattenFolder(outline.folder) @@ -40,7 +48,7 @@ internal class OPMLImporter(private val account: Account) { if (!it.xmlUrl.isNullOrBlank()) { feedEntries.add( AddFeedForm( - url = it.xmlUrl, + url = URL(it.xmlUrl), name = it.title ?: "", folderTitles = folderTitle(outline.title) ) diff --git a/basil/src/main/java/com/jocmp/basil/persistence/FeedRecords.kt b/basil/src/main/java/com/jocmp/basil/persistence/FeedRecords.kt index 1c60ccb9..2bbe0404 100644 --- a/basil/src/main/java/com/jocmp/basil/persistence/FeedRecords.kt +++ b/basil/src/main/java/com/jocmp/basil/persistence/FeedRecords.kt @@ -5,7 +5,7 @@ import com.jocmp.basil.db.Feeds import com.jocmp.basil.accounts.ExternalFeed internal class FeedRecords(val database: Database) { - internal fun findOrCreate(feedURL: String, externalID: String = feedURL): Feeds { + internal fun findOrCreate(feedURL: String): Feeds { val existingFeed = database .feedsQueries .findByURL(feed_url = feedURL) diff --git a/basil/src/main/java/com/jocmp/basil/shared/OptionalURL.kt b/basil/src/main/java/com/jocmp/basil/shared/OptionalURL.kt index b66b721b..3c2c8d14 100644 --- a/basil/src/main/java/com/jocmp/basil/shared/OptionalURL.kt +++ b/basil/src/main/java/com/jocmp/basil/shared/OptionalURL.kt @@ -14,3 +14,6 @@ fun optionalURL(string: String?): URL? { null } } + +val URL?.orEmpty: String + get() = this?.toString() ?: "" diff --git a/basil/src/test/java/com/jocmp/basil/AccountTest.kt b/basil/src/test/java/com/jocmp/basil/AccountTest.kt index 2923487c..681daa5c 100644 --- a/basil/src/test/java/com/jocmp/basil/AccountTest.kt +++ b/basil/src/test/java/com/jocmp/basil/AccountTest.kt @@ -16,6 +16,7 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import java.io.File +import java.net.URL import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertNull @@ -29,7 +30,7 @@ class AccountTest { private lateinit var database: Database private val defaultEntry = AddFeedForm( - url = THE_VERGE_URL, + url = URL(THE_VERGE_URL), name = "The Verge", folderTitles = listOf() ) @@ -78,12 +79,11 @@ class AccountTest { runBlocking { val previousInstance = buildAccount(id = accountID, path = accountPath) - previousInstance.addFolder(title = "Test Title") previousInstance.addFeed( AddFeedForm( - url = THE_VERGE_URL, + url = URL(THE_VERGE_URL), name = "The Verge", - folderTitles = listOf(), + folderTitles = listOf("Test Title"), ) ) } @@ -92,7 +92,7 @@ class AccountTest { val accountTitle = account.folders.first().title assertEquals(expected = "Test Title", actual = accountTitle) - assertEquals(expected = 1, actual = account.feeds.size) + assertEquals(expected = 1, actual = account.flattenedFeeds.size) } @Test @@ -100,7 +100,7 @@ class AccountTest { val accountPath = folder.newFile() val account = buildAccount(id = "777", path = accountPath) val entry = AddFeedForm( - url = "https://theverge.com/rss/index.xml", + url = URL("https://theverge.com/rss/index.xml"), name = "The Verge", folderTitles = listOf(), ) @@ -112,7 +112,7 @@ class AccountTest { val feed = account.feeds.first() assertEquals(expected = entry.name, actual = entry.name) - assertEquals(expected = entry.url, actual = feed.feedURL) + assertEquals(expected = entry.url.toString(), actual = feed.feedURL) } @Test @@ -120,7 +120,7 @@ class AccountTest { val accountPath = folder.newFile() val account = buildAccount(id = "777", path = accountPath) val entry = AddFeedForm( - url = "https://theverge.com/rss/index.xml", + url = URL("https://theverge.com/rss/index.xml"), name = "The Verge", folderTitles = listOf("Tech"), ) @@ -132,7 +132,7 @@ class AccountTest { val feed = account.folders.first().feeds.first() assertEquals(expected = entry.name, actual = entry.name) - assertEquals(expected = entry.url, actual = feed.feedURL) + assertEquals(expected = entry.url.toString(), actual = feed.feedURL) } @Test @@ -142,7 +142,7 @@ class AccountTest { runBlocking { account.addFolder("Tech") } val entry = AddFeedForm( - url = "https://theverge.com/rss/index.xml", + url = URL("https://theverge.com/rss/index.xml"), name = "The Verge", folderTitles = listOf("Tech"), ) @@ -154,7 +154,7 @@ class AccountTest { val feed = account.folders.first().feeds.first() assertEquals(expected = entry.name, actual = feed.name) - assertEquals(expected = entry.url, actual = feed.feedURL) + assertEquals(expected = entry.url.toString(), actual = feed.feedURL) } @Test @@ -164,7 +164,7 @@ class AccountTest { runBlocking { account.addFolder("Tech") } val entry = AddFeedForm( - url = "https://theverge.com/rss/index.xml", + url = URL("https://theverge.com/rss/index.xml"), name = "The Verge", folderTitles = listOf("Tech", "Culture"), ) @@ -177,7 +177,7 @@ class AccountTest { val techFeed = account.folders.first().feeds.first() val cultureFeed = account.folders.first().feeds.first() assertEquals(expected = entry.name, actual = techFeed.name) - assertEquals(expected = entry.url, actual = techFeed.feedURL) + assertEquals(expected = entry.url.toString(), actual = techFeed.feedURL) assertEquals(techFeed, cultureFeed) } @@ -187,7 +187,7 @@ class AccountTest { runBlocking { account.addFeed( AddFeedForm( - url = "https://theverge.com/rss/index.xml", + url = URL("https://theverge.com/rss/index.xml"), name = "The Verge", folderTitles = listOf(), ) @@ -279,7 +279,7 @@ class AccountTest { val otherFeed = runBlocking { account.addFeed( AddFeedForm( - url = ARS_TECHNICA_URL, + url = URL(ARS_TECHNICA_URL), name = "Ars Technica", folderTitles = listOf("Tech") ) @@ -403,7 +403,7 @@ class AccountTest { val account = buildAccount(id = "777", path = folder.newFile()) val entry = AddFeedForm( - url = "https://theverge.com/rss/index.xml", + url = URL("https://theverge.com/rss/index.xml"), name = "The Verge", folderTitles = emptyList() ) @@ -442,7 +442,7 @@ class AccountTest { val account = buildAccount(id = "777", path = folder.newFile()) val entry = AddFeedForm( - url = "https://theverge.com/rss/index.xml", + url = URL("https://theverge.com/rss/index.xml"), name = "The Verge", folderTitles = listOf("Tech", "Culture") ) diff --git a/basil/src/test/java/com/jocmp/basil/opml/OPMLImporterTest.kt b/basil/src/test/java/com/jocmp/basil/opml/OPMLImporterTest.kt index 9ad469f4..cf79027b 100644 --- a/basil/src/test/java/com/jocmp/basil/opml/OPMLImporterTest.kt +++ b/basil/src/test/java/com/jocmp/basil/opml/OPMLImporterTest.kt @@ -22,6 +22,7 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import java.io.File +import kotlin.math.exp import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -71,4 +72,24 @@ class OPMLImporterTest { actual = newsFeeds ) } + + @Test + fun `it handles feeds nested in multiple folders`() = runBlocking { + val uri = testFile("multiple_matching_feeds.xml").inputStream() + + OPMLImporter(account).import(uri) + + val topLevelFeeds = account.feeds.map { it.name } + val appleFeeds = account.folders.find { it.title == "Apple" }!!.feeds.map { it.name } + val blogFeeds = account.folders.find { it.title == "Blogs" }!!.feeds.map { it.name } + val newsFeeds = account.folders.find { it.title == "News" }!!.feeds.map { it.name } + + assertEquals(expected = listOf("Julia Evans"), actual = topLevelFeeds) + assertEquals(expected = listOf("Daring Fireball"), actual = blogFeeds) + assertEquals(expected = listOf("Daring Fireball"), actual = appleFeeds) + assertEquals( + expected = listOf("BBC News - World", "NetNewsWire", "Block Club Chicago"), + actual = newsFeeds + ) + } } diff --git a/basil/src/test/resources/multiple_matching_feeds.xml b/basil/src/test/resources/multiple_matching_feeds.xml new file mode 100644 index 00000000..70063993 --- /dev/null +++ b/basil/src/test/resources/multiple_matching_feeds.xml @@ -0,0 +1,24 @@ + + + + + On My Mac + + + + + + + + + + + + + + + + + + +