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:
@@ -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 = """
|
||||||
|
|||||||
Reference in New Issue
Block a user