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
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,47 @@ All notable changes to the Sheaf Android client are recorded here. Format loosel
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project
uses semantic versioning (`MAJOR.MINOR.PATCH`).

## [Unreleased]

### Added

- **Import and export OpenPlural.** OpenPlural v0.1 is an interchange
format shared across plural apps. You can now import an OpenPlural
`.json` or `.openplural.zip` (with images) export from another app, and
export your own data in OpenPlural format.

- **Export got a proper home.** Settings now has an Export data screen
with a format picker (Sheaf or OpenPlural), a quick JSON-only export,
and a "full backup with images" option. The full backup builds in the
background (confirmed with your password, plus your authenticator code
if you use 2FA) and then appears in a Recent backups list to download.

- **Support screen.** Settings now has a Support entry showing any
custom message your instance operator has set, plus their contact,
service status, and policy links (when configured), and links to the
project source and security contact.

- **Archive members.** Members can be archived: a reversible soft-hide
that keeps them out of the main roster and switcher without deleting
them. Archive from the member editor or a member's long-press menu;
archived members appear in a collapsible section on the members list
and in a dedicated Settings > System > Archived members screen, each
with an Unarchive action.

- **Subgroups.** Groups can now nest. The group editor has a parent
picker, and the groups list indents subgroups under their parent.

- **Formatting help.** The markdown editor toolbar (member bios,
journals, group and system descriptions) gains a help button with a
quick formatting reference, including how to make a single line break
versus a new paragraph.

### Fixed

- **Front history editor rejects an end before the start.** Editing a
front entry now warns and blocks saving when the end time isn't after
the start time, instead of saving a backwards range.

## [1.1.1] - 2026-06-19

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,18 @@ interface SheafApiService {
@Body body: MemberDeleteConfirm = MemberDeleteConfirm(),
): Response<MemberDeletePending>

/** Archive (reversible soft-hide). [body] carries step-up credentials only
* when the system's archive safety category is on; an empty body is fine
* otherwise (the server then 4xxs and the caller retries with creds). */
@POST("/v1/members/{id}/archive")
suspend fun archiveMember(
@Path("id") id: String,
@Body body: MemberArchiveBody = MemberArchiveBody(),
): MemberRead

@POST("/v1/members/{id}/unarchive")
suspend fun unarchiveMember(@Path("id") id: String): MemberRead

@GET("/v1/members/{id}/revisions")
suspend fun listMemberBioRevisions(@Path("id") id: String): List<ContentRevisionRead>

