Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ uses semantic versioning (`MAJOR.MINOR.PATCH`).

## [Unreleased]

### Added

- **Import from Ampersand.** Settings > Data now has an "Import from Ampersand"
option. Choose your Ampersand `.json` export and pick what to bring across
(members, custom fronts, tags, custom fields, front history, journals, notes,
board messages and polls, reminders, images, and Ampersand systems as groups);
the import runs in the background and shows a summary when it finishes.

### Security

- **Your login credentials only ever go to your own server now.** The app used to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,12 @@ interface SheafApiService {
@Part file: MultipartBody.Part,
): PluralSpacePreviewSummary

@Multipart
@POST("/v1/import/ampersand/preview")
suspend fun previewAmpersandImport(
@Part file: MultipartBody.Part,
): AmpersandPreviewSummary

/**
* Preview an OpenPlural v0.1 import. Accepts a bare `.json` export or an
* `.openplural.zip` bundle (the endpoint sniffs the zip magic). Reuses the
Expand Down
24 changes: 24 additions & 0 deletions sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1002,8 +1002,32 @@ object ImportJobSource {
// and the .openplural.zip bundle; the runner sniffs the zip magic and
// unpacks images when present (no separate archive source like Sheaf).
const val OPENPLURAL_FILE = "openplural_file"
const val AMPERSAND_FILE = "ampersand_file"
}

// ── Ampersand import ──────────────────────────────────────────────────────────
//
// Ampersand exports a plain .json file. The preview reports category counts
// only (no per-member list), so selective member import isn't offered on
// Android; every real member is imported. `groups` imports Ampersand systems as
// Sheaf groups; `board_messages` also gates polls.
@JsonClass(generateAdapter = true)
data class AmpersandPreviewSummary(
@Json(name = "system_count") val systemCount: Int = 0,
@Json(name = "member_count") val memberCount: Int = 0,
@Json(name = "custom_front_count") val customFrontCount: Int = 0,
@Json(name = "front_history_count") val frontHistoryCount: Int = 0,
@Json(name = "tag_count") val tagCount: Int = 0,
@Json(name = "custom_field_count") val customFieldCount: Int = 0,
@Json(name = "journal_count") val journalCount: Int = 0,
@Json(name = "note_count") val noteCount: Int = 0,
@Json(name = "board_message_count") val boardMessageCount: Int = 0,
@Json(name = "poll_count") val pollCount: Int = 0,
@Json(name = "reminder_count") val reminderCount: Int = 0,
@Json(name = "asset_count") val assetCount: Int = 0,
@Json(name = "limit_warnings") val limitWarnings: List<String> = emptyList(),
)

// ── Export ──────────────────────────────────────────────────────────────────

/** Async full-backup (with images) request. Step-up: password always, plus
Expand Down
7 changes: 7 additions & 0 deletions sheaf/app/src/main/java/systems/lupine/sheaf/ui/SheafApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ object Routes {
const val PS_IMPORT = "settings/import/pluralspace"
const val PRISM_IMPORT = "settings/import/prism"
const val OPENPLURAL_IMPORT = "settings/import/openplural"
const val AMPERSAND_IMPORT = "settings/import/ampersand"
const val IMPORT_HISTORY = "settings/import/history"
const val IMPORT_DETAIL = "settings/import/history/{jobId}"
const val CUSTOM_FIELDS = "settings/fields"
Expand Down Expand Up @@ -529,6 +530,7 @@ fun SheafApp(
onNavigateToPluralSpaceImport = { navController.navigate(Routes.PS_IMPORT) },
onNavigateToPrismImport = { navController.navigate(Routes.PRISM_IMPORT) },
onNavigateToOpenPluralImport = { navController.navigate(Routes.OPENPLURAL_IMPORT) },
onNavigateToAmpersandImport = { navController.navigate(Routes.AMPERSAND_IMPORT) },
onNavigateToImportHistory = { navController.navigate(Routes.IMPORT_HISTORY) },
)
}
Expand Down Expand Up @@ -588,6 +590,11 @@ fun SheafApp(
onNavigateUp = { navController.navigateUp() },
)
}
composable(Routes.AMPERSAND_IMPORT) {
systems.lupine.sheaf.ui.ampersandimport.AmpersandImportScreen(
onNavigateUp = { navController.navigateUp() },
)
}
composable(Routes.EXPORT_DATA) {
systems.lupine.sheaf.ui.export.ExportDataScreen(
onNavigateUp = { navController.navigateUp() },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
package systems.lupine.sheaf.ui.ampersandimport

import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.FileOpen
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import systems.lupine.sheaf.data.model.AmpersandPreviewSummary
import systems.lupine.sheaf.ui.components.ErrorBanner
import systems.lupine.sheaf.ui.components.SectionHeader
import systems.lupine.sheaf.ui.components.SheafTopAppBar
import systems.lupine.sheaf.ui.importcommon.ImportResult

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AmpersandImportScreen(
onNavigateUp: () -> Unit,
viewModel: AmpersandImportViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()

val filePicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri -> uri?.let { viewModel.pickFile(it) } }

Scaffold(
contentWindowInsets = WindowInsets(0),
topBar = {
SheafTopAppBar(
title = { Text("Import from Ampersand") },
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (state.error != null) ErrorBanner(state.error!!)

when {
state.result != null -> ResultSection(
result = state.result!!,
onImportAnother = { viewModel.reset() },
)
state.isImporting -> CenterSpinner("Importing…")
state.preview != null -> PreviewSection(
fileName = state.fileName!!,
preview = state.preview!!,
options = state.options,
onUpdateOptions = { viewModel.updateOptions(it) },
onImport = { viewModel.runImport() },
onChangeFile = { filePicker.launch(arrayOf("*/*")) },
)
state.isPreviewing -> CenterSpinner("Reading file…")
else -> FilePickSection(onPick = { filePicker.launch(arrayOf("*/*")) })
}
}
}
}

