From 7f921ba006919dd86758d74ac30cc6f7c717e02f Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 00:56:23 +0100 Subject: [PATCH] feat(outlinekit): add collection delete, export, and star endpoints Also adds a postForSuccess helper for endpoints shaped { "success": true } instead of the usual { "data": ... } envelope (collections.delete needed it). --- .../OutlineKit/Core/OutlineAPIClient.swift | 5 ++ .../OutlineKit/LiveOutlineAPIClient.swift | 68 +++++++++++++++ .../Models/OutlineFileOperation.swift | 8 ++ .../OutlineKit/Models/OutlineStar.swift | 9 ++ .../Requests/ExportCollectionRequest.swift | 17 ++++ .../Requests/StarCollectionRequest.swift | 9 ++ .../LiveOutlineAPIClientTests.swift | 84 +++++++++++++++++++ 7 files changed, 200 insertions(+) create mode 100644 OutlineKit/Sources/OutlineKit/Models/OutlineFileOperation.swift create mode 100644 OutlineKit/Sources/OutlineKit/Models/OutlineStar.swift create mode 100644 OutlineKit/Sources/OutlineKit/Requests/ExportCollectionRequest.swift create mode 100644 OutlineKit/Sources/OutlineKit/Requests/StarCollectionRequest.swift diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift index f41182b..a1e2707 100644 --- a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift @@ -19,6 +19,11 @@ public protocol OutlineAPIClient: Sendable { func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] func collectionInfo(id: String) async throws -> OutlineCollection func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection + func deleteCollection(id: String) async throws + /// Kicks off an async export job — this only wraps the trigger, not polling + /// for completion or downloading the resulting file. + func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation + func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar func currentUser() async throws -> OutlineUser } diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index 8cf4414..17623db 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -69,6 +69,19 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { 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 currentUser() async throws -> OutlineUser { try await post("users.info", body: EmptyParams()) } @@ -112,6 +125,61 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { 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(_ 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 SuccessEnvelope: Decodable { + let success: Bool } private struct DocumentInfoParams: Encodable { diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineFileOperation.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineFileOperation.swift new file mode 100644 index 0000000..8e5b3cc --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineFileOperation.swift @@ -0,0 +1,8 @@ +import Foundation + +/// A tracked async job — collection/document export runs server-side and +/// this is the handle to check on it (progress polling isn't wrapped yet). +public struct OutlineFileOperation: Decodable, Sendable { + public let id: String + public let state: String? +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineStar.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineStar.swift new file mode 100644 index 0000000..09f759d --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineStar.swift @@ -0,0 +1,9 @@ +import Foundation + +/// A bookmark on a document or collection, shown in the user's sidebar. +public struct OutlineStar: Decodable, Sendable { + public let id: String + public let index: String? + public let documentId: String? + public let collectionId: String? +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/ExportCollectionRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ExportCollectionRequest.swift new file mode 100644 index 0000000..ebc55af --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ExportCollectionRequest.swift @@ -0,0 +1,17 @@ +import Foundation + +public enum CollectionExportFormat: String, Encodable, Sendable, CaseIterable { + case markdown = "outline-markdown" + case json + case html +} + +public struct ExportCollectionRequest: Encodable, Sendable { + public let id: String + public let format: CollectionExportFormat + + public init(id: String, format: CollectionExportFormat = .markdown) { + self.id = id + self.format = format + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/StarCollectionRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/StarCollectionRequest.swift new file mode 100644 index 0000000..b3f3bd8 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/StarCollectionRequest.swift @@ -0,0 +1,9 @@ +import Foundation + +public struct StarCollectionRequest: Encodable, Sendable { + public let collectionId: String + + public init(collectionId: String) { + self.collectionId = collectionId + } +} diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index 81913e8..d4c44ad 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -35,6 +35,90 @@ private final class StaticTokenStore: TokenStoring, @unchecked Sendable { } final class LiveOutlineAPIClientTests: XCTestCase { + func testDeleteCollectionSucceedsOnSuccessTrue() 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.deleteCollection(id: "col-1") + + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/collections.delete") + } + + func testDeleteCollectionThrowsOnSuccessFalse() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { "success": false } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + do { + try await client.deleteCollection(id: "col-1") + XCTFail("Expected an error when success is false") + } catch { + // expected + } + } + + func testExportCollectionDecodesFileOperation() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "fileOperation": { "id": "op-1", "state": "creating" } + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let operation = try await client.exportCollection(ExportCollectionRequest(id: "col-1")) + + XCTAssertEqual(operation.id, "op-1") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/collections.export") + } + + func testStarCollectionDecodesStar() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "id": "star-1", + "collectionId": "col-1", + "documentId": null + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let star = try await client.starCollection(StarCollectionRequest(collectionId: "col-1")) + + XCTAssertEqual(star.id, "star-1") + XCTAssertEqual(star.collectionId, "col-1") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/stars.create") + } + func testSearchDocumentsDecodesContextAndRanking() async throws { let httpClient = MockHTTPClient() httpClient.responseData = """