fix(outlinekit): correct preferences wire keys to the real server shape
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.
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -437,7 +437,7 @@ struct SettingsView: View {
|
||||
private var notificationBadgeBinding: Binding<NotificationBadgeStyle> {
|
||||
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 } } }
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user