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
|
import Foundation
|
||||||
|
|
||||||
/// `User.preferences` — a free-form JSON blob on Outline's own `User` row,
|
/// `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
|
/// not a fixed-shape API resource. Wire key names below are confirmed
|
||||||
/// Outline's known preference keys, same "expect a correction round once
|
/// against a live server's own web app traffic (captured toggling every
|
||||||
/// tested" treatment as `OutlineDocumentMember`/`OutlinePin`: an unknown key
|
/// Preferences setting one at a time), not guessed — see the `CodingKeys`
|
||||||
/// doesn't break the request (the column round-trips whatever JSON it's
|
/// mapping for the two that don't match this struct's own property names.
|
||||||
/// 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
|
/// The server also validates `preferences` against a known key allowlist
|
||||||
/// app alone.
|
/// 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 struct OutlineUserPreferences: Codable, Hashable, Sendable {
|
||||||
public var rememberLastPath: Bool?
|
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 separateEditing: Bool?
|
||||||
public var useCursorPointer: Bool?
|
public var useCursorPointer: Bool?
|
||||||
public var codeBlockLineNumbers: Bool?
|
public var codeBlockLineNumbers: Bool?
|
||||||
@@ -17,6 +30,9 @@ public struct OutlineUserPreferences: Codable, Hashable, Sendable {
|
|||||||
public var smartText: Bool?
|
public var smartText: Bool?
|
||||||
/// One of `NotificationBadgeStyle`'s raw values.
|
/// One of `NotificationBadgeStyle`'s raw values.
|
||||||
public var notificationBadge: String?
|
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(
|
public init(
|
||||||
rememberLastPath: Bool? = nil,
|
rememberLastPath: Bool? = nil,
|
||||||
@@ -25,7 +41,8 @@ public struct OutlineUserPreferences: Codable, Hashable, Sendable {
|
|||||||
codeBlockLineNumbers: Bool? = nil,
|
codeBlockLineNumbers: Bool? = nil,
|
||||||
showCommentMarker: Bool? = nil,
|
showCommentMarker: Bool? = nil,
|
||||||
smartText: Bool? = nil,
|
smartText: Bool? = nil,
|
||||||
notificationBadge: String? = nil
|
notificationBadge: String? = nil,
|
||||||
|
fullWidthDocuments: Bool? = nil
|
||||||
) {
|
) {
|
||||||
self.rememberLastPath = rememberLastPath
|
self.rememberLastPath = rememberLastPath
|
||||||
self.separateEditing = separateEditing
|
self.separateEditing = separateEditing
|
||||||
@@ -34,15 +51,51 @@ public struct OutlineUserPreferences: Codable, Hashable, Sendable {
|
|||||||
self.showCommentMarker = showCommentMarker
|
self.showCommentMarker = showCommentMarker
|
||||||
self.smartText = smartText
|
self.smartText = smartText
|
||||||
self.notificationBadge = notificationBadge
|
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
|
/// App-icon unread indicator style. Wire values confirmed live (captured
|
||||||
/// wire strings, not confirmed against a live server.
|
/// setting all three from Outline's own web app).
|
||||||
public enum NotificationBadgeStyle: String, CaseIterable, Identifiable, Sendable {
|
public enum NotificationBadgeStyle: String, CaseIterable, Identifiable, Sendable {
|
||||||
case none
|
case none = "disabled"
|
||||||
case unreadIndicator = "unread"
|
case unreadIndicator = "indicator"
|
||||||
case all
|
case unreadCount = "count"
|
||||||
|
|
||||||
public var id: String { rawValue }
|
public var id: String { rawValue }
|
||||||
|
|
||||||
@@ -50,7 +103,7 @@ public enum NotificationBadgeStyle: String, CaseIterable, Identifiable, Sendable
|
|||||||
switch self {
|
switch self {
|
||||||
case .none: return "None"
|
case .none: return "None"
|
||||||
case .unreadIndicator: return "Unread Indicator"
|
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)
|
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 {
|
func testDeleteAccountSendsRequest() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
httpClient.responseData = """
|
httpClient.responseData = """
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ struct SettingsView: View {
|
|||||||
private var notificationBadgeBinding: Binding<NotificationBadgeStyle> {
|
private var notificationBadgeBinding: Binding<NotificationBadgeStyle> {
|
||||||
Binding(
|
Binding(
|
||||||
get: {
|
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 } } }
|
set: { newValue in Task { await savePreference { $0.notificationBadge = newValue.rawValue } } }
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user