Expand Down Expand Up @@ -421,8 +433,35 @@ interface SheafApiService {

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

/**
* Synchronous JSON export. [format] is "sheaf" (native, full-fidelity
* re-import) or "openplural" (v0.1 interchange, uri-only assets). No
* step-up; this is metadata only, no image bytes.
*/
@GET("/v1/export")
suspend fun exportAll(): okhttp3.ResponseBody
suspend fun exportAll(@Query("format") format: String = "sheaf"): okhttp3.ResponseBody

/**
* Enqueue an async full-backup job (JSON + image bytes, zipped). Body
* carries the format ("sheaf_native" or "openplural") and step-up
* credentials (password, plus totp_code when the account has 2FA). The
* server refuses API-key auth and allows only one in-flight job per user.
* Returns 202 + the pending [ExportJobRead]; poll [getExportJob] or
* refresh [listExportJobs] until status is "done", then [downloadExportJob].
*/
@POST("/v1/export/jobs")
suspend fun createExportJob(@Body body: ExportJobRequest): ExportJobRead

@GET("/v1/export/jobs")
suspend fun listExportJobs(): List<ExportJobRead>

@GET("/v1/export/jobs/{id}")
suspend fun getExportJob(@Path("id") id: String): ExportJobRead

/** Stream the finished backup zip. @Streaming so the zip isn't buffered. */
@Streaming
@GET("/v1/export/jobs/{id}/download")
suspend fun downloadExportJob(@Path("id") id: String): okhttp3.ResponseBody

// ── Imports (preview synchronous, submit async) ──────────────────────────
//
Expand Down Expand Up @@ -474,6 +513,18 @@ interface SheafApiService {
@Part file: MultipartBody.Part,
): PluralSpacePreviewSummary

/**
* Preview an OpenPlural v0.1 import. Accepts a bare `.json` export or an
* `.openplural.zip` bundle (the endpoint sniffs the zip magic). Reuses the
* Sheaf preview shape plus a `lineage_length`; submit via [createFileImport]
* with source [ImportJobSource.OPENPLURAL_FILE].
*/
@Multipart
@POST("/v1/import/openplural/preview")
suspend fun previewOpenPluralImport(
@Part file: MultipartBody.Part,
): SheafPreviewSummary

/**
* Preview a Prism (.prism) export. The PRISM1 envelope is decrypted
* server-side with [passphrase]; nothing is persisted. Submit goes
Expand Down
63 changes: 63 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 @@ -16,6 +16,17 @@ data class AuthConfig(
@Json(name = "file_cdn_base") val fileCdnBase: String? = null,
@Json(name = "captcha_provider") val captchaProvider: String? = null,
@Json(name = "captcha_on_login") val captchaOnLogin: Boolean = false,
// Operator-configured contact + policy links, surfaced on the Support
// screen. All optional; the operator may set none of them.
@Json(name = "support_email") val supportEmail: String? = null,
@Json(name = "support_url") val supportUrl: String? = null,
@Json(name = "support_note") val supportNote: String? = null,
// Operator-authored markdown shown at the top of the Support screen.
// Server strips any raw HTML before sending, so it's safe to render.
@Json(name = "support_custom_text") val supportCustomText: String? = null,
@Json(name = "status_url") val statusUrl: String? = null,
@Json(name = "terms_url") val termsUrl: String? = null,
@Json(name = "privacy_url") val privacyUrl: String? = null,
)

@JsonClass(generateAdapter = true)
Expand Down Expand Up @@ -401,8 +412,13 @@ data class MemberRead(
@Json(name = "created_at") val createdAt: String,
@Json(name = "updated_at") val updatedAt: String,
val emoji: String? = null,
// Set when the member is archived: a reversible soft-hide. The list
// endpoint still returns archived members, so the client filters them
// out of the main roster and surfaces them separately.
@Json(name = "archived_at") val archivedAt: String? = null,
) {
val displayNameOrName: String get() = displayName?.takeIf { it.isNotBlank() } ?: name
val isArchived: Boolean get() = archivedAt != null
val initials: String get() = displayNameOrName
.split("\\s+".toRegex())
.take(2)
Expand All @@ -425,6 +441,15 @@ data class MemberCreate(
val note: String? = null,
)

/** Optional step-up credentials for archiving a member. Only consulted when
* the system's "archive" safety category is enabled; an empty body is fine
* otherwise. */
@JsonClass(generateAdapter = true)
data class MemberArchiveBody(
val password: String? = null,
@Json(name = "totp_code") val totpCode: String? = null,
)

@JsonClass(generateAdapter = true)
data class MemberUpdate(
val name: String? = null,
Expand Down Expand Up @@ -734,6 +759,9 @@ data class SheafPreviewSummary(
// plain-JSON shape so older backends that don't return these still parse.
val archive: Boolean = false,
@Json(name = "image_count") val imageCount: Int = 0,
// OpenPlural previews also report how many prior exports the file has
// passed through (its lineage). Absent (0) for native Sheaf previews.
@Json(name = "lineage_length") val lineageLength: Int = 0,
)

@JsonClass(generateAdapter = true)
Expand Down Expand Up @@ -956,6 +984,41 @@ object ImportJobSource {
const val PLURALSPACE_FILE = "pluralspace_file"
// Passphrase-encrypted .prism export; the passphrase rides as `credential`.
const val PRISM_FILE = "prism_file"
// OpenPlural v0.1 interchange file. One source for both the bare .json
// 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"
}

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

/** Async full-backup (with images) request. Step-up: password always, plus
* totpCode when the account has 2FA. format is "sheaf_native" or
* "openplural" (note: the synchronous JSON export uses "sheaf"/"openplural"). */
@JsonClass(generateAdapter = true)
data class ExportJobRequest(
@Json(name = "include_images") val includeImages: Boolean = true,
val format: String,
val password: String,
@Json(name = "totp_code") val totpCode: String? = null,
)

@JsonClass(generateAdapter = true)
data class ExportJobRead(
val id: String,
@Json(name = "include_images") val includeImages: Boolean,
val format: String,
// pending | running | done | failed | expired
val status: String,
@Json(name = "requested_at") val requestedAt: String,
@Json(name = "started_at") val startedAt: String? = null,
@Json(name = "completed_at") val completedAt: String? = null,
@Json(name = "expires_at") val expiresAt: String? = null,
@Json(name = "file_size_bytes") val fileSizeBytes: Long? = null,
val error: String? = null,
) {
val isTerminal: Boolean get() = status == "done" || status == "failed" || status == "expired"
val isDownloadable: Boolean get() = status == "done"
}

/**
Expand Down
28 changes: 28 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 @@ -81,6 +81,7 @@ object Routes {
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 IMPORT_HISTORY = "settings/import/history"
const val IMPORT_DETAIL = "settings/import/history/{jobId}"
const val CUSTOM_FIELDS = "settings/fields"
Expand All @@ -92,14 +93,17 @@ object Routes {
const val ADMIN_USER_DETAIL = "settings/admin/user/{userId}"
const val SYSTEM_SAFETY = "settings/safety"
const val FILES = "settings/files"
const val EXPORT_DATA = "settings/export"
const val DEBUG = "settings/debug"
const val SUPPORT = "settings/support"
// Categorized settings detail screens.
const val SETTINGS_ACCOUNT = "settings/account"
const val SETTINGS_ADMIN_ACTIVITY = "settings/account/admin-activity"
const val SETTINGS_APPEARANCE = "settings/appearance"
const val SETTINGS_NOTIFICATIONS = "settings/notifications"
const val SETTINGS_SERVER = "settings/server"
const val SETTINGS_SYSTEM = "settings/sys"
const val ARCHIVED_MEMBERS = "settings/archived-members"
const val SETTINGS_DATA = "settings/data"
const val SETTINGS_SAFETY = "settings/safety-cat"
const val SETTINGS_DANGER = "settings/danger"
Expand Down Expand Up @@ -345,6 +349,7 @@ fun SheafApp(
onNavigateToSafety = { navController.navigate(Routes.SETTINGS_SAFETY) },
onNavigateToDanger = { navController.navigate(Routes.SETTINGS_DANGER) },
onNavigateToAdminPanel = { navController.navigate(Routes.ADMIN_PANEL) },
onNavigateToSupport = { navController.navigate(Routes.SUPPORT) },
onNavigateToDebug = { navController.navigate(Routes.DEBUG) },
)
}
Expand Down Expand Up @@ -473,6 +478,12 @@ fun SheafApp(
onNavigateUp = { navController.navigateUp() },
onNavigateToCustomFields = { navController.navigate(Routes.CUSTOM_FIELDS) },
onNavigateToTags = { navController.navigate(Routes.SETTINGS_TAGS) },
onNavigateToArchivedMembers = { navController.navigate(Routes.ARCHIVED_MEMBERS) },
)
}
composable(Routes.ARCHIVED_MEMBERS) {
systems.lupine.sheaf.ui.members.ArchivedMembersScreen(
onNavigateUp = { navController.navigateUp() },
)
}
composable(Routes.SETTINGS_TAGS) {
Expand All @@ -484,13 +495,15 @@ fun SheafApp(
systems.lupine.sheaf.ui.settings.DataSettingsScreen(
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) },
onNavigateToImportHistory = { navController.navigate(Routes.IMPORT_HISTORY) },
)
}
Expand Down Expand Up @@ -545,6 +558,16 @@ fun SheafApp(
onNavigateUp = { navController.navigateUp() },
)
}
composable(Routes.OPENPLURAL_IMPORT) {
systems.lupine.sheaf.ui.openpluralimport.OpenPluralImportScreen(
onNavigateUp = { navController.navigateUp() },
)
}
composable(Routes.EXPORT_DATA) {
systems.lupine.sheaf.ui.export.ExportDataScreen(
onNavigateUp = { navController.navigateUp() },
)
}
composable(Routes.IMPORT_HISTORY) {
systems.lupine.sheaf.ui.imports.ImportHistoryScreen(
onNavigateUp = { navController.navigateUp() },
Expand Down Expand Up @@ -603,6 +626,11 @@ fun SheafApp(
composable(Routes.FILES) {
systems.lupine.sheaf.ui.files.FilesScreen(onNavigateUp = { navController.navigateUp() })
}
composable(Routes.SUPPORT) {
systems.lupine.sheaf.ui.support.SupportScreen(
onNavigateUp = { navController.navigateUp() },
)
}
composable(Routes.DEBUG) {
DebugScreen(
onNavigateUp = { navController.navigateUp() },
Expand Down
Loading
Loading