From 353afd93fb55eb9c78b9e17eb2e4956258592064 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 20:28:38 +0100 Subject: [PATCH] feat(outlinekit): add language/preferences plumbing for Preferences settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Caching/CachingOutlineAPIClient.swift | 12 ++++ .../OutlineKit/Core/OutlineAPIClient.swift | 8 +++ .../OutlineKit/LiveOutlineAPIClient.swift | 12 ++++ .../OutlineKit/Models/OutlineUser.swift | 8 ++- .../Models/OutlineUserPreferences.swift | 56 +++++++++++++++ .../Requests/UpdateUserLanguageRequest.swift | 12 ++++ .../UpdateUserPreferencesRequest.swift | 14 ++++ .../CachingOutlineAPIClientTests.swift | 3 + .../LiveOutlineAPIClientTests.swift | 71 +++++++++++++++++++ 9 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 OutlineKit/Sources/OutlineKit/Models/OutlineUserPreferences.swift create mode 100644 OutlineKit/Sources/OutlineKit/Requests/UpdateUserLanguageRequest.swift create mode 100644 OutlineKit/Sources/OutlineKit/Requests/UpdateUserPreferencesRequest.swift diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift index 4f59bf3..fa0db2f 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -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] { diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift index 21f18d9..c9c6c6f 100644 --- a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift @@ -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 } diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index 444a11c..adafae2 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -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(_ path: String, body: Body) async throws -> Response { guard let token = try? tokenStore.token() else { throw OutlineAPIError.tokenUnavailable diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineUser.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineUser.swift index b85a6ca..af4a899 100644 --- a/OutlineKit/Sources/OutlineKit/Models/OutlineUser.swift +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineUser.swift @@ -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 } } diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineUserPreferences.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineUserPreferences.swift new file mode 100644 index 0000000..4ecf802 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineUserPreferences.swift @@ -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" + } + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateUserLanguageRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateUserLanguageRequest.swift new file mode 100644 index 0000000..865b677 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateUserLanguageRequest.swift @@ -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 + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateUserPreferencesRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateUserPreferencesRequest.swift new file mode 100644 index 0000000..d0521c6 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateUserPreferencesRequest.swift @@ -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 + } +} diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift index 8256a1d..06912c1 100644 --- a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -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 {} diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index 3627531..4694a6e 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -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 = """