Skip to content
Draft
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
Expand Up @@ -35,6 +35,7 @@ object AnalyticsEvents {

// Import/Export events
const val EXPORT_CSV = "export_csv"
const val EXPORT_HTML = "export_html"
const val IMPORT_CSV = "import_csv"

// Shortcut events
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ import com.yogeshpaliyal.deepr.util.RequestResult

interface ExportRepository {
suspend fun exportToCsv(uri: Uri? = null): RequestResult<String>
suspend fun exportToHtml(uri: Uri? = null): RequestResult<String>
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ class ExportRepositoryImpl(
CsvWriter()
}

private val htmlWriter by lazy {
HtmlWriter()
}

override suspend fun exportToCsv(uri: Uri?): RequestResult<String> {
val count = deeprQueries.countDeepr().executeAsOne()
if (count == 0L) {
Expand Down Expand Up @@ -102,4 +106,82 @@ class ExportRepositoryImpl(
}
}
}

override suspend fun exportToHtml(uri: Uri?): RequestResult<String> {
val count = deeprQueries.countDeepr().executeAsOne()
if (count == 0L) {
return RequestResult.Error(context.getString(R.string.no_data_to_export))
}
val dataToExportInHtmlFormat = deeprQueries.listDeeprWithTagsAsc().executeAsList()
if (dataToExportInHtmlFormat.isEmpty()) {
return RequestResult.Error(context.getString(R.string.no_data_available_export))
}

val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val fileName = "deepr_export_$timeStamp.html"

return withContext(Dispatchers.IO) {
// If URI is provided, export to that location
if (uri != null) {
return@withContext try {
context.contentResolver.openOutputStream(uri, "wt")?.use { outputStream ->
htmlWriter.writeToHtml(outputStream, dataToExportInHtmlFormat)
}
RequestResult.Success(
context.getString(
R.string.export_success,
uri.toString(),
),
)
} catch (_: Exception) {
RequestResult.Error(context.getString(R.string.export_failed))
}
}

// Default behavior: export to Downloads/Deepr folder
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentValues =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, "text/html")
put(
MediaStore.MediaColumns.RELATIVE_PATH,
"${Environment.DIRECTORY_DOWNLOADS}/Deepr",
)
}

val resolver = context.contentResolver
val defaultUri =
resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)

if (defaultUri != null) {
resolver.openOutputStream(defaultUri)?.use { outputStream ->
htmlWriter.writeToHtml(outputStream, dataToExportInHtmlFormat)
}
RequestResult.Success(
context.getString(
R.string.export_success,
"${Environment.DIRECTORY_DOWNLOADS}/Deepr/$fileName",
),
)
} else {
RequestResult.Error(context.getString(R.string.export_failed))
}
} else {
val downloadsDir =
Environment.getExternalStoragePublicDirectory("${Environment.DIRECTORY_DOWNLOADS}/Deepr")

if (!downloadsDir.exists()) {
downloadsDir.mkdirs()
}

val file = File(downloadsDir, fileName)

FileOutputStream(file).use { outputStream ->
htmlWriter.writeToHtml(outputStream, dataToExportInHtmlFormat)
}
RequestResult.Success(context.getString(R.string.export_success, file.absolutePath))
}
}
}
}
111 changes: 111 additions & 0 deletions app/src/main/java/com/yogeshpaliyal/deepr/backup/HtmlWriter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package com.yogeshpaliyal.deepr.backup

import com.yogeshpaliyal.deepr.ListDeeprWithTagsAsc
import java.io.OutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale

