From a93d8959ba1121dd1ac022b17f52b0c17a88a3b3 Mon Sep 17 00:00:00 2001 From: Nocturnal <241546228+fullynocturnal@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:16:47 -0400 Subject: [PATCH 1/5] fix(api): stop dropping credentials and guards from member, group and system edits The hand-written PATCH adapters only wrote the fields they listed, and the list was missing password and totp_code on all three bodies, never_shareable and fronting_private on members, and privacy on groups. So a re-auth prompt never reached the server, the two member guards never saved, and a group's privacy could not be changed at all. Tests now build each body with every field set and check every key is on the wire, so a field added later without an adapter entry fails here. --- .../sheaf/data/api/PatchJsonAdapters.kt | 9 +++ .../sheaf/data/api/PatchJsonAdaptersTest.kt | 70 +++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/PatchJsonAdapters.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/PatchJsonAdapters.kt index 9e08a93..851dce6 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/PatchJsonAdapters.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/PatchJsonAdapters.kt @@ -77,6 +77,10 @@ class MemberUpdateJsonAdapter(moshi: Moshi) : JsonAdapter() { clears("birthday", value.birthday) clears("note", value.note) clears("emoji", value.emoji) + omitsWhenNull("never_shareable", value.neverShareable) + omitsWhenNull("fronting_private", value.frontingPrivate) + omitsWhenNull("password", value.password) + omitsWhenNull("totp_code", value.totpCode) } writer.endObject() } @@ -99,10 +103,13 @@ class GroupUpdateJsonAdapter(moshi: Moshi) : JsonAdapter() { writer.beginObject() PatchWriter(writer, anyAdapter).apply { omitsWhenNull("name", value.name) + omitsWhenNull("privacy", value.privacy) clears("description", value.description) clears("color", value.color) // Clearing this is how a subgroup is promoted back to top level. clears("parent_id", value.parentId) + omitsWhenNull("password", value.password) + omitsWhenNull("totp_code", value.totpCode) } writer.endObject() } @@ -135,6 +142,8 @@ class SystemUpdateJsonAdapter(moshi: Moshi) : JsonAdapter() { clears("avatar_url", value.avatarUrl) clears("color", value.color) clears("note", value.note) + omitsWhenNull("password", value.password) + omitsWhenNull("totp_code", value.totpCode) } writer.endObject() } diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/PatchJsonAdaptersTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/PatchJsonAdaptersTest.kt index a4443e9..7b805d7 100644 --- a/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/PatchJsonAdaptersTest.kt +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/PatchJsonAdaptersTest.kt @@ -104,6 +104,76 @@ class PatchJsonAdaptersTest { assertTrue("\"show_member_created_date\":false" in system(SystemUpdate(showMemberCreatedDate = false))) } + // ── Every field reaches the wire ────────────────────────────────────────── + + // A hand-written adapter only writes what it lists, so a field left off the + // list is dropped without a sound. That is how the ceilings and the step-up + // credentials went missing: re-auth never reached the server and the guard + // switches never saved. + + private fun assertAllKeys(json: String, keys: List) = + keys.forEach { assertTrue("\"$it\":" in json, "$it missing from: $json") } + + @Test fun `every member field is written when set`() { + val json = member( + MemberUpdate( + name = "A", displayName = "D", description = "d", pronouns = "p", + avatarUrl = "a", bannerUrl = "b", color = "#fff", birthday = "2000-01-01", + privacy = "public", note = "n", emoji = "x", + neverShareable = false, frontingPrivate = false, + password = "pw", totpCode = "123456", + ), + ) + assertAllKeys( + json, + listOf( + "name", "display_name", "description", "pronouns", "avatar_url", "banner_url", + "color", "birthday", "privacy", "note", "emoji", + "never_shareable", "fronting_private", "password", "totp_code", + ), + ) + assertTrue("\"never_shareable\":false" in json, json) + } + + @Test fun `every group field is written when set`() { + val json = group( + GroupUpdate( + name = "G", description = "d", color = "#fff", parentId = "p", + privacy = "public", password = "pw", totpCode = "123456", + ), + ) + assertAllKeys( + json, + listOf("name", "description", "color", "parent_id", "privacy", "password", "totp_code"), + ) + } + + @Test fun `every system field is written when set`() { + val json = system( + SystemUpdate( + name = "S", description = "d", tag = "t", avatarUrl = "a", color = "#fff", + privacy = "public", note = "n", showMemberCreatedDate = true, + password = "pw", totpCode = "123456", + ), + ) + assertAllKeys( + json, + listOf( + "name", "description", "tag", "avatar_url", "color", "privacy", "note", + "show_member_created_date", "password", "totp_code", + ), + ) + } + + @Test fun `unset ceilings, credentials and privacy are left out`() { + // All NOT NULL or step-up only: a null would fail the save or be noise. + listOf(member(MemberUpdate(name = "A")), group(GroupUpdate(name = "G")), system(SystemUpdate(name = "S"))) + .forEach { json -> + listOf("never_shareable", "fronting_private", "privacy", "password", "totp_code") + .forEach { assertFalse("\"$it\"" in json, "$it should be omitted: $json") } + } + } + @Test fun `the bodies stay valid JSON objects`() { listOf(member(MemberUpdate(name = "A")), group(GroupUpdate(name = "G")), system(SystemUpdate(name = "S"))) .forEach { From 45b6d74ffefa2a5ebf29edfd02a8bfe031beeb12 Mon Sep 17 00:00:00 2001 From: Nocturnal <241546228+fullynocturnal@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:16:47 -0400 Subject: [PATCH 2/5] feat(sharing): let a view show everyone set to Public Adds include_all_public_members from server 1.6.0 as another exposure flag, so it steps up and stages the same way the others do. While it is on, the members section says the hand-picked list is kept but not in use, and the audit line reports the served count instead of comparing it to that list. --- .../systems/lupine/sheaf/data/model/ShareModels.kt | 8 ++++++++ .../lupine/sheaf/ui/sharing/ShareViewDetailScreen.kt | 11 +++++++++++ .../sheaf/ui/sharing/ShareViewDetailViewModel.kt | 7 ++++++- .../systems/lupine/sheaf/ui/sharing/SharingScreen.kt | 4 ++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/ShareModels.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/ShareModels.kt index 0189225..363d844 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/ShareModels.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/ShareModels.kt @@ -67,6 +67,9 @@ data class ShareViewRead( // when the system is not public, and (member cards) with permalinks off. @Json(name = "link_preview_effective") val linkPreviewEffective: String = PREVIEW_GENERIC, @Json(name = "member_link_preview_effective") val memberLinkPreviewEffective: String = PREVIEW_GENERIC, + // Serve every member set to Public, worked out live, instead of the + // hand-picked list. `members` is kept but not consulted while this is on. + @Json(name = "include_all_public_members") val includeAllPublicMembers: Boolean = false, @Json(name = "created_at") val createdAt: String, @Json(name = "is_shared") val isShared: Boolean, // A flag flip that exposes more on an already-shared view is staged. The @@ -80,6 +83,7 @@ data class ShareViewRead( @Json(name = "pending_include_groups") val pendingIncludeGroups: Boolean? = null, @Json(name = "pending_link_preview_mode") val pendingLinkPreviewMode: String? = null, @Json(name = "pending_member_link_preview_mode") val pendingMemberLinkPreviewMode: String? = null, + @Json(name = "pending_include_all_public_members") val pendingIncludeAllPublicMembers: Boolean? = null, @Json(name = "flags_activate_at") val flagsActivateAt: String? = null, val members: List = emptyList(), val fields: List = emptyList(), @@ -116,6 +120,7 @@ data class ShareViewUpdate( @Json(name = "include_groups") val includeGroups: Boolean? = null, @Json(name = "link_preview_mode") val linkPreviewMode: String? = null, @Json(name = "member_link_preview_mode") val memberLinkPreviewMode: String? = null, + @Json(name = "include_all_public_members") val includeAllPublicMembers: Boolean? = null, @Json(name = "member_permalinks") val memberPermalinks: Boolean? = null, val password: String? = null, @Json(name = "totp_code") val totpCode: String? = null, @@ -200,6 +205,9 @@ data class ShareAuditEntry( @Json(name = "include_fronting") val includeFronting: Boolean, @Json(name = "include_relationships") val includeRelationships: Boolean, @Json(name = "include_groups") val includeGroups: Boolean, + // With this on, member_count is curation that is not being used and only + // served_member_count describes the page. + @Json(name = "include_all_public_members") val includeAllPublicMembers: Boolean = false, @Json(name = "member_permalinks") val memberPermalinks: Boolean, @Json(name = "relationship_count") val relationshipCount: Int, @Json(name = "group_count") val groupCount: Int, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailScreen.kt index 20e3a92..c876b41 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailScreen.kt @@ -189,6 +189,15 @@ fun ShareViewDetailScreen( ) SectionHeader("Members in this view") + if (view.includeAllPublicMembers) { + Text( + "This view shows everyone set to Public, so the list below is not being " + + "used. It is kept as it is, and comes back if you turn that off. " + + "Members marked never shareable are still never shown.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } view.members.forEach { row -> val name = state.allMembers.find { it.id == row.memberId }?.name ?: "Unknown member" ListItem( @@ -431,6 +440,7 @@ internal fun previewNote( private fun ExposureFlag.liveValue(v: ShareViewRead): Boolean = when (this) { ExposureFlag.INCLUDE_MEMBERS -> v.includeMembers + ExposureFlag.INCLUDE_ALL_PUBLIC_MEMBERS -> v.includeAllPublicMembers ExposureFlag.INCLUDE_BIO -> v.includeBio ExposureFlag.INCLUDE_FRONTING -> v.includeFronting ExposureFlag.FRONTING_SHOW_COUNT -> v.frontingShowCount @@ -440,6 +450,7 @@ private fun ExposureFlag.liveValue(v: ShareViewRead): Boolean = when (this) { private fun ExposureFlag.pendingValue(v: ShareViewRead): Boolean? = when (this) { ExposureFlag.INCLUDE_MEMBERS -> v.pendingIncludeMembers + ExposureFlag.INCLUDE_ALL_PUBLIC_MEMBERS -> v.pendingIncludeAllPublicMembers ExposureFlag.INCLUDE_BIO -> v.pendingIncludeBio ExposureFlag.INCLUDE_FRONTING -> v.pendingIncludeFronting ExposureFlag.FRONTING_SHOW_COUNT -> v.pendingFrontingShowCount diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailViewModel.kt index 2a31046..90d5949 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailViewModel.kt @@ -27,9 +27,13 @@ import systems.lupine.sheaf.data.model.ShareViewUpdate import systems.lupine.sheaf.util.toUserMessage import javax.inject.Inject -/** The six flags that widen what a view serves, and so carry a pending twin. */ +/** The flags that widen what a view serves, and so carry a pending twin. */ enum class ExposureFlag(val label: String, val supporting: String) { INCLUDE_MEMBERS("Show the roster", "The list of members in this view"), + INCLUDE_ALL_PUBLIC_MEMBERS( + "Show everyone set to Public", + "Follows each member's privacy instead of the list below, so someone you set to Public later appears without editing the view", + ), INCLUDE_BIO("Show bios", "Each member's description"), INCLUDE_FRONTING("Show who is fronting", "Live front state for members in this view"), FRONTING_SHOW_COUNT("Count hidden fronters", "Adds \"and N others not shown\""), @@ -207,6 +211,7 @@ class ShareViewDetailViewModel @Inject constructor( val body = when (r) { is ViewRaise.Flag -> when (r.flag) { ExposureFlag.INCLUDE_MEMBERS -> ShareViewUpdate(includeMembers = r.value) + ExposureFlag.INCLUDE_ALL_PUBLIC_MEMBERS -> ShareViewUpdate(includeAllPublicMembers = r.value) ExposureFlag.INCLUDE_BIO -> ShareViewUpdate(includeBio = r.value) ExposureFlag.INCLUDE_FRONTING -> ShareViewUpdate(includeFronting = r.value) ExposureFlag.FRONTING_SHOW_COUNT -> ShareViewUpdate(frontingShowCount = r.value) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/SharingScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/SharingScreen.kt index b200fbf..8608581 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/SharingScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/SharingScreen.kt @@ -329,6 +329,10 @@ private fun auditCounts(entry: ShareAuditEntry): String { val parts = mutableListOf() parts += if (!entry.includeMembers) { "${entry.memberCount} members curated, roster not shown" + } else if (entry.includeAllPublicMembers) { + // The curated rows are not the rule here, so "3 of 5" would compare + // against a list nobody is using. + "${entry.servedMemberCount ?: 0} members (everyone set to Public)" } else { val served = entry.servedMemberCount if (served != null && served != entry.memberCount) { From 562b8232544ebd198681b698ff528dc98380f46c Mon Sep 17 00:00:00 2001 From: Nocturnal <241546228+fullynocturnal@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:16:47 -0400 Subject: [PATCH 3/5] feat(members): show and keep a staged raise to Public Server 1.6.0 stages a member's privacy raise on the member itself (pending_privacy, privacy_activates_at) instead of hiding them from views. The editor now loads the level the member is heading for and says when it takes effect. Privacy is only sent when it was edited, because any other level counts as a lowering and would cancel the wait. Creating a member as Public passes the re-auth credentials through, since that can now publish them. --- .../java/systems/lupine/sheaf/data/model/Models.kt | 8 ++++++++ .../systems/lupine/sheaf/ui/members/MembersScreen.kt | 9 +++++++++ .../lupine/sheaf/ui/members/MembersViewModel.kt | 11 +++++++++-- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt index 7a432be..dc0afc8 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt @@ -509,6 +509,10 @@ data class MemberRead( // Set while a request to drop the fronting guard is waiting out the grace // window. The flag above is still the truth until it passes. @Json(name = "fronting_private_activates_at") val frontingPrivateActivatesAt: String? = null, + // A raise to public waiting out the grace window. `privacy` above is still + // the truth until privacyActivatesAt passes; lowering cancels it. + @Json(name = "pending_privacy") val pendingPrivacy: String? = null, + @Json(name = "privacy_activates_at") val privacyActivatesAt: String? = null, ) { val displayNameOrName: String get() = displayName?.takeIf { it.isNotBlank() } ?: name @@ -548,6 +552,10 @@ data class MemberCreate( // Short glyph shown beside the member's name and in place of an // avatar. Server caps it at 8 code points. val emoji: String? = null, + // Only consulted when creating them public would publish them, i.e. a + // share view serves everyone set to Public. + val password: String? = null, + @Json(name = "totp_code") val totpCode: String? = null, ) /** Optional step-up credentials for archiving a member. Only consulted when diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt index 6b1405c..ca7f4a0 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt @@ -810,6 +810,15 @@ fun MemberDetailScreen( ) { Text(level.replaceFirstChar { it.uppercase() }) } } } + val staged = state.member?.takeIf { it.pendingPrivacy != null && form.privacy == it.pendingPrivacy } + staged?.privacyActivatesAt?.let { at -> + Text( + "Staged: goes Public on ${formatRevisionDate(at, LocalDisplayTimeZone.current)}. " + + "Until then they stay ${staged.privacy}. Picking a lower level cancels it.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } // Two ceilings that outrank any share view. They live here because // this is where the member is edited, not on the sharing screen. diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt index 986cd10..77437ec 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt @@ -422,7 +422,9 @@ class MemberDetailViewModel @Inject constructor( note = m.note ?: "", color = m.color ?: "#7F77DD", birthday = m.birthday ?: "", - privacy = m.privacy, + // A staged raise shows as the level it is heading for, + // so picking anything lower is a change that cancels it. + privacy = m.pendingPrivacy ?: m.privacy, avatarUrl = m.avatarUrl, bannerUrl = m.bannerUrl, neverShareable = m.neverShareable, @@ -507,6 +509,8 @@ class MemberDetailViewModel @Inject constructor( birthday = f.birthday.takeIf { it.isNotBlank() }, privacy = f.privacy, note = f.note.takeIf { it.isNotBlank() }, + password = password?.ifBlank { null }, + totpCode = totpCode?.ifBlank { null }, )).also { createdMemberId = it.id } } else { val update = MemberUpdate( @@ -524,7 +528,10 @@ class MemberDetailViewModel @Inject constructor( bannerUrl = f.bannerUrl, color = f.color.takeIf { it.isNotBlank() }, birthday = f.birthday.takeIf { it.isNotBlank() }, - privacy = f.privacy, + // Any level other than public counts as a lowering, + // which cancels a staged raise, so only send it when + // it was actually changed. + privacy = f.privacy.takeIf { it != _baselineForm.value.privacy }, // Empty string clears the column server-side; this lets // a user wipe a note that was previously set. note = f.note, From 273691f5f68c4a66144d7b46a144f1c542931ab1 Mon Sep 17 00:00:00 2001 From: Nocturnal <241546228+fullynocturnal@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:16:47 -0400 Subject: [PATCH 4/5] feat(import): add the BerryTree importer Uses the 1.6.0 preview endpoint and the shared import flow. Template members are off by default, fronting types are noted on front history, and the unsupported sections and BerryTree's own export errors are listed before anything is imported. --- .../lupine/sheaf/data/api/SheafApiService.kt | 6 ++ .../systems/lupine/sheaf/data/model/Models.kt | 30 ++++++++++ .../sheaf/ui/importflow/ImportSources.kt | 58 +++++++++++++++++++ .../ui/imports/ImportHistoryViewModel.kt | 1 + 4 files changed, 95 insertions(+) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt index 94ab569..5728869 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt @@ -629,6 +629,12 @@ interface SheafApiService { @Part file: MultipartBody.Part, ): AmpersandPreviewSummary + @Multipart + @POST("/v1/import/berrytree/preview") + suspend fun previewBerryTreeImport( + @Part file: MultipartBody.Part, + ): BerryTreePreviewSummary + /** * Preview an OpenPlural v0.1 import. Accepts a bare `.json` export or an * `.openplural.zip` bundle (the endpoint sniffs the zip magic). Reuses the diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt index dc0afc8..a245ed0 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt @@ -1193,6 +1193,7 @@ object ImportJobSource { // unpacks images when present (no separate archive source like Sheaf). const val OPENPLURAL_FILE = "openplural_file" const val AMPERSAND_FILE = "ampersand_file" + const val BERRYTREE_FILE = "berrytree_file" } // ── Ampersand import ────────────────────────────────────────────────────────── @@ -1218,6 +1219,35 @@ data class AmpersandPreviewSummary( @Json(name = "limit_warnings") val limitWarnings: List = emptyList(), ) +// ── BerryTree import ───────────────────────────────────────────────────────── +// +// Experimental server-side: built from a single sample export, so the preview +// lists every section it cannot read yet instead of dropping them quietly. +// Template members are left out of `members` and counted separately. +@JsonClass(generateAdapter = true) +data class BerryTreePreviewSummary( + @Json(name = "system_name") val systemName: String? = null, + @Json(name = "member_count") val memberCount: Int = 0, + val members: List = emptyList(), + @Json(name = "template_count") val templateCount: Int = 0, + @Json(name = "custom_front_count") val customFrontCount: Int = 0, + @Json(name = "fronting_type_count") val frontingTypeCount: Int = 0, + @Json(name = "front_history_count") val frontHistoryCount: Int = 0, + @Json(name = "folder_count") val folderCount: Int = 0, + @Json(name = "tag_count") val tagCount: Int = 0, + @Json(name = "custom_field_count") val customFieldCount: Int = 0, + @Json(name = "unsupported_sections") val unsupportedSections: List = emptyList(), + // What BerryTree's own exporter says it failed to write. + @Json(name = "export_errors") val exportErrors: List = emptyList(), + @Json(name = "limit_warnings") val limitWarnings: List = emptyList(), +) + +@JsonClass(generateAdapter = true) +data class BerryTreePreviewItem(val id: String, val name: String) + +@JsonClass(generateAdapter = true) +data class BerryTreeSection(val name: String, val count: Int) + // ── Export ────────────────────────────────────────────────────────────────── /** Async full-backup (with images) request. Step-up: password always, plus 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 index 23db0df..a1f7f3f 100644 --- 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 @@ -455,6 +455,63 @@ private val ampersand = ImportSource( }, ) +private val berryTree = ImportSource( + id = "berrytree", + source = ImportJobSource.BERRYTREE_FILE, + label = "BerryTree", + title = "Import from BerryTree", + subtitle = "Experimental: use a BerryTree .json export", + constants = mapOf(CONFLICT_SKIP to "\"skip\""), + alwaysEmitMemberIds = true, + input = ImportInput.File( + prompt = "Choose your BerryTree export to get started.", + help = "Export your data from BerryTree as JSON, then select the file here. This importer is " + + "experimental: the preview lists anything in your file it can't read yet before " + + "anything is imported.", + ), + preview = { api, req -> + val s = api.previewBerryTreeImport(req.file!!) + val x = mutableListOf() + s.unsupportedSections.forEach { x += "${it.name} (${it.count}) - not supported yet, will be skipped" } + if (s.exportErrors.isNotEmpty()) { + x += "BerryTree reported problems writing this export, so it was incomplete before it got here:" + x += s.exportErrors + } + x += s.limitWarnings + ImportPreview( + systemName = s.systemName, + headline = "Experimental. If something you need is listed as not supported, " + + "get in touch and bring your export.", + 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( + "templates", "Template members", s.templateCount, default = false, + note = "These count against your member limit. Only brought across when all members are selected.", + ), + counted("custom_fronts", "Custom statuses, as custom fronts", s.customFrontCount), + counted("folders", "Folders, as groups", s.folderCount), + 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, + note = if (s.frontingTypeCount > 0) "Fronting types are kept on each entry's status text." else null, + ), + ), + skipped = x, + ) + }, +) + /** Every source the Import screen can run, in the order the picker lists them. */ val importSources: List = listOf( simplyPlural, @@ -466,6 +523,7 @@ val importSources: List = listOf( prism, openPlural, ampersand, + berryTree, ) fun importSourceById(id: String?): ImportSource? = importSources.firstOrNull { it.id == id } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/imports/ImportHistoryViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/imports/ImportHistoryViewModel.kt index d3b9a7a..f82021c 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/imports/ImportHistoryViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/imports/ImportHistoryViewModel.kt @@ -103,6 +103,7 @@ internal fun importSourceLabel(source: String): String = when (source) { "tupperbox_file" -> "Tupperbox" "pluralspace_file" -> "PluralSpace" "prism_file" -> "Prism" + "berrytree_file" -> "BerryTree" else -> source } From 3894e929cf6b2319e8a9313bc5ccf4f079e22a6d Mon Sep 17 00:00:00 2001 From: Nocturnal <241546228+fullynocturnal@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:16:47 -0400 Subject: [PATCH 5/5] docs: changelog for sharing 1.6.0 changes and BerryTree import --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f4098..27d9880 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,40 @@ 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 + +- **A share view can show everyone set to Public.** Instead of adding members + one at a time, turn on "Show everyone set to Public" and the view follows + each member's privacy setting from then on. The list you picked by hand is + kept but not used while this is on, and comes back if you turn it off. + Members marked never shareable are still never shown. On a view that's + already live, turning it on asks you to re-authenticate and waits out the + grace period like any other way of showing more. Needs a server running + 1.6.0 or later. + +- **Import from BerryTree (experimental).** Brings across members, custom + statuses (as custom fronts), front history, folders (as groups), tags and + custom fields. Template members are left out unless you tick them. Before + anything is imported, the preview lists every part of your export that + can't be read yet. Needs a server running 1.6.0 or later. + +### Changed + +- **Raising a member to Public now shows when it takes effect.** With a + System Safety grace period set, the member editor says when a member will + go Public, and picking a lower level cancels it. Editing anything else + about them no longer cancels the wait by accident. + +### Fixed + +- **Re-authentication now reaches the server when editing members, groups + and system settings.** The password and code you typed were dropped before + the request went out, so a change that needed them could never save. + "Never shareable" and "Keep fronting private" on a member now save too, and + so does changing a group's privacy. + ## [1.4.1] - 2026-09-20 ### Changed