Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,8 +1,28 @@
package com.yogeshpaliyal.deepr.backup

import android.net.Uri
import com.yogeshpaliyal.deepr.backup.importer.BookmarkImporter
import com.yogeshpaliyal.deepr.util.RequestResult

interface ImportRepository {
suspend fun importFromCsv(uri: Uri): RequestResult<ImportResult>

/**
* Get all available bookmark importers.
*
* @return A list of [BookmarkImporter] instances
*/
fun getAvailableImporters(): List<BookmarkImporter>

/**
* Import bookmarks using a specific importer.
*
* @param uri The URI of the file to import from
* @param importer The [BookmarkImporter] to use for importing
* @return A [RequestResult] containing the [ImportResult] or an error
*/
suspend fun importBookmarks(
uri: Uri,
importer: BookmarkImporter,
): RequestResult<ImportResult>
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,96 +2,32 @@ package com.yogeshpaliyal.deepr.backup

import android.content.Context
import android.net.Uri
import com.opencsv.CSVParserBuilder
import com.opencsv.CSVReaderBuilder
import com.opencsv.exceptions.CsvException
import com.yogeshpaliyal.deepr.DeeprQueries
import com.yogeshpaliyal.deepr.util.Constants
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.util.RequestResult
import java.io.IOException

class ImportRepositoryImpl(
private val context: Context,
private val deeprQueries: DeeprQueries,
) : ImportRepository {
override suspend fun importFromCsv(uri: Uri): RequestResult<ImportResult> {
var updatedCount = 0
var skippedCount = 0
private val csvImporter = CsvBookmarkImporter(context, deeprQueries)
private val chromeImporter = ChromeBookmarkImporter(context, deeprQueries)
private val mozillaImporter = MozillaBookmarkImporter(context, deeprQueries)

try {
context.contentResolver.openInputStream(uri)?.use { inputStream ->
inputStream.reader().use { reader ->
val customParser =
CSVParserBuilder()
.build()
val csvReader =
CSVReaderBuilder(reader)
.withCSVParser(customParser)
.build()
override suspend fun importFromCsv(uri: Uri): RequestResult<ImportResult> = csvImporter.import(uri)

// verify header first
val header = csvReader.readNext()
if (header == null ||
header.size < 3 ||
header[0] != Constants.Header.LINK ||
header[1] != Constants.Header.CREATED_AT ||
header[2] != Constants.Header.OPENED_COUNT
) {
return RequestResult.Error("Invalid CSV header")
}
override fun getAvailableImporters(): List<BookmarkImporter> =
listOf(
csvImporter,
chromeImporter,
mozillaImporter,
)

csvReader.forEach { row ->
if (row.size >= 3) {
val link = row[0]
val openedCount = row[2].toLongOrNull() ?: 0L
val name = row.getOrNull(3)?.toString() ?: ""
val notes = row.getOrNull(4)?.toString() ?: ""
val tagsString = row.getOrNull(5)?.toString() ?: ""
val thumbnail = row.getOrNull(6)?.toString() ?: ""
val existing = deeprQueries.getDeeprByLink(link).executeAsOneOrNull()
if (link.isNotBlank() && existing == null) {
updatedCount++
deeprQueries.transaction {
deeprQueries.insertDeepr(
link = link,
openedCount = openedCount,
name = name,
notes = notes,
thumbnail = thumbnail,
)
val linkId = deeprQueries.lastInsertRowId().executeAsOne()

// Import tags if present
if (tagsString.isNotBlank()) {
val tagNames = tagsString.split(",").map { it.trim() }.filter { it.isNotEmpty() }
tagNames.forEach { tagName ->
// Insert tag if it doesn't exist
deeprQueries.insertTag(tagName)
// Get tag ID and link it
val tag = deeprQueries.getTagByName(tagName).executeAsOneOrNull()
if (tag != null) {
deeprQueries.addTagToLink(linkId, tag.id)
}
}
}
}
} else {
skippedCount++
}
} else {
skippedCount++
}
}
}
}

return RequestResult.Success(ImportResult(updatedCount, skippedCount))
} catch (e: IOException) {
return RequestResult.Error("Error reading file: ${e.message}")
} catch (e: CsvException) {
return RequestResult.Error("Error parsing CSV file: ${e.message}")
} catch (e: Exception) {
return RequestResult.Error("An unexpected error occurred: ${e.message}")
}
}
override suspend fun importBookmarks(
uri: Uri,
importer: BookmarkImporter,
): RequestResult<ImportResult> = importer.import(uri)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.yogeshpaliyal.deepr.backup.importer

import android.net.Uri
import com.yogeshpaliyal.deepr.backup.ImportResult
import com.yogeshpaliyal.deepr.util.RequestResult

/**
* Base interface for importing bookmarks from various sources.
* This interface can be extended to support different import formats
* such as CSV, HTML, Chrome bookmarks, Mozilla bookmarks, etc.
*/
interface BookmarkImporter {
/**
* Import bookmarks from the given URI.
*
* @param uri The URI of the file to import from
* @return A [RequestResult] containing the [ImportResult] or an error
*/
suspend fun import(uri: Uri): RequestResult<ImportResult>

/**
* Get the display name of this importer.
*
* @return A human-readable name for this importer (e.g., "CSV", "HTML", "Chrome Bookmarks")
*/
fun getDisplayName(): String

/**
* Get the supported MIME types for this importer.
*
* @return An array of MIME types that this importer can handle
*/
fun getSupportedMimeTypes(): Array<String>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.yogeshpaliyal.deepr.backup.importer

import android.content.Context
import com.yogeshpaliyal.deepr.DeeprQueries
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element

/**
* Importer for Chrome/Chromium browser bookmarks HTML format.
*/
class ChromeBookmarkImporter(
context: Context,
deeprQueries: DeeprQueries,
) : HtmlBookmarkImporter(context, deeprQueries) {
override fun getDisplayName(): String = "Chrome Bookmarks"

override fun extractBookmarks(document: Document): List<Bookmark> {
val bookmarks = mutableListOf<Bookmark>()

// Chrome bookmarks use <a> tags inside <dt> elements
val links = document.select("dt > a[href]")

links.forEach { link ->
val url = link.attr("href")
val title = link.text()
val folder = extractChromeFolder(link)
val addDate = link.attr("add_date")
val tags = link.attr("tags")

if (url.isNotBlank()) {
val tagList =
if (tags.isNotBlank()) {
tags.split(",").map { it.trim() }.filter { it.isNotEmpty() }
} else {
null
}

bookmarks.add(
Bookmark(
url = url,
title = title.ifBlank { url },
folder = folder,
tags = tagList,
),
)
}
}

return bookmarks
}

private fun extractChromeFolder(element: Element): String? {
val folders = mutableListOf<String>()
var current = element.parent()

while (current != null) {
// Look for <h3> tags which represent folder names in Chrome bookmarks
val h3 = current.selectFirst("h3")
if (h3 != null && current.tagName() == "dt") {
folders.add(h3.text())
}

// Move up the tree
current = current.parent()

// Stop at the root bookmark folder
if (current?.tagName() == "dl" && current.parent()?.tagName() == "html") {
break
}
}

return if (folders.isNotEmpty()) folders.reversed().joinToString(" / ") else null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package com.yogeshpaliyal.deepr.backup.importer

import android.content.Context
import android.net.Uri
import com.opencsv.CSVParserBuilder
import com.opencsv.CSVReaderBuilder
import com.opencsv.exceptions.CsvException
import com.yogeshpaliyal.deepr.DeeprQueries
import com.yogeshpaliyal.deepr.backup.ImportResult
import com.yogeshpaliyal.deepr.util.Constants
import com.yogeshpaliyal.deepr.util.RequestResult
import java.io.IOException

/**
* Importer for CSV files containing bookmark data.
*/
class CsvBookmarkImporter(
private val context: Context,
private val deeprQueries: DeeprQueries,
) : BookmarkImporter {
override suspend fun import(uri: Uri): RequestResult<ImportResult> {
var updatedCount = 0
var skippedCount = 0

try {
context.contentResolver.openInputStream(uri)?.use { inputStream ->
inputStream.reader().use { reader ->
val customParser =
CSVParserBuilder()
.build()
val csvReader =
CSVReaderBuilder(reader)
.withCSVParser(customParser)
.build()

// verify header first
val header = csvReader.readNext()
if (header == null ||
header.size < 3 ||
header[0] != Constants.Header.LINK ||
header[1] != Constants.Header.CREATED_AT ||
header[2] != Constants.Header.OPENED_COUNT
) {
return RequestResult.Error("Invalid CSV header")
}

csvReader.forEach { row ->
if (row.size >= 3) {
val link = row[0]
val openedCount = row[2].toLongOrNull() ?: 0L
val name = row.getOrNull(3)?.toString() ?: ""
val notes = row.getOrNull(4)?.toString() ?: ""
val tagsString = row.getOrNull(5)?.toString() ?: ""
val thumbnail = row.getOrNull(6)?.toString() ?: ""
val existing = deeprQueries.getDeeprByLink(link).executeAsOneOrNull()
if (link.isNotBlank() && existing == null) {
updatedCount++
deeprQueries.transaction {
deeprQueries.insertDeepr(
link = link,
openedCount = openedCount,
name = name,
notes = notes,
thumbnail = thumbnail,
)
val linkId = deeprQueries.lastInsertRowId().executeAsOne()

// Import tags if present
if (tagsString.isNotBlank()) {
val tagNames = tagsString.split(",").map { it.trim() }.filter { it.isNotEmpty() }
tagNames.forEach { tagName ->
// Insert tag if it doesn't exist
deeprQueries.insertTag(tagName)
// Get tag ID and link it
val tag = deeprQueries.getTagByName(tagName).executeAsOneOrNull()
if (tag != null) {
deeprQueries.addTagToLink(linkId, tag.id)
}
}
}
}
} else {
skippedCount++
}
} else {
skippedCount++
}
}
}
}

return RequestResult.Success(ImportResult(updatedCount, skippedCount))
} catch (e: IOException) {
return RequestResult.Error("Error reading file: ${e.message}")
} catch (e: CsvException) {
return RequestResult.Error("Error parsing CSV file: ${e.message}")
} catch (e: Exception) {
return RequestResult.Error("An unexpected error occurred: ${e.message}")
}
}

override fun getDisplayName(): String = "CSV"

override fun getSupportedMimeTypes(): Array<String> =
arrayOf(
"text/csv",
"text/comma-separated-values",
"application/csv",
)
}
Loading