From 564cce0637ad5f2a81fc7522c6f1f7324e30fadd Mon Sep 17 00:00:00 2001 From: psmattas Date: Mon, 17 Aug 2026 21:35:17 +0100 Subject: [PATCH] fix(outlinekit): correct preferences wire keys to the real server shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All preference toggles showed as off regardless of server state, and saving notificationBadge 400'd with "notificationBadge: Invalid Input" — the guessed wire keys/values from the earlier speculative commit were wrong. Fixed against a live network capture of Outline's own web app toggling every one of these settings: - separateEditing -> seamlessEdit, and inverted (seamlessEdit is separate editing's negation, confirmed by toggling it live) - showCommentMarker -> commentsInGutter - smartText -> enableSmartText - notificationBadge values -> "disabled"/"indicator"/"count", not the guessed "none"/"unread"/"all" - rememberLastPath/useCursorPointer/codeBlockLineNumbers were already correct Also preserves fullWidthDocuments (a real preference this app has no UI for) on round-trip, since the client always sends the whole preferences object back on save — dropping an unrecognized key during decode would otherwise silently clear it the next time any toggle here gets saved. --- .../Models/OutlineUserPreferences.swift | 81 +++++++++++++++---- .../LiveOutlineAPIClientTests.swift | 50 ++++++++++++ Outpost/Features/Account/SettingsView.swift | 2 +- 3 files changed, 118 insertions(+), 15 deletions(-) diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineUserPreferences.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineUserPreferences.swift index 4ecf802..b5b2c63 100644 --- a/OutlineKit/Sources/OutlineKit/Models/OutlineUserPreferences.swift +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineUserPreferences.swift @@ -1,15 +1,28 @@ import Foundation /// `User.preferences` — a free-form JSON blob on Outline's own `User` row, -/// not a fixed-shape API resource. Field names here are best-effort against -/// Outline's known preference keys, same "expect a correction round once -/// tested" treatment as `OutlineDocumentMember`/`OutlinePin`: an unknown key -/// doesn't break the request (the column round-trips whatever JSON it's -/// given), but a wrong key silently fails to affect Outline's own behavior -/// for that toggle server-side even though it round-trips fine within this -/// app alone. +/// not a fixed-shape API resource. Wire key names below are confirmed +/// against a live server's own web app traffic (captured toggling every +/// Preferences setting one at a time), not guessed — see the `CodingKeys` +/// mapping for the two that don't match this struct's own property names. +/// +/// The server also validates `preferences` against a known key allowlist +/// and rejects the whole `users.update` call (not just the bad field) if +/// any key it doesn't recognize is present — confirmed live via a +/// `"notificationBadge: Invalid Input"` error when an earlier, wrong value +/// was sent. That's also why `fullWidthDocuments` is kept here even though +/// this app has no UI for it yet: this app always sends the *whole* +/// preferences object back on every save (see +/// `UpdateUserPreferencesRequest`), so silently dropping an unknown key +/// during decode would permanently clear it the next time any other +/// preference here gets saved. public struct OutlineUserPreferences: Codable, Hashable, Sendable { public var rememberLastPath: Bool? + /// App-facing polarity: `true` means "separate editing mode is on" — + /// the opposite of the wire's own `seamlessEdit` (seamless editing and + /// separate editing modes are each other's negation), inverted in + /// `init(from:)`/`encode(to:)` so nothing outside this file has to + /// remember that. public var separateEditing: Bool? public var useCursorPointer: Bool? public var codeBlockLineNumbers: Bool? @@ -17,6 +30,9 @@ public struct OutlineUserPreferences: Codable, Hashable, Sendable { public var smartText: Bool? /// One of `NotificationBadgeStyle`'s raw values. public var notificationBadge: String? + /// No UI in this app yet — preserved purely so saving any other + /// preference here doesn't clobber it. See the type doc comment. + public var fullWidthDocuments: Bool? public init( rememberLastPath: Bool? = nil, @@ -25,7 +41,8 @@ public struct OutlineUserPreferences: Codable, Hashable, Sendable { codeBlockLineNumbers: Bool? = nil, showCommentMarker: Bool? = nil, smartText: Bool? = nil, - notificationBadge: String? = nil + notificationBadge: String? = nil, + fullWidthDocuments: Bool? = nil ) { self.rememberLastPath = rememberLastPath self.separateEditing = separateEditing @@ -34,15 +51,51 @@ public struct OutlineUserPreferences: Codable, Hashable, Sendable { self.showCommentMarker = showCommentMarker self.smartText = smartText self.notificationBadge = notificationBadge + self.fullWidthDocuments = fullWidthDocuments + } + + private enum CodingKeys: String, CodingKey { + case rememberLastPath + case seamlessEdit + case useCursorPointer + case codeBlockLineNumbers + case commentsInGutter + case enableSmartText + case notificationBadge + case fullWidthDocuments + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + rememberLastPath = try container.decodeIfPresent(Bool.self, forKey: .rememberLastPath) + separateEditing = try container.decodeIfPresent(Bool.self, forKey: .seamlessEdit).map { !$0 } + useCursorPointer = try container.decodeIfPresent(Bool.self, forKey: .useCursorPointer) + codeBlockLineNumbers = try container.decodeIfPresent(Bool.self, forKey: .codeBlockLineNumbers) + showCommentMarker = try container.decodeIfPresent(Bool.self, forKey: .commentsInGutter) + smartText = try container.decodeIfPresent(Bool.self, forKey: .enableSmartText) + notificationBadge = try container.decodeIfPresent(String.self, forKey: .notificationBadge) + fullWidthDocuments = try container.decodeIfPresent(Bool.self, forKey: .fullWidthDocuments) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(rememberLastPath, forKey: .rememberLastPath) + try container.encodeIfPresent(separateEditing.map { !$0 }, forKey: .seamlessEdit) + try container.encodeIfPresent(useCursorPointer, forKey: .useCursorPointer) + try container.encodeIfPresent(codeBlockLineNumbers, forKey: .codeBlockLineNumbers) + try container.encodeIfPresent(showCommentMarker, forKey: .commentsInGutter) + try container.encodeIfPresent(smartText, forKey: .enableSmartText) + try container.encodeIfPresent(notificationBadge, forKey: .notificationBadge) + try container.encodeIfPresent(fullWidthDocuments, forKey: .fullWidthDocuments) } } -/// App-icon unread indicator style. Values are a guess at Outline's own -/// wire strings, not confirmed against a live server. +/// App-icon unread indicator style. Wire values confirmed live (captured +/// setting all three from Outline's own web app). public enum NotificationBadgeStyle: String, CaseIterable, Identifiable, Sendable { - case none - case unreadIndicator = "unread" - case all + case none = "disabled" + case unreadIndicator = "indicator" + case unreadCount = "count" public var id: String { rawValue } @@ -50,7 +103,7 @@ public enum NotificationBadgeStyle: String, CaseIterable, Identifiable, Sendable switch self { case .none: return "None" case .unreadIndicator: return "Unread Indicator" - case .all: return "Unread Count" + case .unreadCount: return "Unread Count" } } } diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index 4694a6e..8420f53 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -1128,6 +1128,56 @@ final class LiveOutlineAPIClientTests: XCTestCase { XCTAssertEqual(sentPreferences?["useCursorPointer"] as? Bool, true) } + /// Locks in the wire mapping confirmed against a live server's own web + /// app traffic: `seamlessEdit`/`commentsInGutter`/`enableSmartText` are + /// the real keys (not `separateEditing`/`showCommentMarker`/`smartText` + /// this struct exposes), and `seamlessEdit` is the *negation* of this + /// app's `separateEditing`. + func testOutlineUserPreferencesDecodesRealWireKeys() throws { + let json = """ + { + "seamlessEdit": false, + "commentsInGutter": true, + "enableSmartText": true, + "rememberLastPath": true, + "useCursorPointer": true, + "codeBlockLineNumbers": false, + "notificationBadge": "indicator", + "fullWidthDocuments": true + } + """.data(using: .utf8)! + + let preferences = try JSONDecoder().decode(OutlineUserPreferences.self, from: json) + + XCTAssertEqual(preferences.separateEditing, true, "seamlessEdit: false means separate editing is ON") + XCTAssertEqual(preferences.showCommentMarker, true) + XCTAssertEqual(preferences.smartText, true) + XCTAssertEqual(preferences.rememberLastPath, true) + XCTAssertEqual(preferences.useCursorPointer, true) + XCTAssertEqual(preferences.codeBlockLineNumbers, false) + XCTAssertEqual(preferences.notificationBadge, "indicator") + XCTAssertEqual(preferences.fullWidthDocuments, true) + } + + func testOutlineUserPreferencesEncodesRealWireKeysAndInvertsSeparateEditing() throws { + var preferences = OutlineUserPreferences() + preferences.separateEditing = true + preferences.showCommentMarker = false + preferences.smartText = true + preferences.fullWidthDocuments = true + + let data = try JSONEncoder().encode(preferences) + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + XCTAssertEqual(object?["seamlessEdit"] as? Bool, false, "separateEditing: true must encode as seamlessEdit: false") + XCTAssertEqual(object?["commentsInGutter"] as? Bool, false) + XCTAssertEqual(object?["enableSmartText"] as? Bool, true) + XCTAssertEqual(object?["fullWidthDocuments"] as? Bool, true) + XCTAssertNil(object?["separateEditing"], "must not leak this app's own field name onto the wire") + XCTAssertNil(object?["showCommentMarker"]) + XCTAssertNil(object?["smartText"]) + } + func testDeleteAccountSendsRequest() async throws { let httpClient = MockHTTPClient() httpClient.responseData = """ diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index d125f2b..7ebd559 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -437,7 +437,7 @@ struct SettingsView: View { private var notificationBadgeBinding: Binding { Binding( get: { - session.userPreferences?.notificationBadge.flatMap(NotificationBadgeStyle.init(rawValue:)) ?? .all + session.userPreferences?.notificationBadge.flatMap(NotificationBadgeStyle.init(rawValue:)) ?? .unreadCount }, set: { newValue in Task { await savePreference { $0.notificationBadge = newValue.rawValue } } } )