From f66b404c55e338018641d504baef71dd49186cf7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 16:54:10 +0000 Subject: [PATCH 1/3] Initial plan From 7b7c9825bb232e896ce00ae89f35a4236d0b85ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 17:01:24 +0000 Subject: [PATCH 2/3] Add TextFileImporter with preview functionality Co-authored-by: yogeshpaliyal <9381846+yogeshpaliyal@users.noreply.github.com> --- .../yogeshpaliyal/deepr/DeeprApplication.kt | 3 + .../com/yogeshpaliyal/deepr/MainActivity.kt | 7 + .../deepr/backup/ImportRepositoryImpl.kt | 3 + .../deepr/backup/LinkImportCandidate.kt | 14 + .../deepr/backup/importer/TextFileImporter.kt | 166 +++++++++ .../deepr/ui/screens/ImportPreviewScreen.kt | 334 ++++++++++++++++++ .../deepr/ui/screens/RestoreScreen.kt | 37 +- .../backup/importer/TextFileImporterTest.kt | 148 ++++++++ 8 files changed, 709 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/yogeshpaliyal/deepr/backup/LinkImportCandidate.kt create mode 100644 app/src/main/java/com/yogeshpaliyal/deepr/backup/importer/TextFileImporter.kt create mode 100644 app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/ImportPreviewScreen.kt create mode 100644 app/src/test/java/com/yogeshpaliyal/deepr/backup/importer/TextFileImporterTest.kt diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/DeeprApplication.kt b/app/src/main/java/com/yogeshpaliyal/deepr/DeeprApplication.kt index 338fb4f..68b3d32 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/DeeprApplication.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/DeeprApplication.kt @@ -12,6 +12,7 @@ import com.yogeshpaliyal.deepr.backup.ExportRepository import com.yogeshpaliyal.deepr.backup.ExportRepositoryImpl import com.yogeshpaliyal.deepr.backup.ImportRepository import com.yogeshpaliyal.deepr.backup.ImportRepositoryImpl +import com.yogeshpaliyal.deepr.backup.importer.TextFileImporter import com.yogeshpaliyal.deepr.data.HtmlParser import com.yogeshpaliyal.deepr.data.NetworkRepository import com.yogeshpaliyal.deepr.preference.AppPreferenceDataStore @@ -72,6 +73,8 @@ class DeeprApplication : Application() { single { ImportRepositoryImpl(androidContext(), get()) } + single { TextFileImporter(androidContext(), get()) } + single { SyncRepositoryImpl(androidContext(), get(), get()) } single { AutoBackupWorker(androidContext(), get(), get()) } diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/MainActivity.kt b/app/src/main/java/com/yogeshpaliyal/deepr/MainActivity.kt index 3d0428c..9f5284f 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/MainActivity.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/MainActivity.kt @@ -26,6 +26,8 @@ import com.yogeshpaliyal.deepr.ui.screens.AboutUs import com.yogeshpaliyal.deepr.ui.screens.AboutUsScreen import com.yogeshpaliyal.deepr.ui.screens.BackupScreen import com.yogeshpaliyal.deepr.ui.screens.BackupScreenContent +import com.yogeshpaliyal.deepr.ui.screens.ImportPreviewScreen +import com.yogeshpaliyal.deepr.ui.screens.ImportPreviewScreenContent import com.yogeshpaliyal.deepr.ui.screens.LocalNetworkServer import com.yogeshpaliyal.deepr.ui.screens.LocalNetworkServerScreen import com.yogeshpaliyal.deepr.ui.screens.RestoreScreen @@ -181,6 +183,11 @@ fun Dashboard( RestoreScreenContent(backStack) } + is ImportPreviewScreen -> + NavEntry(key) { + ImportPreviewScreenContent(key.uri, backStack) + } + else -> NavEntry(Unit) { Text("Unknown route") } } }, diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/backup/ImportRepositoryImpl.kt b/app/src/main/java/com/yogeshpaliyal/deepr/backup/ImportRepositoryImpl.kt index f902107..82ef1be 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/backup/ImportRepositoryImpl.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/backup/ImportRepositoryImpl.kt @@ -7,6 +7,7 @@ import com.yogeshpaliyal.deepr.backup.importer.BookmarkImporter import com.yogeshpaliyal.deepr.backup.importer.ChromeBookmarkImporter import com.yogeshpaliyal.deepr.backup.importer.CsvBookmarkImporter import com.yogeshpaliyal.deepr.backup.importer.MozillaBookmarkImporter +import com.yogeshpaliyal.deepr.backup.importer.TextFileImporter import com.yogeshpaliyal.deepr.util.RequestResult class ImportRepositoryImpl( @@ -16,6 +17,7 @@ class ImportRepositoryImpl( private val csvImporter = CsvBookmarkImporter(context, deeprQueries) private val chromeImporter = ChromeBookmarkImporter(context, deeprQueries) private val mozillaImporter = MozillaBookmarkImporter(context, deeprQueries) + private val textFileImporter = TextFileImporter(context, deeprQueries) override suspend fun importFromCsv(uri: Uri): RequestResult = csvImporter.import(uri) @@ -24,6 +26,7 @@ class ImportRepositoryImpl( csvImporter, chromeImporter, mozillaImporter, + textFileImporter, ) override suspend fun importBookmarks( diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/backup/LinkImportCandidate.kt b/app/src/main/java/com/yogeshpaliyal/deepr/backup/LinkImportCandidate.kt new file mode 100644 index 0000000..22080c4 --- /dev/null +++ b/app/src/main/java/com/yogeshpaliyal/deepr/backup/LinkImportCandidate.kt @@ -0,0 +1,14 @@ +package com.yogeshpaliyal.deepr.backup + +import androidx.annotation.Keep + +/** + * Represents a link candidate for import with selection state. + */ +@Keep +data class LinkImportCandidate( + val link: String, + val isValid: Boolean, + val isDuplicate: Boolean = false, + val isSelected: Boolean = true, // By default all are selected +) diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/backup/importer/TextFileImporter.kt b/app/src/main/java/com/yogeshpaliyal/deepr/backup/importer/TextFileImporter.kt new file mode 100644 index 0000000..6fd7529 --- /dev/null +++ b/app/src/main/java/com/yogeshpaliyal/deepr/backup/importer/TextFileImporter.kt @@ -0,0 +1,166 @@ +package com.yogeshpaliyal.deepr.backup.importer + +import android.content.Context +import android.net.Uri +import com.yogeshpaliyal.deepr.DeeprQueries +import com.yogeshpaliyal.deepr.backup.ImportResult +import com.yogeshpaliyal.deepr.backup.LinkImportCandidate +import com.yogeshpaliyal.deepr.util.RequestResult +import com.yogeshpaliyal.deepr.util.isValidDeeplink +import java.io.IOException + +/** + * Importer for text files containing links. + * Supports both comma-separated and newline-separated links. + */ +class TextFileImporter( + private val context: Context, + private val deeprQueries: DeeprQueries, +) : BookmarkImporter { + override suspend fun import(uri: Uri): RequestResult { + var updatedCount = 0 + var skippedCount = 0 + + try { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + inputStream.reader().use { reader -> + val content = reader.readText() + val links = extractLinks(content) + + links.forEach { link -> + val trimmedLink = link.trim() + if (trimmedLink.isNotBlank() && isValidDeeplink(trimmedLink)) { + val existing = deeprQueries.getDeeprByLink(trimmedLink).executeAsOneOrNull() + if (existing == null) { + updatedCount++ + deeprQueries.insertDeepr( + link = trimmedLink, + name = "", + openedCount = 0L, + notes = "", + thumbnail = "", + ) + } else { + skippedCount++ + } + } else { + skippedCount++ + } + } + } + } + + return RequestResult.Success(ImportResult(updatedCount, skippedCount)) + } catch (e: IOException) { + return RequestResult.Error("Error reading file: ${e.message}") + } catch (e: Exception) { + return RequestResult.Error("An unexpected error occurred: ${e.message}") + } + } + + /** + * Parse the text file and extract link candidates for preview. + */ + suspend fun parseForPreview(uri: Uri): RequestResult> { + try { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + inputStream.reader().use { reader -> + val content = reader.readText() + val links = extractLinks(content) + + val candidates = links.mapNotNull { link -> + val trimmedLink = link.trim() + if (trimmedLink.isNotBlank()) { + val isValid = isValidDeeplink(trimmedLink) + val isDuplicate = if (isValid) { + deeprQueries.getDeeprByLink(trimmedLink).executeAsOneOrNull() != null + } else { + false + } + LinkImportCandidate( + link = trimmedLink, + isValid = isValid, + isDuplicate = isDuplicate, + isSelected = isValid && !isDuplicate, // Only select valid, non-duplicate links by default + ) + } else { + null + } + } + + return RequestResult.Success(candidates) + } + } + + return RequestResult.Error("Unable to open file") + } catch (e: IOException) { + return RequestResult.Error("Error reading file: ${e.message}") + } catch (e: Exception) { + return RequestResult.Error("An unexpected error occurred: ${e.message}") + } + } + + /** + * Import selected links from the preview. + */ + suspend fun importSelected(links: List): RequestResult { + var updatedCount = 0 + var skippedCount = 0 + + try { + links.forEach { link -> + val trimmedLink = link.trim() + if (trimmedLink.isNotBlank() && isValidDeeplink(trimmedLink)) { + val existing = deeprQueries.getDeeprByLink(trimmedLink).executeAsOneOrNull() + if (existing == null) { + updatedCount++ + deeprQueries.insertDeepr( + link = trimmedLink, + name = "", + openedCount = 0L, + notes = "", + thumbnail = "", + ) + } else { + skippedCount++ + } + } else { + skippedCount++ + } + } + + return RequestResult.Success(ImportResult(updatedCount, skippedCount)) + } catch (e: Exception) { + return RequestResult.Error("An unexpected error occurred: ${e.message}") + } + } + + override fun getDisplayName(): String = "Text File" + + override fun getSupportedMimeTypes(): Array = + arrayOf( + "text/plain", + "text/*", + ) + + /** + * Extract links from text content. + * Supports both comma-separated and newline-separated links. + */ + private fun extractLinks(content: String): List { + val links = mutableListOf() + + // First, try to split by commas + val commaSeparated = content.split(",") + + // If we have multiple items from comma split, use those + if (commaSeparated.size > 1) { + links.addAll(commaSeparated.map { it.trim() }) + } else { + // Otherwise, split by newlines + links.addAll(content.split("\n").map { it.trim() }) + } + + return links.filter { it.isNotBlank() } + } +} diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/ImportPreviewScreen.kt b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/ImportPreviewScreen.kt new file mode 100644 index 0000000..ef15725 --- /dev/null +++ b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/ImportPreviewScreen.kt @@ -0,0 +1,334 @@ +package com.yogeshpaliyal.deepr.ui.screens + +import android.net.Uri +import android.widget.Toast +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.yogeshpaliyal.deepr.R +import com.yogeshpaliyal.deepr.backup.LinkImportCandidate +import com.yogeshpaliyal.deepr.backup.importer.TextFileImporter +import com.yogeshpaliyal.deepr.util.RequestResult +import com.yogeshpaliyal.deepr.viewmodel.AccountViewModel +import compose.icons.TablerIcons +import compose.icons.tablericons.ArrowLeft +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.androidx.compose.koinViewModel +import org.koin.core.component.KoinComponent +import org.koin.core.component.get + +data class ImportPreviewScreen(val uri: Uri) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ImportPreviewScreenContent( + uri: Uri, + backStack: SnapshotStateList, + modifier: Modifier = Modifier, + viewModel: AccountViewModel = koinViewModel(), +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var candidates by remember { mutableStateOf>(emptyList()) } + val selectedStates = remember { mutableStateMapOf() } + var isLoading by remember { mutableStateOf(true) } + var isImporting by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + + // Load the preview data + LaunchedEffect(uri) { + scope.launch { + withContext(Dispatchers.IO) { + val textFileImporter: TextFileImporter = object : KoinComponent { + fun getImporter() = get() + }.getImporter() + + when (val result = textFileImporter.parseForPreview(uri)) { + is RequestResult.Success -> { + candidates = result.data + // Initialize selection states + result.data.forEach { candidate -> + selectedStates[candidate.link] = candidate.isSelected + } + isLoading = false + } + is RequestResult.Error -> { + errorMessage = result.message + isLoading = false + } + } + } + } + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + Text("Select Links to Import") + }, + navigationIcon = { + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + + IconButton(onClick = { + backStack.removeLastOrNull() + }) { + Icon( + TablerIcons.ArrowLeft, + contentDescription = stringResource(R.string.back), + modifier = + if (isRtl) { + Modifier.graphicsLayer(scaleX = -1f) + } else { + Modifier + }, + ) + } + }, + ) + }, + bottomBar = { + if (!isLoading && errorMessage == null) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Button( + onClick = { + // Select all valid, non-duplicate links + candidates.forEach { candidate -> + if (candidate.isValid && !candidate.isDuplicate) { + selectedStates[candidate.link] = true + } + } + }, + modifier = Modifier.weight(1f).padding(end = 8.dp), + ) { + Text("Select All") + } + Button( + onClick = { + // Deselect all + candidates.forEach { candidate -> + selectedStates[candidate.link] = false + } + }, + modifier = Modifier.weight(1f).padding(start = 8.dp), + ) { + Text("Deselect All") + } + } + } + }, + ) { innerPadding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding) + .consumeWindowInsets(innerPadding), + ) { + when { + isLoading -> { + Text( + "Loading links...", + modifier = Modifier.padding(16.dp), + ) + } + errorMessage != null -> { + Text( + "Error: $errorMessage", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(16.dp), + ) + } + candidates.isEmpty() -> { + Text( + "No links found in the file", + modifier = Modifier.padding(16.dp), + ) + } + else -> { + LazyColumn( + modifier = Modifier.weight(1f), + ) { + item { + Text( + "Found ${candidates.size} link(s). Select the ones you want to import:", + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } + + items(candidates) { candidate -> + LinkImportItem( + candidate = candidate, + isSelected = selectedStates[candidate.link] ?: false, + onSelectionChange = { selected -> + selectedStates[candidate.link] = selected + }, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = { + val selectedLinks = + candidates + .filter { selectedStates[it.link] == true && it.isValid && !it.isDuplicate } + .map { it.link } + + if (selectedLinks.isEmpty()) { + Toast.makeText(context, "No valid links selected", Toast.LENGTH_SHORT).show() + return@Button + } + + isImporting = true + scope.launch { + withContext(Dispatchers.IO) { + val textFileImporter: TextFileImporter = object : KoinComponent { + fun getImporter() = get() + }.getImporter() + + when (val result = textFileImporter.importSelected(selectedLinks)) { + is RequestResult.Success -> { + withContext(Dispatchers.Main) { + Toast.makeText( + context, + "Import complete! Added: ${result.data.importedCount}, Skipped: ${result.data.skippedCount}", + Toast.LENGTH_LONG, + ).show() + backStack.removeLastOrNull() + } + } + is RequestResult.Error -> { + withContext(Dispatchers.Main) { + Toast.makeText( + context, + "Import failed: ${result.message}", + Toast.LENGTH_LONG, + ).show() + } + } + } + isImporting = false + } + } + }, + enabled = !isImporting && selectedStates.values.any { it }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text(if (isImporting) "Importing..." else "Import Selected Links") + } + } + } + } + } +} + +@Composable +fun LinkImportItem( + candidate: LinkImportCandidate, + isSelected: Boolean, + onSelectionChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .border( + width = 1.dp, + color = when { + !candidate.isValid -> MaterialTheme.colorScheme.error.copy(alpha = 0.5f) + candidate.isDuplicate -> MaterialTheme.colorScheme.tertiary.copy(alpha = 0.5f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + }, + shape = MaterialTheme.shapes.small, + ).padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = isSelected, + onCheckedChange = { checked -> + if (candidate.isValid && !candidate.isDuplicate) { + onSelectionChange(checked) + } + }, + enabled = candidate.isValid && !candidate.isDuplicate, + ) + + Column( + modifier = Modifier.padding(start = 8.dp).weight(1f), + ) { + Text( + text = candidate.link, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + if (!candidate.isValid) { + Text( + text = "Invalid link", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } else if (candidate.isDuplicate) { + Text( + text = "Already exists", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + } +} diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/RestoreScreen.kt b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/RestoreScreen.kt index a213264..afc03cf 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/RestoreScreen.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/RestoreScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.yogeshpaliyal.deepr.R +import com.yogeshpaliyal.deepr.backup.importer.TextFileImporter import com.yogeshpaliyal.deepr.ui.components.ServerStatusBar import com.yogeshpaliyal.deepr.ui.components.SettingsItem import com.yogeshpaliyal.deepr.ui.components.SettingsSection @@ -57,6 +58,14 @@ fun RestoreScreenContent( // Get available importers from the view model val availableImporters = remember { viewModel.getAvailableImporters() } + // Separate text file importer for special handling + val textFileImporter = remember { + availableImporters.firstOrNull { it is TextFileImporter } + } + val otherImporters = remember { + availableImporters.filterNot { it is TextFileImporter } + } + // Track which importer is being used for the current file picker var selectedImporter by remember { mutableStateOf( @@ -64,7 +73,17 @@ fun RestoreScreenContent( ) } - // Launcher for picking files to import + // Launcher for picking text files to preview + val textFilePreviewLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri -> + uri?.let { + backStack.add(ImportPreviewScreen(it)) + } + } + + // Launcher for picking files to import directly val importFileLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument(), @@ -132,8 +151,20 @@ fun RestoreScreenContent( verticalArrangement = Arrangement.spacedBy(16.dp), ) { SettingsSection("Import") { - // Add import options for each available importer - availableImporters.forEach { importer -> + // Add special handling for text file import with preview + textFileImporter?.let { importer -> + SettingsItem( + TablerIcons.Download, + title = "Import from ${importer.getDisplayName()}", + description = "Import links from ${importer.getDisplayName()} with preview", + onClick = { + textFilePreviewLauncher.launch(importer.getSupportedMimeTypes()) + }, + ) + } + + // Add import options for other importers (direct import) + otherImporters.forEach { importer -> SettingsItem( TablerIcons.Download, title = "Import from ${importer.getDisplayName()}", diff --git a/app/src/test/java/com/yogeshpaliyal/deepr/backup/importer/TextFileImporterTest.kt b/app/src/test/java/com/yogeshpaliyal/deepr/backup/importer/TextFileImporterTest.kt new file mode 100644 index 0000000..017847b --- /dev/null +++ b/app/src/test/java/com/yogeshpaliyal/deepr/backup/importer/TextFileImporterTest.kt @@ -0,0 +1,148 @@ +package com.yogeshpaliyal.deepr.backup.importer + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for the TextFileImporter. + */ +class TextFileImporterTest { + @Test + fun textFileImporter_hasCorrectDisplayName() { + val displayName = "Text File" + assertEquals("Text File", displayName) + } + + @Test + fun textFileImporter_supportedMimeTypes() { + val expectedMimeTypes = arrayOf("text/plain", "text/*") + val mimeTypes = expectedMimeTypes + + assertEquals(2, mimeTypes.size) + assertTrue(mimeTypes.contains("text/plain")) + assertTrue(mimeTypes.contains("text/*")) + } + + @Test + fun textFileImporter_extractsNewlineSeparatedLinks() { + val content = """ + https://example.com + https://google.com + https://github.com + """.trimIndent() + + val links = extractLinksFromContent(content) + + assertEquals(3, links.size) + assertEquals("https://example.com", links[0]) + assertEquals("https://google.com", links[1]) + assertEquals("https://github.com", links[2]) + } + + @Test + fun textFileImporter_extractsCommaSeparatedLinks() { + val content = "https://example.com, https://google.com, https://github.com" + + val links = extractLinksFromContent(content) + + assertEquals(3, links.size) + assertEquals("https://example.com", links[0]) + assertEquals("https://google.com", links[1]) + assertEquals("https://github.com", links[2]) + } + + @Test + fun textFileImporter_handlesEmptyLines() { + val content = """ + https://example.com + + https://google.com + + https://github.com + """.trimIndent() + + val links = extractLinksFromContent(content) + + assertEquals(3, links.size) + assertEquals("https://example.com", links[0]) + assertEquals("https://google.com", links[1]) + assertEquals("https://github.com", links[2]) + } + + @Test + fun textFileImporter_handlesWhitespace() { + val content = """ + https://example.com + https://google.com + https://github.com + """.trimIndent() + + val links = extractLinksFromContent(content) + + assertEquals(3, links.size) + assertEquals("https://example.com", links[0]) + assertEquals("https://google.com", links[1]) + assertEquals("https://github.com", links[2]) + } + + @Test + fun textFileImporter_handlesCommaSeparatedWithWhitespace() { + val content = "https://example.com , https://google.com , https://github.com" + + val links = extractLinksFromContent(content) + + assertEquals(3, links.size) + assertEquals("https://example.com", links[0]) + assertEquals("https://google.com", links[1]) + assertEquals("https://github.com", links[2]) + } + + @Test + fun textFileImporter_preferCommaSeparationOverNewline() { + // If content has multiple commas, it should split by comma + val content = """ + https://example.com, https://google.com + https://github.com, https://stackoverflow.com + """.trimIndent() + + val links = extractLinksFromContent(content) + + // Should split by comma, resulting in 4 links + assertEquals(4, links.size) + } + + @Test + fun textFileImporter_handlesMixedDeeplinks() { + val content = """ + https://example.com + myapp://open/screen + tel:+1234567890 + """.trimIndent() + + val links = extractLinksFromContent(content) + + assertEquals(3, links.size) + assertEquals("https://example.com", links[0]) + assertEquals("myapp://open/screen", links[1]) + assertEquals("tel:+1234567890", links[2]) + } + + // Helper function that mimics the TextFileImporter's extractLinks logic + private fun extractLinksFromContent(content: String): List { + val links = mutableListOf() + + // First, try to split by commas + val commaSeparated = content.split(",") + + // If we have multiple items from comma split, use those + if (commaSeparated.size > 1) { + links.addAll(commaSeparated.map { it.trim() }) + } else { + // Otherwise, split by newlines + links.addAll(content.split("\n").map { it.trim() }) + } + + return links.filter { it.isNotBlank() } + } +} From 59dbfb781958a73e8cc1a483abccba480f7c6755 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 17:05:56 +0000 Subject: [PATCH 3/3] Address code review feedback: improve DI and link extraction logic Co-authored-by: yogeshpaliyal <9381846+yogeshpaliyal@users.noreply.github.com> --- .../deepr/backup/importer/TextFileImporter.kt | 19 +++++++---- .../deepr/ui/screens/ImportPreviewScreen.kt | 12 ++----- .../backup/importer/TextFileImporterTest.kt | 33 ++++++++++++++++--- 3 files changed, 43 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/backup/importer/TextFileImporter.kt b/app/src/main/java/com/yogeshpaliyal/deepr/backup/importer/TextFileImporter.kt index 6fd7529..ca6ee65 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/backup/importer/TextFileImporter.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/backup/importer/TextFileImporter.kt @@ -150,12 +150,19 @@ class TextFileImporter( private fun extractLinks(content: String): List { val links = mutableListOf() - // First, try to split by commas - val commaSeparated = content.split(",") - - // If we have multiple items from comma split, use those - if (commaSeparated.size > 1) { - links.addAll(commaSeparated.map { it.trim() }) + // Split by commas to check if comma-separated format is used + val commaSeparated = content.split(",").map { it.trim() } + + // Check if comma separation produces valid results + // We consider it comma-separated if: + // 1. We have multiple items after split + // 2. Most items look like they could be links (contain :// or .) + val isCommaSeparated = + commaSeparated.size > 1 && + commaSeparated.count { it.contains("://") || (it.contains(".") && !it.contains("\n")) } >= commaSeparated.size * 0.5 + + if (isCommaSeparated) { + links.addAll(commaSeparated) } else { // Otherwise, split by newlines links.addAll(content.split("\n").map { it.trim() }) diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/ImportPreviewScreen.kt b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/ImportPreviewScreen.kt index ef15725..5c9032c 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/ImportPreviewScreen.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/ImportPreviewScreen.kt @@ -52,8 +52,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.androidx.compose.koinViewModel -import org.koin.core.component.KoinComponent -import org.koin.core.component.get +import org.koin.compose.koinInject data class ImportPreviewScreen(val uri: Uri) @@ -64,6 +63,7 @@ fun ImportPreviewScreenContent( backStack: SnapshotStateList, modifier: Modifier = Modifier, viewModel: AccountViewModel = koinViewModel(), + textFileImporter: TextFileImporter = koinInject(), ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -77,10 +77,6 @@ fun ImportPreviewScreenContent( LaunchedEffect(uri) { scope.launch { withContext(Dispatchers.IO) { - val textFileImporter: TextFileImporter = object : KoinComponent { - fun getImporter() = get() - }.getImporter() - when (val result = textFileImporter.parseForPreview(uri)) { is RequestResult.Success -> { candidates = result.data @@ -230,10 +226,6 @@ fun ImportPreviewScreenContent( isImporting = true scope.launch { withContext(Dispatchers.IO) { - val textFileImporter: TextFileImporter = object : KoinComponent { - fun getImporter() = get() - }.getImporter() - when (val result = textFileImporter.importSelected(selectedLinks)) { is RequestResult.Success -> { withContext(Dispatchers.Main) { diff --git a/app/src/test/java/com/yogeshpaliyal/deepr/backup/importer/TextFileImporterTest.kt b/app/src/test/java/com/yogeshpaliyal/deepr/backup/importer/TextFileImporterTest.kt index 017847b..f0638a2 100644 --- a/app/src/test/java/com/yogeshpaliyal/deepr/backup/importer/TextFileImporterTest.kt +++ b/app/src/test/java/com/yogeshpaliyal/deepr/backup/importer/TextFileImporterTest.kt @@ -128,16 +128,39 @@ class TextFileImporterTest { assertEquals("tel:+1234567890", links[2]) } + @Test + fun textFileImporter_handlesUrlsWithCommasInQueryParams() { + // URLs with commas in query parameters should be treated as newline-separated + val content = """ + https://example.com?tags=a,b,c + https://google.com?items=x,y,z + """.trimIndent() + + val links = extractLinksFromContent(content) + + // Should split by newline, not comma + assertEquals(2, links.size) + assertEquals("https://example.com?tags=a,b,c", links[0]) + assertEquals("https://google.com?items=x,y,z", links[1]) + } + // Helper function that mimics the TextFileImporter's extractLinks logic private fun extractLinksFromContent(content: String): List { val links = mutableListOf() - // First, try to split by commas - val commaSeparated = content.split(",") + // Split by commas to check if comma-separated format is used + val commaSeparated = content.split(",").map { it.trim() } + + // Check if comma separation produces valid results + // We consider it comma-separated if: + // 1. We have multiple items after split + // 2. Most items look like they could be links (contain :// or .) + val isCommaSeparated = + commaSeparated.size > 1 && + commaSeparated.count { it.contains("://") || (it.contains(".") && !it.contains("\n")) } >= commaSeparated.size * 0.5 - // If we have multiple items from comma split, use those - if (commaSeparated.size > 1) { - links.addAll(commaSeparated.map { it.trim() }) + if (isCommaSeparated) { + links.addAll(commaSeparated) } else { // Otherwise, split by newlines links.addAll(content.split("\n").map { it.trim() })