diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index 2ef0d1b..437b7b5 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -137,7 +137,12 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { } public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { - try await post("pins.list", body: request) + // `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 { @@ -298,6 +303,11 @@ private struct ListStarsResponse: Decodable { let stars: [OutlineStar] } +private struct PinsListPayload: Decodable { + let pins: [OutlinePin] + let documents: [OutlineDocument] +} + private struct StarIDParams: Encodable { let id: String } diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlinePin.swift b/OutlineKit/Sources/OutlineKit/Models/OutlinePin.swift index 1637c40..d6a66ec 100644 --- a/OutlineKit/Sources/OutlineKit/Models/OutlinePin.swift +++ b/OutlineKit/Sources/OutlineKit/Models/OutlinePin.swift @@ -1,11 +1,11 @@ import Foundation -/// A document pinned to the top of a collection (or the team home), backed by -/// `pins.*`. Not in the vendored OpenAPI spec (`docs/reference/outline-openapi`) -/// — that spec has no `Pins` tag at all — but the endpoint exists on Outline's -/// actual server (`server/routes/api/pins.ts` upstream). Shape reconstructed -/// from general knowledge of Outline's API, not verified against this spec; -/// treat field names as best-effort until confirmed against a live server. +/// A document pinned to the top of a collection, or to team Home when +/// `collectionId` is `nil`. Backed by `pins.*` — not in the vendored OpenAPI +/// spec (`docs/reference/outline-openapi`, no `Pins` tag at all), but +/// confirmed real against a live server's network traffic. `collectionId: nil` +/// is what "Pin to Home" actually sends; a non-nil value is "Pin to +/// Collection", a distinct action. public struct OutlinePin: Decodable, Identifiable, Sendable { public let id: String public let documentId: String diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift index e8bc0d7..29e2701 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift @@ -1,6 +1,7 @@ import Foundation -/// See `OutlinePin` — best-effort shape, not in the vendored spec. +/// See `OutlinePin`. `collectionId: nil` = "Pin to Home", non-nil = "Pin to +/// Collection" — these are distinct actions on the real server. public struct CreatePinRequest: Encodable, Sendable { public let documentId: String public let collectionId: String? diff --git a/OutlineKit/Sources/OutlineKit/Requests/ListPinsRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListPinsRequest.swift index 29dc61f..b6a8d5d 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/ListPinsRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/ListPinsRequest.swift @@ -1,6 +1,6 @@ import Foundation -/// See `OutlinePin` — best-effort shape, not in the vendored spec. +/// See `OutlinePin`. `collectionId: nil` lists Home pins only. public struct ListPinsRequest: Encodable, Sendable { public let collectionId: String? diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index f972c50..7ac7075 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -735,6 +735,34 @@ final class LiveOutlineAPIClientTests: XCTestCase { XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.create") } + func testListPinsDecodesNestedPinsArray() async throws { + // Real shape confirmed against a live server: `data` is + // `{ pins: [...], documents: [...] }`, not a bare array. + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "pagination": { "limit": 25, "offset": 0 }, + "data": { + "pins": [ + { "id": "pin-1", "documentId": "doc-1", "collectionId": null, "index": "h" } + ], + "documents": [] + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let pins = try await client.listPins(ListPinsRequest(collectionId: nil)) + + XCTAssertEqual(pins.first?.id, "pin-1") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.list") + } + func testCreateSubscriptionDecodesSubscription() async throws { let httpClient = MockHTTPClient() httpClient.responseData = """ diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift index 190aa98..258b93b 100644 --- a/Outpost/Features/Collections/CollectionDocumentsOutline.swift +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -311,7 +311,10 @@ private struct DocumentNodeRow: View { Button("New Document") { isShowingNewDocumentSheet = true } - // No `pins.*` endpoint in the API — nothing to back this with. + // `pins.*` is real and works (see `OutlinePin`), but "Pin to + // Collection" (non-nil `collectionId`) is distinct from the reader + // toolbar's "Pin to Home" and needs its own per-row state/N+1 + // consideration for a whole tree — not wired here yet. Button("Pin") {} .disabled(true) diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index d4bb4e5..5bc5353 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -296,7 +296,7 @@ struct DocumentReaderView: View { Button("New Document") { isShowingNewDocumentSheet = true } - Button(viewModel.isPinned ? "Unpin" : "Pin") { + Button(viewModel.isPinned ? "Unpin from Home" : "Pin to Home") { Task { await togglePin() } } diff --git a/Outpost/Features/Collections/DocumentReaderViewModel.swift b/Outpost/Features/Collections/DocumentReaderViewModel.swift index 47bc3c2..4ec090b 100644 --- a/Outpost/Features/Collections/DocumentReaderViewModel.swift +++ b/Outpost/Features/Collections/DocumentReaderViewModel.swift @@ -75,7 +75,9 @@ final class DocumentReaderViewModel { } func loadPinAndSubscriptionState() async { - if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collectionId)), + // `collectionId: nil` = Home pins. This menu's Pin action is "Pin to + // Home", not "Pin to Collection" — those are distinct on the server. + if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)), let match = pins.first(where: { $0.documentId == documentId }) { isPinned = true pinId = match.id @@ -110,7 +112,7 @@ final class DocumentReaderViewModel { throw error } } else { - let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: collectionId)) + let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: nil)) pinId = pin.id isPinned = true }