diff --git a/CHANGELOG.md b/CHANGELOG.md index d602c79d..412487e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +* Add HTML bookmarks as an export format + ## [1.38.0] - 2023-11-20 ### Changed diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/datatransfer/DataTransferScreen.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/datatransfer/DataTransferScreen.kt index 746f4b93..fac71d59 100644 --- a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/datatransfer/DataTransferScreen.kt +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/datatransfer/DataTransferScreen.kt @@ -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) } } diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LobstersPostsScreen.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LobstersPostsScreen.kt index 1c006a26..9db1e56b 100644 --- a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LobstersPostsScreen.kt +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LobstersPostsScreen.kt @@ -322,6 +322,7 @@ fun LobstersPostsScreen( context = context, importPosts = viewModel::importPosts, exportPosts = viewModel::exportPosts, + exportPostsAsHtml = viewModel::exportPostsAsHtml, snackbarHostState = snackbarHostState, ) } diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/viewmodel/ClawViewModel.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/viewmodel/ClawViewModel.kt index 4b6c6d30..e297f90c 100644 --- a/android/src/main/kotlin/dev/msfjarvis/claw/android/viewmodel/ClawViewModel.kt +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/viewmodel/ClawViewModel.kt @@ -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) } } diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/viewmodel/DataTransferRepository.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/viewmodel/DataTransferRepository.kt index 39c445e4..5348bb3d 100644 --- a/android/src/main/kotlin/dev/msfjarvis/claw/android/viewmodel/DataTransferRepository.kt +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/viewmodel/DataTransferRepository.kt @@ -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 = + """ + + + +
\n") + for (post in posts) { + append( + """ +