/**
* Writer for generating Netscape/Mozilla bookmark HTML format.
* This format is compatible with Firefox, Chrome, and other browsers.
*/
class HtmlWriter {
fun writeToHtml(
outputStream: OutputStream,
data: List<ListDeeprWithTagsAsc>,
) {
outputStream.bufferedWriter().use { writer ->
// Write HTML header
writer.write("<!DOCTYPE NETSCAPE-Bookmark-file-1>\n")
writer.write("<!-- This is an automatically generated file.\n")
writer.write(" It will be read and overwritten.\n")
writer.write(" DO NOT EDIT! -->\n")
writer.write("<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">\n")
writer.write("<TITLE>Bookmarks</TITLE>\n")
writer.write("<H1>Bookmarks</H1>\n")
// Note: <DL><p> is the standard Netscape bookmark format (not a typo)
// The <p> is part of the original format spec from Netscape Navigator
writer.write("<DL><p>\n")

// Group links by tags to create folders
val tagGroups = mutableMapOf<String, MutableList<ListDeeprWithTagsAsc>>()
val untaggedLinks = mutableListOf<ListDeeprWithTagsAsc>()

data.forEach { item ->
val tags = item.tagsNames?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
if (tags.isNullOrEmpty()) {
untaggedLinks.add(item)
} else {
// Add link to each tag group
tags.forEach { tag ->
tagGroups.getOrPut(tag) { mutableListOf() }.add(item)
}
}
}

// Write untagged links first
if (untaggedLinks.isNotEmpty()) {
untaggedLinks.forEach { item ->
writeBookmark(writer, item)
}
}

// Write tagged links in folders
tagGroups.entries.sortedBy { it.key }.forEach { (tag, links) ->
writer.write(" <DT><H3>${escapeHtml(tag)}</H3>\n")
writer.write(" <DL><p>\n")
links.forEach { item ->
writeBookmark(writer, item, indent = " ")
}
// Note: </DL><p> is the standard Netscape bookmark format
writer.write(" </DL><p>\n")
}

// Note: </DL><p> is the standard Netscape bookmark format
writer.write("</DL><p>\n")
}
}

private fun writeBookmark(
writer: java.io.BufferedWriter,
item: ListDeeprWithTagsAsc,
indent: String = " ",
) {
// Convert timestamp to seconds (Unix timestamp)
val addDate = try {
val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
val date = dateFormat.parse(item.createdAt)
date?.time?.div(1000) ?: 0
} catch (e: Exception) {
0
}

val tags = item.tagsNames?.replace(",", ", ")?.trim() ?: ""
val title = item.name?.ifBlank { item.link } ?: item.link

writer.write("$indent<DT><A HREF=\"${escapeHtml(item.link)}\"")
writer.write(" ADD_DATE=\"$addDate\"")
if (tags.isNotEmpty()) {
writer.write(" TAGS=\"${escapeHtml(tags)}\"")
}
if (item.isFavourite) {
writer.write(" ICON=\"★\"")
}
writer.write(">${escapeHtml(title)}</A>\n")

// Add notes as description if present
if (!item.notes.isNullOrBlank()) {
writer.write("$indent<DD>${escapeHtml(item.notes)}\n")
}
}

private fun escapeHtml(text: String): String {
return text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ fun BackupScreenContent(
}
}

// Launcher for picking HTML export location
val htmlExportLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/html"),
) { uri ->
uri?.let {
viewModel.exportHtmlData(it)
}
}

// Collect sync preference states
val syncEnabled by viewModel.syncEnabled.collectAsStateWithLifecycle()
val syncFilePath by viewModel.syncFilePath.collectAsStateWithLifecycle()
Expand Down Expand Up @@ -192,6 +202,20 @@ fun BackupScreenContent(
csvExportLauncher.launch("deepr_export_$timeStamp.csv")
},
)

SettingsItem(
TablerIcons.Upload,
title = stringResource(R.string.export_to_html),
description = stringResource(R.string.export_to_html_description),
onClick = {
val timeStamp =
SimpleDateFormat(
"yyyyMMdd_HHmmss",
Locale.US,
).format(Date())
htmlExportLauncher.launch("deepr_export_$timeStamp.html")
},
)
}

SettingsSection("Local File Sync") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,23 @@ class AccountViewModel(
}
}

fun exportHtmlData(uri: Uri? = null) {
viewModelScope.launch(Dispatchers.IO) {
when (val result = exportRepository.exportToHtml(uri)) {
is RequestResult.Success -> {
exportResultChannel.send("Export completed: ${result.data}")
analyticsManager.logEvent(
com.yogeshpaliyal.deepr.analytics.AnalyticsEvents.EXPORT_HTML,
)
}

is RequestResult.Error -> {
exportResultChannel.send("Export failed: ${result.message}")
}
}
}
}

fun importCsvData(uri: Uri) {
viewModelScope.launch(Dispatchers.IO) {
importResultChannel.send("Importing, please wait...")
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@
<string name="import_deeplinks_description">Import links from CSV file</string>
<string name="export_deeplinks">Export links</string>
<string name="export_deeplinks_description">Export links to CSV file</string>
<string name="export_to_html">Export to HTML</string>
<string name="export_to_html_description">Export links to HTML bookmarks file</string>
<string name="shortcut_icon">Shortcut Icon</string>
<string name="use_link_app_icon">Use link supporting app icon</string>
<string name="use_deepr_app_icon">Use Deepr app icon</string>
Expand Down
Loading
Loading