feat(android): add the ability to export posts as browser bookmarks

This commit is contained in:
Harsh Shandilya 2023-12-05 12:42:07 +05:30
parent 311f7c0574
commit 338ad07db7
No known key found for this signature in database
5 changed files with 119 additions and 11 deletions

View file

@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material.icons.filled.WebStories
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.SnackbarHostState
@ -30,25 +31,28 @@ import java.io.OutputStream
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
private const val MIME_TYPE = "application/json"
private const val JSON_MIME_TYPE = "application/json"
private const val HTML_MIME_TYPE = "application/html"
@Composable
fun DataTransferScreen(
context: Context,
importPosts: suspend (InputStream) -> Unit,
exportPosts: suspend (OutputStream) -> Unit,
exportPostsAsHtml: suspend (OutputStream) -> Unit,
snackbarHostState: SnackbarHostState,
modifier: Modifier = Modifier,
) {
val coroutineScope = rememberCoroutineScope()
Column(modifier = modifier) {
ImportOption(context, coroutineScope, importPosts, snackbarHostState)
ExportOption(context, coroutineScope, exportPosts, snackbarHostState)
JsonImportOption(context, coroutineScope, importPosts, snackbarHostState)
JsonExportOption(context, coroutineScope, exportPosts, snackbarHostState)
HtmlExportOption(context, coroutineScope, exportPostsAsHtml, snackbarHostState)
}
}
@Composable
private fun ImportOption(
private fun JsonImportOption(
context: Context,
coroutineScope: CoroutineScope,
importPosts: suspend (InputStream) -> Unit,
@ -72,19 +76,64 @@ private fun ImportOption(
description = "Import saved posts from a previously generated export",
icon = Icons.Filled.Download,
) {
importAction.launch(MIME_TYPE)
importAction.launch(JSON_MIME_TYPE)
}
}
@Composable
private fun ExportOption(
private fun JsonExportOption(
context: Context,
coroutineScope: CoroutineScope,
exportPosts: suspend (OutputStream) -> Unit,
snackbarHostState: SnackbarHostState,
) {
GenericExportOption(
title = "Export posts to file",
description = "Write all saved posts into a JSON file that can be imported at a later date",
icon = Icons.Filled.Upload,
fileName = "claw-export.json",
mimeType = JSON_MIME_TYPE,
context = context,
coroutineScope = coroutineScope,
exportPosts = exportPosts,
snackbarHostState = snackbarHostState,
)
}
@Composable
private fun HtmlExportOption(
context: Context,
coroutineScope: CoroutineScope,
exportPosts: suspend (OutputStream) -> Unit,
snackbarHostState: SnackbarHostState,
) {
GenericExportOption(
title = "Export posts as bookmarks",
description = "Write all saved posts into a HTML file that can be imported by web browsers",
icon = Icons.Filled.WebStories,
fileName = "claw-export.html",
mimeType = HTML_MIME_TYPE,
context = context,
coroutineScope = coroutineScope,
exportPosts = exportPosts,
snackbarHostState = snackbarHostState,
)
}
@Composable
private fun GenericExportOption(
title: String,
description: String,
icon: ImageVector,
fileName: String,
mimeType: String,
context: Context,
coroutineScope: CoroutineScope,
exportPosts: suspend (OutputStream) -> Unit,
snackbarHostState: SnackbarHostState,
) {
val exportAction =
rememberLauncherForActivityResult(CreateDocument(MIME_TYPE)) { uri ->
rememberLauncherForActivityResult(CreateDocument(mimeType)) { uri ->
if (uri == null) {
coroutineScope.launch { snackbarHostState.showSnackbarDismissing("No file selected") }
return@rememberLauncherForActivityResult
@ -97,11 +146,11 @@ private fun ExportOption(
}
}
SettingsActionItem(
title = "Export posts to file",
description = "Write all saved posts into a JSON file that can be imported at a later date",
icon = Icons.Filled.Upload,
title = title,
description = description,
icon = icon,
) {
exportAction.launch("claw-export.json")
exportAction.launch(fileName)
}
}

View file

@ -322,6 +322,7 @@ fun LobstersPostsScreen(
context = context,
importPosts = viewModel::importPosts,
exportPosts = viewModel::exportPosts,
exportPostsAsHtml = viewModel::exportPostsAsHtml,
snackbarHostState = snackbarHostState,
)
}

View file

@ -179,6 +179,9 @@ constructor(
suspend fun exportPosts(output: OutputStream) = dataTransferRepository.exportPosts(output)
suspend fun exportPostsAsHtml(output: OutputStream) =
dataTransferRepository.exportPostsAsHTML(output)
fun markPostAsRead(postId: String) {
viewModelScope.launch { readPostsRepository.markRead(postId) }
}

View file

@ -13,6 +13,8 @@ import dev.msfjarvis.claw.database.local.SavedPost
import dev.msfjarvis.claw.database.local.SavedPostQueries
import java.io.InputStream
import java.io.OutputStream
import java.time.Instant
import java.time.format.DateTimeFormatter
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
@ -50,4 +52,53 @@ constructor(
val posts = withContext(dbDispatcher) { savedPostQueries.selectAllPosts().executeAsList() }
withContext(ioDispatcher) { json.encodeToStream(serializer, posts, output) }
}
suspend fun exportPostsAsHTML(output: OutputStream) {
fun computeTimestamp(post: SavedPost): Long {
val temporal = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(post.createdAt)
val instant = Instant.from(temporal)
return instant.toEpochMilli()
}
val posts = withContext(dbDispatcher) { savedPostQueries.selectAllPosts().executeAsList() }
val header =
"""
<!DOCTYPE NETSCAPE-Bookmark-file-1>
<!-- This is an automatically generated file.
It will be read and overwritten.
DO NOT EDIT! -->
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
"""
.trimIndent()
val html = buildString {
append(header)
append("<DD><p>\n")
for (post in posts) {
append(
"""
<DT><A HREF="${post.url.ifEmpty { post.commentsUrl }}" ADD_DATE="${computeTimestamp(post)}" PRIVATE="0" TAGS="${post.tags.joinToString(",")}">${post.title}</A>
<DD>${post.title}
"""
.trimIndent()
)
}
append(
"""
<DT><A HREF="https://example.com/" ADD_DATE="0" PRIVATE="0" TAGS="delete,me,pls">Padding post</A>
<DD>Linkding ignores the last entry so this pads the difference for imports
"""
.trimIndent()
)
append("</DD></p>\n")
}
withContext(ioDispatcher) {
val writer = output.bufferedWriter()
writer.write(html)
writer.flush()
}
}
}