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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user