feat(outlinekit): add language/preferences plumbing for Preferences settings

OutlineUser gains language + preferences fields, new updateUserLanguage/
updateUserPreferences/deleteAccount client methods. Preference wire keys
are best-effort against Outline's own naming, same speculative treatment
as OutlinePin/OutlineDocumentMember — expect a correction round once
tested against a live server.
This commit is contained in:
2026-08-15 20:28:38 +01:00
parent cd6277f5aa
commit eb4e1472b9
9 changed files with 195 additions and 1 deletions
@@ -355,6 +355,18 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
try await live.updateUserName(request) try await live.updateUserName(request)
} }
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
try await live.updateUserLanguage(request)
}
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
try await live.updateUserPreferences(request)
}
public func deleteAccount() async throws {
try await live.deleteAccount()
}
// MARK: - Sync management (Settings surface) // MARK: - Sync management (Settings surface)
public func pendingOperations() async -> [PendingOperationSummary] { public func pendingOperations() async -> [PendingOperationSummary] {
@@ -83,4 +83,12 @@ public protocol OutlineAPIClient: Sendable {
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser
/// `users.update`, name only. See `UpdateUserNameRequest`. /// `users.update`, name only. See `UpdateUserNameRequest`.
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser
/// `users.update`, language only. See `UpdateUserLanguageRequest`.
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser
/// `users.update`, preferences only. See `UpdateUserPreferencesRequest`.
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser
/// Backed by `users.delete` self-service account deletion, no
/// confirmation code param confirmed live, matches every other simple
/// no-body delete in this API.
func deleteAccount() async throws
} }
@@ -290,6 +290,18 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
try await post("users.update", body: request) try await post("users.update", body: request)
} }
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
try await post("users.update", body: request)
}
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
try await post("users.update", body: request)
}
public func deleteAccount() async throws {
try await postForSuccess("users.delete", body: EmptyParams())
}
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response { private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
guard let token = try? tokenStore.token() else { guard let token = try? tokenStore.token() else {
throw OutlineAPIError.tokenUnavailable throw OutlineAPIError.tokenUnavailable
@@ -6,18 +6,24 @@ public struct OutlineUser: Codable, Identifiable, Hashable, Sendable {
public let email: String? public let email: String?
public let avatarUrl: String? public let avatarUrl: String?
public let role: String? public let role: String?
public let language: String?
public let preferences: OutlineUserPreferences?
public init( public init(
id: String, id: String,
name: String, name: String,
email: String? = nil, email: String? = nil,
avatarUrl: String? = nil, avatarUrl: String? = nil,
role: String? = nil role: String? = nil,
language: String? = nil,
preferences: OutlineUserPreferences? = nil
) { ) {
self.id = id self.id = id
self.name = name self.name = name
self.email = email self.email = email
self.avatarUrl = avatarUrl self.avatarUrl = avatarUrl
self.role = role self.role = role
self.language = language
self.preferences = preferences
} }
} }
@@ -0,0 +1,56 @@
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.
public struct OutlineUserPreferences: Codable, Hashable, Sendable {
public var rememberLastPath: Bool?
public var separateEditing: Bool?
public var useCursorPointer: Bool?
public var codeBlockLineNumbers: Bool?
public var showCommentMarker: Bool?
public var smartText: Bool?
/// One of `NotificationBadgeStyle`'s raw values.
public var notificationBadge: String?
public init(
rememberLastPath: Bool? = nil,
separateEditing: Bool? = nil,
useCursorPointer: Bool? = nil,
codeBlockLineNumbers: Bool? = nil,
showCommentMarker: Bool? = nil,
smartText: Bool? = nil,
notificationBadge: String? = nil
) {
self.rememberLastPath = rememberLastPath
self.separateEditing = separateEditing
self.useCursorPointer = useCursorPointer
self.codeBlockLineNumbers = codeBlockLineNumbers
self.showCommentMarker = showCommentMarker
self.smartText = smartText
self.notificationBadge = notificationBadge
}
}
/// App-icon unread indicator style. Values are a guess at Outline's own
/// wire strings, not confirmed against a live server.
public enum NotificationBadgeStyle: String, CaseIterable, Identifiable, Sendable {
case none
case unreadIndicator = "unread"
case all
public var id: String { rawValue }
public var label: String {
switch self {
case .none: return "None"
case .unreadIndicator: return "Unread Indicator"
case .all: return "Unread Count"
}
}
}
@@ -0,0 +1,12 @@
import Foundation
/// `users.update`, language only.
public struct UpdateUserLanguageRequest: Encodable, Sendable {
public let id: String
public let language: String
public init(id: String, language: String) {
self.id = id
self.language = language
}
}
@@ -0,0 +1,14 @@
import Foundation
/// `users.update`, preferences only. Sends the *whole* preferences object
/// back (not a single changed key) avoids needing to know whether the
/// server deep-merges a partial `preferences` body or replaces it outright.
public struct UpdateUserPreferencesRequest: Encodable, Sendable {
public let id: String
public let preferences: OutlineUserPreferences
public init(id: String, preferences: OutlineUserPreferences) {
self.id = id
self.preferences = preferences
}
}
@@ -94,6 +94,9 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
func deleteAttachment(id: String) async throws { throw NotStubbed() } func deleteAttachment(id: String) async throws { throw NotStubbed() }
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() } func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() } func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() }
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser { throw NotStubbed() }
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser { throw NotStubbed() }
func deleteAccount() async throws { throw NotStubbed() }
} }
private struct StubTransportError: Error {} private struct StubTransportError: Error {}
@@ -1074,6 +1074,77 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update") XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
} }
func testUpdateUserLanguageSendsRequestAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"language": "fr_FR"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserLanguage(UpdateUserLanguageRequest(id: "user-1", language: "fr_FR"))
XCTAssertEqual(user.language, "fr_FR")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
}
func testUpdateUserPreferencesSendsWholeObjectAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"preferences": { "rememberLastPath": true, "useCursorPointer": true }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let preferences = OutlineUserPreferences(rememberLastPath: true, useCursorPointer: true)
let user = try await client.updateUserPreferences(UpdateUserPreferencesRequest(id: "user-1", preferences: preferences))
XCTAssertEqual(user.preferences?.rememberLastPath, true)
XCTAssertEqual(user.preferences?.useCursorPointer, true)
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
let sentPreferences = sentBody?["preferences"] as? [String: Any]
XCTAssertEqual(sentPreferences?["rememberLastPath"] as? Bool, true)
XCTAssertEqual(sentPreferences?["useCursorPointer"] as? Bool, true)
}
func testDeleteAccountSendsRequest() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.deleteAccount()
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.delete")
}
func testDeleteAttachmentSendsRequest() async throws { func testDeleteAttachmentSendsRequest() async throws {
let httpClient = MockHTTPClient() let httpClient = MockHTTPClient()
httpClient.responseData = """ httpClient.responseData = """