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. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -355,6 +355,18 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
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)
|
||||
|
||||
public func pendingOperations() async -> [PendingOperationSummary] {
|
||||
|
||||
@@ -83,4 +83,12 @@ public protocol OutlineAPIClient: Sendable {
|
||||
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser
|
||||
/// `users.update`, name only. See `UpdateUserNameRequest`.
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
guard let token = try? tokenStore.token() else {
|
||||
throw OutlineAPIError.tokenUnavailable
|
||||
|
||||
@@ -6,18 +6,24 @@ public struct OutlineUser: Codable, Identifiable, Hashable, Sendable {
|
||||
public let email: String?
|
||||
public let avatarUrl: String?
|
||||
public let role: String?
|
||||
public let language: String?
|
||||
public let preferences: OutlineUserPreferences?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
email: String? = nil,
|
||||
avatarUrl: String? = nil,
|
||||
role: String? = nil
|
||||
role: String? = nil,
|
||||
language: String? = nil,
|
||||
preferences: OutlineUserPreferences? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.email = email
|
||||
self.avatarUrl = avatarUrl
|
||||
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 updateUserAvatar(_ request: UpdateUserAvatarRequest) 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 {}
|
||||
|
||||
@@ -1074,6 +1074,77 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
||||
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 {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
|
||||
Reference in New Issue
Block a user