diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift index 6fa7a12..092493d 100644 --- a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift @@ -15,6 +15,19 @@ public protocol OutlineAPIClient: Sendable { func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument + func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar + func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate + func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] + func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument + func archiveDocument(id: String) async throws -> OutlineDocument + func moveDocument(_ request: MoveDocumentRequest) async throws + func deleteDocument(_ request: DeleteDocumentRequest) async throws + /// Requires insights to be enabled on the document server-side. Backed by `documents.insights`. + func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] + /// Backed by `revisions.list` — omits full body content for performance. + func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] + /// Returns the document's markdown source directly (not a file operation job). Backed by `documents.export`. + func exportDocument(id: String) async throws -> String func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] func collectionInfo(id: String) async throws -> OutlineCollection diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index ef96a74..7e36512 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -67,6 +67,47 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { try await post("documents.update", body: request) } + public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar { + try await post("stars.create", body: request) + } + + public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate { + try await post("documents.templatize", body: request) + } + + public func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] { + let result: DuplicateDocumentResponse = try await post("documents.duplicate", body: request) + return result.documents + } + + public func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument { + try await post("documents.unpublish", body: request) + } + + public func archiveDocument(id: String) async throws -> OutlineDocument { + try await post("documents.archive", body: DocumentIDRequest(id: id)) + } + + public func moveDocument(_ request: MoveDocumentRequest) async throws { + let _: MoveDocumentResponse = try await post("documents.move", body: request) + } + + public func deleteDocument(_ request: DeleteDocumentRequest) async throws { + try await postForSuccess("documents.delete", body: request) + } + + public func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] { + try await post("documents.insights", body: request) + } + + public func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] { + try await post("revisions.list", body: request) + } + + public func exportDocument(id: String) async throws -> String { + try await post("documents.export", body: DocumentIDRequest(id: id)) + } + public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] { try await post("collections.list", body: CollectionListParams(offset: offset, limit: limit)) } @@ -188,6 +229,15 @@ private struct ExportCollectionResponse: Decodable { let fileOperation: OutlineFileOperation } +private struct DuplicateDocumentResponse: Decodable { + let documents: [OutlineDocument] +} + +private struct MoveDocumentResponse: Decodable { + let documents: [OutlineDocument]? + let collections: [OutlineCollection]? +} + private struct SuccessEnvelope: Decodable { let success: Bool } diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineDocumentInsight.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineDocumentInsight.swift new file mode 100644 index 0000000..fd115f6 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineDocumentInsight.swift @@ -0,0 +1,15 @@ +import Foundation + +/// A daily/weekly activity rollup for a document, backed by `documents.insights`. +public struct OutlineDocumentInsight: Decodable, Sendable { + /// Plain `YYYY-MM-DD` (format: date, not date-time) — kept as `String` since + /// the client's `.iso8601` decoding strategy can't parse it. + public let date: String + public let period: String + public let viewCount: Int + public let viewerCount: Int + public let commentCount: Int + public let reactionCount: Int + public let revisionCount: Int + public let editorCount: Int +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineRevision.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineRevision.swift new file mode 100644 index 0000000..cefa438 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineRevision.swift @@ -0,0 +1,14 @@ +import Foundation + +/// A historical snapshot of a document, backed by `revisions.list`/`revisions.info`. +/// `revisions.list` omits `data`/`text` for performance — this model only +/// carries what's listed, not the full revision body. +public struct OutlineRevision: Decodable, Identifiable, Sendable { + public let id: String + public let documentId: String + public let title: String + public let name: String? + public let icon: String? + public let collaborators: [OutlineUser] + public let createdAt: Date +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineTemplate.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineTemplate.swift new file mode 100644 index 0000000..154cf59 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineTemplate.swift @@ -0,0 +1,8 @@ +import Foundation + +/// Result of `documents.templatize` — the app doesn't browse/manage templates +/// yet, so this only carries enough to confirm the action succeeded. +public struct OutlineTemplate: Decodable, Identifiable, Sendable { + public let id: String + public let title: String +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/DeleteDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/DeleteDocumentRequest.swift new file mode 100644 index 0000000..ce2dff6 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/DeleteDocumentRequest.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct DeleteDocumentRequest: Encodable, Sendable { + public let id: String + public let permanent: Bool + + public init(id: String, permanent: Bool = false) { + self.id = id + self.permanent = permanent + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/DocumentIDRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/DocumentIDRequest.swift new file mode 100644 index 0000000..e7bfb93 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/DocumentIDRequest.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Shared body shape for endpoints that take only `{ "id": ... }` — +/// `documents.archive`, `documents.export`. +public struct DocumentIDRequest: Encodable, Sendable { + public let id: String + + public init(id: String) { + self.id = id + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/DocumentInsightsRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/DocumentInsightsRequest.swift new file mode 100644 index 0000000..69a0699 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/DocumentInsightsRequest.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct DocumentInsightsRequest: Encodable, Sendable { + public let id: String + public let startDate: Date? + public let endDate: Date? + + public init(id: String, startDate: Date? = nil, endDate: Date? = nil) { + self.id = id + self.startDate = startDate + self.endDate = endDate + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/DuplicateDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/DuplicateDocumentRequest.swift new file mode 100644 index 0000000..ec3303b --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/DuplicateDocumentRequest.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct DuplicateDocumentRequest: Encodable, Sendable { + public let id: String + public let title: String? + public let recursive: Bool + public let publish: Bool + + public init(id: String, title: String? = nil, recursive: Bool = true, publish: Bool = true) { + self.id = id + self.title = title + self.recursive = recursive + self.publish = publish + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/ListRevisionsRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListRevisionsRequest.swift new file mode 100644 index 0000000..66d79a1 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ListRevisionsRequest.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct ListRevisionsRequest: Encodable, Sendable { + public let documentId: String + public let offset: Int + public let limit: Int + + public init(documentId: String, offset: Int = 0, limit: Int = 25) { + self.documentId = documentId + self.offset = offset + self.limit = limit + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/MoveDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/MoveDocumentRequest.swift new file mode 100644 index 0000000..b87ee9e --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/MoveDocumentRequest.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct MoveDocumentRequest: Encodable, Sendable { + public let id: String + public let collectionId: String + public let parentDocumentId: String? + + public init(id: String, collectionId: String, parentDocumentId: String? = nil) { + self.id = id + self.collectionId = collectionId + self.parentDocumentId = parentDocumentId + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/StarDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/StarDocumentRequest.swift new file mode 100644 index 0000000..54036a4 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/StarDocumentRequest.swift @@ -0,0 +1,9 @@ +import Foundation + +public struct StarDocumentRequest: Encodable, Sendable { + public let documentId: String + + public init(documentId: String) { + self.documentId = documentId + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/TemplatizeDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/TemplatizeDocumentRequest.swift new file mode 100644 index 0000000..c919668 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/TemplatizeDocumentRequest.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct TemplatizeDocumentRequest: Encodable, Sendable { + public let id: String + public let collectionId: String? + public let publish: Bool + + public init(id: String, collectionId: String? = nil, publish: Bool = true) { + self.id = id + self.collectionId = collectionId + self.publish = publish + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/UnpublishDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UnpublishDocumentRequest.swift new file mode 100644 index 0000000..4d44fe6 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/UnpublishDocumentRequest.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct UnpublishDocumentRequest: Encodable, Sendable { + public let id: String + public let detach: Bool + + public init(id: String, detach: Bool = false) { + self.id = id + self.detach = detach + } +} diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index 06c8a08..671334e 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -330,6 +330,215 @@ final class LiveOutlineAPIClientTests: XCTestCase { } } + func testStarDocumentDecodesStar() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "id": "star-1", + "collectionId": null, + "documentId": "doc-1" + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let star = try await client.starDocument(StarDocumentRequest(documentId: "doc-1")) + + XCTAssertEqual(star.documentId, "doc-1") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/stars.create") + } + + func testDuplicateDocumentDecodesDocumentsArray() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "documents": [ + { + "id": "doc-2", + "title": "Hello (copy)", + "text": "World", + "url": "/doc/hello-copy-doc-2", + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-02T00:00:00.000Z" + } + ] + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let documents = try await client.duplicateDocument(DuplicateDocumentRequest(id: "doc-1")) + + XCTAssertEqual(documents.first?.id, "doc-2") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.duplicate") + } + + func testMoveDocumentSendsDestination() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { "data": { "documents": [], "collections": [] } } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + try await client.moveDocument(MoveDocumentRequest(id: "doc-1", collectionId: "col-2", parentDocumentId: "doc-9")) + + struct SentBody: Decodable { + let id: String + let collectionId: String + let parentDocumentId: String? + } + + let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody) + let decodedBody = try JSONDecoder().decode(SentBody.self, from: sentBody) + XCTAssertEqual(decodedBody.collectionId, "col-2") + XCTAssertEqual(decodedBody.parentDocumentId, "doc-9") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.move") + } + + func testDeleteDocumentSucceedsOnSuccessTrue() 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.deleteDocument(DeleteDocumentRequest(id: "doc-1")) + + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.delete") + } + + func testArchiveDocumentDecodesDocument() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "id": "doc-1", + "title": "Hello", + "text": "World", + "url": "/doc/hello-doc-1", + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-02T00:00:00.000Z", + "archivedAt": "2026-01-03T00:00:00.000Z" + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let document = try await client.archiveDocument(id: "doc-1") + + XCTAssertNotNil(document.archivedAt) + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.archive") + } + + func testExportDocumentReturnsMarkdownString() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { "data": "# Hello\\n\\nWorld" } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let markdown = try await client.exportDocument(id: "doc-1") + + XCTAssertEqual(markdown, "# Hello\n\nWorld") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.export") + } + + func testListRevisionsDecodesRevisions() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": [ + { + "id": "rev-1", + "documentId": "doc-1", + "title": "Hello", + "name": null, + "icon": null, + "collaborators": [ + { "id": "user-1", "name": "Jane Doe" } + ], + "createdAt": "2026-01-01T00:00:00.000Z" + } + ] + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let revisions = try await client.listRevisions(ListRevisionsRequest(documentId: "doc-1")) + + XCTAssertEqual(revisions.first?.id, "rev-1") + XCTAssertEqual(revisions.first?.collaborators.first?.name, "Jane Doe") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/revisions.list") + } + + func testDocumentInsightsDecodesRollups() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": [ + { + "date": "2026-01-01", + "period": "day", + "viewCount": 3, + "viewerCount": 2, + "commentCount": 0, + "reactionCount": 0, + "revisionCount": 1, + "editorCount": 1 + } + ] + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let insights = try await client.documentInsights(DocumentInsightsRequest(id: "doc-1")) + + XCTAssertEqual(insights.first?.viewCount, 3) + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.insights") + } + func testMissingTokenThrowsTokenUnavailable() async throws { let httpClient = MockHTTPClient() let client = LiveOutlineAPIClient( diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift index 3e3a013..136a02a 100644 --- a/Outpost/Features/Collections/CollectionDocumentsOutline.swift +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -1,11 +1,14 @@ #if os(macOS) +import AppKit import SwiftUI +import UniformTypeIdentifiers import OutlineKit /// Nested document outline under an expanded collection in the sidebar tree — /// real hierarchy via `parentDocumentId`, see DocumentNode for why that's /// client-side rather than `collections.documents`. struct CollectionDocumentsOutline: View { + let apiClient: OutlineAPIClient @State private var viewModel: DocumentsViewModel let sortOption: SidebarSortOption let refreshToken: Int @@ -26,6 +29,7 @@ struct CollectionDocumentsOutline: View { selectedDocumentID: String?, onSelectDocument: @escaping ([OutlineDocument]) -> Void ) { + self.apiClient = apiClient _viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection)) self.sortOption = sortOption self.refreshToken = refreshToken @@ -47,11 +51,13 @@ struct CollectionDocumentsOutline: View { } else { ForEach(tree) { node in DocumentNodeRow( + apiClient: apiClient, node: node, depth: 0, ancestors: [], selectedDocumentID: selectedDocumentID, - onSelectDocument: onSelectDocument + onSelectDocument: onSelectDocument, + onDocumentsChanged: { await viewModel.load() } ) } } @@ -61,15 +67,29 @@ struct CollectionDocumentsOutline: View { } private struct DocumentNodeRow: View { + let apiClient: OutlineAPIClient let node: DocumentNode let depth: Int /// Chain from root down to (not including) this node. let ancestors: [OutlineDocument] let selectedDocumentID: String? let onSelectDocument: ([OutlineDocument]) -> Void + let onDocumentsChanged: () async -> Void @State private var isExpanded = false + @State private var isShowingRenameAlert = false + @State private var renameText = "" + @State private var isShowingUnpublishConfirmation = false + @State private var isShowingArchiveConfirmation = false + @State private var isShowingDeleteConfirmation = false + @State private var isShowingMoveSheet = false + @State private var isShowingHistorySheet = false + @State private var isShowingInsightsSheet = false + @State private var isShowingPresentSheet = false + @State private var isShowingSearchSheet = false + @State private var actionErrorMessage: String? + private var isSelected: Bool { node.id == selectedDocumentID } @@ -128,15 +148,18 @@ private struct DocumentNodeRow: View { isSelected ? Color.accentColor.opacity(0.15) : Color.clear, in: RoundedRectangle(cornerRadius: 6) ) + .contextMenu { contextMenuContent } if isExpanded { ForEach(node.children) { child in DocumentNodeRow( + apiClient: apiClient, node: child, depth: depth + 1, ancestors: ancestors + [node.document], selectedDocumentID: selectedDocumentID, - onSelectDocument: onSelectDocument + onSelectDocument: onSelectDocument, + onDocumentsChanged: onDocumentsChanged ) } } @@ -150,6 +173,285 @@ private struct DocumentNodeRow: View { isExpanded = true } } + .alert("Rename Document", isPresented: $isShowingRenameAlert) { + TextField("Title", text: $renameText) + Button("Cancel", role: .cancel) {} + Button("Rename") { + Task { await rename() } + } + } + .confirmationDialog( + "Unpublish \"\(node.document.title.isEmpty ? "Untitled" : node.document.title)\"?", + isPresented: $isShowingUnpublishConfirmation, + titleVisibility: .visible + ) { + Button("Unpublish", role: .destructive) { + Task { await unpublish() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This moves the document back to a draft and out of the collection.") + } + .confirmationDialog( + "Archive \"\(node.document.title.isEmpty ? "Untitled" : node.document.title)\"?", + isPresented: $isShowingArchiveConfirmation, + titleVisibility: .visible + ) { + Button("Archive", role: .destructive) { + Task { await archive() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Archived documents are hidden from the collection but can be restored later.") + } + .confirmationDialog( + "Delete \"\(node.document.title.isEmpty ? "Untitled" : node.document.title)\"?", + isPresented: $isShowingDeleteConfirmation, + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + Task { await delete() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This moves the document to the trash. If not restored within 30 days it's permanently deleted.") + } + .alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) { + Button("OK") { actionErrorMessage = nil } + } message: { + Text(actionErrorMessage ?? "") + } + .sheet(isPresented: $isShowingMoveSheet) { + MoveDocumentSheet(apiClient: apiClient, document: node.document) { + await onDocumentsChanged() + } + } + .sheet(isPresented: $isShowingHistorySheet) { + DocumentHistorySheet(apiClient: apiClient, document: node.document) + } + .sheet(isPresented: $isShowingInsightsSheet) { + DocumentInsightsSheet(apiClient: apiClient, document: node.document) + } + .sheet(isPresented: $isShowingPresentSheet) { + DocumentPresentSheet(apiClient: apiClient, document: node.document) + } + .sheet(isPresented: $isShowingSearchSheet) { + DocumentSearchSheet(apiClient: apiClient, document: node.document) + } + } + + @ViewBuilder + private var contextMenuContent: some View { + Button("Star") { + Task { await star() } + } + // No `subscriptions.*` endpoint in the API — nothing to back this with. + Button("Unsubscribe") {} + .disabled(true) + + Divider() + + // Editing isn't wired up yet anywhere in the app (Phase 1 is + // read-only) — this stays disabled rather than opening a half-built path. + Button("Edit") {} + .disabled(true) + Button("Rename…") { + renameText = node.document.title + isShowingRenameAlert = true + } + // Sharing/membership management is its own subsystem, not a one-off + // action — deferred rather than half-built here. + Button("Permissions…") {} + .disabled(true) + + Divider() + + Button("Templatize") { + Task { await templatize() } + } + Button("Duplicate") { + Task { await duplicate() } + } + Button("Unpublish") { + isShowingUnpublishConfirmation = true + } + Button("Archive…") { + isShowingArchiveConfirmation = true + } + + Divider() + + Button("Move") { + isShowingMoveSheet = true + } + // Multipart file upload is its own subsystem (file picker, format + // detection, progress) — deferred rather than half-built here. + Button("Import Document…") {} + .disabled(true) + Button("New Document") { + Task { await createChildDocument() } + } + // No `pins.*` endpoint in the API — nothing to back this with. + Button("Pin") {} + .disabled(true) + + Divider() + + Button("History") { + isShowingHistorySheet = true + } + Button("Insights") { + isShowingInsightsSheet = true + } + Button("Present") { + isShowingPresentSheet = true + } + + Divider() + + Button("Download") { + Task { await download() } + } + Button("Copy") { + Task { await copyMarkdown() } + } + Button("Print") { + Task { await printDocument() } + } + Button("Search in Document") { + isShowingSearchSheet = true + } + + Divider() + + Button("Delete…", role: .destructive) { + isShowingDeleteConfirmation = true + } + } + + private func star() async { + do { + _ = try await apiClient.starDocument(StarDocumentRequest(documentId: node.document.id)) + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't star this document.") + } + } + + private func rename() async { + do { + _ = try await apiClient.updateDocument(UpdateDocumentRequest(id: node.document.id, title: renameText)) + await onDocumentsChanged() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't rename this document.") + } + } + + private func templatize() async { + do { + _ = try await apiClient.templatizeDocument(TemplatizeDocumentRequest(id: node.document.id)) + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't templatize this document.") + } + } + + private func duplicate() async { + do { + _ = try await apiClient.duplicateDocument(DuplicateDocumentRequest(id: node.document.id)) + await onDocumentsChanged() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't duplicate this document.") + } + } + + private func unpublish() async { + do { + _ = try await apiClient.unpublishDocument(UnpublishDocumentRequest(id: node.document.id)) + await onDocumentsChanged() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't unpublish this document.") + } + } + + private func archive() async { + do { + _ = try await apiClient.archiveDocument(id: node.document.id) + await onDocumentsChanged() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't archive this document.") + } + } + + private func createChildDocument() async { + guard let collectionId = node.document.collectionId else { + actionErrorMessage = "This document isn't in a collection." + return + } + do { + _ = try await apiClient.createDocument( + CreateDocumentRequest( + title: "Untitled", + text: "", + collectionId: collectionId, + parentDocumentId: node.document.id + ) + ) + await onDocumentsChanged() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new document.") + } + } + + private func delete() async { + do { + try await apiClient.deleteDocument(DeleteDocumentRequest(id: node.document.id)) + await onDocumentsChanged() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete this document.") + } + } + + private func download() async { + do { + let markdown = try await apiClient.exportDocument(id: node.document.id) + let panel = NSSavePanel() + panel.nameFieldStringValue = "\(node.document.title.isEmpty ? "Untitled" : node.document.title).md" + panel.allowedContentTypes = [.text] + guard panel.runModal() == .OK, let url = panel.url else { return } + try markdown.write(to: url, atomically: true, encoding: .utf8) + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't download this document.") + } + } + + private func copyMarkdown() async { + do { + let full = try await apiClient.documentInfo(id: node.document.id) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(full.text, forType: .string) + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't copy this document.") + } + } + + /// No access to the reader's rendered `NSTextView` (MarkdownEngine + /// doesn't expose it), so this prints the raw markdown source as plain + /// monospaced text via a standalone `NSTextView` built just for the print + /// job, rather than a styled rendering of the document. + private func printDocument() async { + do { + let full = try await apiClient.documentInfo(id: node.document.id) + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 612, height: 792)) + textView.string = full.text + textView.font = .monospacedSystemFont(ofSize: 11, weight: .regular) + + let operation = NSPrintOperation(view: textView) + operation.printInfo.horizontalPagination = .fit + operation.printInfo.verticalPagination = .automatic + operation.run() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't print this document.") + } } } #endif diff --git a/Outpost/Features/Collections/DocumentHistorySheet.swift b/Outpost/Features/Collections/DocumentHistorySheet.swift new file mode 100644 index 0000000..ef2d812 --- /dev/null +++ b/Outpost/Features/Collections/DocumentHistorySheet.swift @@ -0,0 +1,75 @@ +#if os(macOS) +import SwiftUI +import OutlineKit + +/// Read-only revision list, backed by `revisions.list`. No diff/restore view — +/// `revisions.list` omits body content for performance, and restoring is a +/// bigger follow-up feature, not a one-off action. +struct DocumentHistorySheet: View { + @Environment(\.dismiss) private var dismiss + + let apiClient: OutlineAPIClient + let document: OutlineDocument + + @State private var revisions: [OutlineRevision] = [] + @State private var isLoading = false + @State private var errorMessage: String? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("History") + .font(.headline) + Spacer() + Button("Done") { dismiss() } + } + .padding() + + Divider() + + Group { + if isLoading && revisions.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage { + ContentUnavailableView { + Label("Couldn't Load History", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } + } else if revisions.isEmpty { + ContentUnavailableView("No Revisions Yet", systemImage: "clock") + } else { + List(revisions) { revision in + VStack(alignment: .leading, spacing: 2) { + Text(revision.title.isEmpty ? "Untitled" : revision.title) + .font(.body) + HStack(spacing: 4) { + Text(revision.createdAt, format: .dateTime.day().month().year().hour().minute()) + if let author = revision.collaborators.first?.name { + Text("\u{2022} \(author)") + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(width: 420, height: 420) + .task { await load() } + } + + private func load() async { + isLoading = true + defer { isLoading = false } + do { + revisions = try await apiClient.listRevisions(ListRevisionsRequest(documentId: document.id)) + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't load revision history.") + } + } +} +#endif diff --git a/Outpost/Features/Collections/DocumentInsightsSheet.swift b/Outpost/Features/Collections/DocumentInsightsSheet.swift new file mode 100644 index 0000000..a6ef91b --- /dev/null +++ b/Outpost/Features/Collections/DocumentInsightsSheet.swift @@ -0,0 +1,99 @@ +#if os(macOS) +import SwiftUI +import OutlineKit + +/// Backed by `documents.insights` — server returns an error if insights +/// aren't enabled on the document, surfaced like any other load failure. +struct DocumentInsightsSheet: View { + @Environment(\.dismiss) private var dismiss + + let apiClient: OutlineAPIClient + let document: OutlineDocument + + @State private var rollups: [OutlineDocumentInsight] = [] + @State private var isLoading = false + @State private var errorMessage: String? + + private var totalViews: Int { rollups.reduce(0) { $0 + $1.viewCount } } + private var totalViewers: Int { rollups.reduce(0) { $0 + $1.viewerCount } } + private var totalComments: Int { rollups.reduce(0) { $0 + $1.commentCount } } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Insights") + .font(.headline) + Spacer() + Button("Done") { dismiss() } + } + .padding() + + Divider() + + Group { + if isLoading && rollups.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage { + ContentUnavailableView { + Label("Couldn't Load Insights", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } + } else { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 24) { + statColumn("Views", totalViews) + statColumn("Viewers", totalViewers) + statColumn("Comments", totalComments) + } + + Text("Last 30 days, by day") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + if rollups.isEmpty { + Text("No activity in this window.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + List(rollups, id: \.date) { rollup in + HStack { + Text(rollup.date) + Spacer() + Text("\(rollup.viewCount) views \u{00B7} \(rollup.viewerCount) viewers") + .foregroundStyle(.secondary) + } + .font(.callout) + } + } + } + .padding() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(width: 420, height: 420) + .task { await load() } + } + + private func statColumn(_ label: String, _ value: Int) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text("\(value)") + .font(.title2.weight(.semibold)) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private func load() async { + isLoading = true + defer { isLoading = false } + do { + rollups = try await apiClient.documentInsights(DocumentInsightsRequest(id: document.id)) + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't load insights for this document.") + } + } +} +#endif diff --git a/Outpost/Features/Collections/DocumentPresentSheet.swift b/Outpost/Features/Collections/DocumentPresentSheet.swift new file mode 100644 index 0000000..de64d36 --- /dev/null +++ b/Outpost/Features/Collections/DocumentPresentSheet.swift @@ -0,0 +1,82 @@ +#if os(macOS) +import SwiftUI +import MarkdownEngine +import OutlineKit + +/// Distraction-free reading view — no toolbar/sidebar chrome, larger type. +/// Presentation is just a bigger render of the same markdown, not a real +/// slide-by-slide deck (Outline's own "Present" isn't slide-based either). +struct DocumentPresentSheet: View { + @Environment(\.dismiss) private var dismiss + + let apiClient: OutlineAPIClient + let document: OutlineDocument + + @State private var text: String + @State private var isLoading = false + @State private var errorMessage: String? + + init(apiClient: OutlineAPIClient, document: OutlineDocument) { + self.apiClient = apiClient + self.document = document + _text = State(initialValue: document.text) + } + + var body: some View { + ZStack(alignment: .topTrailing) { + if isLoading && text.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage { + ContentUnavailableView { + Label("Couldn't Load Document", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } + } else { + ScrollView { + VStack(alignment: .leading) { + if let emoji = document.emoji { + Text(emoji).font(.system(size: 48)) + } + Text(document.title.isEmpty ? "Untitled" : document.title) + .font(.system(size: 34, weight: .bold)) + .padding(.bottom, 8) + NativeTextViewWrapper( + text: $text, + configuration: .init(heightBehavior: .fitsContent), + isEditable: false + ) + .font(.system(size: 18)) + } + .frame(maxWidth: 720, alignment: .leading) + .padding(48) + .frame(maxWidth: .infinity) + } + } + + Button { + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .padding(20) + } + .frame(minWidth: 800, minHeight: 600) + .background(.background) + .task { await load() } + } + + private func load() async { + isLoading = true + defer { isLoading = false } + do { + text = try await apiClient.documentInfo(id: document.id).text + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't load this document.") + } + } +} +#endif diff --git a/Outpost/Features/Collections/DocumentSearchSheet.swift b/Outpost/Features/Collections/DocumentSearchSheet.swift new file mode 100644 index 0000000..7dd3b52 --- /dev/null +++ b/Outpost/Features/Collections/DocumentSearchSheet.swift @@ -0,0 +1,144 @@ +#if os(macOS) +import SwiftUI +import OutlineKit + +/// In-document find. `MarkdownEngine`'s `NativeTextViewWrapper` doesn't expose +/// its underlying `NSTextView` (no `NSTextFinder`/coordinator access in its +/// public API), so this renders the raw markdown source as plain per-line +/// text instead of the styled reader view — the tradeoff is losing rich +/// rendering in exchange for real match highlighting and scroll-to-match via +/// `ScrollViewReader`, which wouldn't be possible against an opaque view. +struct DocumentSearchSheet: View { + @Environment(\.dismiss) private var dismiss + + let apiClient: OutlineAPIClient + let document: OutlineDocument + + @State private var lines: [String] = [] + @State private var isLoading = false + @State private var errorMessage: String? + @State private var query = "" + @State private var currentMatchIndex = 0 + @FocusState private var isSearchFieldFocused: Bool + + private var matchingLineIndices: [Int] { + guard !query.isEmpty else { return [] } + return lines.indices.filter { lines[$0].localizedCaseInsensitiveContains(query) } + } + + var body: some View { + VStack(spacing: 0) { + HStack { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Search in \"\(document.title.isEmpty ? "Untitled" : document.title)\"", text: $query) + .textFieldStyle(.plain) + .focused($isSearchFieldFocused) + .onChange(of: query) { currentMatchIndex = 0 } + + if !matchingLineIndices.isEmpty { + Text("\(currentMatchIndex + 1) of \(matchingLineIndices.count)") + .font(.caption) + .foregroundStyle(.secondary) + Button { + step(-1) + } label: { + Image(systemName: "chevron.up") + } + Button { + step(1) + } label: { + Image(systemName: "chevron.down") + } + } else if !query.isEmpty { + Text("No matches") + .font(.caption) + .foregroundStyle(.secondary) + } + + Button("Done") { dismiss() } + } + .padding() + + Divider() + + Group { + if isLoading && lines.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage { + ContentUnavailableView { + Label("Couldn't Load Document", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } + } else { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 4) { + ForEach(Array(lines.enumerated()), id: \.offset) { index, line in + Text(line.isEmpty ? " " : line) + .font(.system(.body, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + .background( + isCurrentMatch(index) ? Color.yellow.opacity(0.4) + : matchingLineIndices.contains(index) ? Color.yellow.opacity(0.15) + : Color.clear + ) + .id(index) + } + } + .padding() + } + .onChange(of: currentMatchIndex) { + guard let target = matchingLineIndices[safe: currentMatchIndex] else { return } + withAnimation { + proxy.scrollTo(target, anchor: .center) + } + } + .onChange(of: query) { + guard let target = matchingLineIndices.first else { return } + withAnimation { + proxy.scrollTo(target, anchor: .center) + } + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(width: 640, height: 520) + .task { + await load() + isSearchFieldFocused = true + } + } + + private func isCurrentMatch(_ index: Int) -> Bool { + matchingLineIndices[safe: currentMatchIndex] == index + } + + private func step(_ delta: Int) { + guard !matchingLineIndices.isEmpty else { return } + let count = matchingLineIndices.count + currentMatchIndex = ((currentMatchIndex + delta) % count + count) % count + } + + private func load() async { + isLoading = true + defer { isLoading = false } + do { + let text = try await apiClient.documentInfo(id: document.id).text + lines = text.components(separatedBy: "\n") + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't load this document.") + } + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} +#endif diff --git a/Outpost/Features/Collections/MoveDocumentSheet.swift b/Outpost/Features/Collections/MoveDocumentSheet.swift new file mode 100644 index 0000000..e4bbbf8 --- /dev/null +++ b/Outpost/Features/Collections/MoveDocumentSheet.swift @@ -0,0 +1,122 @@ +#if os(macOS) +import SwiftUI +import OutlineKit + +/// Destination picker for `documents.move` — collection, then optionally a +/// root-level document in that collection to nest under. Doesn't offer +/// picking a destination deeper than one level (i.e. can't move under a +/// grandchild) — kept intentionally shallow rather than mirroring the full +/// sidebar tree in a picker. +struct MoveDocumentSheet: View { + @Environment(\.dismiss) private var dismiss + + let apiClient: OutlineAPIClient + let document: OutlineDocument + let onMoved: () async -> Void + + @State private var collections: [OutlineCollection] = [] + @State private var selectedCollectionID: String? + @State private var rootDocuments: [OutlineDocument] = [] + @State private var selectedParentID: String? + @State private var isLoadingCollections = false + @State private var isLoadingDestinationDocuments = false + @State private var isMoving = false + @State private var errorMessage: String? + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Move \"\(document.title.isEmpty ? "Untitled" : document.title)\"") + .font(.headline) + + if isLoadingCollections { + ProgressView().frame(maxWidth: .infinity) + } else { + Picker("Collection", selection: $selectedCollectionID) { + Text("Choose a collection").tag(String?.none) + ForEach(collections) { collection in + Text(collection.name).tag(Optional(collection.id)) + } + } + .labelsHidden() + + Picker("Location", selection: $selectedParentID) { + Text("Collection root").tag(String?.none) + ForEach(rootDocuments.filter { $0.id != document.id }) { candidate in + Text(candidate.title.isEmpty ? "Untitled" : candidate.title).tag(Optional(candidate.id)) + } + } + .labelsHidden() + .disabled(selectedCollectionID == nil || isLoadingDestinationDocuments) + } + + if let errorMessage { + Text(errorMessage) + .font(.callout) + .foregroundStyle(.red) + } + + HStack { + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + Button("Move") { + Task { await move() } + } + .disabled(selectedCollectionID == nil || isMoving) + } + } + .padding(20) + .frame(width: 360) + .task { + await loadCollections() + selectedCollectionID = document.collectionId + } + .task(id: selectedCollectionID) { + await loadRootDocuments() + } + } + + private func loadCollections() async { + isLoadingCollections = true + defer { isLoadingCollections = false } + do { + collections = try await apiClient.listCollections(offset: 0, limit: 100) + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't load collections.") + } + } + + private func loadRootDocuments() async { + guard let selectedCollectionID else { + rootDocuments = [] + return + } + isLoadingDestinationDocuments = true + defer { isLoadingDestinationDocuments = false } + do { + rootDocuments = try await apiClient.listDocuments( + collectionId: selectedCollectionID, + parentDocumentId: nil, + offset: 0, + limit: 100 + ) + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't load destination documents.") + } + } + + private func move() async { + guard let selectedCollectionID else { return } + isMoving = true + defer { isMoving = false } + do { + try await apiClient.moveDocument( + MoveDocumentRequest(id: document.id, collectionId: selectedCollectionID, parentDocumentId: selectedParentID) + ) + await onMoved() + dismiss() + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't move this document.") + } + } +} +#endif