diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift index 90d34fb..cfdd47d 100644 --- a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift @@ -35,6 +35,8 @@ public protocol OutlineAPIClient: Sendable { func createShare(_ request: CreateShareRequest) async throws -> OutlineShare func shareInfo(documentId: String) async throws -> OutlineShare? func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare + func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] + func revokeShare(id: String) async throws /// See `OutlinePin` — best-effort, not in the vendored spec. func createPin(_ request: CreatePinRequest) async throws -> OutlinePin func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index 18090e6..77154c4 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -132,6 +132,14 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { try await post("shares.update", body: request) } + public func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] { + try await post("shares.list", body: request) + } + + public func revokeShare(id: String) async throws { + try await postForSuccess("shares.revoke", body: StarIDParams(id: id)) + } + public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin { try await post("pins.create", body: request) } diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineShare.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineShare.swift index 1cb4660..c507af3 100644 --- a/OutlineKit/Sources/OutlineKit/Models/OutlineShare.swift +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineShare.swift @@ -1,10 +1,41 @@ import Foundation /// A public share link for a document or collection, backed by `shares.*`. +/// +/// Only `id` and `published` are guaranteed present on every real share +/// object — everything else is modeled as optional even where the official +/// API docs don't explicitly mark it nullable, since self-hosted servers +/// have repeatedly diverged from the hosted app's documented response shape +/// (see `OutlinePin`'s history) and a single unexpectedly-null field used to +/// crash this decode entirely ("Got an unexpected response from the +/// server"). public struct OutlineShare: Decodable, Identifiable, Sendable { public let id: String + public let published: Bool + public let documentId: String? public let collectionId: String? - public let url: String - public let published: Bool + public let documentTitle: String? + public let documentUrl: String? + public let sourceTitle: String? + public let sourcePath: String? + public let urlId: String? + public let url: String? + public let domain: String? + /// Overrides the source document/collection title on the publicly + /// shared page, if set. + public let title: String? + /// Overrides the workspace branding icon on the publicly shared page, + /// if set. + public let iconUrl: String? + public let includeChildDocuments: Bool? + public let allowSubscriptions: Bool? + public let allowIndexing: Bool? + public let showLastUpdated: Bool? + public let showTOC: Bool? + public let views: Int? + public let createdBy: OutlineUser? + public let createdAt: Date? + public let updatedAt: Date? + public let lastAccessedAt: Date? } diff --git a/OutlineKit/Sources/OutlineKit/Requests/ListSharesRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListSharesRequest.swift new file mode 100644 index 0000000..ea8869f --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ListSharesRequest.swift @@ -0,0 +1,18 @@ +import Foundation + +public struct ListSharesRequest: Encodable, Sendable { + public let offset: Int + public let limit: Int + public let sort: String? + public let direction: String? + /// Filter to shared documents matching a search query. + public let query: String? + + public init(offset: Int = 0, limit: Int = 25, sort: String? = nil, direction: String? = nil, query: String? = nil) { + self.offset = offset + self.limit = limit + self.sort = sort + self.direction = direction + self.query = query + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateShareRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateShareRequest.swift index 9b9abc5..322a298 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/UpdateShareRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateShareRequest.swift @@ -3,9 +3,16 @@ import Foundation public struct UpdateShareRequest: Encodable, Sendable { public let id: String public let published: Bool + /// Overrides the title displayed on the publicly shared page. `nil` + /// leaves it unset in the payload (server keeps whatever it already + /// has) rather than clearing it — send an empty string to clear. + public let title: String? + public let iconUrl: String? - public init(id: String, published: Bool) { + public init(id: String, published: Bool, title: String? = nil, iconUrl: String? = nil) { self.id = id self.published = published + self.title = title + self.iconUrl = iconUrl } } diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index 7ac7075..712683d 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -669,6 +669,111 @@ final class LiveOutlineAPIClientTests: XCTestCase { XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.create") } + func testShareInfoDecodesFullDocumentedShape() async throws { + // Matches the exact response shape from Outline's official + // shares.info docs, including every field the hosted app can send — + // the model must tolerate all of this without throwing. + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "id": "123e4567-e89b-12d3-a456-426614174000", + "documentTitle": "React best practices", + "documentUrl": "https://example.com", + "sourceTitle": "string", + "sourcePath": "string", + "documentId": null, + "collectionId": null, + "urlId": null, + "url": "https://example.com", + "domain": null, + "title": null, + "iconUrl": null, + "published": false, + "includeChildDocuments": true, + "allowSubscriptions": true, + "allowIndexing": true, + "showLastUpdated": true, + "showTOC": true, + "views": 1, + "createdAt": "2026-08-12T17:41:28.333Z", + "createdBy": { + "id": "123e4567-e89b-12d3-a456-426614174000", + "name": "Jane Doe", + "avatarUrl": "https://example.com", + "color": "string", + "email": "hello@example.com", + "role": "admin", + "isSuspended": true, + "lastActiveAt": null, + "timezone": null, + "createdAt": "2026-08-12T17:41:28.333Z", + "updatedAt": "2026-08-12T17:41:28.333Z", + "deletedAt": null + }, + "updatedAt": "2026-08-12T17:41:28.333Z", + "lastAccessedAt": null + }, + "policies": [ + { "id": "123e4567-e89b-12d3-a456-426614174000", "abilities": { "read": true, "update": true, "delete": false } } + ] + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let share = try await client.shareInfo(documentId: "doc-1") + + XCTAssertEqual(share?.published, false) + XCTAssertEqual(share?.views, 1) + XCTAssertEqual(share?.createdBy?.name, "Jane Doe") + XCTAssertNil(share?.documentId) + } + + func testListSharesDecodesSharesArray() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": [ + { "id": "share-1", "documentId": "doc-1", "collectionId": null, "url": "https://outline.example.com/s/share-1", "published": true } + ], + "pagination": { "offset": 0, "limit": 25 } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let shares = try await client.listShares(ListSharesRequest()) + + XCTAssertEqual(shares.first?.id, "share-1") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.list") + } + + func testRevokeShareSendsRequest() 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.revokeShare(id: "share-1") + + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.revoke") + } + func testShareInfoReturnsNilOnNotFound() async throws { let httpClient = MockHTTPClient() httpClient.statusCode = 404 diff --git a/Outpost/Features/Collections/DocumentReaderViewModel.swift b/Outpost/Features/Collections/DocumentReaderViewModel.swift index 3005440..92b329a 100644 --- a/Outpost/Features/Collections/DocumentReaderViewModel.swift +++ b/Outpost/Features/Collections/DocumentReaderViewModel.swift @@ -28,7 +28,6 @@ final class DocumentReaderViewModel { private var pinId: String? private(set) var isSubscribed = false private var subscriptionId: String? - private(set) var share: OutlineShare? /// `nil` until checked. Inferred from whether `documents.insights` /// succeeds or fails — `insightsEnabled` isn't readable back off @@ -102,10 +101,6 @@ final class DocumentReaderViewModel { } } - func loadShare() async { - share = try? await apiClient.shareInfo(documentId: documentId) - } - func loadInsightsEnabledState() async { do { _ = try await apiClient.documentInsights(DocumentInsightsRequest(id: documentId)) @@ -151,10 +146,6 @@ final class DocumentReaderViewModel { } } - func createOrLoadShare() async throws { - share = try await apiClient.createShare(CreateShareRequest(documentId: documentId)) - } - /// Turning editing off saves; turning it on is just a mode switch. func toggleEditing() async { guard isEditing else { diff --git a/Outpost/Features/Collections/DocumentShareSheet.swift b/Outpost/Features/Collections/DocumentShareSheet.swift index 1cae350..f4dc33f 100644 --- a/Outpost/Features/Collections/DocumentShareSheet.swift +++ b/Outpost/Features/Collections/DocumentShareSheet.swift @@ -11,8 +11,11 @@ struct DocumentShareSheet: View { let documentId: String @State private var share: OutlineShare? + @State private var titleOverride = "" @State private var isLoading = false @State private var isUpdating = false + @State private var isRevoking = false + @State private var isShowingRevokeConfirmation = false @State private var errorMessage: String? @State private var didCopy = false @@ -32,21 +35,23 @@ struct DocumentShareSheet: View { .font(.callout) .foregroundStyle(.red) } else if let share { - HStack { - Text(share.url) - .font(.callout) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - Button { - copyLink(share.url) - } label: { - Image(systemName: didCopy ? "checkmark" : "doc.on.doc") + if let url = share.url { + HStack { + Text(url) + .font(.callout) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button { + copyLink(url) + } label: { + Image(systemName: didCopy ? "checkmark" : "doc.on.doc") + } + .buttonStyle(.plain) } - .buttonStyle(.plain) + .padding(8) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6)) } - .padding(8) - .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6)) Toggle( "Published — accessible without sign-in", @@ -56,6 +61,25 @@ struct DocumentShareSheet: View { ) ) .disabled(isUpdating) + + VStack(alignment: .leading, spacing: 4) { + Text("Public page title") + .font(.caption) + .foregroundStyle(.secondary) + TextField("Uses the document's title if empty", text: $titleOverride) + .textFieldStyle(.roundedBorder) + .disabled(isUpdating) + .onSubmit { + Task { await setTitle(titleOverride) } + } + } + + Divider() + + Button("Revoke Link", role: .destructive) { + isShowingRevokeConfirmation = true + } + .disabled(isRevoking) } else { Button("Create Share Link") { Task { await create() } @@ -65,6 +89,18 @@ struct DocumentShareSheet: View { .padding(20) .frame(width: 380) .task { await load() } + .confirmationDialog( + "Revoke this share link?", + isPresented: $isShowingRevokeConfirmation, + titleVisibility: .visible + ) { + Button("Revoke", role: .destructive) { + Task { await revoke() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Anyone using this link will no longer be able to access the document.") + } } private func copyLink(_ url: String) { @@ -83,6 +119,7 @@ struct DocumentShareSheet: View { defer { isLoading = false } do { share = try await apiClient.shareInfo(documentId: documentId) + titleOverride = share?.title ?? "" } catch { errorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.") } @@ -93,6 +130,7 @@ struct DocumentShareSheet: View { defer { isLoading = false } do { share = try await apiClient.createShare(CreateShareRequest(documentId: documentId)) + titleOverride = share?.title ?? "" } catch { errorMessage = outlineErrorMessage(error, fallback: "Couldn't create a share link.") } @@ -103,10 +141,40 @@ struct DocumentShareSheet: View { isUpdating = true defer { isUpdating = false } do { - self.share = try await apiClient.updateShare(UpdateShareRequest(id: share.id, published: published)) + self.share = try await apiClient.updateShare( + UpdateShareRequest(id: share.id, published: published, title: share.title) + ) } catch { errorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.") } } + + private func setTitle(_ title: String) async { + guard let share else { return } + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed != (share.title ?? "") else { return } + isUpdating = true + defer { isUpdating = false } + do { + self.share = try await apiClient.updateShare( + UpdateShareRequest(id: share.id, published: share.published, title: trimmed) + ) + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.") + } + } + + private func revoke() async { + guard let share else { return } + isRevoking = true + defer { isRevoking = false } + do { + try await apiClient.revokeShare(id: share.id) + self.share = nil + titleOverride = "" + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't revoke this share link.") + } + } } #endif