From c1810f38f1c1044d56937272a88f935f3dfff4b6 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 16:32:59 +0100 Subject: [PATCH] fix(pins): decode pins.list correctly, use collectionId:nil for Pin to Home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pins.list's real response is {data: {pins: [...], documents: [...]}}, not a bare array — confirmed against a live server's network traffic. Decoding straight to [OutlinePin] threw on every call, and call sites swallow that with try?, so pins never showed up anywhere (including docs already pinned via the real web app). Also: the reader's Pin action was sending the document's own collectionId, which is "Pin to Collection" — a different action from "Pin to Home" (collectionId: null), which is what the web app's "Pin to Home" menu item actually does and what the Home page's pinned section filters for. --- .../OutlineKit/LiveOutlineAPIClient.swift | 12 +++++++- .../OutlineKit/Models/OutlinePin.swift | 12 ++++---- .../Requests/CreatePinRequest.swift | 3 +- .../OutlineKit/Requests/ListPinsRequest.swift | 2 +- .../LiveOutlineAPIClientTests.swift | 28 +++++++++++++++++++ .../CollectionDocumentsOutline.swift | 5 +++- .../Collections/DocumentReaderView.swift | 2 +- .../Collections/DocumentReaderViewModel.swift | 6 ++-- 8 files changed, 57 insertions(+), 13 deletions(-) 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 }