mirror of
https://github.com/msfjarvis/compose-lobsters
synced 2025-08-15 08:47:03 +05:30
feat(android): add the ability to export posts as browser bookmarks
This commit is contained in:
parent
311f7c0574
commit
338ad07db7
5 changed files with 119 additions and 11 deletions
|
@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
* Add HTML bookmarks as an export format
|
||||||
|
|
||||||
## [1.38.0] - 2023-11-20
|
## [1.38.0] - 2023-11-20
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Download
|
import androidx.compose.material.icons.filled.Download
|
||||||
import androidx.compose.material.icons.filled.Upload
|
import androidx.compose.material.icons.filled.Upload
|
||||||
|
import androidx.compose.material.icons.filled.WebStories
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
@ -30,25 +31,28 @@ import java.io.OutputStream
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
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
|
@Composable
|
||||||
fun DataTransferScreen(
|
fun DataTransferScreen(
|
||||||
context: Context,
|
context: Context,
|
||||||
importPosts: suspend (InputStream) -> Unit,
|
importPosts: suspend (InputStream) -> Unit,
|
||||||
exportPosts: suspend (OutputStream) -> Unit,
|
exportPosts: suspend (OutputStream) -> Unit,
|
||||||
|
exportPostsAsHtml: suspend (OutputStream) -> Unit,
|
||||||
snackbarHostState: SnackbarHostState,
|
snackbarHostState: SnackbarHostState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
ImportOption(context, coroutineScope, importPosts, snackbarHostState)
|
JsonImportOption(context, coroutineScope, importPosts, snackbarHostState)
|
||||||
ExportOption(context, coroutineScope, exportPosts, snackbarHostState)
|
JsonExportOption(context, coroutineScope, exportPosts, snackbarHostState)
|
||||||
|
HtmlExportOption(context, coroutineScope, exportPostsAsHtml, snackbarHostState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ImportOption(
|
private fun JsonImportOption(
|
||||||
context: Context,
|
context: Context,
|
||||||
coroutineScope: CoroutineScope,
|
coroutineScope: CoroutineScope,
|
||||||
importPosts: suspend (InputStream) -> Unit,
|
importPosts: suspend (InputStream) -> Unit,
|
||||||
|
@ -72,19 +76,64 @@ private fun ImportOption(
|
||||||
description = "Import saved posts from a previously generated export",
|
description = "Import saved posts from a previously generated export",
|
||||||
icon = Icons.Filled.Download,
|
icon = Icons.Filled.Download,
|
||||||
) {
|
) {
|
||||||
importAction.launch(MIME_TYPE)
|
importAction.launch(JSON_MIME_TYPE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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,
|
context: Context,
|
||||||
coroutineScope: CoroutineScope,
|
coroutineScope: CoroutineScope,
|
||||||
exportPosts: suspend (OutputStream) -> Unit,
|
exportPosts: suspend (OutputStream) -> Unit,
|
||||||
snackbarHostState: SnackbarHostState,
|
snackbarHostState: SnackbarHostState,
|
||||||
) {
|
) {
|
||||||
val exportAction =
|
val exportAction =
|
||||||
rememberLauncherForActivityResult(CreateDocument(MIME_TYPE)) { uri ->
|
rememberLauncherForActivityResult(CreateDocument(mimeType)) { uri ->
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
coroutineScope.launch { snackbarHostState.showSnackbarDismissing("No file selected") }
|
coroutineScope.launch { snackbarHostState.showSnackbarDismissing("No file selected") }
|
||||||
return@rememberLauncherForActivityResult
|
return@rememberLauncherForActivityResult
|
||||||
|
@ -97,11 +146,11 @@ private fun ExportOption(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
title = "Export posts to file",
|
title = title,
|
||||||
description = "Write all saved posts into a JSON file that can be imported at a later date",
|
description = description,
|
||||||
icon = Icons.Filled.Upload,
|
icon = icon,
|
||||||
) {
|
) {
|
||||||
exportAction.launch("claw-export.json")
|
exportAction.launch(fileName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -322,6 +322,7 @@ fun LobstersPostsScreen(
|
||||||
context = context,
|
context = context,
|
||||||
importPosts = viewModel::importPosts,
|
importPosts = viewModel::importPosts,
|
||||||
exportPosts = viewModel::exportPosts,
|
exportPosts = viewModel::exportPosts,
|
||||||
|
exportPostsAsHtml = viewModel::exportPostsAsHtml,
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -179,6 +179,9 @@ constructor(
|
||||||
|
|
||||||
suspend fun exportPosts(output: OutputStream) = dataTransferRepository.exportPosts(output)
|
suspend fun exportPosts(output: OutputStream) = dataTransferRepository.exportPosts(output)
|
||||||
|
|
||||||
|
suspend fun exportPostsAsHtml(output: OutputStream) =
|
||||||
|
dataTransferRepository.exportPostsAsHTML(output)
|
||||||
|
|
||||||
fun markPostAsRead(postId: String) {
|
fun markPostAsRead(postId: String) {
|
||||||
viewModelScope.launch { readPostsRepository.markRead(postId) }
|
viewModelScope.launch { readPostsRepository.markRead(postId) }
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,8 @@ import dev.msfjarvis.claw.database.local.SavedPost
|
||||||
import dev.msfjarvis.claw.database.local.SavedPostQueries
|
import dev.msfjarvis.claw.database.local.SavedPostQueries
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
@ -50,4 +52,53 @@ constructor(
|
||||||
val posts = withContext(dbDispatcher) { savedPostQueries.selectAllPosts().executeAsList() }
|
val posts = withContext(dbDispatcher) { savedPostQueries.selectAllPosts().executeAsList() }
|
||||||
withContext(ioDispatcher) { json.encodeToStream(serializer, posts, output) }
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue