From ea235e726018a4161887d539e0c8dff45646bc53 Mon Sep 17 00:00:00 2001 From: SiteRelEnby <125829806+SiteRelEnby@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:20:00 -0400 Subject: [PATCH] feat(import): add Ampersand importer The backend already exposes the Ampersand import source (preview at /v1/import/ampersand/preview, submit via the unified /v1/imports/file runner with source "ampersand_file"), so this is the Android client for it, cloned from the PluralSpace importer. Ampersand's preview reports category counts only (no per-member list), so there is no selective-member UI: every real member is imported (member_ids stays null). The options mirror the server's AmpersandImportOptions (conflict_strategy=skip plus the ten content toggles), each defaulted on only when the export actually contains that kind of content. Groups imports Ampersand systems as Sheaf groups; the board-messages toggle also gates polls. Reuses the shared import machinery: streaming file upload, the latched idempotency key so a poll-failure retry resumes the same job, terminalResult() for the generic counts render, and the standard poll loop. Wired into Settings > Data (card + route) alongside the other importers. --- CHANGELOG.md | 8 + .../lupine/sheaf/data/api/SheafApiService.kt | 6 + .../systems/lupine/sheaf/data/model/Models.kt | 24 ++ .../java/systems/lupine/sheaf/ui/SheafApp.kt | 7 + .../ampersandimport/AmpersandImportScreen.kt | 278 ++++++++++++++++++ .../AmpersandImportViewModel.kt | 214 ++++++++++++++ .../ui/settings/SettingsCategoryScreens.kt | 8 + 7 files changed, 545 insertions(+) create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/ampersandimport/AmpersandImportScreen.kt create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/ampersandimport/AmpersandImportViewModel.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ee479ad..0e54105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ uses semantic versioning (`MAJOR.MINOR.PATCH`). ## [Unreleased] +### Added + +- **Import from Ampersand.** Settings > Data now has an "Import from Ampersand" + option. Choose your Ampersand `.json` export and pick what to bring across + (members, custom fronts, tags, custom fields, front history, journals, notes, + board messages and polls, reminders, images, and Ampersand systems as groups); + the import runs in the background and shows a summary when it finishes. + ### Security - **Your login credentials only ever go to your own server now.** The app used to diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt index 3285802..c3d24e2 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt @@ -525,6 +525,12 @@ interface SheafApiService { @Part file: MultipartBody.Part, ): PluralSpacePreviewSummary + @Multipart + @POST("/v1/import/ampersand/preview") + suspend fun previewAmpersandImport( + @Part file: MultipartBody.Part, + ): AmpersandPreviewSummary + /** * Preview an OpenPlural v0.1 import. Accepts a bare `.json` export or an * `.openplural.zip` bundle (the endpoint sniffs the zip magic). Reuses the diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt index 2243aad..0b1e738 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt @@ -1002,8 +1002,32 @@ object ImportJobSource { // and the .openplural.zip bundle; the runner sniffs the zip magic and // unpacks images when present (no separate archive source like Sheaf). const val OPENPLURAL_FILE = "openplural_file" + const val AMPERSAND_FILE = "ampersand_file" } +// ── Ampersand import ────────────────────────────────────────────────────────── +// +// Ampersand exports a plain .json file. The preview reports category counts +// only (no per-member list), so selective member import isn't offered on +// Android; every real member is imported. `groups` imports Ampersand systems as +// Sheaf groups; `board_messages` also gates polls. +@JsonClass(generateAdapter = true) +data class AmpersandPreviewSummary( + @Json(name = "system_count") val systemCount: Int = 0, + @Json(name = "member_count") val memberCount: Int = 0, + @Json(name = "custom_front_count") val customFrontCount: Int = 0, + @Json(name = "front_history_count") val frontHistoryCount: Int = 0, + @Json(name = "tag_count") val tagCount: Int = 0, + @Json(name = "custom_field_count") val customFieldCount: Int = 0, + @Json(name = "journal_count") val journalCount: Int = 0, + @Json(name = "note_count") val noteCount: Int = 0, + @Json(name = "board_message_count") val boardMessageCount: Int = 0, + @Json(name = "poll_count") val pollCount: Int = 0, + @Json(name = "reminder_count") val reminderCount: Int = 0, + @Json(name = "asset_count") val assetCount: Int = 0, + @Json(name = "limit_warnings") val limitWarnings: List = emptyList(), +) + // ── Export ────────────────────────────────────────────────────────────────── /** Async full-backup (with images) request. Step-up: password always, plus diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt index d36ca57..2396216 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt @@ -84,6 +84,7 @@ object Routes { const val PS_IMPORT = "settings/import/pluralspace" const val PRISM_IMPORT = "settings/import/prism" const val OPENPLURAL_IMPORT = "settings/import/openplural" + const val AMPERSAND_IMPORT = "settings/import/ampersand" const val IMPORT_HISTORY = "settings/import/history" const val IMPORT_DETAIL = "settings/import/history/{jobId}" const val CUSTOM_FIELDS = "settings/fields" @@ -529,6 +530,7 @@ fun SheafApp( onNavigateToPluralSpaceImport = { navController.navigate(Routes.PS_IMPORT) }, onNavigateToPrismImport = { navController.navigate(Routes.PRISM_IMPORT) }, onNavigateToOpenPluralImport = { navController.navigate(Routes.OPENPLURAL_IMPORT) }, + onNavigateToAmpersandImport = { navController.navigate(Routes.AMPERSAND_IMPORT) }, onNavigateToImportHistory = { navController.navigate(Routes.IMPORT_HISTORY) }, ) } @@ -588,6 +590,11 @@ fun SheafApp( onNavigateUp = { navController.navigateUp() }, ) } + composable(Routes.AMPERSAND_IMPORT) { + systems.lupine.sheaf.ui.ampersandimport.AmpersandImportScreen( + onNavigateUp = { navController.navigateUp() }, + ) + } composable(Routes.EXPORT_DATA) { systems.lupine.sheaf.ui.export.ExportDataScreen( onNavigateUp = { navController.navigateUp() }, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/ampersandimport/AmpersandImportScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/ampersandimport/AmpersandImportScreen.kt new file mode 100644 index 0000000..1a40bc2 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/ampersandimport/AmpersandImportScreen.kt @@ -0,0 +1,278 @@ +package systems.lupine.sheaf.ui.ampersandimport + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.FileOpen +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import systems.lupine.sheaf.data.model.AmpersandPreviewSummary +import systems.lupine.sheaf.ui.components.ErrorBanner +import systems.lupine.sheaf.ui.components.SectionHeader +import systems.lupine.sheaf.ui.components.SheafTopAppBar +import systems.lupine.sheaf.ui.importcommon.ImportResult + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AmpersandImportScreen( + onNavigateUp: () -> Unit, + viewModel: AmpersandImportViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + + val filePicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> uri?.let { viewModel.pickFile(it) } } + + Scaffold( + contentWindowInsets = WindowInsets(0), + topBar = { + SheafTopAppBar( + title = { Text("Import from Ampersand") }, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (state.error != null) ErrorBanner(state.error!!) + + when { + state.result != null -> ResultSection( + result = state.result!!, + onImportAnother = { viewModel.reset() }, + ) + state.isImporting -> CenterSpinner("Importing…") + state.preview != null -> PreviewSection( + fileName = state.fileName!!, + preview = state.preview!!, + options = state.options, + onUpdateOptions = { viewModel.updateOptions(it) }, + onImport = { viewModel.runImport() }, + onChangeFile = { filePicker.launch(arrayOf("*/*")) }, + ) + state.isPreviewing -> CenterSpinner("Reading file…") + else -> FilePickSection(onPick = { filePicker.launch(arrayOf("*/*")) }) + } + } + } +} + +@Composable +private fun CenterSpinner(label: String) { + Box(Modifier.fillMaxWidth().padding(vertical = 48.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { + CircularProgressIndicator() + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@Composable +private fun FilePickSection(onPick: () -> Unit) { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + Icons.Outlined.FileOpen, + contentDescription = null, + modifier = Modifier.size(52.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + Text("Choose your Ampersand export to get started.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Button(onClick = onPick) { Text("Choose file") } + Text( + "In Ampersand, open Settings → Import & export → Export, and save the " + + ".json file. Then select it here.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } +} + +@Composable +private fun PreviewSection( + fileName: String, + preview: AmpersandPreviewSummary, + options: AmpersandImportOptions, + onUpdateOptions: (AmpersandImportOptions.() -> AmpersandImportOptions) -> Unit, + onImport: () -> Unit, + onChangeFile: () -> Unit, +) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(fileName, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), maxLines = 1) + TextButton(onClick = onChangeFile) { Text("Change") } + } + + HorizontalDivider() + SectionHeader("What to import") + + // Members always import; shown for context, not as a toggle. + if (preview.memberCount > 0) { + Text( + "Members (${preview.memberCount})", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + } + + if (preview.systemCount > 0) { + ImportToggleRow( + label = "Systems as groups (${preview.systemCount})", + checked = options.groups, + onCheckedChange = { onUpdateOptions { copy(groups = it) } }, + ) + } + if (preview.customFrontCount > 0) { + ImportToggleRow( + label = "Custom fronts (${preview.customFrontCount})", + checked = options.customFronts, + onCheckedChange = { onUpdateOptions { copy(customFronts = it) } }, + ) + } + if (preview.tagCount > 0) { + ImportToggleRow( + label = "Tags (${preview.tagCount})", + checked = options.tags, + onCheckedChange = { onUpdateOptions { copy(tags = it) } }, + ) + } + if (preview.customFieldCount > 0) { + ImportToggleRow( + label = "Custom fields (${preview.customFieldCount})", + checked = options.customFields, + onCheckedChange = { onUpdateOptions { copy(customFields = it) } }, + ) + } + if (preview.frontHistoryCount > 0) { + ImportToggleRow( + label = "Front history (${preview.frontHistoryCount} entries)", + checked = options.frontHistory, + onCheckedChange = { onUpdateOptions { copy(frontHistory = it) } }, + ) + } + if (preview.journalCount > 0) { + ImportToggleRow( + label = "Journal entries (${preview.journalCount})", + checked = options.journals, + onCheckedChange = { onUpdateOptions { copy(journals = it) } }, + ) + } + if (preview.noteCount > 0) { + ImportToggleRow( + label = "Notes (${preview.noteCount})", + checked = options.notes, + onCheckedChange = { onUpdateOptions { copy(notes = it) } }, + ) + } + if (preview.boardMessageCount > 0 || preview.pollCount > 0) { + val label = buildString { + append("Board messages") + if (preview.boardMessageCount > 0) append(" (${preview.boardMessageCount})") + if (preview.pollCount > 0) append(" & polls (${preview.pollCount})") + } + ImportToggleRow( + label = label, + checked = options.boardMessages, + onCheckedChange = { onUpdateOptions { copy(boardMessages = it) } }, + ) + } + if (preview.reminderCount > 0) { + ImportToggleRow( + label = "Reminders (${preview.reminderCount})", + checked = options.reminders, + onCheckedChange = { onUpdateOptions { copy(reminders = it) } }, + ) + } + if (preview.assetCount > 0) { + ImportToggleRow( + label = "Images (${preview.assetCount})", + checked = options.images, + onCheckedChange = { onUpdateOptions { copy(images = it) } }, + ) + } + + preview.limitWarnings.forEach { warning -> + SkippedNote(warning) + } + + Spacer(Modifier.height(4.dp)) + + Button(onClick = onImport, modifier = Modifier.fillMaxWidth().height(52.dp)) { + Text("Import") + } +} + +@Composable +private fun ImportToggleRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Checkbox(checked = checked, onCheckedChange = onCheckedChange) + Text(label, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +private fun SkippedNote(text: String) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Spacer(Modifier.width(12.dp)) + Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun ResultSection(result: ImportResult, onImportAnother: () -> Unit) { + SectionHeader("Import complete") + + val rows = result.rows() + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (rows.isEmpty()) { + Text("Nothing new to import.", style = MaterialTheme.typography.bodyMedium) + } else { + rows.forEach { (label, count) -> ResultRow(label, count) } + } + } + } + + if (result.warnings.isNotEmpty()) { + SectionHeader("Warnings") + result.warnings.forEach { warning -> + Text("• $warning", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + Spacer(Modifier.height(4.dp)) + + OutlinedButton(onClick = onImportAnother, modifier = Modifier.fillMaxWidth()) { + Text("Import another file") + } +} + +@Composable +private fun ResultRow(label: String, count: Int) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Text(count.toString(), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + } +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/ampersandimport/AmpersandImportViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/ampersandimport/AmpersandImportViewModel.kt new file mode 100644 index 0000000..8279848 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/ampersandimport/AmpersandImportViewModel.kt @@ -0,0 +1,214 @@ +package systems.lupine.sheaf.ui.ampersandimport + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import systems.lupine.sheaf.data.api.SheafApiService +import systems.lupine.sheaf.data.api.streamingFilePart +import systems.lupine.sheaf.data.model.AmpersandPreviewSummary +import systems.lupine.sheaf.data.model.ImportJobRead +import systems.lupine.sheaf.data.model.ImportJobSource +import systems.lupine.sheaf.data.model.ImportJobStatus +import systems.lupine.sheaf.ui.importcommon.ImportResult +import systems.lupine.sheaf.ui.importcommon.terminalResult +import systems.lupine.sheaf.util.toUserMessage +import java.util.UUID +import javax.inject.Inject + +// All boolean toggles default on, mirroring the server's AmpersandImportOptions +// defaults. There is no member selection: the preview reports counts only, not a +// member list, so every real member is imported (member_ids stays null). +data class AmpersandImportOptions( + val customFronts: Boolean = true, + val customFields: Boolean = true, + val tags: Boolean = true, + val groups: Boolean = true, + val frontHistory: Boolean = true, + val journals: Boolean = true, + val notes: Boolean = true, + val boardMessages: Boolean = true, + val reminders: Boolean = true, + val images: Boolean = true, +) + +data class AmpersandImportUiState( + val fileName: String? = null, + val isPreviewing: Boolean = false, + val preview: AmpersandPreviewSummary? = null, + val options: AmpersandImportOptions = AmpersandImportOptions(), + val isImporting: Boolean = false, + val result: ImportResult? = null, + val error: String? = null, +) + +@HiltViewModel +class AmpersandImportViewModel @Inject constructor( + private val api: SheafApiService, + @ApplicationContext private val context: Context, +) : ViewModel() { + + private val _state = MutableStateFlow(AmpersandImportUiState()) + val state: StateFlow = _state.asStateFlow() + + private var fileUri: Uri? = null + private var cachedFileName: String? = null + + fun pickFile(uri: Uri) { + idempotencyKey = null + viewModelScope.launch { + _state.update { it.copy(isPreviewing = true, error = null, preview = null, result = null) } + fileUri = uri + val name = resolveFileName(uri) ?: "ampersand.json" + cachedFileName = name + _state.update { it.copy(fileName = name) } + preview(uri, name) + } + } + + private suspend fun preview(uri: Uri, name: String) { + runCatching { api.previewAmpersandImport(filePart(uri, name)) } + .onSuccess { summary -> + _state.update { + it.copy( + isPreviewing = false, + preview = summary, + // Default a toggle on only when the export actually has + // that kind of content. + options = AmpersandImportOptions( + customFronts = summary.customFrontCount > 0, + customFields = summary.customFieldCount > 0, + tags = summary.tagCount > 0, + groups = summary.systemCount > 0, + frontHistory = summary.frontHistoryCount > 0, + journals = summary.journalCount > 0, + notes = summary.noteCount > 0, + boardMessages = summary.boardMessageCount > 0 || summary.pollCount > 0, + reminders = summary.reminderCount > 0, + images = summary.assetCount > 0, + ), + ) + } + } + .onFailure { e -> + _state.update { + it.copy(isPreviewing = false, error = e.toUserMessage("Preview failed - check the file and try again")) + } + } + } + + fun updateOptions(update: AmpersandImportOptions.() -> AmpersandImportOptions) { + _state.update { it.copy(options = it.options.update()) } + } + + // Stable across retries of the same import attempt. If the job was created + // but polling then failed, the retry must not spawn a second import: reusing + // the key lets the server return the existing job instead of creating another. + // Reset when a new file is picked or the job reaches a terminal state. + private var idempotencyKey: String? = null + + private fun nextIdempotencyKey(): String = + idempotencyKey ?: UUID.randomUUID().toString().also { idempotencyKey = it } + + fun runImport() { + val uri = fileUri ?: return + val name = cachedFileName ?: "ampersand.json" + val opts = _state.value.options + + viewModelScope.launch { + _state.update { it.copy(isImporting = true, error = null) } + runCatching { + val job = api.createFileImport( + file = filePart(uri, name), + source = ImportJobSource.AMPERSAND_FILE.toFormPart(), + idempotencyKey = nextIdempotencyKey().toFormPart(), + options = buildOptionsJson(opts).toJsonPart(), + ) + pollUntilTerminal(job) + } + .onSuccess { final -> handleTerminal(final) } + .onFailure { e -> _state.update { it.copy(isImporting = false, error = e.toUserMessage("Import failed - please try again")) } } + } + } + + private suspend fun pollUntilTerminal(initial: ImportJobRead): ImportJobRead { + var current = initial + while (current.status !in ImportJobStatus.terminal) { + delay(POLL_INTERVAL_MS) + current = api.getImportJob(current.id) + } + return current + } + + private fun handleTerminal(job: ImportJobRead) { + idempotencyKey = null + when (val outcome = job.terminalResult()) { + is ImportResult -> _state.update { it.copy(isImporting = false, result = outcome) } + else -> _state.update { + it.copy( + isImporting = false, + error = job.lastError ?: "Import didn't complete (status: ${job.status})", + ) + } + } + } + + fun reset() { + fileUri = null + cachedFileName = null + _state.value = AmpersandImportUiState() + } + + private fun filePart(uri: Uri, name: String) = + streamingFilePart(context.contentResolver, uri, name) + + private fun String.toFormPart(): RequestBody = + toRequestBody("text/plain".toMediaType()) + + private fun String.toJsonPart(): RequestBody = + toRequestBody("application/json".toMediaType()) + + private fun resolveFileName(uri: Uri): String? = runCatching { + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val col = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + cursor.moveToFirst() + cursor.getString(col) + } + }.getOrNull() + + companion object { + private const val POLL_INTERVAL_MS: Long = 1500 + } +} + +/** + * Hand-build the options JSON. The backend uses `extra="forbid"`, so the field + * names here must match AmpersandImportOptions exactly. `conflict_strategy` + * defaults to "skip" (mirrors the web client) and `member_ids` is always null + * (import all real members). + */ +private fun buildOptionsJson(opts: AmpersandImportOptions): String { + val parts = mutableListOf() + parts += "\"conflict_strategy\":\"skip\"" + parts += "\"member_ids\":null" + parts += "\"custom_fronts\":${opts.customFronts}" + parts += "\"custom_fields\":${opts.customFields}" + parts += "\"tags\":${opts.tags}" + parts += "\"groups\":${opts.groups}" + parts += "\"front_history\":${opts.frontHistory}" + parts += "\"journals\":${opts.journals}" + parts += "\"notes\":${opts.notes}" + parts += "\"board_messages\":${opts.boardMessages}" + parts += "\"reminders\":${opts.reminders}" + parts += "\"images\":${opts.images}" + return parts.joinToString(",", prefix = "{", postfix = "}") +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsCategoryScreens.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsCategoryScreens.kt index c2f688f..4612984 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsCategoryScreens.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SettingsCategoryScreens.kt @@ -815,6 +815,7 @@ fun DataSettingsScreen( onNavigateToPluralSpaceImport: () -> Unit, onNavigateToPrismImport: () -> Unit, onNavigateToOpenPluralImport: () -> Unit, + onNavigateToAmpersandImport: () -> Unit, onNavigateToImportHistory: () -> Unit, viewModel: SettingsViewModel = hiltViewModel(), ) { @@ -921,6 +922,13 @@ fun DataSettingsScreen( onClick = onNavigateToOpenPluralImport, ) HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) + SettingItem( + icon = Icons.Outlined.Upload, + title = "Import from Ampersand", + subtitle = "Use an Ampersand .json data export", + onClick = onNavigateToAmpersandImport, + ) + HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) SettingItem( icon = Icons.Outlined.History, title = "Import history",