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 de4654d..e92a9e9 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 @@ -69,8 +69,10 @@ import systems.lupine.sheaf.ui.notifications.ReceivingScreen import systems.lupine.sheaf.ui.notifications.RedeemNotificationScreen import systems.lupine.sheaf.ui.notifications.YourDevicesScreen import systems.lupine.sheaf.ui.people.PeopleScreen -import systems.lupine.sheaf.ui.importsp.ImportScreen -import systems.lupine.sheaf.ui.sheafimport.SheafImportScreen +import systems.lupine.sheaf.ui.importflow.ImportScreen +import systems.lupine.sheaf.ui.importflow.SIMPLY_PLURAL_SOURCE +import systems.lupine.sheaf.ui.importflow.importSources +import systems.lupine.sheaf.ui.importflow.ImportSourcePickerScreen import systems.lupine.sheaf.ui.fields.CustomFieldsScreen import systems.lupine.sheaf.ui.apikeys.ApiKeysScreen import systems.lupine.sheaf.ui.sessions.SessionsScreen @@ -98,15 +100,8 @@ object Routes { const val ANALYTICS = "analytics" const val SETTINGS = "settings" const val SYSTEM_EDIT = "settings/system" - const val SP_IMPORT = "settings/import/simplyplural" - const val SHEAF_IMPORT = "settings/import/sheaf" - const val PK_IMPORT = "settings/import/pluralkit" - const val PK_API_IMPORT = "settings/import/pluralkit-api" - const val TB_IMPORT = "settings/import/tupperbox" - 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_PICKER = "settings/import" + const val IMPORT_RUN = "settings/import/run/{source}" const val IMPORT_HISTORY = "settings/import/history" const val IMPORT_DETAIL = "settings/import/history/{jobId}" const val CUSTOM_FIELDS = "settings/fields" @@ -156,6 +151,9 @@ object Routes { const val MESSAGES_BOARD = "messages/board/{kind}/{memberId}" } +/** Concrete [Routes.IMPORT_RUN] path for one [importSources] entry. */ +fun importRoute(sourceId: String): String = "settings/import/run/$sourceId" + // ── Tab definitions ─────────────────────────────────────────────────────────── data class TopLevelDest( @@ -374,7 +372,7 @@ fun SheafApp( composable(Routes.ONBOARDING) { OnboardingScreen( onNavigateToSystemSafety = { navController.navigate(Routes.SYSTEM_SAFETY) }, - onNavigateToSpImport = { navController.navigate(Routes.SP_IMPORT) }, + onNavigateToSpImport = { navController.navigate(importRoute(SIMPLY_PLURAL_SOURCE)) }, onContinue = { authViewModel.completeOnboarding() navController.navigate(Routes.HOME) { @@ -664,15 +662,7 @@ fun SheafApp( onNavigateUp = { navController.navigateUp() }, onNavigateToFiles = { navController.navigate(Routes.FILES) }, onNavigateToExportData = { navController.navigate(Routes.EXPORT_DATA) }, - onNavigateToSpImport = { navController.navigate(Routes.SP_IMPORT) }, - onNavigateToSheafImport = { navController.navigate(Routes.SHEAF_IMPORT) }, - onNavigateToPkFileImport = { navController.navigate(Routes.PK_IMPORT) }, - onNavigateToPkApiImport = { navController.navigate(Routes.PK_API_IMPORT) }, - onNavigateToTupperboxImport = { navController.navigate(Routes.TB_IMPORT) }, - onNavigateToPluralSpaceImport = { navController.navigate(Routes.PS_IMPORT) }, - onNavigateToPrismImport = { navController.navigate(Routes.PRISM_IMPORT) }, - onNavigateToOpenPluralImport = { navController.navigate(Routes.OPENPLURAL_IMPORT) }, - onNavigateToAmpersandImport = { navController.navigate(Routes.AMPERSAND_IMPORT) }, + onNavigateToImport = { navController.navigate(Routes.IMPORT_PICKER) }, onNavigateToImportHistory = { navController.navigate(Routes.IMPORT_HISTORY) }, ) } @@ -689,46 +679,14 @@ fun SheafApp( composable(Routes.SYSTEM_EDIT) { SystemEditScreen(onNavigateUp = { navController.navigateUp() }) } - composable(Routes.SP_IMPORT) { - ImportScreen(onNavigateUp = { navController.navigateUp() }) - } - composable(Routes.SHEAF_IMPORT) { - SheafImportScreen(onNavigateUp = { navController.navigateUp() }) - } - composable(Routes.PK_IMPORT) { - systems.lupine.sheaf.ui.pkimport.PluralKitFileImportScreen( - onNavigateUp = { navController.navigateUp() }, - ) - } - composable(Routes.PK_API_IMPORT) { - systems.lupine.sheaf.ui.pkapiimport.PluralKitApiImportScreen( - onNavigateUp = { navController.navigateUp() }, - ) - } - composable(Routes.TB_IMPORT) { - systems.lupine.sheaf.ui.tbimport.TupperboxImportScreen( - onNavigateUp = { navController.navigateUp() }, - ) - } - composable(Routes.PS_IMPORT) { - systems.lupine.sheaf.ui.pluralspaceimport.PluralSpaceImportScreen( - onNavigateUp = { navController.navigateUp() }, - ) - } - composable(Routes.PRISM_IMPORT) { - systems.lupine.sheaf.ui.prismimport.PrismImportScreen( + composable(Routes.IMPORT_PICKER) { + ImportSourcePickerScreen( onNavigateUp = { navController.navigateUp() }, + onPickSource = { navController.navigate(importRoute(it)) }, ) } - composable(Routes.OPENPLURAL_IMPORT) { - systems.lupine.sheaf.ui.openpluralimport.OpenPluralImportScreen( - onNavigateUp = { navController.navigateUp() }, - ) - } - composable(Routes.AMPERSAND_IMPORT) { - systems.lupine.sheaf.ui.ampersandimport.AmpersandImportScreen( - onNavigateUp = { navController.navigateUp() }, - ) + composable(Routes.IMPORT_RUN) { + ImportScreen(onNavigateUp = { navController.navigateUp() }) } composable(Routes.EXPORT_DATA) { systems.lupine.sheaf.ui.export.ExportDataScreen( 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 deleted file mode 100644 index b45aa89..0000000 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/ampersandimport/AmpersandImportScreen.kt +++ /dev/null @@ -1,278 +0,0 @@ -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 your data to a JSON file (note: this is a different option to 'Export your data', which " + - "produces an incompatible file format), 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 deleted file mode 100644 index 8279848..0000000 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/ampersandimport/AmpersandImportViewModel.kt +++ /dev/null @@ -1,214 +0,0 @@ -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/importflow/ImportPreview.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importflow/ImportPreview.kt new file mode 100644 index 0000000..d4da183 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importflow/ImportPreview.kt @@ -0,0 +1,63 @@ +package systems.lupine.sheaf.ui.importflow + +/** A member the preview found, offered for per-member selection. */ +data class PreviewMember(val id: String, val name: String) + +/** + * One options field the user can turn on or off. + * + * [key] is the backend field name. Every category is written into the submit + * body whether or not it was shown: the backend forbids unknown fields but + * still expects the known ones, and a hidden category's [default] is the value + * it should carry. [visible] and [default] are independent, because PluralKit + * shows front history unchecked while Simply Plural hides what its export + * doesn't carry. + */ +data class ImportCategory( + val key: String, + val label: String, + val visible: Boolean, + val default: Boolean, + /** Shown under the row while it is checked. */ + val note: String? = null, +) + +/** "Groups" + 4 -> a visible "Groups (4)" row, checked by default. */ +internal fun counted( + key: String, + label: String, + count: Int, + default: Boolean = count > 0, + note: String? = null, +) = ImportCategory( + key = key, + label = "$label ($count)", + visible = count > 0, + default = default, + note = note, +) + +/** + * Source-agnostic preview. Each [ImportSource] maps its own preview response + * into this shape; the screen and the options body are written against it, so + * a new import source is a descriptor rather than another screen. + */ +data class ImportPreview( + val systemName: String? = null, + /** A line above the options, e.g. OpenPlural's export lineage. */ + val headline: String? = null, + /** Empty when the source doesn't offer picking individual members. */ + val members: List = emptyList(), + /** What the export holds, which a truncated [members] list may undercount. */ + val memberCount: Int = members.size, + /** Rows shown checked and disabled: these always import. */ + val locked: List = emptyList(), + val categories: List = emptyList(), + /** Records with no Sheaf equivalent, e.g. "Notes (3), will be skipped". */ + val skipped: List = emptyList(), + /** Sheaf only: a complete-backup zip submits under a different source. */ + val archive: Boolean = false, +) { + /** Initial checkbox state, including the categories that stay hidden. */ + fun defaults(): Map = categories.associate { it.key to it.default } +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importflow/ImportScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importflow/ImportScreen.kt new file mode 100644 index 0000000..6dc1f94 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importflow/ImportScreen.kt @@ -0,0 +1,412 @@ +package systems.lupine.sheaf.ui.importflow + +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.text.KeyboardOptions +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.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +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.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +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 ImportScreen( + onNavigateUp: () -> Unit, + viewModel: ImportViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + val source = viewModel.source + + val filePicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> uri?.let { viewModel.pickFile(it) } } + val pickFile = { filePicker.launch(arrayOf("*/*")) } + + Scaffold( + contentWindowInsets = WindowInsets(0), + topBar = { + SheafTopAppBar( + title = { Text(source.title) }, + 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!!, + restartLabel = source.restartLabel, + onImportAnother = { viewModel.reset() }, + ) + + state.isImporting -> Waiting("Importing…") + + state.preview != null -> PreviewSection( + fileName = state.fileName, + preview = state.preview!!, + selected = state.selected, + selectedMemberIds = state.selectedMemberIds, + onSetCategory = viewModel::setCategory, + onSetAllMembers = viewModel::setAllMembers, + onToggleMember = viewModel::toggleMember, + onImport = { viewModel.runImport() }, + onChangeFile = { pickFile() }, + ) + + state.isPreviewing -> Waiting( + if (source.input is ImportInput.Token) "Fetching…" else "Reading file…" + ) + + else -> InputSection( + input = source.input, + fileName = state.fileName, + credential = state.credential, + onPickFile = { pickFile() }, + onCredentialChange = viewModel::updateCredential, + onPreview = { viewModel.runPreview() }, + ) + } + } + } +} + +@Composable +private fun Waiting(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) + } + } +} + +// ── Input ───────────────────────────────────────────────────────────────────── + +@Composable +private fun InputSection( + input: ImportInput, + fileName: String?, + credential: String, + onPickFile: () -> Unit, + onCredentialChange: (String) -> Unit, + onPreview: () -> Unit, +) { + when (input) { + is ImportInput.File -> 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( + input.prompt, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button(onClick = onPickFile) { Text("Choose file") } + Text( + input.help, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + + is ImportInput.EncryptedFile -> Column( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + input.help, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Outlined.FileOpen, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + Text( + fileName ?: "No file selected", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + maxLines = 1, + ) + TextButton(onClick = onPickFile) { Text(if (fileName == null) "Choose file" else "Change") } + } + SecretField(input.fieldLabel, credential, onCredentialChange) + Button( + onClick = onPreview, + enabled = fileName != null && credential.isNotBlank(), + modifier = Modifier.fillMaxWidth().height(52.dp), + ) { Text(input.action) } + } + + is ImportInput.Token -> Column( + modifier = Modifier.padding(top = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + input.help, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + SecretField(input.fieldLabel, credential, onCredentialChange) + Text( + input.footnote, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + ) + Button( + onClick = onPreview, + enabled = credential.isNotBlank(), + modifier = Modifier.fillMaxWidth().height(52.dp), + ) { Text(input.action) } + } + } +} + +@Composable +private fun SecretField(label: String, value: String, onValueChange: (String) -> Unit) { + var reveal by remember { mutableStateOf(false) } + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + visualTransformation = if (reveal) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { reveal = !reveal }) { + Icon( + if (reveal) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = if (reveal) "Hide $label" else "Show $label", + ) + } + }, + ) +} + +// ── Preview + options ───────────────────────────────────────────────────────── + +@Composable +private fun PreviewSection( + fileName: String?, + preview: ImportPreview, + selected: Map, + selectedMemberIds: Set?, + onSetCategory: (String, Boolean) -> Unit, + onSetAllMembers: (Boolean) -> Unit, + onToggleMember: (String) -> Unit, + onImport: () -> Unit, + onChangeFile: () -> Unit, +) { + val memberIds = selectedMemberIds ?: preview.members.map { it.id }.toSet() + + if (fileName != null) { + 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") } + } + } + + if (preview.systemName != null) { + Text( + "System: ${preview.systemName}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + } + + if (preview.headline != null) { + Text( + preview.headline, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + HorizontalDivider() + SectionHeader("What to import") + + // The system profile row sits above the roster on every source that has + // one; everything else follows the members it applies to. + val profile = preview.categories.firstOrNull { it.key == "system_profile" } + if (profile != null && profile.visible) { + CategoryRow(profile, selected[profile.key] == true, onSetCategory) + } + + preview.locked.forEach { label -> + ToggleRow(label = label, checked = true, onCheckedChange = {}, enabled = false) + } + + if (preview.members.isNotEmpty()) { + ToggleRow( + label = "Members (${preview.memberCount})", + checked = memberIds.isNotEmpty(), + onCheckedChange = onSetAllMembers, + ) + Column(modifier = Modifier.padding(start = 16.dp)) { + preview.members.forEach { member -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Checkbox( + checked = member.id in memberIds, + onCheckedChange = { onToggleMember(member.id) }, + ) + Text(member.name, style = MaterialTheme.typography.bodyMedium) + } + } + } + } + + preview.categories + .filter { it.visible && it.key != "system_profile" } + .forEach { CategoryRow(it, selected[it.key] == true, onSetCategory) } + + preview.skipped.forEach { note -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Spacer(Modifier.width(12.dp)) + Text( + note, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(Modifier.height(4.dp)) + + Button(onClick = onImport, modifier = Modifier.fillMaxWidth().height(52.dp)) { + Text("Import") + } +} + +@Composable +private fun CategoryRow( + category: ImportCategory, + checked: Boolean, + onSetCategory: (String, Boolean) -> Unit, +) { + ToggleRow( + label = category.label, + checked = checked, + onCheckedChange = { onSetCategory(category.key, it) }, + ) + if (checked && category.note != null) { + Text( + category.note, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun ToggleRow( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true, +) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Checkbox(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) + Text(label, style = MaterialTheme.typography.bodyMedium) + } +} + +// ── Result ──────────────────────────────────────────────────────────────────── + +@Composable +private fun ResultSection( + result: ImportResult, + restartLabel: String, + 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) -> + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Text( + count.toString(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + } + } + } + } + } + + 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(restartLabel) + } +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importflow/ImportSourcePickerScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importflow/ImportSourcePickerScreen.kt new file mode 100644 index 0000000..1183dd7 --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importflow/ImportSourcePickerScreen.kt @@ -0,0 +1,87 @@ +package systems.lupine.sheaf.ui.importflow + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.filled.ChevronRight +import androidx.compose.material.icons.outlined.CloudDownload +import androidx.compose.material.icons.outlined.Upload +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import systems.lupine.sheaf.ui.components.SheafTopAppBar + +/** + * Which app to import from. The nine sources used to be nine rows in Settings + * and nine screens behind them; they differ only in what they can carry, which + * is what the subtitles say. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ImportSourcePickerScreen( + onNavigateUp: () -> Unit, + onPickSource: (String) -> Unit, +) { + Scaffold( + contentWindowInsets = WindowInsets(0), + topBar = { + SheafTopAppBar( + title = { Text("Import data") }, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), + ) { + importSources.forEachIndexed { i, source -> + if (i > 0) HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) + Surface(onClick = { onPickSource(source.id) }, modifier = Modifier.fillMaxWidth()) { + ListItem( + headlineContent = { Text(source.label) }, + supportingContent = { + Text(source.subtitle, maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + leadingContent = { + Icon( + if (source.credentialApi) Icons.Outlined.CloudDownload else Icons.Outlined.Upload, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingContent = { + Icon( + Icons.Default.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + } + } + } + } +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importflow/ImportSources.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importflow/ImportSources.kt new file mode 100644 index 0000000..23db0df --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importflow/ImportSources.kt @@ -0,0 +1,516 @@ +package systems.lupine.sheaf.ui.importflow + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import systems.lupine.sheaf.data.api.SheafApiService +import systems.lupine.sheaf.data.model.ImportJobSource +import systems.lupine.sheaf.data.model.PKApiPreviewBody +import systems.lupine.sheaf.ui.importcommon.jsonQuote + +/** What the user has to supply before a preview can run. */ +sealed interface ImportInput { + /** Pick a file; the preview runs as soon as one is chosen. */ + data class File(val prompt: String, val help: String) : ImportInput + + /** Pick a file and type a passphrase, then preview on demand. */ + data class EncryptedFile( + val help: String, + val fieldLabel: String, + val action: String, + ) : ImportInput + + /** Type a secret, then preview on demand. */ + data class Token( + val help: String, + val fieldLabel: String, + val footnote: String, + val action: String, + ) : ImportInput +} + +/** The file part and/or secret a preview call needs. */ +class PreviewRequest( + val file: MultipartBody.Part?, + val credential: String?, +) + +/** + * Everything that differs between one import source and the next. + * + * The flow itself (pick, preview, choose what to bring across, submit, poll, + * report) is identical for all of them and lives in [ImportViewModel] and + * [ImportScreen]. Adding a source means adding an entry to [importSources]. + */ +data class ImportSource( + /** Route slug, e.g. "simplyplural". */ + val id: String, + /** One of the [ImportJobSource] constants. */ + val source: String, + val label: String, + val title: String, + val subtitle: String, + val input: ImportInput, + val preview: suspend (SheafApiService, PreviewRequest) -> ImportPreview, + val defaultFileName: String = "export.json", + val previewError: String = "Preview failed — check the file and try again", + /** Fixed options fields, as raw JSON values. */ + val constants: Map = emptyMap(), + /** Write `"member_ids":null` when the selection hasn't been narrowed. */ + val alwaysEmitMemberIds: Boolean = false, + /** PluralKit's live pull goes to the credential endpoint, not the file one. */ + val credentialApi: Boolean = false, + val restartLabel: String = "Import another file", + /** Sheaf's complete-backup zip submits under a different source. */ + val submitSource: (ImportPreview) -> String = { source }, +) + +/** Onboarding links straight to this one, so it has a name outside the list. */ +const val SIMPLY_PLURAL_SOURCE = "simplyplural" + +private const val CONFLICT_SKIP = "conflict_strategy" + +private val simplyPlural = ImportSource( + id = SIMPLY_PLURAL_SOURCE, + source = ImportJobSource.SIMPLYPLURAL_FILE, + label = "Simply Plural", + title = "Import from Simply Plural", + subtitle = "Import members, groups, and history", + input = ImportInput.File( + prompt = "Choose your Simply Plural export file to get started.", + help = "In Simply Plural, go to Settings → Account → Export Data → Request data export. " + + "You'll receive the export by email — download the JSON file from there, then select it here.", + ), + preview = { api, req -> + val s = api.previewSimplyPluralImport(req.file!!) + ImportPreview( + systemName = s.systemName, + members = s.members.map { PreviewMember(it.id, it.name) }, + memberCount = s.memberCount, + categories = listOf( + ImportCategory( + key = "system_profile", + label = "System profile", + visible = s.systemName != null, + default = s.systemName != null, + ), + counted("groups", "Groups", s.groupCount), + counted("custom_fronts", "Custom fronts", s.customFrontCount), + ImportCategory( + key = "front_history", + label = "Front history (${s.frontHistoryCount} entries)", + visible = s.frontHistoryCount > 0, + default = s.frontHistoryCount > 0, + ), + counted("custom_fields", "Custom fields", s.customFieldCount), + ), + skipped = if (s.noteCount > 0) { + listOf("Notes (${s.noteCount}) — not supported, will be skipped") + } else { + emptyList() + }, + ) + }, +) + +private val sheaf = ImportSource( + id = "sheaf", + source = ImportJobSource.SHEAF_FILE, + label = "Sheaf export", + title = "Import from Sheaf Export", + subtitle = "Restore from a Sheaf JSON backup", + input = ImportInput.File( + prompt = "Choose your Sheaf export to get started: the JSON export, or a complete backup zip that includes images.", + help = "Export your data from Sheaf, then select the downloaded file here. A JSON export brings your data across; " + + "a complete backup (zip) brings your images too.", + ), + defaultFileName = "sheaf_export.json", + preview = { api, req -> sheafShapedPreview(api.previewSheafImport(req.file!!), lineage = false) }, + submitSource = { if (it.archive) ImportJobSource.SHEAF_ARCHIVE else ImportJobSource.SHEAF_FILE }, +) + +private val openPlural = ImportSource( + id = "openplural", + source = ImportJobSource.OPENPLURAL_FILE, + label = "OpenPlural", + title = "Import from OpenPlural", + subtitle = "Use an OpenPlural .json or .openplural.zip export", + input = ImportInput.File( + prompt = "Choose an OpenPlural export from another compatible app: the plain .json, or an .openplural.zip backup that also includes images.", + help = "OpenPlural is an interchange format shared across plural apps. A .json brings your data across; " + + "an .openplural.zip brings your images too.", + ), + preview = { api, req -> sheafShapedPreview(api.previewOpenPluralImport(req.file!!), lineage = true) }, +) + +/** Sheaf and OpenPlural share a preview shape; only the lineage line differs. */ +private fun sheafShapedPreview( + s: systems.lupine.sheaf.data.model.SheafPreviewSummary, + lineage: Boolean, +): ImportPreview { + val locked = buildList { + if (s.memberCount > 0) add("Members (${s.memberCount})") + // Images ride with the records they belong to (avatars, banners), so + // there's no toggle; the count is here so the user knows they're coming. + if (s.archive && s.imageCount > 0) add("Images (${s.imageCount})") + } + return ImportPreview( + headline = if (lineage && s.lineageLength > 0) { + "Passed through ${s.lineageLength} prior export${if (s.lineageLength == 1) "" else "s"}." + } else { + null + }, + locked = locked, + archive = s.archive, + categories = listOf( + ImportCategory("system_profile", "System profile", visible = true, default = true), + ImportCategory( + key = "fronts", + label = "Front history (${s.frontCount} entries)", + visible = s.frontCount > 0, + default = s.frontCount > 0, + ), + counted("groups", "Groups", s.groupCount), + counted("tags", "Tags", s.tagCount), + counted("custom_fields", "Custom fields", s.customFieldCount), + ), + ) +} + +private const val PK_HISTORY_NOTE = + "Large switch logs can take a while to import — the job runs in the background and shows up under Import history while it's working." + +private val pluralKitFile = ImportSource( + id = "pluralkit", + source = ImportJobSource.PLURALKIT_FILE, + label = "PluralKit (file)", + title = "Import from PluralKit (file)", + subtitle = "Use a PK export JSON from `pk;export`", + input = ImportInput.File( + prompt = "Choose your PluralKit export JSON to get started.", + help = "Export from PluralKit with the `pk;export` command in DM. PK sends you a download link; save the JSON and pick it here. " + + "Front history can be very large — leave it unchecked unless you specifically want switch logs in Sheaf.", + ), + preview = { api, req -> + val s = api.previewPluralKitFileImport(req.file!!) + ImportPreview( + locked = if (s.memberCount > 0) listOf("Members (${s.memberCount})") else emptyList(), + categories = listOf( + ImportCategory( + key = "system_profile", + label = "System profile", + visible = true, + default = s.systemName != null, + ), + counted("groups", "Groups", s.groupCount), + ImportCategory( + key = "front_history", + label = "Front history (${s.switchCount} switches)", + visible = s.switchCount > 0, + // Switch logs can be huge; the user opts in explicitly. + default = false, + note = PK_HISTORY_NOTE, + ), + ), + ) + }, +) + +private val pluralKitApi = ImportSource( + id = "pluralkit-api", + source = ImportJobSource.PLURALKIT_API, + label = "PluralKit (API)", + title = "Import from PluralKit (API)", + subtitle = "Connect with your PK token to import live", + input = ImportInput.Token( + help = "Paste your PluralKit token to fetch your system directly. " + + "Run `pk;token` in DM with PluralKit on Discord; PK will reply with the token.", + fieldLabel = "PluralKit token", + footnote = "The token is sent over HTTPS to Sheaf, used once for the import job, encrypted while the job runs, " + + "and wiped on completion. It is never logged.", + action = "Preview", + ), + credentialApi = true, + restartLabel = "Import another system", + previewError = "Couldn't reach PluralKit. Check your token and try again.", + preview = { api, req -> + val s = api.previewPluralKitApiImport(PKApiPreviewBody(req.credential.orEmpty())) + ImportPreview( + systemName = s.systemName, + locked = if (s.memberCount > 0) listOf("Members (${s.memberCount})") else emptyList(), + categories = listOf( + ImportCategory( + key = "system_profile", + label = "System profile", + visible = true, + default = s.systemName != null, + ), + counted("groups", "Groups", s.groupCount), + // The API preview's switch count is a sampled summary (PK + // paginates them), so the row can't promise an exact number. + ImportCategory( + key = "front_history", + label = "Front history (switch log)", + visible = true, + default = false, + note = "PluralKit will paginate the switch log over multiple requests; the import job continues in the " + + "background and shows up under Import history while it's running.", + ), + ), + ) + }, +) + +private val tupperbox = ImportSource( + id = "tupperbox", + source = ImportJobSource.TUPPERBOX_FILE, + label = "Tupperbox", + title = "Import from Tupperbox", + subtitle = "Use a Tupperbox export JSON from `tul!export`", + defaultFileName = "tuppers.json", + input = ImportInput.File( + prompt = "Choose your Tupperbox export JSON to get started.", + help = "Export from Tupperbox with the `tul!export` command in DM. Tupperbox sends back a JSON file with your tuppers and groups; pick it here. " + + "Tupperbox exports don't carry fronting history or system-level metadata, so only members and groups land in Sheaf.", + ), + preview = { api, req -> + val s = api.previewTupperboxImport(req.file!!) + ImportPreview( + locked = if (s.memberCount > 0) listOf("Tuppers (${s.memberCount})") else emptyList(), + categories = listOf(counted("groups", "Groups", s.groupCount)), + ) + }, +) + +private val pluralSpace = ImportSource( + id = "pluralspace", + source = ImportJobSource.PLURALSPACE_FILE, + label = "PluralSpace", + title = "Import from PluralSpace", + subtitle = "Use a PluralSpace .zip data export", + constants = mapOf(CONFLICT_SKIP to "\"skip\""), + alwaysEmitMemberIds = true, + input = ImportInput.File( + prompt = "Choose your PluralSpace export to get started.", + help = "In PluralSpace, open Settings → Data export and generate an export. " + + "Download the resulting .zip, then select it here.", + ), + preview = { api, req -> + val s = api.previewPluralSpaceImport(req.file!!) + ImportPreview( + systemName = s.systemName, + members = s.members.map { PreviewMember(it.id, it.name) }, + memberCount = s.memberCount, + categories = listOf( + ImportCategory( + key = "system_profile", + label = "System profile", + visible = s.systemName != null, + default = s.systemName != null, + ), + ImportCategory("member_avatars", "Member avatars", visible = s.memberCount > 0, default = true), + ImportCategory("roles_as_tags", "Roles as tags", visible = s.memberCount > 0, default = true), + counted("groups", "Groups", s.groupCount), + counted("custom_fronts", "Custom fronts", s.customFrontCount), + counted("custom_fields", "Custom fields", s.customFieldCount), + ImportCategory( + key = "fronts", + label = "Front history (${s.frontCount} entries)", + visible = s.frontCount > 0, + default = s.frontCount > 0, + ), + counted("journal_entries", "Journal entries", s.journalEntryCount), + ImportCategory( + key = "chat_messages", + label = "Chat messages (${s.chatMessageCount}, collapsed to system board)", + visible = s.chatMessageCount > 0, + default = s.chatMessageCount > 0, + ), + counted("polls", "Polls", s.pollCount), + ), + skipped = if (s.thoughtCount > 0) { + listOf("Thoughts (${s.thoughtCount}) — no Sheaf equivalent, will be skipped") + } else { + emptyList() + }, + ) + }, +) + +private val prism = ImportSource( + id = "prism", + source = ImportJobSource.PRISM_FILE, + label = "Prism", + title = "Import from Prism", + subtitle = "Use an encrypted .prism export and its passphrase", + defaultFileName = "export.prism", + constants = mapOf(CONFLICT_SKIP to "\"skip\""), + alwaysEmitMemberIds = true, + previewError = "Couldn't decrypt the export — check the passphrase and try again", + input = ImportInput.EncryptedFile( + help = "In Prism, go to Settings → Data → Export, choose a passphrase, and save the " + + ".prism file. Select it here together with the same passphrase. The passphrase " + + "is encrypted at rest while the import runs and wiped when it finishes.", + fieldLabel = "Decryption passphrase", + action = "Decrypt + preview", + ), + preview = { api, req -> + val s = api.previewPrismImport(req.file!!, req.credential!!.toFormPart()) + val skippedCount = s.sleepSessionCount + s.habitCount + s.reminderCount + ImportPreview( + systemName = s.systemName, + members = s.members.map { PreviewMember(it.id, it.name) }, + memberCount = s.memberCount, + categories = listOf( + ImportCategory( + key = "system_profile", + label = "System profile", + visible = s.systemName != null, + default = s.systemName != null, + ), + ImportCategory("member_avatars", "Member avatars", visible = s.memberCount > 0, default = true), + counted("member_groups", "Member groups", s.groupCount), + counted("custom_fields", "Custom fields", s.customFieldCount), + ImportCategory( + key = "front_sessions", + label = "Front history (${s.frontSessionCount} sessions)", + visible = s.frontSessionCount > 0, + default = s.frontSessionCount > 0, + ), + ImportCategory( + key = "notes", + label = "Notes (${s.noteCount}, as journal entries)", + visible = s.noteCount > 0, + default = s.noteCount > 0, + ), + counted("polls", "Polls", s.pollCount), + ImportCategory( + key = "conversations", + label = "Chat messages (${s.messageCount}, collapsed to system board)", + visible = s.conversationCount > 0, + default = s.conversationCount > 0, + ), + counted("member_board_posts", "Member board posts", s.memberBoardPostCount), + counted("media_attachments", "Media attachments", s.mediaAttachmentCount), + ), + skipped = if (skippedCount > 0) { + listOf( + "Skipped on import: ${s.sleepSessionCount} sleep, ${s.habitCount} habits, " + + "${s.reminderCount} reminders — no Sheaf surface for these yet.", + ) + } else { + emptyList() + }, + ) + }, +) + +private val ampersand = ImportSource( + id = "ampersand", + source = ImportJobSource.AMPERSAND_FILE, + label = "Ampersand", + title = "Import from Ampersand", + subtitle = "Use an Ampersand .json data export", + constants = mapOf(CONFLICT_SKIP to "\"skip\""), + alwaysEmitMemberIds = true, + input = ImportInput.File( + prompt = "Choose your Ampersand export to get started.", + help = "In Ampersand, open Settings → Import & export → Export your data to a JSON file (note: this is a different option to 'Export your data', which " + + "produces an incompatible file format), and save the .json file. Then select it here.", + ), + preview = { api, req -> + val s = api.previewAmpersandImport(req.file!!) + val boardLabel = buildString { + append("Board messages") + if (s.boardMessageCount > 0) append(" (${s.boardMessageCount})") + if (s.pollCount > 0) append(" & polls (${s.pollCount})") + } + ImportPreview( + locked = if (s.memberCount > 0) listOf("Members (${s.memberCount})") else emptyList(), + categories = listOf( + counted("groups", "Systems as groups", s.systemCount), + counted("custom_fronts", "Custom fronts", s.customFrontCount), + counted("tags", "Tags", s.tagCount), + counted("custom_fields", "Custom fields", s.customFieldCount), + ImportCategory( + key = "front_history", + label = "Front history (${s.frontHistoryCount} entries)", + visible = s.frontHistoryCount > 0, + default = s.frontHistoryCount > 0, + ), + counted("journals", "Journal entries", s.journalCount), + counted("notes", "Notes", s.noteCount), + ImportCategory( + key = "board_messages", + label = boardLabel, + visible = s.boardMessageCount > 0 || s.pollCount > 0, + default = s.boardMessageCount > 0 || s.pollCount > 0, + ), + counted("reminders", "Reminders", s.reminderCount), + counted("images", "Images", s.assetCount), + ), + skipped = s.limitWarnings, + ) + }, +) + +/** Every source the Import screen can run, in the order the picker lists them. */ +val importSources: List = listOf( + simplyPlural, + sheaf, + pluralKitFile, + pluralKitApi, + tupperbox, + pluralSpace, + prism, + openPlural, + ampersand, +) + +fun importSourceById(id: String?): ImportSource? = importSources.firstOrNull { it.id == id } + +/** + * Hand-build the options JSON. The backend uses `extra="forbid"`, so the field + * names have to match its options model exactly; small enough that a Moshi + * adapter for it would be indirection for its own sake, and the body is + * embedded raw into the API-import body below rather than encoded twice. + */ +internal fun buildOptionsJson( + source: ImportSource, + values: Map, + memberIds: List?, +): String { + val parts = mutableListOf() + source.constants.forEach { (k, v) -> parts += "${jsonQuote(k)}:$v" } + values.forEach { (k, v) -> parts += "${jsonQuote(k)}:$v" } + if (memberIds != null) { + parts += "\"member_ids\":[${memberIds.joinToString(",") { jsonQuote(it) }}]" + } else if (source.alwaysEmitMemberIds) { + parts += "\"member_ids\":null" + } + return parts.joinToString(",", prefix = "{", postfix = "}") +} + +/** + * Hand-build the credential-API submit body. The options field is embedded as + * a raw JSON object (already encoded). Mirrors the server-side + * `ImportApiCreateRequest` shape: `source`, `idempotency_key`, `pk_token`, + * `options`. + * + * The token is a value the user pasted in from somewhere else, so it goes + * through [jsonQuote] rather than straight interpolation. + */ +internal fun buildApiImportBodyJson( + token: String, + idempotencyKey: String, + options: String, +): String = + """{"source":"${ImportJobSource.PLURALKIT_API}",""" + + """"idempotency_key":"$idempotencyKey",""" + + """"pk_token":${jsonQuote(token)},""" + + """"options":$options}""" + +internal fun String.toFormPart(): RequestBody = toRequestBody("text/plain".toMediaType()) + +internal fun String.toJsonPart(): RequestBody = toRequestBody("application/json".toMediaType()) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importflow/ImportViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importflow/ImportViewModel.kt new file mode 100644 index 0000000..0884c8d --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importflow/ImportViewModel.kt @@ -0,0 +1,251 @@ +package systems.lupine.sheaf.ui.importflow + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import androidx.lifecycle.SavedStateHandle +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.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import systems.lupine.sheaf.data.api.SheafApiService +import systems.lupine.sheaf.data.api.streamingFilePart +import systems.lupine.sheaf.data.model.ImportJobRead +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 + +data class ImportUiState( + val fileName: String? = null, + /** Prism's passphrase or PluralKit's token; screen-lifetime only. */ + val credential: String = "", + val isPreviewing: Boolean = false, + val preview: ImportPreview? = null, + /** Category key to checked, seeded from [ImportPreview.defaults]. */ + val selected: Map = emptyMap(), + /** null = every member in the preview. */ + val selectedMemberIds: Set? = null, + val isImporting: Boolean = false, + val result: ImportResult? = null, + val error: String? = null, +) + +/** + * The one importer. Which source it's running is a nav argument; everything + * that varies between sources lives in the [ImportSource] descriptor, so this + * only knows the shape they share: supply an input, preview it, choose what to + * bring across, submit, poll until the job lands, report what happened. + */ +@HiltViewModel +class ImportViewModel @Inject constructor( + private val api: SheafApiService, + @ApplicationContext private val context: Context, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + val source: ImportSource = importSourceById(savedStateHandle.get("source")) + ?: importSources.first() + + private val _state = MutableStateFlow(ImportUiState()) + val state: StateFlow = _state.asStateFlow() + + // Holds the picked file URI across the preview -> import flow; the upload + // streams from it rather than buffering the whole file in memory. + private var fileUri: Uri? = null + private var cachedFileName: String? = null + + fun pickFile(uri: Uri) { + idempotencyKey = null + fileUri = uri + val name = resolveFileName(uri) ?: source.defaultFileName + cachedFileName = name + _state.update { it.copy(fileName = name, error = null) } + // A plain file source has everything it needs the moment one is picked; + // an encrypted one still needs the passphrase, so it previews on demand. + if (source.input is ImportInput.File) runPreview() + } + + fun updateCredential(value: String) { + _state.update { it.copy(credential = value, error = null) } + } + + fun runPreview() { + val needsFile = source.input !is ImportInput.Token + if (needsFile && fileUri == null) { + _state.update { it.copy(error = "Choose a file first.") } + return + } + val credential = _state.value.credential.trim() + if (source.input !is ImportInput.File && credential.isEmpty()) { + _state.update { it.copy(error = "Enter your ${credentialNoun()} first.") } + return + } + + viewModelScope.launch { + _state.update { it.copy(isPreviewing = true, error = null, preview = null, result = null) } + val request = PreviewRequest( + file = fileUri?.let { filePart(it, cachedFileName ?: source.defaultFileName) }, + credential = credential.takeIf { it.isNotEmpty() }, + ) + runCatching { source.preview(api, request) } + .onSuccess { preview -> + _state.update { + it.copy( + isPreviewing = false, + preview = preview, + selected = preview.defaults(), + selectedMemberIds = null, + ) + } + } + .onFailure { e -> + _state.update { it.copy(isPreviewing = false, error = e.toUserMessage(source.previewError)) } + } + } + } + + fun setCategory(key: String, checked: Boolean) { + _state.update { it.copy(selected = it.selected + (key to checked)) } + } + + fun setAllMembers(checked: Boolean) { + val preview = _state.value.preview ?: return + val ids = if (checked) preview.members.map { it.id }.toSet() else emptySet() + _state.update { it.copy(selectedMemberIds = ids) } + } + + fun toggleMember(id: String) { + val preview = _state.value.preview ?: return + val current = _state.value.selectedMemberIds ?: preview.members.map { it.id }.toSet() + _state.update { + it.copy(selectedMemberIds = if (id in current) current - id else current + id) + } + } + + // 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 current = _state.value + val preview = current.preview ?: return + val credential = current.credential.trim() + if (source.credentialApi && credential.isEmpty()) return + + val uri = fileUri + if (!source.credentialApi && uri == null) return + + val options = buildOptionsJson(source, current.selected, narrowedMemberIds(preview)) + val name = cachedFileName ?: source.defaultFileName + + viewModelScope.launch { + _state.update { it.copy(isImporting = true, error = null) } + runCatching { + val job = if (source.credentialApi) { + val body = buildApiImportBodyJson(credential, nextIdempotencyKey(), options) + api.createApiImport(body.toJsonPart()) + } else { + api.createFileImport( + file = filePart(uri!!, name), + source = source.submitSource(preview).toFormPart(), + idempotencyKey = nextIdempotencyKey().toFormPart(), + options = options.toJsonPart(), + credential = credential.takeIf { it.isNotEmpty() }?.toFormPart(), + ) + } + pollUntilTerminal(job) + } + .onSuccess { final -> + // The server owns the encrypted copy on the job row now, so + // drop our plaintext one. + _state.update { it.copy(credential = "") } + handleTerminal(final) + } + .onFailure { e -> + _state.update { it.copy(isImporting = false, error = e.toUserMessage("Import failed — please try again")) } + } + } + } + + /** + * member_ids: null imports every member, otherwise only the explicitly + * selected subset. Narrow only when the selection diverges from all of + * [ImportPreview.members]. + */ + private fun narrowedMemberIds(preview: ImportPreview): List? { + if (preview.members.isEmpty()) return null + val all = preview.members.map { it.id } + val selected = _state.value.selectedMemberIds + return if (selected == null || selected.containsAll(all)) null else selected.toList() + } + + /** + * Re-poll [SheafApiService.getImportJob] every [POLL_INTERVAL_MS] until the + * job status lands in [ImportJobStatus.terminal]. The viewModelScope + * cancels this loop when the screen tears down. + */ + 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 + val outcome = job.terminalResult() + if (outcome != null) { + _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 + idempotencyKey = null + _state.value = ImportUiState() + } + + private fun credentialNoun(): String = when (source.input) { + is ImportInput.EncryptedFile -> "passphrase" + else -> "token" + } + + private fun filePart(uri: Uri, name: String) = + streamingFilePart(context.contentResolver, uri, name) + + 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 + } +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importsp/ImportScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importsp/ImportScreen.kt deleted file mode 100644 index bc1a06b..0000000 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importsp/ImportScreen.kt +++ /dev/null @@ -1,295 +0,0 @@ -package systems.lupine.sheaf.ui.importsp - -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.SPImportResult -import systems.lupine.sheaf.data.model.SPPreviewSummary -import systems.lupine.sheaf.ui.components.ErrorBanner -import systems.lupine.sheaf.ui.components.SectionHeader -import systems.lupine.sheaf.ui.components.SheafTopAppBar - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ImportScreen( - onNavigateUp: () -> Unit, - viewModel: ImportViewModel = 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 Simply Plural") }, - 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 -> Box( - Modifier.fillMaxWidth().padding(vertical = 48.dp), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { - CircularProgressIndicator() - Text("Importing…", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - state.preview != null -> PreviewSection( - fileName = state.fileName!!, - preview = state.preview!!, - options = state.options, - onUpdateOptions = { viewModel.updateOptions(it) }, - onToggleMember = { viewModel.toggleMember(it) }, - onImport = { viewModel.runImport() }, - onChangeFile = { filePicker.launch(arrayOf("*/*")) }, - ) - state.isPreviewing -> Box( - Modifier.fillMaxWidth().padding(vertical = 48.dp), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { - CircularProgressIndicator() - Text("Reading file…", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - else -> FilePickSection(onPick = { filePicker.launch(arrayOf("*/*")) }) - } - } - } -} - -// ── File pick ───────────────────────────────────────────────────────────────── - -@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 Simply Plural export file to get started.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - Button(onClick = onPick) { Text("Choose file") } - Text( - "In Simply Plural, go to Settings → Account → Export Data → Request data export. " + - "You'll receive the export by email — download the JSON file from there, then select it here.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - ) - } -} - -// ── Preview + options ───────────────────────────────────────────────────────── - -@Composable -private fun PreviewSection( - fileName: String, - preview: SPPreviewSummary, - options: ImportOptions, - onUpdateOptions: (ImportOptions.() -> ImportOptions) -> Unit, - onToggleMember: (String) -> Unit, - onImport: () -> Unit, - onChangeFile: () -> Unit, -) { - val effectiveMemberIds = options.selectedMemberIds ?: preview.members.map { it.id }.toSet() - - 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") } - } - - if (preview.systemName != null) { - Text("System: ${preview.systemName}", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) - } - - HorizontalDivider() - - SectionHeader("What to import") - - if (preview.systemName != null) { - ImportToggleRow( - label = "System profile", - checked = options.systemProfile, - onCheckedChange = { onUpdateOptions { copy(systemProfile = it) } }, - ) - } - - if (preview.memberCount > 0) { - ImportToggleRow( - label = "Members (${preview.memberCount})", - checked = effectiveMemberIds.isNotEmpty(), - onCheckedChange = { checked -> - val ids = if (checked) preview.members.map { it.id }.toSet() else emptySet() - onUpdateOptions { copy(selectedMemberIds = ids) } - }, - ) - if (preview.members.isNotEmpty()) { - Column(modifier = Modifier.padding(start = 16.dp)) { - preview.members.forEach { member -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Checkbox( - checked = member.id in effectiveMemberIds, - onCheckedChange = { onToggleMember(member.id) }, - ) - Text(member.name, style = MaterialTheme.typography.bodyMedium) - } - } - } - } - } - - if (preview.groupCount > 0) { - ImportToggleRow( - label = "Groups (${preview.groupCount})", - 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.frontHistoryCount > 0) { - ImportToggleRow( - label = "Front history (${preview.frontHistoryCount} entries)", - checked = options.frontHistory, - onCheckedChange = { onUpdateOptions { copy(frontHistory = it) } }, - ) - } - - if (preview.customFieldCount > 0) { - ImportToggleRow( - label = "Custom fields (${preview.customFieldCount})", - checked = options.customFields, - onCheckedChange = { onUpdateOptions { copy(customFields = it) } }, - ) - } - - if (preview.noteCount > 0) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Spacer(Modifier.width(12.dp)) - Text( - "Notes (${preview.noteCount}) — not supported, will be skipped", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - 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) - } -} - -// ── Result ──────────────────────────────────────────────────────────────────── - -@Composable -private fun ResultSection(result: SPImportResult, onImportAnother: () -> Unit) { - SectionHeader("Import complete") - - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - ResultRow("Members imported", result.membersImported) - ResultRow("Groups imported", result.groupsImported) - ResultRow("Custom fronts imported", result.customFrontsImported) - ResultRow("Front history entries", result.frontsImported) - ResultRow("Custom fields imported", result.customFieldsImported) - if (result.notesSkipped > 0) { - ResultRow("Notes skipped", result.notesSkipped) - } - } - } - - 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/importsp/ImportViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importsp/ImportViewModel.kt deleted file mode 100644 index 81951cf..0000000 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importsp/ImportViewModel.kt +++ /dev/null @@ -1,233 +0,0 @@ -package systems.lupine.sheaf.ui.importsp - -import systems.lupine.sheaf.ui.importcommon.jsonQuote -import android.content.Context -import android.net.Uri -import android.provider.OpenableColumns -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import systems.lupine.sheaf.data.api.SheafApiService -import systems.lupine.sheaf.data.api.streamingFilePart -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.data.model.SPImportResult -import systems.lupine.sheaf.data.model.SPPreviewSummary -import systems.lupine.sheaf.util.toUserMessage -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 java.util.UUID -import javax.inject.Inject - -data class ImportOptions( - val systemProfile: Boolean = true, - val selectedMemberIds: Set? = null, // null = all - val customFronts: Boolean = true, - val customFields: Boolean = true, - val groups: Boolean = true, - val frontHistory: Boolean = true, -) - -data class ImportUiState( - val fileName: String? = null, - val isPreviewing: Boolean = false, - val preview: SPPreviewSummary? = null, - val options: ImportOptions = ImportOptions(), - val isImporting: Boolean = false, - val result: SPImportResult? = null, - val error: String? = null, -) - -@HiltViewModel -class ImportViewModel @Inject constructor( - private val api: SheafApiService, - @ApplicationContext private val context: Context, -) : ViewModel() { - - private val _state = MutableStateFlow(ImportUiState()) - val state: StateFlow = _state.asStateFlow() - - // Holds the picked file URI across the preview -> import flow; the upload - // streams from it rather than buffering the whole file in memory. - 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) ?: "export.json" - cachedFileName = name - _state.update { it.copy(fileName = name) } - preview(uri, name) - } - } - - private suspend fun preview(uri: Uri, name: String) { - runCatching { api.previewSimplyPluralImport(filePart(uri, name)) } - .onSuccess { summary -> - _state.update { - it.copy( - isPreviewing = false, - preview = summary, - options = ImportOptions( - systemProfile = summary.systemName != null, - selectedMemberIds = null, - customFronts = summary.customFrontCount > 0, - customFields = summary.customFieldCount > 0, - groups = summary.groupCount > 0, - frontHistory = summary.frontHistoryCount > 0, - ), - ) - } - } - .onFailure { e -> _state.update { it.copy(isPreviewing = false, error = e.toUserMessage("Preview failed — check the file and try again")) } } - } - - fun updateOptions(update: ImportOptions.() -> ImportOptions) { - _state.update { it.copy(options = it.options.update()) } - } - - fun toggleMember(id: String) { - val preview = _state.value.preview ?: return - val current = _state.value.options.selectedMemberIds - ?: preview.members.map { it.id }.toSet() - val updated = if (id in current) current - id else current + id - _state.update { it.copy(options = it.options.copy(selectedMemberIds = updated)) } - } - - // 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 ?: "export.json" - val opts = _state.value.options - // member_ids: null = import every member, otherwise only the - // explicitly-selected subset. We only narrow when the selection - // diverges from "all of preview.members". - val narrowedMemberIds = _state.value.preview?.members - ?.map { it.id } - ?.let { all -> - val selected = opts.selectedMemberIds - if (selected == null || selected.containsAll(all)) null else selected.toList() - } - - viewModelScope.launch { - _state.update { it.copy(isImporting = true, error = null) } - runCatching { - val job = api.createFileImport( - file = filePart(uri, name), - source = ImportJobSource.SIMPLYPLURAL_FILE.toFormPart(), - idempotencyKey = nextIdempotencyKey().toFormPart(), - options = buildSpOptionsJson(opts, narrowedMemberIds).toJsonPart(), - ) - pollUntilTerminal(job) - } - .onSuccess { final -> handleTerminal(final) } - .onFailure { e -> _state.update { it.copy(isImporting = false, error = e.toUserMessage("Import failed — please try again")) } } - } - } - - /** - * Re-poll [getImportJob] every [POLL_INTERVAL_MS] until the job - * status lands in [ImportJobStatus.terminal]. The viewModelScope - * cancels this loop when the screen tears down. - */ - 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 (job.status) { - ImportJobStatus.COMPLETE -> { - val counts = job.counts - val warnings = job.events - .filter { it.level == "warning" } - .map { e -> e.recordRef?.let { "$it: ${e.message}" } ?: e.message } - val result = SPImportResult( - membersImported = counts["members_imported"] ?: 0, - customFrontsImported = counts["custom_fronts_imported"] ?: 0, - frontsImported = counts["fronts_imported"] ?: 0, - groupsImported = counts["groups_imported"] ?: 0, - customFieldsImported = counts["custom_fields_imported"] ?: 0, - notesSkipped = counts["notes_skipped"] ?: 0, - warnings = warnings, - ) - _state.update { it.copy(isImporting = false, result = result) } - } - 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 = ImportUiState() - } - - 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. Tiny enough that pulling a Moshi - * adapter for it would be over-engineering, and the backend uses - * `extra="forbid"` so any typo'd field 422s — easier to read the - * literal field names here than chase a serializer indirection. - */ -private fun buildSpOptionsJson(opts: ImportOptions, memberIds: List?): String { - val parts = mutableListOf() - parts += "\"system_profile\":${opts.systemProfile}" - parts += "\"custom_fronts\":${opts.customFronts}" - parts += "\"custom_fields\":${opts.customFields}" - parts += "\"groups\":${opts.groups}" - parts += "\"front_history\":${opts.frontHistory}" - if (memberIds != null) { - val ids = memberIds.joinToString(",") { jsonQuote(it) } - parts += "\"member_ids\":[$ids]" - } - return parts.joinToString(",", prefix = "{", postfix = "}") -} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/openpluralimport/OpenPluralImportScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/openpluralimport/OpenPluralImportScreen.kt deleted file mode 100644 index 6686739..0000000 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/openpluralimport/OpenPluralImportScreen.kt +++ /dev/null @@ -1,262 +0,0 @@ -package systems.lupine.sheaf.ui.openpluralimport - -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.SheafImportResult -import systems.lupine.sheaf.data.model.SheafPreviewSummary -import systems.lupine.sheaf.ui.components.ErrorBanner -import systems.lupine.sheaf.ui.components.SectionHeader -import systems.lupine.sheaf.ui.components.SheafTopAppBar - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun OpenPluralImportScreen( - onNavigateUp: () -> Unit, - viewModel: OpenPluralImportViewModel = 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 OpenPlural") }, - 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 an OpenPlural export from another compatible app: the plain .json, or an .openplural.zip backup that also includes images.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Button(onClick = onPick) { Text("Choose file") } - Text( - "OpenPlural is an interchange format shared across plural apps. A .json brings your data across; an .openplural.zip brings your images too.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - ) - } -} - -@Composable -private fun PreviewSection( - fileName: String, - preview: SheafPreviewSummary, - options: OpenPluralImportOptions, - onUpdateOptions: (OpenPluralImportOptions.() -> OpenPluralImportOptions) -> 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") } - } - - // Lineage: how many prior exports this file has passed through. Helps a - // user understand round-trips across apps. - if (preview.lineageLength > 0) { - Text( - "Passed through ${preview.lineageLength} prior export${if (preview.lineageLength == 1) "" else "s"}.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - HorizontalDivider() - SectionHeader("What to import") - - ToggleRow( - label = "System profile", - checked = options.systemProfile, - onCheckedChange = { onUpdateOptions { copy(systemProfile = it) } }, - ) - - if (preview.memberCount > 0) { - ToggleRow(label = "Members (${preview.memberCount})", checked = true, onCheckedChange = {}, enabled = false) - } - - if (preview.frontCount > 0) { - ToggleRow( - label = "Front history (${preview.frontCount} entries)", - checked = options.fronts, - onCheckedChange = { onUpdateOptions { copy(fronts = it) } }, - ) - } - - if (preview.groupCount > 0) { - ToggleRow( - label = "Groups (${preview.groupCount})", - checked = options.groups, - onCheckedChange = { onUpdateOptions { copy(groups = it) } }, - ) - } - - if (preview.tagCount > 0) { - ToggleRow( - label = "Tags (${preview.tagCount})", - checked = options.tags, - onCheckedChange = { onUpdateOptions { copy(tags = it) } }, - ) - } - - if (preview.customFieldCount > 0) { - ToggleRow( - label = "Custom fields (${preview.customFieldCount})", - checked = options.customFields, - onCheckedChange = { onUpdateOptions { copy(customFields = it) } }, - ) - } - - // Bundle images ride with the records they belong to (avatars, banners), - // so there's no separate toggle; surface the count so the user knows. - if (preview.archive && preview.imageCount > 0) { - ToggleRow(label = "Images (${preview.imageCount})", checked = true, onCheckedChange = {}, enabled = false) - } - - Spacer(Modifier.height(4.dp)) - - Button( - onClick = onImport, - modifier = Modifier.fillMaxWidth().height(52.dp), - ) { Text("Import") } -} - -@Composable -private fun ToggleRow( - label: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - enabled: Boolean = true, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Checkbox(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) - Text(label, style = MaterialTheme.typography.bodyMedium) - } -} - -@Composable -private fun ResultSection(result: SheafImportResult, onImportAnother: () -> Unit) { - SectionHeader("Import complete") - - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - ResultRow("Members imported", result.membersImported) - ResultRow("Front history entries", result.frontsImported) - ResultRow("Groups imported", result.groupsImported) - ResultRow("Tags imported", result.tagsImported) - ResultRow("Custom fields imported", result.customFieldsImported) - if (result.imagesImported > 0) { - ResultRow("Images imported", result.imagesImported) - } - } - } - - 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/openpluralimport/OpenPluralImportViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/openpluralimport/OpenPluralImportViewModel.kt deleted file mode 100644 index ec364f8..0000000 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/openpluralimport/OpenPluralImportViewModel.kt +++ /dev/null @@ -1,210 +0,0 @@ -package systems.lupine.sheaf.ui.openpluralimport - -import android.content.Context -import android.net.Uri -import android.provider.OpenableColumns -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import systems.lupine.sheaf.data.api.SheafApiService -import systems.lupine.sheaf.data.api.streamingFilePart -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.data.model.SheafImportResult -import systems.lupine.sheaf.data.model.SheafPreviewSummary -import systems.lupine.sheaf.util.toUserMessage -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 java.util.UUID -import javax.inject.Inject - -/** - * OpenPlural import. Mirrors the Sheaf importer (same preview shape, same - * options, same async job + poll flow); the only differences are the - * preview endpoint and that OpenPlural uses a single import source for both - * the bare `.json` and the `.openplural.zip` bundle. Reuses - * [SheafPreviewSummary] / [SheafImportResult] since the backend returns the - * Sheaf shape (plus `lineage_length`). - */ -data class OpenPluralImportOptions( - val systemProfile: Boolean = true, - val fronts: Boolean = true, - val groups: Boolean = true, - val tags: Boolean = true, - val customFields: Boolean = true, -) - -data class OpenPluralImportUiState( - val fileName: String? = null, - val isPreviewing: Boolean = false, - val preview: SheafPreviewSummary? = null, - val options: OpenPluralImportOptions = OpenPluralImportOptions(), - val isImporting: Boolean = false, - val result: SheafImportResult? = null, - val error: String? = null, -) - -@HiltViewModel -class OpenPluralImportViewModel @Inject constructor( - private val api: SheafApiService, - @ApplicationContext private val context: Context, -) : ViewModel() { - - private val _state = MutableStateFlow(OpenPluralImportUiState()) - 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) ?: "openplural_export.json" - cachedFileName = name - _state.update { it.copy(fileName = name) } - preview(uri, name) - } - } - - private suspend fun preview(uri: Uri, name: String) { - runCatching { api.previewOpenPluralImport(filePart(uri, name)) } - .onSuccess { summary -> - _state.update { - it.copy( - isPreviewing = false, - preview = summary, - options = OpenPluralImportOptions( - systemProfile = true, - fronts = summary.frontCount > 0, - groups = summary.groupCount > 0, - tags = summary.tagCount > 0, - customFields = summary.customFieldCount > 0, - ), - ) - } - } - .onFailure { e -> _state.update { it.copy(isPreviewing = false, error = e.toUserMessage("Preview failed - check the file and try again")) } } - } - - fun updateOptions(update: OpenPluralImportOptions.() -> OpenPluralImportOptions) { - _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 ?: "openplural_export.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.OPENPLURAL_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 (job.status) { - ImportJobStatus.COMPLETE -> { - val counts = job.counts - val warnings = job.events - .filter { it.level == "warning" } - .map { e -> e.recordRef?.let { "$it: ${e.message}" } ?: e.message } - val result = SheafImportResult( - membersImported = counts["members_imported"] ?: 0, - frontsImported = counts["fronts_imported"] ?: 0, - groupsImported = counts["groups_imported"] ?: 0, - tagsImported = counts["tags_imported"] ?: 0, - customFieldsImported = counts["custom_fields_imported"] ?: 0, - imagesImported = counts["images_imported"] ?: 0, - warnings = warnings, - ) - _state.update { it.copy(isImporting = false, result = result) } - } - 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 = OpenPluralImportUiState() - } - - 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-built options JSON; the backend uses extra="forbid" so a typo'd - * field 422s. Only the toggles the screen exposes are surfaced; the rest - * (journals, messages, polls, reminders, notifications, images) fall through - * to their backend defaults, matching the Sheaf importer. - */ -private fun buildOptionsJson(opts: OpenPluralImportOptions): String { - val parts = listOf( - "\"system_profile\":${opts.systemProfile}", - "\"fronts\":${opts.fronts}", - "\"groups\":${opts.groups}", - "\"tags\":${opts.tags}", - "\"custom_fields\":${opts.customFields}", - ) - return parts.joinToString(",", prefix = "{", postfix = "}") -} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/pkapiimport/PluralKitApiImportScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/pkapiimport/PluralKitApiImportScreen.kt deleted file mode 100644 index 20faab3..0000000 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/pkapiimport/PluralKitApiImportScreen.kt +++ /dev/null @@ -1,246 +0,0 @@ -package systems.lupine.sheaf.ui.pkapiimport - -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -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.Visibility -import androidx.compose.material.icons.outlined.VisibilityOff -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.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import systems.lupine.sheaf.data.model.PKImportResult -import systems.lupine.sheaf.data.model.PKPreviewSummary -import systems.lupine.sheaf.ui.components.ErrorBanner -import systems.lupine.sheaf.ui.components.SectionHeader -import systems.lupine.sheaf.ui.components.SheafTopAppBar - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun PluralKitApiImportScreen( - onNavigateUp: () -> Unit, - viewModel: PluralKitApiImportViewModel = hiltViewModel(), -) { - val state by viewModel.state.collectAsState() - - Scaffold( - contentWindowInsets = WindowInsets(0), - topBar = { - SheafTopAppBar( - title = { Text("Import from PluralKit (API)") }, - 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( - preview = state.preview!!, - options = state.options, - onUpdateOptions = { viewModel.updateOptions(it) }, - onImport = { viewModel.runImport() }, - ) - state.isPreviewing -> CenterSpinner("Talking to PluralKit…") - else -> TokenSection( - token = state.token, - onTokenChange = { viewModel.updateToken(it) }, - onPreview = { viewModel.runPreview() }, - ) - } - } - } -} - -@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 TokenSection( - token: String, - onTokenChange: (String) -> Unit, - onPreview: () -> Unit, -) { - var showToken by remember { mutableStateOf(false) } - - Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(top = 16.dp)) { - Text( - "Paste your PluralKit token to fetch your system directly. " + - "Run `pk;token` in DM with PluralKit on Discord; PK will reply with the token.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - OutlinedTextField( - value = token, - onValueChange = onTokenChange, - label = { Text("PluralKit token") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - visualTransformation = if (showToken) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - IconButton(onClick = { showToken = !showToken }) { - Icon( - if (showToken) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = if (showToken) "Hide token" else "Show token", - ) - } - }, - ) - Text( - "The token is sent over HTTPS to Sheaf, used once for the import job, encrypted while the job runs, and wiped on completion. It is never logged.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), - ) - Button( - onClick = onPreview, - enabled = token.isNotBlank(), - modifier = Modifier.fillMaxWidth().height(52.dp), - ) { Text("Preview") } - } -} - -@Composable -private fun PreviewSection( - preview: PKPreviewSummary, - options: PKApiImportOptions, - onUpdateOptions: (PKApiImportOptions.() -> PKApiImportOptions) -> Unit, - onImport: () -> Unit, -) { - SectionHeader("Preview") - - if (preview.systemName != null) { - Text( - preview.systemName, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - } - - HorizontalDivider() - SectionHeader("What to import") - - ToggleRow( - label = "System profile", - checked = options.systemProfile, - onCheckedChange = { onUpdateOptions { copy(systemProfile = it) } }, - ) - - if (preview.memberCount > 0) { - ToggleRow( - label = "Members (${preview.memberCount})", - checked = true, - onCheckedChange = {}, - enabled = false, - ) - } - - if (preview.groupCount > 0) { - ToggleRow( - label = "Groups (${preview.groupCount})", - checked = options.groups, - onCheckedChange = { onUpdateOptions { copy(groups = it) } }, - ) - } - - // Switch count on API preview is a sampled summary (PK paginates them), - // so we can't show "exact N entries" — just offer the toggle. - ToggleRow( - label = "Front history (switch log)", - checked = options.frontHistory, - onCheckedChange = { onUpdateOptions { copy(frontHistory = it) } }, - ) - if (options.frontHistory) { - Text( - "PluralKit will paginate the switch log over multiple requests; the import job continues in the background and shows up under Import history while it's running.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Spacer(Modifier.height(4.dp)) - - Button( - onClick = onImport, - modifier = Modifier.fillMaxWidth().height(52.dp), - ) { Text("Import") } -} - -@Composable -private fun ToggleRow( - label: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - enabled: Boolean = true, -) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Checkbox(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) - Text(label, style = MaterialTheme.typography.bodyMedium) - } -} - -@Composable -private fun ResultSection(result: PKImportResult, onImportAnother: () -> Unit) { - SectionHeader("Import complete") - - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - ResultRow("Members imported", result.membersImported) - ResultRow("Groups imported", result.groupsImported) - ResultRow("Front history entries", result.frontsImported) - } - } - - if (result.warnings.isNotEmpty()) { - SectionHeader("Warnings") - result.warnings.forEach { w -> - Text("• $w", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - - Spacer(Modifier.height(4.dp)) - OutlinedButton(onClick = onImportAnother, modifier = Modifier.fillMaxWidth()) { - Text("Import another system") - } -} - -@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/pkapiimport/PluralKitApiImportViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/pkapiimport/PluralKitApiImportViewModel.kt deleted file mode 100644 index f1dace1..0000000 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/pkapiimport/PluralKitApiImportViewModel.kt +++ /dev/null @@ -1,224 +0,0 @@ -package systems.lupine.sheaf.ui.pkapiimport - -import systems.lupine.sheaf.ui.importcommon.jsonQuote -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.RequestBody.Companion.toRequestBody -import systems.lupine.sheaf.data.api.SheafApiService -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.data.model.PKApiPreviewBody -import systems.lupine.sheaf.data.model.PKImportResult -import systems.lupine.sheaf.data.model.PKPreviewSummary -import systems.lupine.sheaf.util.toUserMessage -import java.util.UUID -import javax.inject.Inject - -data class PKApiImportOptions( - val systemProfile: Boolean = true, - val selectedMemberIds: Set? = null, - val groups: Boolean = true, - val frontHistory: Boolean = false, -) - -data class PKApiImportUiState( - val token: String = "", - val isPreviewing: Boolean = false, - val preview: PKPreviewSummary? = null, - val options: PKApiImportOptions = PKApiImportOptions(), - val isImporting: Boolean = false, - val result: PKImportResult? = null, - val error: String? = null, -) - -/** - * Live-API PluralKit import. User enters a PK token; preview hits the - * PK API server-side (token is request-scoped, never persisted in - * preview). On submit, the token rides along to /v1/imports/api, where - * the backend encrypts it at rest while the job runs and wipes it on - * finalize. - * - * The token lives in viewmodel state for the duration of this screen - * only — not in DataStore, not anywhere persistent. If the user - * navigates away, the token goes with the viewmodel. - */ -@HiltViewModel -class PluralKitApiImportViewModel @Inject constructor( - private val api: SheafApiService, -) : ViewModel() { - - private val _state = MutableStateFlow(PKApiImportUiState()) - val state: StateFlow = _state.asStateFlow() - - fun updateToken(token: String) { - _state.update { it.copy(token = token, error = null) } - } - - fun runPreview() { - val token = _state.value.token.trim() - if (token.isEmpty()) { - _state.update { it.copy(error = "Enter your PluralKit token first.") } - return - } - viewModelScope.launch { - _state.update { it.copy(isPreviewing = true, error = null, preview = null, result = null) } - runCatching { api.previewPluralKitApiImport(PKApiPreviewBody(token)) } - .onSuccess { summary -> - _state.update { - it.copy( - isPreviewing = false, - preview = summary, - options = PKApiImportOptions( - systemProfile = summary.systemName != null, - groups = summary.groupCount > 0, - frontHistory = false, - ), - ) - } - } - .onFailure { e -> - _state.update { - it.copy( - isPreviewing = false, - error = e.toUserMessage("Couldn't reach PluralKit. Check your token and try again."), - ) - } - } - } - } - - fun updateOptions(update: PKApiImportOptions.() -> PKApiImportOptions) { - _state.update { it.copy(options = it.options.update()) } - } - - fun toggleMember(id: String) { - val preview = _state.value.preview ?: return - val current = _state.value.options.selectedMemberIds - ?: preview.members.map { it.id }.toSet() - val updated = if (id in current) current - id else current + id - _state.update { it.copy(options = it.options.copy(selectedMemberIds = updated)) } - } - - // 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 once the job reaches a terminal state, so the next run is a new import. - private var idempotencyKey: String? = null - - private fun nextIdempotencyKey(): String = - idempotencyKey ?: UUID.randomUUID().toString().also { idempotencyKey = it } - - fun runImport() { - val token = _state.value.token.trim().takeIf { it.isNotEmpty() } ?: return - val opts = _state.value.options - val narrowedMemberIds = _state.value.preview?.members - ?.map { it.id } - ?.let { all -> - val selected = opts.selectedMemberIds - if (selected == null || selected.containsAll(all)) null else selected.toList() - } - - viewModelScope.launch { - _state.update { it.copy(isImporting = true, error = null) } - runCatching { - val body = buildApiImportBodyJson( - token = token, - idempotencyKey = nextIdempotencyKey(), - options = buildPkApiOptionsJson(opts, narrowedMemberIds), - ) - val job = api.createApiImport(body.toRequestBody("application/json".toMediaType())) - 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 (job.status) { - ImportJobStatus.COMPLETE -> { - val counts = job.counts - val warnings = job.events - .filter { it.level == "warning" } - .map { e -> e.recordRef?.let { "$it: ${e.message}" } ?: e.message } - val result = PKImportResult( - membersImported = counts["members_imported"] ?: 0, - groupsImported = counts["groups_imported"] ?: 0, - frontsImported = counts["fronts_imported"] ?: 0, - warnings = warnings, - ) - _state.update { it.copy(isImporting = false, result = result) } - } - else -> _state.update { - it.copy( - isImporting = false, - error = job.lastError ?: "Import didn't complete (status: ${job.status})", - ) - } - } - } - - fun reset() { - _state.value = PKApiImportUiState() - } - - companion object { - private const val POLL_INTERVAL_MS: Long = 1500 - } -} - -/** PK API options JSON. Same shape as PK file options; kept separate to - * avoid a cross-package dependency between the two importer modules. */ -internal fun buildPkApiOptionsJson(opts: PKApiImportOptions, memberIds: List?): String { - val parts = mutableListOf() - parts += "\"system_profile\":${opts.systemProfile}" - parts += "\"groups\":${opts.groups}" - parts += "\"front_history\":${opts.frontHistory}" - if (memberIds != null) { - val ids = memberIds.joinToString(",") { jsonQuote(it) } - parts += "\"member_ids\":[$ids]" - } - return parts.joinToString(",", prefix = "{", postfix = "}") -} - -/** - * Hand-build the credential-API submit body. The options field is - * embedded as a raw JSON object (already-encoded). Mirrors the - * server-side `ImportApiCreateRequest` shape: `source`, - * `idempotency_key`, `pk_token`, `options`. - */ -internal fun buildApiImportBodyJson( - token: String, - idempotencyKey: String, - options: String, -): String { - // Token only needs minimal escaping — PK tokens are URL-safe base64-ish - // strings that wouldn't normally contain quotes or backslashes, but - // we still escape defensively against a pasted token with whitespace - // or odd characters. - return """{"source":"${ImportJobSource.PLURALKIT_API}",""" + - """"idempotency_key":"$idempotencyKey",""" + - """"pk_token":${jsonQuote(token)},""" + - """"options":$options}""" -} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/pkimport/PluralKitFileImportScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/pkimport/PluralKitFileImportScreen.kt deleted file mode 100644 index 89ebdb3..0000000 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/pkimport/PluralKitFileImportScreen.kt +++ /dev/null @@ -1,225 +0,0 @@ -package systems.lupine.sheaf.ui.pkimport - -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.PKImportResult -import systems.lupine.sheaf.data.model.PKPreviewSummary -import systems.lupine.sheaf.ui.components.ErrorBanner -import systems.lupine.sheaf.ui.components.SectionHeader -import systems.lupine.sheaf.ui.components.SheafTopAppBar - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun PluralKitFileImportScreen( - onNavigateUp: () -> Unit, - viewModel: PluralKitFileImportViewModel = 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 PluralKit (file)") }, - 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 PluralKit export JSON to get started.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Button(onClick = onPick) { Text("Choose file") } - Text( - "Export from PluralKit with the `pk;export` command in DM. PK sends you a download link; save the JSON and pick it here. " + - "Front history can be very large — leave it unchecked unless you specifically want switch logs in Sheaf.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - ) - } -} - -@Composable -private fun PreviewSection( - fileName: String, - preview: PKPreviewSummary, - options: PKFileImportOptions, - onUpdateOptions: (PKFileImportOptions.() -> PKFileImportOptions) -> 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") - - ToggleRow( - label = "System profile", - checked = options.systemProfile, - onCheckedChange = { onUpdateOptions { copy(systemProfile = it) } }, - ) - - if (preview.memberCount > 0) { - ToggleRow( - label = "Members (${preview.memberCount})", - checked = true, - onCheckedChange = {}, - enabled = false, - ) - } - - if (preview.groupCount > 0) { - ToggleRow( - label = "Groups (${preview.groupCount})", - checked = options.groups, - onCheckedChange = { onUpdateOptions { copy(groups = it) } }, - ) - } - - if (preview.switchCount > 0) { - ToggleRow( - label = "Front history (${preview.switchCount} switches)", - checked = options.frontHistory, - onCheckedChange = { onUpdateOptions { copy(frontHistory = it) } }, - ) - if (options.frontHistory) { - Text( - "Large switch logs can take a while to import — the job runs in the background and shows up under Import history while it's working.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - Spacer(Modifier.height(4.dp)) - - Button( - onClick = onImport, - modifier = Modifier.fillMaxWidth().height(52.dp), - ) { Text("Import") } -} - -@Composable -private fun ToggleRow( - label: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - enabled: Boolean = true, -) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Checkbox(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) - Text(label, style = MaterialTheme.typography.bodyMedium) - } -} - -@Composable -private fun ResultSection(result: PKImportResult, onImportAnother: () -> Unit) { - SectionHeader("Import complete") - - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - ResultRow("Members imported", result.membersImported) - ResultRow("Groups imported", result.groupsImported) - ResultRow("Front history entries", result.frontsImported) - } - } - - if (result.warnings.isNotEmpty()) { - SectionHeader("Warnings") - result.warnings.forEach { w -> - Text("• $w", 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/pkimport/PluralKitFileImportViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/pkimport/PluralKitFileImportViewModel.kt deleted file mode 100644 index 6cdb3b3..0000000 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/pkimport/PluralKitFileImportViewModel.kt +++ /dev/null @@ -1,230 +0,0 @@ -package systems.lupine.sheaf.ui.pkimport - -import systems.lupine.sheaf.ui.importcommon.jsonQuote -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.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -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.ImportJobRead -import systems.lupine.sheaf.data.model.ImportJobSource -import systems.lupine.sheaf.data.model.ImportJobStatus -import systems.lupine.sheaf.data.model.PKImportResult -import systems.lupine.sheaf.data.model.PKPreviewSummary -import systems.lupine.sheaf.util.toUserMessage -import java.util.UUID -import javax.inject.Inject - -data class PKFileImportOptions( - val systemProfile: Boolean = true, - val selectedMemberIds: Set? = null, // null = all - val groups: Boolean = true, - val frontHistory: Boolean = false, // backend default — switch logs can be huge -) - -data class PKFileImportUiState( - val fileName: String? = null, - val isPreviewing: Boolean = false, - val preview: PKPreviewSummary? = null, - val options: PKFileImportOptions = PKFileImportOptions(), - val isImporting: Boolean = false, - val result: PKImportResult? = null, - val error: String? = null, -) - -/** - * PluralKit file import. Same async-submit-then-poll shape as the SP and - * Sheaf importers; see [systems.lupine.sheaf.ui.importsp.ImportViewModel] - * for the poll-loop rationale. - */ -@HiltViewModel -class PluralKitFileImportViewModel @Inject constructor( - private val api: SheafApiService, - @ApplicationContext private val context: Context, -) : ViewModel() { - - private val _state = MutableStateFlow(PKFileImportUiState()) - 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) ?: "export.json" - cachedFileName = name - _state.update { it.copy(fileName = name) } - preview(uri, name) - } - } - - private suspend fun preview(uri: Uri, name: String) { - runCatching { api.previewPluralKitFileImport(filePart(uri, name)) } - .onSuccess { summary -> - _state.update { - it.copy( - isPreviewing = false, - preview = summary, - options = PKFileImportOptions( - systemProfile = summary.systemName != null, - selectedMemberIds = null, - groups = summary.groupCount > 0, - frontHistory = false, // user opts in explicitly - ), - ) - } - } - .onFailure { e -> - _state.update { - it.copy( - isPreviewing = false, - error = e.toUserMessage("Preview failed — check the file and try again"), - ) - } - } - } - - fun updateOptions(update: PKFileImportOptions.() -> PKFileImportOptions) { - _state.update { it.copy(options = it.options.update()) } - } - - fun toggleMember(id: String) { - val preview = _state.value.preview ?: return - val current = _state.value.options.selectedMemberIds - ?: preview.members.map { it.id }.toSet() - val updated = if (id in current) current - id else current + id - _state.update { it.copy(options = it.options.copy(selectedMemberIds = updated)) } - } - - // 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 ?: "export.json" - val opts = _state.value.options - val narrowedMemberIds = _state.value.preview?.members - ?.map { it.id } - ?.let { all -> - val selected = opts.selectedMemberIds - if (selected == null || selected.containsAll(all)) null else selected.toList() - } - - viewModelScope.launch { - _state.update { it.copy(isImporting = true, error = null) } - runCatching { - val job = api.createFileImport( - file = filePart(uri, name), - source = ImportJobSource.PLURALKIT_FILE.toFormPart(), - idempotencyKey = nextIdempotencyKey().toFormPart(), - options = buildPkOptionsJson(opts, narrowedMemberIds).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 (job.status) { - ImportJobStatus.COMPLETE -> { - val counts = job.counts - val warnings = job.events - .filter { it.level == "warning" } - .map { e -> e.recordRef?.let { "$it: ${e.message}" } ?: e.message } - val result = PKImportResult( - membersImported = counts["members_imported"] ?: 0, - groupsImported = counts["groups_imported"] ?: 0, - frontsImported = counts["fronts_imported"] ?: 0, - warnings = warnings, - ) - _state.update { it.copy(isImporting = false, result = result) } - } - 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 = PKFileImportUiState() - } - - 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-built options JSON for both PK paths (file + API). Backend uses - * extra=forbid on PKImportOptions; spelling fields out at the call site - * keeps the contract visible. - */ -internal fun buildPkOptionsJson(opts: PKFileImportOptions, memberIds: List?): String { - val parts = mutableListOf() - parts += "\"system_profile\":${opts.systemProfile}" - parts += "\"groups\":${opts.groups}" - parts += "\"front_history\":${opts.frontHistory}" - if (memberIds != null) { - val ids = memberIds.joinToString(",") { jsonQuote(it) } - parts += "\"member_ids\":[$ids]" - } - return parts.joinToString(",", prefix = "{", postfix = "}") -} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/pluralspaceimport/PluralSpaceImportScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/pluralspaceimport/PluralSpaceImportScreen.kt deleted file mode 100644 index c5e7124..0000000 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/pluralspaceimport/PluralSpaceImportScreen.kt +++ /dev/null @@ -1,299 +0,0 @@ -package systems.lupine.sheaf.ui.pluralspaceimport - -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.PluralSpacePreviewSummary -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 PluralSpaceImportScreen( - onNavigateUp: () -> Unit, - viewModel: PluralSpaceImportViewModel = 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 PluralSpace") }, - 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) }, - onToggleMember = { viewModel.toggleMember(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 PluralSpace export to get started.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - Button(onClick = onPick) { Text("Choose file") } - Text( - "In PluralSpace, open Settings → Data export and generate an export. " + - "Download the resulting .zip, then select it here.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - ) - } -} - -@Composable -private fun PreviewSection( - fileName: String, - preview: PluralSpacePreviewSummary, - options: PluralSpaceImportOptions, - onUpdateOptions: (PluralSpaceImportOptions.() -> PluralSpaceImportOptions) -> Unit, - onToggleMember: (String) -> Unit, - onImport: () -> Unit, - onChangeFile: () -> Unit, -) { - val effectiveMemberIds = options.selectedMemberIds ?: preview.members.map { it.id }.toSet() - - 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") } - } - - if (preview.systemName != null) { - Text("System: ${preview.systemName}", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) - } - - HorizontalDivider() - SectionHeader("What to import") - - if (preview.systemName != null) { - ImportToggleRow( - label = "System profile", - checked = options.systemProfile, - onCheckedChange = { onUpdateOptions { copy(systemProfile = it) } }, - ) - } - - if (preview.memberCount > 0) { - ImportToggleRow( - label = "Members (${preview.memberCount})", - checked = effectiveMemberIds.isNotEmpty(), - onCheckedChange = { checked -> - val ids = if (checked) preview.members.map { it.id }.toSet() else emptySet() - onUpdateOptions { copy(selectedMemberIds = ids) } - }, - ) - if (preview.members.isNotEmpty()) { - Column(modifier = Modifier.padding(start = 16.dp)) { - preview.members.forEach { member -> - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Checkbox( - checked = member.id in effectiveMemberIds, - onCheckedChange = { onToggleMember(member.id) }, - ) - Text(member.name, style = MaterialTheme.typography.bodyMedium) - } - } - } - } - ImportToggleRow( - label = "Member avatars", - checked = options.memberAvatars, - onCheckedChange = { onUpdateOptions { copy(memberAvatars = it) } }, - ) - ImportToggleRow( - label = "Roles as tags", - checked = options.rolesAsTags, - onCheckedChange = { onUpdateOptions { copy(rolesAsTags = it) } }, - ) - } - - if (preview.groupCount > 0) { - ImportToggleRow( - label = "Groups (${preview.groupCount})", - 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.customFieldCount > 0) { - ImportToggleRow( - label = "Custom fields (${preview.customFieldCount})", - checked = options.customFields, - onCheckedChange = { onUpdateOptions { copy(customFields = it) } }, - ) - } - - if (preview.frontCount > 0) { - ImportToggleRow( - label = "Front history (${preview.frontCount} entries)", - checked = options.fronts, - onCheckedChange = { onUpdateOptions { copy(fronts = it) } }, - ) - } - - if (preview.journalEntryCount > 0) { - ImportToggleRow( - label = "Journal entries (${preview.journalEntryCount})", - checked = options.journalEntries, - onCheckedChange = { onUpdateOptions { copy(journalEntries = it) } }, - ) - } - - if (preview.chatMessageCount > 0) { - ImportToggleRow( - label = "Chat messages (${preview.chatMessageCount}, collapsed to system board)", - checked = options.chatMessages, - onCheckedChange = { onUpdateOptions { copy(chatMessages = it) } }, - ) - } - - if (preview.pollCount > 0) { - ImportToggleRow( - label = "Polls (${preview.pollCount})", - checked = options.polls, - onCheckedChange = { onUpdateOptions { copy(polls = it) } }, - ) - } - - if (preview.thoughtCount > 0) { - SkippedNote("Thoughts (${preview.thoughtCount}) — no Sheaf equivalent, will be skipped") - } - - 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/pluralspaceimport/PluralSpaceImportViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/pluralspaceimport/PluralSpaceImportViewModel.kt deleted file mode 100644 index 89396e1..0000000 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/pluralspaceimport/PluralSpaceImportViewModel.kt +++ /dev/null @@ -1,224 +0,0 @@ -package systems.lupine.sheaf.ui.pluralspaceimport - -import systems.lupine.sheaf.ui.importcommon.jsonQuote -import android.content.Context -import android.net.Uri -import android.provider.OpenableColumns -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import systems.lupine.sheaf.data.api.SheafApiService -import systems.lupine.sheaf.data.api.streamingFilePart -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.data.model.PluralSpacePreviewSummary -import systems.lupine.sheaf.ui.importcommon.ImportResult -import systems.lupine.sheaf.ui.importcommon.terminalResult -import systems.lupine.sheaf.util.toUserMessage -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 java.util.UUID -import javax.inject.Inject - -data class PluralSpaceImportOptions( - val systemProfile: Boolean = true, - val selectedMemberIds: Set? = null, // null = all - val customFronts: Boolean = true, - val memberAvatars: Boolean = true, - val rolesAsTags: Boolean = true, - val groups: Boolean = true, - val customFields: Boolean = true, - val fronts: Boolean = true, - val journalEntries: Boolean = true, - val chatMessages: Boolean = true, - val polls: Boolean = true, -) - -data class PluralSpaceImportUiState( - val fileName: String? = null, - val isPreviewing: Boolean = false, - val preview: PluralSpacePreviewSummary? = null, - val options: PluralSpaceImportOptions = PluralSpaceImportOptions(), - val isImporting: Boolean = false, - val result: ImportResult? = null, - val error: String? = null, -) - -@HiltViewModel -class PluralSpaceImportViewModel @Inject constructor( - private val api: SheafApiService, - @ApplicationContext private val context: Context, -) : ViewModel() { - - private val _state = MutableStateFlow(PluralSpaceImportUiState()) - 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) ?: "export.zip" - cachedFileName = name - _state.update { it.copy(fileName = name) } - preview(uri, name) - } - } - - private suspend fun preview(uri: Uri, name: String) { - runCatching { api.previewPluralSpaceImport(filePart(uri, name)) } - .onSuccess { summary -> - _state.update { - it.copy( - isPreviewing = false, - preview = summary, - options = PluralSpaceImportOptions( - systemProfile = summary.systemName != null, - selectedMemberIds = null, - customFronts = summary.customFrontCount > 0, - groups = summary.groupCount > 0, - customFields = summary.customFieldCount > 0, - fronts = summary.frontCount > 0, - journalEntries = summary.journalEntryCount > 0, - chatMessages = summary.chatMessageCount > 0, - polls = summary.pollCount > 0, - ), - ) - } - } - .onFailure { e -> _state.update { it.copy(isPreviewing = false, error = e.toUserMessage("Preview failed — check the file and try again")) } } - } - - fun updateOptions(update: PluralSpaceImportOptions.() -> PluralSpaceImportOptions) { - _state.update { it.copy(options = it.options.update()) } - } - - fun toggleMember(id: String) { - val preview = _state.value.preview ?: return - val current = _state.value.options.selectedMemberIds - ?: preview.members.map { it.id }.toSet() - val updated = if (id in current) current - id else current + id - _state.update { it.copy(options = it.options.copy(selectedMemberIds = updated)) } - } - - // 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 ?: "export.zip" - val opts = _state.value.options - val narrowedMemberIds = _state.value.preview?.members - ?.map { it.id } - ?.let { all -> - val selected = opts.selectedMemberIds - if (selected == null || selected.containsAll(all)) null else selected.toList() - } - - viewModelScope.launch { - _state.update { it.copy(isImporting = true, error = null) } - runCatching { - val job = api.createFileImport( - file = filePart(uri, name), - source = ImportJobSource.PLURALSPACE_FILE.toFormPart(), - idempotencyKey = nextIdempotencyKey().toFormPart(), - options = buildOptionsJson(opts, narrowedMemberIds).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 = PluralSpaceImportUiState() - } - - 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 the PluralSpace importer's options model - * exactly. `conflict_strategy` defaults to "skip" (mirrors the web client). - */ -private fun buildOptionsJson(opts: PluralSpaceImportOptions, memberIds: List?): String { - val parts = mutableListOf() - parts += "\"system_profile\":${opts.systemProfile}" - parts += "\"conflict_strategy\":\"skip\"" - parts += "\"custom_fronts\":${opts.customFronts}" - parts += "\"member_avatars\":${opts.memberAvatars}" - parts += "\"roles_as_tags\":${opts.rolesAsTags}" - parts += "\"groups\":${opts.groups}" - parts += "\"custom_fields\":${opts.customFields}" - parts += "\"fronts\":${opts.fronts}" - parts += "\"journal_entries\":${opts.journalEntries}" - parts += "\"chat_messages\":${opts.chatMessages}" - parts += "\"polls\":${opts.polls}" - if (memberIds != null) { - val ids = memberIds.joinToString(",") { jsonQuote(it) } - parts += "\"member_ids\":[$ids]" - } else { - parts += "\"member_ids\":null" - } - return parts.joinToString(",", prefix = "{", postfix = "}") -} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/prismimport/PrismImportScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/prismimport/PrismImportScreen.kt deleted file mode 100644 index ad5ca93..0000000 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/prismimport/PrismImportScreen.kt +++ /dev/null @@ -1,345 +0,0 @@ -package systems.lupine.sheaf.ui.prismimport - -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.text.KeyboardOptions -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.material.icons.outlined.Visibility -import androidx.compose.material.icons.outlined.VisibilityOff -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.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import systems.lupine.sheaf.data.model.PrismPreviewSummary -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 PrismImportScreen( - onNavigateUp: () -> Unit, - viewModel: PrismImportViewModel = 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 Prism") }, - 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( - preview = state.preview!!, - options = state.options, - onUpdateOptions = { viewModel.updateOptions(it) }, - onToggleMember = { viewModel.toggleMember(it) }, - onImport = { viewModel.runImport() }, - ) - state.isPreviewing -> CenterSpinner("Decrypting…") - else -> UploadSection( - fileName = state.fileName, - passphrase = state.passphrase, - onPickFile = { filePicker.launch(arrayOf("*/*")) }, - onPassphraseChange = { viewModel.updatePassphrase(it) }, - onPreview = { viewModel.runPreview() }, - ) - } - } - } -} - -@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 UploadSection( - fileName: String?, - passphrase: String, - onPickFile: () -> Unit, - onPassphraseChange: (String) -> Unit, - onPreview: () -> Unit, -) { - var showPassphrase by remember { mutableStateOf(false) } - - Column( - modifier = Modifier.fillMaxWidth().padding(top = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - "In Prism, go to Settings → Data → Export, choose a passphrase, and save the " + - ".prism file. Select it here together with the same passphrase. The passphrase " + - "is encrypted at rest while the import runs and wiped when it finishes.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon( - Icons.Outlined.FileOpen, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ) - Text( - fileName ?: "No file selected", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - maxLines = 1, - ) - TextButton(onClick = onPickFile) { Text(if (fileName == null) "Choose file" else "Change") } - } - - OutlinedTextField( - value = passphrase, - onValueChange = onPassphraseChange, - label = { Text("Decryption passphrase") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - visualTransformation = if (showPassphrase) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - IconButton(onClick = { showPassphrase = !showPassphrase }) { - Icon( - if (showPassphrase) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = if (showPassphrase) "Hide passphrase" else "Show passphrase", - ) - } - }, - ) - - Button( - onClick = onPreview, - enabled = fileName != null && passphrase.isNotBlank(), - modifier = Modifier.fillMaxWidth().height(52.dp), - ) { Text("Decrypt + preview") } - } -} - -@Composable -private fun PreviewSection( - preview: PrismPreviewSummary, - options: PrismImportOptions, - onUpdateOptions: (PrismImportOptions.() -> PrismImportOptions) -> Unit, - onToggleMember: (String) -> Unit, - onImport: () -> Unit, -) { - val effectiveMemberIds = options.selectedMemberIds ?: preview.members.map { it.id }.toSet() - - if (preview.systemName != null) { - Text("System: ${preview.systemName}", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - } - - HorizontalDivider() - SectionHeader("What to import") - - if (preview.systemName != null) { - ToggleRow( - label = "System profile", - checked = options.systemProfile, - onCheckedChange = { onUpdateOptions { copy(systemProfile = it) } }, - ) - } - - if (preview.memberCount > 0) { - ToggleRow( - label = "Members (${preview.memberCount})", - checked = effectiveMemberIds.isNotEmpty(), - onCheckedChange = { checked -> - val ids = if (checked) preview.members.map { it.id }.toSet() else emptySet() - onUpdateOptions { copy(selectedMemberIds = ids) } - }, - ) - if (preview.members.isNotEmpty()) { - Column(modifier = Modifier.padding(start = 16.dp)) { - preview.members.forEach { member -> - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Checkbox( - checked = member.id in effectiveMemberIds, - onCheckedChange = { onToggleMember(member.id) }, - ) - Text(member.name, style = MaterialTheme.typography.bodyMedium) - } - } - } - } - ToggleRow( - label = "Member avatars", - checked = options.memberAvatars, - onCheckedChange = { onUpdateOptions { copy(memberAvatars = it) } }, - ) - } - - if (preview.groupCount > 0) { - ToggleRow( - label = "Member groups (${preview.groupCount})", - checked = options.memberGroups, - onCheckedChange = { onUpdateOptions { copy(memberGroups = it) } }, - ) - } - - if (preview.customFieldCount > 0) { - ToggleRow( - label = "Custom fields (${preview.customFieldCount})", - checked = options.customFields, - onCheckedChange = { onUpdateOptions { copy(customFields = it) } }, - ) - } - - if (preview.frontSessionCount > 0) { - ToggleRow( - label = "Front history (${preview.frontSessionCount} sessions)", - checked = options.frontSessions, - onCheckedChange = { onUpdateOptions { copy(frontSessions = it) } }, - ) - } - - if (preview.noteCount > 0) { - ToggleRow( - label = "Notes (${preview.noteCount}, as journal entries)", - checked = options.notes, - onCheckedChange = { onUpdateOptions { copy(notes = it) } }, - ) - } - - if (preview.pollCount > 0) { - ToggleRow( - label = "Polls (${preview.pollCount})", - checked = options.polls, - onCheckedChange = { onUpdateOptions { copy(polls = it) } }, - ) - } - - if (preview.conversationCount > 0) { - ToggleRow( - label = "Chat messages (${preview.messageCount}, collapsed to system board)", - checked = options.conversations, - onCheckedChange = { onUpdateOptions { copy(conversations = it) } }, - ) - } - - if (preview.memberBoardPostCount > 0) { - ToggleRow( - label = "Member board posts (${preview.memberBoardPostCount})", - checked = options.memberBoardPosts, - onCheckedChange = { onUpdateOptions { copy(memberBoardPosts = it) } }, - ) - } - - if (preview.mediaAttachmentCount > 0) { - ToggleRow( - label = "Media attachments (${preview.mediaAttachmentCount})", - checked = options.mediaAttachments, - onCheckedChange = { onUpdateOptions { copy(mediaAttachments = it) } }, - ) - } - - val skipped = preview.sleepSessionCount + preview.habitCount + preview.reminderCount - if (skipped > 0) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Spacer(Modifier.width(12.dp)) - Text( - "Skipped on import: ${preview.sleepSessionCount} sleep, ${preview.habitCount} habits, " + - "${preview.reminderCount} reminders — no Sheaf surface for these yet.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - Spacer(Modifier.height(4.dp)) - - Button(onClick = onImport, modifier = Modifier.fillMaxWidth().height(52.dp)) { - Text("Import") - } -} - -@Composable -private fun ToggleRow(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 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/prismimport/PrismImportViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/prismimport/PrismImportViewModel.kt deleted file mode 100644 index 824b277..0000000 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/prismimport/PrismImportViewModel.kt +++ /dev/null @@ -1,249 +0,0 @@ -package systems.lupine.sheaf.ui.prismimport - -import systems.lupine.sheaf.ui.importcommon.jsonQuote -import android.content.Context -import android.net.Uri -import android.provider.OpenableColumns -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import systems.lupine.sheaf.data.api.SheafApiService -import systems.lupine.sheaf.data.api.streamingFilePart -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.data.model.PrismPreviewSummary -import systems.lupine.sheaf.ui.importcommon.ImportResult -import systems.lupine.sheaf.ui.importcommon.terminalResult -import systems.lupine.sheaf.util.toUserMessage -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 java.util.UUID -import javax.inject.Inject - -data class PrismImportOptions( - val systemProfile: Boolean = true, - val selectedMemberIds: Set? = null, // null = all - val memberAvatars: Boolean = true, - val memberGroups: Boolean = true, - val customFields: Boolean = true, - val frontSessions: Boolean = true, - val notes: Boolean = true, - val polls: Boolean = true, - val conversations: Boolean = true, - val memberBoardPosts: Boolean = true, - val mediaAttachments: Boolean = true, -) - -data class PrismImportUiState( - val fileName: String? = null, - val passphrase: String = "", - val isPreviewing: Boolean = false, - val preview: PrismPreviewSummary? = null, - val options: PrismImportOptions = PrismImportOptions(), - val isImporting: Boolean = false, - val result: ImportResult? = null, - val error: String? = null, -) { - val hasFile: Boolean get() = fileName != null -} - -@HiltViewModel -class PrismImportViewModel @Inject constructor( - private val api: SheafApiService, - @ApplicationContext private val context: Context, -) : ViewModel() { - - private val _state = MutableStateFlow(PrismImportUiState()) - val state: StateFlow = _state.asStateFlow() - - private var fileUri: Uri? = null - private var cachedFileName: String? = null - - fun pickFile(uri: Uri) { - idempotencyKey = null - fileUri = uri - val name = resolveFileName(uri) ?: "export.prism" - cachedFileName = name - _state.update { it.copy(fileName = name, error = null) } - } - - fun updatePassphrase(value: String) { - _state.update { it.copy(passphrase = value, error = null) } - } - - fun runPreview() { - val uri = fileUri - if (uri == null) { - _state.update { it.copy(error = "Choose a .prism file first.") } - return - } - val passphrase = _state.value.passphrase - if (passphrase.isEmpty()) { - _state.update { it.copy(error = "Enter the decryption passphrase.") } - return - } - val name = cachedFileName ?: "export.prism" - viewModelScope.launch { - _state.update { it.copy(isPreviewing = true, error = null, preview = null, result = null) } - runCatching { api.previewPrismImport(filePart(uri, name), passphrase.toFormPart()) } - .onSuccess { summary -> - _state.update { - it.copy( - isPreviewing = false, - preview = summary, - options = PrismImportOptions( - systemProfile = summary.systemName != null, - selectedMemberIds = null, - memberGroups = summary.groupCount > 0, - customFields = summary.customFieldCount > 0, - frontSessions = summary.frontSessionCount > 0, - notes = summary.noteCount > 0, - polls = summary.pollCount > 0, - conversations = summary.conversationCount > 0, - memberBoardPosts = summary.memberBoardPostCount > 0, - mediaAttachments = summary.mediaAttachmentCount > 0, - ), - ) - } - } - .onFailure { e -> _state.update { it.copy(isPreviewing = false, error = e.toUserMessage("Couldn't decrypt the export — check the passphrase and try again")) } } - } - } - - fun updateOptions(update: PrismImportOptions.() -> PrismImportOptions) { - _state.update { it.copy(options = it.options.update()) } - } - - fun toggleMember(id: String) { - val preview = _state.value.preview ?: return - val current = _state.value.options.selectedMemberIds - ?: preview.members.map { it.id }.toSet() - val updated = if (id in current) current - id else current + id - _state.update { it.copy(options = it.options.copy(selectedMemberIds = updated)) } - } - - // 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 ?: "export.prism" - val passphrase = _state.value.passphrase.takeIf { it.isNotEmpty() } ?: return - val opts = _state.value.options - val narrowedMemberIds = _state.value.preview?.members - ?.map { it.id } - ?.let { all -> - val selected = opts.selectedMemberIds - if (selected == null || selected.containsAll(all)) null else selected.toList() - } - - viewModelScope.launch { - _state.update { it.copy(isImporting = true, error = null) } - runCatching { - val job = api.createFileImport( - file = filePart(uri, name), - source = ImportJobSource.PRISM_FILE.toFormPart(), - idempotencyKey = nextIdempotencyKey().toFormPart(), - options = buildOptionsJson(opts, narrowedMemberIds).toJsonPart(), - credential = passphrase.toFormPart(), - ) - pollUntilTerminal(job) - } - .onSuccess { final -> - // Drop the passphrase from memory once the server owns the - // encrypted copy on the job row. - _state.update { it.copy(passphrase = "") } - 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 = PrismImportUiState() - } - - 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 the Prism importer's options model exactly. - * `conflict_strategy` defaults to "skip" (mirrors the web client). - */ -private fun buildOptionsJson(opts: PrismImportOptions, memberIds: List?): String { - val parts = mutableListOf() - parts += "\"system_profile\":${opts.systemProfile}" - parts += "\"conflict_strategy\":\"skip\"" - parts += "\"member_avatars\":${opts.memberAvatars}" - parts += "\"member_groups\":${opts.memberGroups}" - parts += "\"custom_fields\":${opts.customFields}" - parts += "\"front_sessions\":${opts.frontSessions}" - parts += "\"notes\":${opts.notes}" - parts += "\"polls\":${opts.polls}" - parts += "\"conversations\":${opts.conversations}" - parts += "\"member_board_posts\":${opts.memberBoardPosts}" - parts += "\"media_attachments\":${opts.mediaAttachments}" - if (memberIds != null) { - val ids = memberIds.joinToString(",") { jsonQuote(it) } - parts += "\"member_ids\":[$ids]" - } else { - parts += "\"member_ids\":null" - } - 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 a85684f..2b71cc1 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 @@ -793,15 +793,7 @@ fun DataSettingsScreen( onNavigateUp: () -> Unit, onNavigateToFiles: () -> Unit, onNavigateToExportData: () -> Unit, - onNavigateToSpImport: () -> Unit, - onNavigateToSheafImport: () -> Unit, - onNavigateToPkFileImport: () -> Unit, - onNavigateToPkApiImport: () -> Unit, - onNavigateToTupperboxImport: () -> Unit, - onNavigateToPluralSpaceImport: () -> Unit, - onNavigateToPrismImport: () -> Unit, - onNavigateToOpenPluralImport: () -> Unit, - onNavigateToAmpersandImport: () -> Unit, + onNavigateToImport: () -> Unit, onNavigateToImportHistory: () -> Unit, viewModel: SettingsViewModel = hiltViewModel(), ) { @@ -854,65 +846,9 @@ fun DataSettingsScreen( HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) SettingItem( icon = Icons.Outlined.Upload, - title = "Import from Simply Plural", - subtitle = "Import members, groups, and history", - onClick = onNavigateToSpImport, - ) - HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) - SettingItem( - icon = Icons.Outlined.Upload, - title = "Import from Sheaf Export", - subtitle = "Restore from a Sheaf JSON backup", - onClick = onNavigateToSheafImport, - ) - HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) - SettingItem( - icon = Icons.Outlined.Upload, - title = "Import from PluralKit (file)", - subtitle = "Use a PK export JSON from `pk;export`", - onClick = onNavigateToPkFileImport, - ) - HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) - SettingItem( - icon = Icons.Outlined.CloudDownload, - title = "Import from PluralKit (API)", - subtitle = "Connect with your PK token to import live", - onClick = onNavigateToPkApiImport, - ) - HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) - SettingItem( - icon = Icons.Outlined.Upload, - title = "Import from Tupperbox", - subtitle = "Use a Tupperbox export JSON from `tul!export`", - onClick = onNavigateToTupperboxImport, - ) - HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) - SettingItem( - icon = Icons.Outlined.Upload, - title = "Import from PluralSpace", - subtitle = "Use a PluralSpace .zip data export", - onClick = onNavigateToPluralSpaceImport, - ) - HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) - SettingItem( - icon = Icons.Outlined.Upload, - title = "Import from Prism", - subtitle = "Use an encrypted .prism export and its passphrase", - onClick = onNavigateToPrismImport, - ) - HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) - SettingItem( - icon = Icons.Outlined.Upload, - title = "Import from OpenPlural", - subtitle = "Use an OpenPlural .json or .openplural.zip export", - 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, + title = "Import data", + subtitle = "Simply Plural, PluralKit, Tupperbox, Prism, and more", + onClick = onNavigateToImport, ) HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) SettingItem( diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sheafimport/SheafImportScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sheafimport/SheafImportScreen.kt deleted file mode 100644 index 038949e..0000000 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sheafimport/SheafImportScreen.kt +++ /dev/null @@ -1,266 +0,0 @@ -package systems.lupine.sheaf.ui.sheafimport - -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.SheafImportResult -import systems.lupine.sheaf.data.model.SheafPreviewSummary -import systems.lupine.sheaf.ui.components.ErrorBanner -import systems.lupine.sheaf.ui.components.SectionHeader -import systems.lupine.sheaf.ui.components.SheafTopAppBar - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SheafImportScreen( - onNavigateUp: () -> Unit, - viewModel: SheafImportViewModel = 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 Sheaf Export") }, - 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 -> SheafResultSection( - result = state.result!!, - onImportAnother = { viewModel.reset() }, - ) - state.isImporting -> Box( - Modifier.fillMaxWidth().padding(vertical = 48.dp), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { - CircularProgressIndicator() - Text("Importing…", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - state.preview != null -> SheafPreviewSection( - fileName = state.fileName!!, - preview = state.preview!!, - options = state.options, - onUpdateOptions = { viewModel.updateOptions(it) }, - onImport = { viewModel.runImport() }, - onChangeFile = { filePicker.launch(arrayOf("*/*")) }, - ) - state.isPreviewing -> Box( - Modifier.fillMaxWidth().padding(vertical = 48.dp), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { - CircularProgressIndicator() - Text("Reading file…", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - else -> SheafFilePickSection(onPick = { filePicker.launch(arrayOf("*/*")) }) - } - } - } -} - -@Composable -private fun SheafFilePickSection(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 Sheaf export to get started: the JSON export, or a complete backup zip that includes images.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Button(onClick = onPick) { Text("Choose file") } - Text( - "Export your data from Sheaf, then select the downloaded file here. A JSON export brings your data across; a complete backup (zip) brings your images too.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - ) - } -} - -@Composable -private fun SheafPreviewSection( - fileName: String, - preview: SheafPreviewSummary, - options: SheafImportOptions, - onUpdateOptions: (SheafImportOptions.() -> SheafImportOptions) -> 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") - - SheafImportToggleRow( - label = "System profile", - checked = options.systemProfile, - onCheckedChange = { onUpdateOptions { copy(systemProfile = it) } }, - ) - - if (preview.memberCount > 0) { - SheafImportToggleRow( - label = "Members (${preview.memberCount})", - checked = true, - onCheckedChange = {}, - enabled = false, - ) - } - - if (preview.frontCount > 0) { - SheafImportToggleRow( - label = "Front history (${preview.frontCount} entries)", - checked = options.fronts, - onCheckedChange = { onUpdateOptions { copy(fronts = it) } }, - ) - } - - if (preview.groupCount > 0) { - SheafImportToggleRow( - label = "Groups (${preview.groupCount})", - checked = options.groups, - onCheckedChange = { onUpdateOptions { copy(groups = it) } }, - ) - } - - if (preview.tagCount > 0) { - SheafImportToggleRow( - label = "Tags (${preview.tagCount})", - checked = options.tags, - onCheckedChange = { onUpdateOptions { copy(tags = it) } }, - ) - } - - if (preview.customFieldCount > 0) { - SheafImportToggleRow( - label = "Custom fields (${preview.customFieldCount})", - checked = options.customFields, - onCheckedChange = { onUpdateOptions { copy(customFields = it) } }, - ) - } - - // Complete-backup zip: images ride with the records they belong to - // (avatars, banners, ...), so there's no separate toggle; surface the - // count so the user knows they're included. - if (preview.archive && preview.imageCount > 0) { - SheafImportToggleRow( - label = "Images (${preview.imageCount})", - checked = true, - onCheckedChange = {}, - enabled = false, - ) - } - - Spacer(Modifier.height(4.dp)) - - Button( - onClick = onImport, - modifier = Modifier.fillMaxWidth().height(52.dp), - ) { Text("Import") } -} - -@Composable -private fun SheafImportToggleRow( - label: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - enabled: Boolean = true, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Checkbox(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) - Text(label, style = MaterialTheme.typography.bodyMedium) - } -} - -@Composable -private fun SheafResultSection(result: SheafImportResult, onImportAnother: () -> Unit) { - SectionHeader("Import complete") - - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - SheafResultRow("Members imported", result.membersImported) - SheafResultRow("Front history entries", result.frontsImported) - SheafResultRow("Groups imported", result.groupsImported) - SheafResultRow("Tags imported", result.tagsImported) - SheafResultRow("Custom fields imported", result.customFieldsImported) - if (result.imagesImported > 0) { - SheafResultRow("Images imported", result.imagesImported) - } - } - } - - 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 SheafResultRow(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/sheafimport/SheafImportViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sheafimport/SheafImportViewModel.kt deleted file mode 100644 index 7c61da7..0000000 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sheafimport/SheafImportViewModel.kt +++ /dev/null @@ -1,214 +0,0 @@ -package systems.lupine.sheaf.ui.sheafimport - -import android.content.Context -import android.net.Uri -import android.provider.OpenableColumns -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import systems.lupine.sheaf.data.api.SheafApiService -import systems.lupine.sheaf.data.api.streamingFilePart -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.data.model.SheafImportResult -import systems.lupine.sheaf.data.model.SheafPreviewSummary -import systems.lupine.sheaf.util.toUserMessage -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 java.util.UUID -import javax.inject.Inject - -data class SheafImportOptions( - val systemProfile: Boolean = true, - val fronts: Boolean = true, - val groups: Boolean = true, - val tags: Boolean = true, - val customFields: Boolean = true, -) - -data class SheafImportUiState( - val fileName: String? = null, - val isPreviewing: Boolean = false, - val preview: SheafPreviewSummary? = null, - val options: SheafImportOptions = SheafImportOptions(), - val isImporting: Boolean = false, - val result: SheafImportResult? = null, - val error: String? = null, -) - -@HiltViewModel -class SheafImportViewModel @Inject constructor( - private val api: SheafApiService, - @ApplicationContext private val context: Context, -) : ViewModel() { - - private val _state = MutableStateFlow(SheafImportUiState()) - 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) ?: "sheaf_export.json" - cachedFileName = name - _state.update { it.copy(fileName = name) } - preview(uri, name) - } - } - - private suspend fun preview(uri: Uri, name: String) { - runCatching { api.previewSheafImport(filePart(uri, name)) } - .onSuccess { summary -> - _state.update { - it.copy( - isPreviewing = false, - preview = summary, - options = SheafImportOptions( - systemProfile = true, - fronts = summary.frontCount > 0, - groups = summary.groupCount > 0, - tags = summary.tagCount > 0, - customFields = summary.customFieldCount > 0, - ), - ) - } - } - .onFailure { e -> _state.update { it.copy(isPreviewing = false, error = e.toUserMessage("Preview failed — check the file and try again")) } } - } - - fun updateOptions(update: SheafImportOptions.() -> SheafImportOptions) { - _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 ?: "sheaf_export.json" - val opts = _state.value.options - // The preview tells us whether this is a complete-backup zip; submit - // under the matching source so the runner unpacks the images. - val source = if (_state.value.preview?.archive == true) { - ImportJobSource.SHEAF_ARCHIVE - } else { - ImportJobSource.SHEAF_FILE - } - viewModelScope.launch { - _state.update { it.copy(isImporting = true, error = null) } - runCatching { - val job = api.createFileImport( - file = filePart(uri, name), - source = source.toFormPart(), - idempotencyKey = nextIdempotencyKey().toFormPart(), - options = buildSheafOptionsJson(opts).toJsonPart(), - ) - pollUntilTerminal(job) - } - .onSuccess { final -> handleTerminal(final) } - .onFailure { e -> _state.update { it.copy(isImporting = false, error = e.toUserMessage("Import failed — please try again")) } } - } - } - - /** See [ImportViewModel.pollUntilTerminal] for the polling-loop rationale. */ - 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 (job.status) { - ImportJobStatus.COMPLETE -> { - val counts = job.counts - val warnings = job.events - .filter { it.level == "warning" } - .map { e -> e.recordRef?.let { "$it: ${e.message}" } ?: e.message } - val result = SheafImportResult( - membersImported = counts["members_imported"] ?: 0, - frontsImported = counts["fronts_imported"] ?: 0, - groupsImported = counts["groups_imported"] ?: 0, - tagsImported = counts["tags_imported"] ?: 0, - customFieldsImported = counts["custom_fields_imported"] ?: 0, - imagesImported = counts["images_imported"] ?: 0, - warnings = warnings, - ) - _state.update { it.copy(isImporting = false, result = result) } - } - 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 = SheafImportUiState() - } - - 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-built options JSON — the backend uses extra="forbid" so any - * typo'd field 422s; spelling it out keeps the field names visible - * at the call site rather than buried in a serializer. - * - * Only the toggles the existing Sheaf import screen exposes are - * surfaced. Other SheafImportOptions slots (journals, messages, polls, - * reminders, notifications) fall through to their backend defaults - * (currently True) — future UI work can add them as explicit toggles. - */ -private fun buildSheafOptionsJson(opts: SheafImportOptions): String { - val parts = listOf( - "\"system_profile\":${opts.systemProfile}", - "\"fronts\":${opts.fronts}", - "\"groups\":${opts.groups}", - "\"tags\":${opts.tags}", - "\"custom_fields\":${opts.customFields}", - ) - return parts.joinToString(",", prefix = "{", postfix = "}") -} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/tbimport/TupperboxImportScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/tbimport/TupperboxImportScreen.kt deleted file mode 100644 index 3dd0807..0000000 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/tbimport/TupperboxImportScreen.kt +++ /dev/null @@ -1,203 +0,0 @@ -package systems.lupine.sheaf.ui.tbimport - -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.TBImportResult -import systems.lupine.sheaf.data.model.TBPreviewSummary -import systems.lupine.sheaf.ui.components.ErrorBanner -import systems.lupine.sheaf.ui.components.SectionHeader -import systems.lupine.sheaf.ui.components.SheafTopAppBar - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun TupperboxImportScreen( - onNavigateUp: () -> Unit, - viewModel: TupperboxImportViewModel = 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 Tupperbox") }, - 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 Tupperbox export JSON to get started.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Button(onClick = onPick) { Text("Choose file") } - Text( - "Export from Tupperbox with the `tul!export` command in DM. Tupperbox sends back a JSON file with your tuppers and groups; pick it here. " + - "Tupperbox exports don't carry fronting history or system-level metadata, so only members and groups land in Sheaf.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - ) - } -} - -@Composable -private fun PreviewSection( - fileName: String, - preview: TBPreviewSummary, - options: TBImportOptions, - onUpdateOptions: (TBImportOptions.() -> TBImportOptions) -> 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") - - if (preview.memberCount > 0) { - ToggleRow( - label = "Tuppers (${preview.memberCount})", - checked = true, - onCheckedChange = {}, - enabled = false, - ) - } - - if (preview.groupCount > 0) { - ToggleRow( - label = "Groups (${preview.groupCount})", - checked = options.groups, - onCheckedChange = { onUpdateOptions { copy(groups = it) } }, - ) - } - - Spacer(Modifier.height(4.dp)) - - Button( - onClick = onImport, - modifier = Modifier.fillMaxWidth().height(52.dp), - ) { Text("Import") } -} - -@Composable -private fun ToggleRow( - label: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - enabled: Boolean = true, -) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Checkbox(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) - Text(label, style = MaterialTheme.typography.bodyMedium) - } -} - -@Composable -private fun ResultSection(result: TBImportResult, onImportAnother: () -> Unit) { - SectionHeader("Import complete") - - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - ResultRow("Tuppers imported", result.membersImported) - ResultRow("Groups imported", result.groupsImported) - } - } - - if (result.warnings.isNotEmpty()) { - SectionHeader("Warnings") - result.warnings.forEach { w -> - Text("• $w", 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/tbimport/TupperboxImportViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/tbimport/TupperboxImportViewModel.kt deleted file mode 100644 index 5221e61..0000000 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/tbimport/TupperboxImportViewModel.kt +++ /dev/null @@ -1,217 +0,0 @@ -package systems.lupine.sheaf.ui.tbimport - -import systems.lupine.sheaf.ui.importcommon.jsonQuote -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.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -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.ImportJobRead -import systems.lupine.sheaf.data.model.ImportJobSource -import systems.lupine.sheaf.data.model.ImportJobStatus -import systems.lupine.sheaf.data.model.TBImportResult -import systems.lupine.sheaf.data.model.TBPreviewSummary -import systems.lupine.sheaf.util.toUserMessage -import java.util.UUID -import javax.inject.Inject - -data class TBImportOptions( - val selectedMemberIds: Set? = null, - val groups: Boolean = true, -) - -data class TBImportUiState( - val fileName: String? = null, - val isPreviewing: Boolean = false, - val preview: TBPreviewSummary? = null, - val options: TBImportOptions = TBImportOptions(), - val isImporting: Boolean = false, - val result: TBImportResult? = null, - val error: String? = null, -) - -/** - * Tupperbox file import. Smaller surface than PK/SP/Sheaf — TB exports - * carry just tuppers + groups, no system metadata, no fronting history. - */ -@HiltViewModel -class TupperboxImportViewModel @Inject constructor( - private val api: SheafApiService, - @ApplicationContext private val context: Context, -) : ViewModel() { - - private val _state = MutableStateFlow(TBImportUiState()) - 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) ?: "tuppers.json" - cachedFileName = name - _state.update { it.copy(fileName = name) } - preview(uri, name) - } - } - - private suspend fun preview(uri: Uri, name: String) { - runCatching { api.previewTupperboxImport(filePart(uri, name)) } - .onSuccess { summary -> - _state.update { - it.copy( - isPreviewing = false, - preview = summary, - options = TBImportOptions( - selectedMemberIds = null, - groups = summary.groupCount > 0, - ), - ) - } - } - .onFailure { e -> - _state.update { - it.copy( - isPreviewing = false, - error = e.toUserMessage("Preview failed — check the file and try again"), - ) - } - } - } - - fun updateOptions(update: TBImportOptions.() -> TBImportOptions) { - _state.update { it.copy(options = it.options.update()) } - } - - fun toggleMember(id: String) { - val preview = _state.value.preview ?: return - val current = _state.value.options.selectedMemberIds - ?: preview.members.map { it.id }.toSet() - val updated = if (id in current) current - id else current + id - _state.update { it.copy(options = it.options.copy(selectedMemberIds = updated)) } - } - - // 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 ?: "tuppers.json" - val opts = _state.value.options - val narrowedMemberIds = _state.value.preview?.members - ?.map { it.id } - ?.let { all -> - val selected = opts.selectedMemberIds - if (selected == null || selected.containsAll(all)) null else selected.toList() - } - - viewModelScope.launch { - _state.update { it.copy(isImporting = true, error = null) } - runCatching { - val job = api.createFileImport( - file = filePart(uri, name), - source = ImportJobSource.TUPPERBOX_FILE.toFormPart(), - idempotencyKey = nextIdempotencyKey().toFormPart(), - options = buildTbOptionsJson(opts, narrowedMemberIds).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 (job.status) { - ImportJobStatus.COMPLETE -> { - val counts = job.counts - val warnings = job.events - .filter { it.level == "warning" } - .map { e -> e.recordRef?.let { "$it: ${e.message}" } ?: e.message } - val result = TBImportResult( - membersImported = counts["members_imported"] ?: 0, - groupsImported = counts["groups_imported"] ?: 0, - warnings = warnings, - ) - _state.update { it.copy(isImporting = false, result = result) } - } - 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 = TBImportUiState() - } - - 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 - } -} - -internal fun buildTbOptionsJson(opts: TBImportOptions, memberIds: List?): String { - val parts = mutableListOf() - parts += "\"groups\":${opts.groups}" - if (memberIds != null) { - val ids = memberIds.joinToString(",") { jsonQuote(it) } - parts += "\"member_ids\":[$ids]" - } - return parts.joinToString(",", prefix = "{", postfix = "}") -} diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/ui/importcommon/ImportBodyTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/importcommon/ImportBodyTest.kt index d172377..fb60322 100644 --- a/sheaf/app/src/test/java/systems/lupine/sheaf/ui/importcommon/ImportBodyTest.kt +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/importcommon/ImportBodyTest.kt @@ -5,10 +5,15 @@ import com.squareup.moshi.Types import systems.lupine.sheaf.data.model.ImportJobEvent import systems.lupine.sheaf.data.model.ImportJobRead import systems.lupine.sheaf.data.model.ImportJobStatus -import systems.lupine.sheaf.ui.pkapiimport.buildApiImportBodyJson +import systems.lupine.sheaf.ui.importflow.ImportCategory +import systems.lupine.sheaf.ui.importflow.ImportPreview +import systems.lupine.sheaf.ui.importflow.buildApiImportBodyJson +import systems.lupine.sheaf.ui.importflow.buildOptionsJson +import systems.lupine.sheaf.ui.importflow.importSourceById import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertTrue import kotlin.test.assertNull /** @@ -111,6 +116,48 @@ class ImportBodyTest { assertEquals(listOf("Members imported" to 12, "Groups imported" to 3), result.rows()) } + // ── Options bodies (one builder now, nine sources) ────────────────────── + + private fun options(sourceId: String, values: Map, memberIds: List? = null) = + parse(buildOptionsJson(importSourceById(sourceId)!!, values, memberIds)) + + @Test fun `a hidden category is still written, at its default`() { + // The backend forbids unknown fields but expects the known ones, so a + // category the export didn't populate has to go out as false rather + // than be dropped. Nine hand-written builders each got this right on + // their own; this is the one that has to now. + val preview = ImportPreview( + categories = listOf( + ImportCategory("groups", "Groups (2)", visible = true, default = true), + ImportCategory("front_history", "Front history", visible = false, default = false), + ), + ) + val body = options("simplyplural", preview.defaults()) + assertEquals(true, body["groups"]) + assertEquals(false, body["front_history"]) + } + + @Test fun `sources that want an explicit null member_ids get one`() { + // Prism, PluralSpace and Ampersand send "member_ids":null; Simply + // Plural, PluralKit and Tupperbox omit the key entirely. Asserted on + // the raw body because the map adapter here won't carry a null value. + val prism = buildOptionsJson(importSourceById("prism")!!, emptyMap(), null) + val sp = buildOptionsJson(importSourceById("simplyplural")!!, emptyMap(), null) + assertTrue(""""member_ids":null""" in prism, prism) + assertTrue("member_ids" !in sp, sp) + } + + @Test fun `a narrowed selection sends the ids it was given`() { + val body = options("simplyplural", mapOf("groups" to true), memberIds = listOf("m1", "m2")) + assertEquals(listOf("m1", "m2"), body["member_ids"]) + } + + @Test fun `constant fields ride along with the toggles`() { + val body = options("pluralspace", mapOf("polls" to false)) + assertEquals("skip", body["conflict_strategy"]) + assertEquals(false, body["polls"]) + } + @Test fun `zero counts are dropped and the rest sort by size`() { val result = ImportResult( counts = mapOf("groups_imported" to 3, "members_imported" to 12, "fronts_imported" to 0),