fix(pins): decode pins.list correctly, use collectionId:nil for Pin to Home

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.
This commit is contained in:
2026-08-14 16:32:59 +01:00
parent 23193034e6
commit c1810f38f1
8 changed files with 57 additions and 13 deletions
@@ -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
}
@@ -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
@@ -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?
@@ -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?
@@ -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 = """
@@ -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)
@@ -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() }
}
@@ -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
}