Wraps Outline's Comments API in OutlineKit for the first time - comments.list is documented in the vendored spec; comments.resolve/ unresolve are not, confirmed real against Outline's own server source instead of guessed. Comment bodies are ProseMirror documents (data), not plain text - added a small recursive JSONValue tree plus a best-effort plainText() walk for display, since nothing here needs to write comment bodies back (out of scope for this pass, see DocumentCommentsSheet's doc comment). Reader toolbar gets a marker (bubble icon + count badge) only when the document actually has comments, per explicit instruction that this should be a simple symbol rather than a true in-document gutter marker at each comment's anchor position - anchoring is plain-text- substring-based server-side and doesn't map cleanly onto this app's own Markdown rendering. Opens a read-only comment list with a Resolve/Unresolve toggle per the confirmed scope for this pass; no creating or replying yet.
574 lines
22 KiB
Swift
574 lines
22 KiB
Swift
import Foundation
|
|
|
|
/// Talks to Outline's RPC-style REST API (`POST /api/<namespace>.<method>`, Bearer auth).
|
|
public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|
private let baseURL: URL
|
|
private let httpClient: HTTPClient
|
|
private let tokenStore: TokenStoring
|
|
private let decoder: JSONDecoder
|
|
private let encoder: JSONEncoder
|
|
|
|
public init(
|
|
configuration: OutlineConfiguration,
|
|
tokenStore: TokenStoring,
|
|
httpClient: HTTPClient = URLSessionHTTPClient()
|
|
) {
|
|
self.baseURL = configuration.baseURL
|
|
self.tokenStore = tokenStore
|
|
self.httpClient = httpClient
|
|
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
self.decoder = decoder
|
|
|
|
let encoder = JSONEncoder()
|
|
encoder.dateEncodingStrategy = .iso8601
|
|
self.encoder = encoder
|
|
}
|
|
|
|
public func authInfo() async throws -> OutlineAuthInfo {
|
|
try await post("auth.info", body: EmptyParams())
|
|
}
|
|
|
|
public func documentInfo(id: String) async throws -> OutlineDocument {
|
|
try await post("documents.info", body: DocumentInfoParams(id: id))
|
|
}
|
|
|
|
public func listDocuments(
|
|
collectionId: String?,
|
|
parentDocumentId: String?,
|
|
offset: Int,
|
|
limit: Int
|
|
) async throws -> [OutlineDocument] {
|
|
try await post(
|
|
"documents.list",
|
|
body: DocumentListParams(
|
|
collectionId: collectionId,
|
|
parentDocumentId: parentDocumentId,
|
|
offset: offset,
|
|
limit: limit
|
|
)
|
|
)
|
|
}
|
|
|
|
public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] {
|
|
try await post("documents.list", body: request)
|
|
}
|
|
|
|
public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] {
|
|
try await post("documents.viewed", body: PaginationParams(offset: offset, limit: limit))
|
|
}
|
|
|
|
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
|
|
try await post("documents.search", body: request)
|
|
}
|
|
|
|
public func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] {
|
|
try await post("documents.search_titles", body: request)
|
|
}
|
|
|
|
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
|
|
try await post("documents.create", body: request)
|
|
}
|
|
|
|
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
|
try await post("documents.update", body: request)
|
|
}
|
|
|
|
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
|
|
try await post("stars.create", body: request)
|
|
}
|
|
|
|
public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate {
|
|
try await post("documents.templatize", body: request)
|
|
}
|
|
|
|
public func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] {
|
|
let result: DuplicateDocumentResponse = try await post("documents.duplicate", body: request)
|
|
return result.documents
|
|
}
|
|
|
|
public func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument {
|
|
try await post("documents.unpublish", body: request)
|
|
}
|
|
|
|
public func archiveDocument(id: String) async throws -> OutlineDocument {
|
|
try await post("documents.archive", body: DocumentIDRequest(id: id))
|
|
}
|
|
|
|
public func moveDocument(_ request: MoveDocumentRequest) async throws {
|
|
let _: MoveDocumentResponse = try await post("documents.move", body: request)
|
|
}
|
|
|
|
public func deleteDocument(_ request: DeleteDocumentRequest) async throws {
|
|
try await postForSuccess("documents.delete", body: request)
|
|
}
|
|
|
|
public func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] {
|
|
try await post("documents.insights", body: request)
|
|
}
|
|
|
|
public func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] {
|
|
try await post("revisions.list", body: request)
|
|
}
|
|
|
|
public func exportDocument(id: String) async throws -> String {
|
|
try await post("documents.export", body: DocumentIDRequest(id: id))
|
|
}
|
|
|
|
public func createShare(_ request: CreateShareRequest) async throws -> OutlineShare {
|
|
try await post("shares.create", body: request)
|
|
}
|
|
|
|
public func shareInfo(documentId: String) async throws -> OutlineShare? {
|
|
// Real shape confirmed against a live server: `data` is
|
|
// `{ shares: [...] }`, not the bare share object the docs imply —
|
|
// same pattern as `pins.list`. A document could in principle have
|
|
// more than one share record; the first is what the reader's
|
|
// share sheet cares about.
|
|
let payload: SharesInfoPayload? = try await postOptional("shares.info", body: ShareInfoRequest(documentId: documentId))
|
|
return payload?.shares.first
|
|
}
|
|
|
|
public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare {
|
|
try await post("shares.update", body: request)
|
|
}
|
|
|
|
public func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] {
|
|
try await post("shares.list", body: request)
|
|
}
|
|
|
|
public func revokeShare(id: String) async throws {
|
|
try await postForSuccess("shares.revoke", body: StarIDParams(id: id))
|
|
}
|
|
|
|
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
|
|
try await post("pins.create", body: request)
|
|
}
|
|
|
|
public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
|
|
// `data` here is `{ pins: [...], documents: [...] }`, not a bare
|
|
// array — confirmed against a live server. Decoding straight to
|
|
// `[OutlinePin]` throws on every call, which `try?` at call sites
|
|
// swallows silently, so pins never showed up anywhere.
|
|
let payload: PinsListPayload = try await post("pins.list", body: request)
|
|
return payload.pins
|
|
}
|
|
|
|
public func deletePin(id: String) async throws {
|
|
try await postForSuccess("pins.delete", body: StarIDParams(id: id))
|
|
}
|
|
|
|
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
|
|
try await post("subscriptions.create", body: request)
|
|
}
|
|
|
|
public func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] {
|
|
try await post("subscriptions.list", body: request)
|
|
}
|
|
|
|
public func deleteSubscription(id: String) async throws {
|
|
try await postForSuccess("subscriptions.delete", body: StarIDParams(id: id))
|
|
}
|
|
|
|
public func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] {
|
|
try await post("views.list", body: request)
|
|
}
|
|
|
|
public func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] {
|
|
try await post("comments.list", body: request)
|
|
}
|
|
|
|
public func resolveComment(id: String) async throws -> OutlineComment {
|
|
try await post("comments.resolve", body: StarIDParams(id: id))
|
|
}
|
|
|
|
public func unresolveComment(id: String) async throws -> OutlineComment {
|
|
try await post("comments.unresolve", body: StarIDParams(id: id))
|
|
}
|
|
|
|
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
|
|
try await post("documents.add_user", body: request)
|
|
}
|
|
|
|
public func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws {
|
|
try await postForSuccess("documents.remove_user", body: request)
|
|
}
|
|
|
|
public func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] {
|
|
try await post("documents.users", body: request)
|
|
}
|
|
|
|
public func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] {
|
|
try await post("users.list", body: request)
|
|
}
|
|
|
|
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
|
|
try await post("collections.list", body: CollectionListParams(offset: offset, limit: limit))
|
|
}
|
|
|
|
public func collectionInfo(id: String) async throws -> OutlineCollection {
|
|
try await post("collections.info", body: CollectionInfoParams(id: id))
|
|
}
|
|
|
|
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
|
|
try await post("collections.update", body: request)
|
|
}
|
|
|
|
public func deleteCollection(id: String) async throws {
|
|
try await postForSuccess("collections.delete", body: CollectionInfoParams(id: id))
|
|
}
|
|
|
|
public func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation {
|
|
let result: ExportCollectionResponse = try await post("collections.export", body: request)
|
|
return result.fileOperation
|
|
}
|
|
|
|
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
|
|
try await post("stars.create", body: request)
|
|
}
|
|
|
|
public func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] {
|
|
let result: ListStarsResponse = try await post("stars.list", body: request)
|
|
return result.stars
|
|
}
|
|
|
|
public func deleteStar(id: String) async throws {
|
|
try await postForSuccess("stars.delete", body: StarIDParams(id: id))
|
|
}
|
|
|
|
public func currentUser() async throws -> OutlineUser {
|
|
try await post("users.info", body: EmptyParams())
|
|
}
|
|
|
|
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
|
|
try await post("attachments.create", body: request)
|
|
}
|
|
|
|
/// No Bearer/CSRF header attached here, deliberately — the presigned
|
|
/// `form.sig` field (short-lived, scoped to this exact upload key) is
|
|
/// what authorizes this specific request, the same way an S3 presigned
|
|
/// POST works. Best-effort against a live server: if it turns out the
|
|
/// self-hosted local-storage backend also wants a bearer token here,
|
|
/// that's a one-line addition once confirmed, not a design change.
|
|
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
|
|
let (body, contentType) = MultipartFormDataBuilder.build(
|
|
fields: result.form,
|
|
fileFieldName: "file",
|
|
fileName: result.attachment.name ?? "avatar",
|
|
fileData: fileData,
|
|
fileContentType: result.form["Content-Type"] ?? "application/octet-stream"
|
|
)
|
|
|
|
// `uploadUrl` is a host-relative path (e.g. "/api/files.create") on
|
|
// a self-hosted local-storage backend, not an absolute S3 URL —
|
|
// resolving against `baseURL` handles both: `URL(string:relativeTo:)`
|
|
// replaces the whole path for a leading-slash relative string per
|
|
// RFC 3986, same resolution already used for user/team avatar URLs.
|
|
guard let uploadURL = URL(string: result.uploadUrl, relativeTo: baseURL)?.absoluteURL else {
|
|
throw OutlineAPIError.transport(URLError(.badURL))
|
|
}
|
|
|
|
var request = URLRequest(url: uploadURL)
|
|
request.httpMethod = "POST"
|
|
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
|
request.httpBody = body
|
|
|
|
let data: Data
|
|
let response: HTTPURLResponse
|
|
do {
|
|
(data, response) = try await httpClient.send(request)
|
|
} catch let error as OutlineAPIError {
|
|
throw error
|
|
} catch {
|
|
throw OutlineAPIError.transport(error)
|
|
}
|
|
|
|
guard (200...299).contains(response.statusCode) else {
|
|
let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data)
|
|
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
|
|
}
|
|
}
|
|
|
|
public func fetchAuthenticatedFile(path: String) async throws -> Data {
|
|
guard let token = try? tokenStore.token() else {
|
|
throw OutlineAPIError.tokenUnavailable
|
|
}
|
|
// Same host-relative-or-absolute resolution as uploadAttachmentFile's uploadUrl.
|
|
guard let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else {
|
|
throw OutlineAPIError.transport(URLError(.badURL))
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
// URLSession's default redirect handling drops Authorization on a
|
|
// cross-host redirect (same as a browser dropping cookies on one) —
|
|
// exactly what's wanted here: authorize the request to Outline's own
|
|
// `attachments.redirect`, not the presigned storage URL it 302s to.
|
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
|
|
let data: Data
|
|
let response: HTTPURLResponse
|
|
do {
|
|
(data, response) = try await httpClient.send(request)
|
|
} catch let error as OutlineAPIError {
|
|
throw error
|
|
} catch {
|
|
throw OutlineAPIError.transport(error)
|
|
}
|
|
|
|
guard (200...299).contains(response.statusCode) else {
|
|
switch response.statusCode {
|
|
case 401:
|
|
throw OutlineAPIError.unauthorized
|
|
case 404:
|
|
throw OutlineAPIError.notFound
|
|
default:
|
|
throw OutlineAPIError.server(status: response.statusCode, message: nil)
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
|
|
public func deleteAttachment(id: String) async throws {
|
|
try await postForSuccess("attachments.delete", body: StarIDParams(id: id))
|
|
}
|
|
|
|
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
|
|
try await post("users.update", body: request)
|
|
}
|
|
|
|
public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser {
|
|
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())
|
|
}
|
|
|
|
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
|
try await post("users.notificationsSubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
|
|
}
|
|
|
|
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
|
try await post("users.notificationsUnsubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
|
|
}
|
|
|
|
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
|
|
try await post("apiKeys.list", body: request)
|
|
}
|
|
|
|
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
|
|
try await post("apiKeys.create", body: request)
|
|
}
|
|
|
|
public func deleteApiKey(id: String) async throws {
|
|
try await postForSuccess("apiKeys.delete", body: StarIDParams(id: id))
|
|
}
|
|
|
|
public func installationInfo() async throws -> OutlineInstallationInfo {
|
|
try await post("installation.info", 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
|
|
}
|
|
|
|
var request = URLRequest(url: baseURL.appendingPathComponent("api/\(path)"))
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
request.httpBody = try encoder.encode(body)
|
|
|
|
let data: Data
|
|
let response: HTTPURLResponse
|
|
do {
|
|
(data, response) = try await httpClient.send(request)
|
|
} catch let error as OutlineAPIError {
|
|
throw error
|
|
} catch {
|
|
throw OutlineAPIError.transport(error)
|
|
}
|
|
|
|
guard (200...299).contains(response.statusCode) else {
|
|
let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data)
|
|
switch response.statusCode {
|
|
case 401:
|
|
throw OutlineAPIError.unauthorized
|
|
case 404:
|
|
throw OutlineAPIError.notFound
|
|
default:
|
|
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
|
|
}
|
|
}
|
|
|
|
do {
|
|
return try decoder.decode(OutlineEnvelope<Response>.self, from: data).data
|
|
} catch {
|
|
throw OutlineAPIError.decoding(error)
|
|
}
|
|
}
|
|
|
|
/// Like `post(_:body:)`, but for endpoints where "no result" comes back
|
|
/// as a 200 with a completely empty body instead of a real 404 —
|
|
/// confirmed against a live server for `shares.info` (no share yet for
|
|
/// a given document). A 404 is still treated as nil too.
|
|
private func postOptional<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response? {
|
|
guard let token = try? tokenStore.token() else {
|
|
throw OutlineAPIError.tokenUnavailable
|
|
}
|
|
|
|
var request = URLRequest(url: baseURL.appendingPathComponent("api/\(path)"))
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
request.httpBody = try encoder.encode(body)
|
|
|
|
let data: Data
|
|
let response: HTTPURLResponse
|
|
do {
|
|
(data, response) = try await httpClient.send(request)
|
|
} catch let error as OutlineAPIError {
|
|
throw error
|
|
} catch {
|
|
throw OutlineAPIError.transport(error)
|
|
}
|
|
|
|
guard (200...299).contains(response.statusCode) else {
|
|
let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data)
|
|
switch response.statusCode {
|
|
case 401:
|
|
throw OutlineAPIError.unauthorized
|
|
case 404:
|
|
return nil
|
|
default:
|
|
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
|
|
}
|
|
}
|
|
|
|
guard !data.isEmpty else { return nil }
|
|
|
|
do {
|
|
return try decoder.decode(OutlineEnvelope<Response>.self, from: data).data
|
|
} catch {
|
|
throw OutlineAPIError.decoding(error)
|
|
}
|
|
}
|
|
|
|
/// For endpoints shaped `{ "success": true }` instead of `{ "data": ... }`
|
|
/// (e.g. `collections.delete`) — `post(_:body:)`'s envelope decode doesn't fit.
|
|
private func postForSuccess<Body: Encodable>(_ path: String, body: Body) async throws {
|
|
guard let token = try? tokenStore.token() else {
|
|
throw OutlineAPIError.tokenUnavailable
|
|
}
|
|
|
|
var request = URLRequest(url: baseURL.appendingPathComponent("api/\(path)"))
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
request.httpBody = try encoder.encode(body)
|
|
|
|
let data: Data
|
|
let response: HTTPURLResponse
|
|
do {
|
|
(data, response) = try await httpClient.send(request)
|
|
} catch let error as OutlineAPIError {
|
|
throw error
|
|
} catch {
|
|
throw OutlineAPIError.transport(error)
|
|
}
|
|
|
|
guard (200...299).contains(response.statusCode) else {
|
|
let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data)
|
|
switch response.statusCode {
|
|
case 401:
|
|
throw OutlineAPIError.unauthorized
|
|
case 404:
|
|
throw OutlineAPIError.notFound
|
|
default:
|
|
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
|
|
}
|
|
}
|
|
|
|
do {
|
|
let envelope = try decoder.decode(SuccessEnvelope.self, from: data)
|
|
guard envelope.success else {
|
|
throw OutlineAPIError.server(status: response.statusCode, message: nil)
|
|
}
|
|
} catch let error as OutlineAPIError {
|
|
throw error
|
|
} catch {
|
|
throw OutlineAPIError.decoding(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ExportCollectionResponse: Decodable {
|
|
let fileOperation: OutlineFileOperation
|
|
}
|
|
|
|
private struct DuplicateDocumentResponse: Decodable {
|
|
let documents: [OutlineDocument]
|
|
}
|
|
|
|
private struct PinsListPayload: Decodable {
|
|
let pins: [OutlinePin]
|
|
let documents: [OutlineDocument]
|
|
}
|
|
|
|
private struct SharesInfoPayload: Decodable {
|
|
let shares: [OutlineShare]
|
|
}
|
|
|
|
private struct ListStarsResponse: Decodable {
|
|
let stars: [OutlineStar]
|
|
}
|
|
|
|
private struct StarIDParams: Encodable {
|
|
let id: String
|
|
}
|
|
|
|
private struct MoveDocumentResponse: Decodable {
|
|
let documents: [OutlineDocument]?
|
|
let collections: [OutlineCollection]?
|
|
}
|
|
|
|
private struct SuccessEnvelope: Decodable {
|
|
let success: Bool
|
|
}
|
|
|
|
private struct DocumentInfoParams: Encodable {
|
|
let id: String
|
|
}
|
|
|
|
private struct DocumentListParams: Encodable {
|
|
let collectionId: String?
|
|
let parentDocumentId: String?
|
|
let offset: Int
|
|
let limit: Int
|
|
}
|
|
|
|
private struct PaginationParams: Encodable {
|
|
let offset: Int
|
|
let limit: Int
|
|
}
|
|
|
|
private struct CollectionListParams: Encodable {
|
|
let offset: Int
|
|
let limit: Int
|
|
}
|
|
|
|
private struct CollectionInfoParams: Encodable {
|
|
let id: String
|
|
}
|
|
|
|
private struct EmptyParams: Encodable {}
|