@Composable
private fun CenterSpinner(label: String) {
Box(Modifier.fillMaxWidth().padding(vertical = 48.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
CircularProgressIndicator()
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}

@Composable
private fun FilePickSection(onPick: () -> Unit) {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
Icons.Outlined.FileOpen,
contentDescription = null,
modifier = Modifier.size(52.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
Text("Choose your Ampersand export to get started.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Button(onClick = onPick) { Text("Choose file") }
Text(
"In Ampersand, open Settings → Import & export → Export, and save the " +
".json file. Then select it here.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
}
}

@Composable
private fun PreviewSection(
fileName: String,
preview: AmpersandPreviewSummary,
options: AmpersandImportOptions,
onUpdateOptions: (AmpersandImportOptions.() -> AmpersandImportOptions) -> Unit,
onImport: () -> Unit,
onChangeFile: () -> Unit,
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(fileName, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), maxLines = 1)
TextButton(onClick = onChangeFile) { Text("Change") }
}

HorizontalDivider()
SectionHeader("What to import")

// Members always import; shown for context, not as a toggle.
if (preview.memberCount > 0) {
Text(
"Members (${preview.memberCount})",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
}

if (preview.systemCount > 0) {
ImportToggleRow(
label = "Systems as groups (${preview.systemCount})",
checked = options.groups,
onCheckedChange = { onUpdateOptions { copy(groups = it) } },
)
}
if (preview.customFrontCount > 0) {
ImportToggleRow(
label = "Custom fronts (${preview.customFrontCount})",
checked = options.customFronts,
onCheckedChange = { onUpdateOptions { copy(customFronts = it) } },
)
}
if (preview.tagCount > 0) {
ImportToggleRow(
label = "Tags (${preview.tagCount})",
checked = options.tags,
onCheckedChange = { onUpdateOptions { copy(tags = it) } },
)
}
if (preview.customFieldCount > 0) {
ImportToggleRow(
label = "Custom fields (${preview.customFieldCount})",
checked = options.customFields,
onCheckedChange = { onUpdateOptions { copy(customFields = it) } },
)
}
if (preview.frontHistoryCount > 0) {
ImportToggleRow(
label = "Front history (${preview.frontHistoryCount} entries)",
checked = options.frontHistory,
onCheckedChange = { onUpdateOptions { copy(frontHistory = it) } },
)
}
if (preview.journalCount > 0) {
ImportToggleRow(
label = "Journal entries (${preview.journalCount})",
checked = options.journals,
onCheckedChange = { onUpdateOptions { copy(journals = it) } },
)
}
if (preview.noteCount > 0) {
ImportToggleRow(
label = "Notes (${preview.noteCount})",
checked = options.notes,
onCheckedChange = { onUpdateOptions { copy(notes = it) } },
)
}
if (preview.boardMessageCount > 0 || preview.pollCount > 0) {
val label = buildString {
append("Board messages")
if (preview.boardMessageCount > 0) append(" (${preview.boardMessageCount})")
if (preview.pollCount > 0) append(" & polls (${preview.pollCount})")
}
ImportToggleRow(
label = label,
checked = options.boardMessages,
onCheckedChange = { onUpdateOptions { copy(boardMessages = it) } },
)
}
if (preview.reminderCount > 0) {
ImportToggleRow(
label = "Reminders (${preview.reminderCount})",
checked = options.reminders,
onCheckedChange = { onUpdateOptions { copy(reminders = it) } },
)
}
if (preview.assetCount > 0) {
ImportToggleRow(
label = "Images (${preview.assetCount})",
checked = options.images,
onCheckedChange = { onUpdateOptions { copy(images = it) } },
)
}

preview.limitWarnings.forEach { warning ->
SkippedNote(warning)
}

Spacer(Modifier.height(4.dp))

Button(onClick = onImport, modifier = Modifier.fillMaxWidth().height(52.dp)) {
Text("Import")
}
}

@Composable
private fun ImportToggleRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Checkbox(checked = checked, onCheckedChange = onCheckedChange)
Text(label, style = MaterialTheme.typography.bodyMedium)
}
}

@Composable
private fun SkippedNote(text: String) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Spacer(Modifier.width(12.dp))
Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}

@Composable
private fun ResultSection(result: ImportResult, onImportAnother: () -> Unit) {
SectionHeader("Import complete")

val rows = result.rows()
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (rows.isEmpty()) {
Text("Nothing new to import.", style = MaterialTheme.typography.bodyMedium)
} else {
rows.forEach { (label, count) -> ResultRow(label, count) }
}
}
}

if (result.warnings.isNotEmpty()) {
SectionHeader("Warnings")
result.warnings.forEach { warning ->
Text("• $warning", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}

Spacer(Modifier.height(4.dp))

OutlinedButton(onClick = onImportAnother, modifier = Modifier.fillMaxWidth()) {
Text("Import another file")
}
}

@Composable
private fun ResultRow(label: String, count: Int) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Text(count.toString(), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
}
}
Loading
Loading