Flipping "Published" 403'd with authorization_error, confirmed via a raw curl (bypassing our client entirely, real token) to be a genuine server-side restriction independent of this app — workspace public sharing is enabled, token is full-scope, it's not document-specific. Root cause is most likely Outline gating that action behind an interactive session rather than API-token auth, but that's not definitively confirmed server-side. Removed the toggle per explicit instruction; kept link create/copy/revoke and title override (same endpoint, not reported broken). Replaced it with real per-document user permissions: - OutlineMembership/OutlineDocumentMember models - documents.add_user (confirmed shape from official docs), documents.remove_user/documents.users (speculative, same "best-effort until a live server confirms" treatment OutlinePin originally got), users.list for the invite search (standard, high-confidence) - DocumentShareSheet gets a "People with access" section: search and invite with a Can-view/Can-edit picker, existing members listed with a remove button - Reader toolbar's long-disabled "Permissions…" menu item now opens this same sheet instead of doing nothing Sidebar's own disabled "Permissions…" stub has no sheet wired up to it yet — comment updated to be accurate, not fixed (use the reader's menu instead). documents.users/documents.remove_user are unverified against a live server, same as every other speculative endpoint this session — expect a correction round once tested.
511 lines
20 KiB
Swift
511 lines
20 KiB
Swift
#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
|
|
let collection: OutlineCollection
|
|
@State private var viewModel: DocumentsViewModel
|
|
let sortOption: SidebarSortOption
|
|
let refreshToken: Int
|
|
/// Bumped from `ContentView_macOS` whenever a document is created from
|
|
/// somewhere that has no direct handle on this row — the reader's
|
|
/// toolbar "New Document" button and Home's, specifically. Those can't
|
|
/// call `onDocumentsChanged()` the way a same-row sheet does, since they
|
|
/// don't know which (if any) sidebar row corresponds to where the new
|
|
/// document landed, so every expanded row just reloads itself.
|
|
let externalRefreshToken: Int
|
|
let selectedDocumentID: String?
|
|
/// Full chain from root to the clicked document (inclusive) — lets the
|
|
/// toolbar render the real hierarchy instead of just the leaf title.
|
|
let onSelectDocument: ([OutlineDocument]) -> Void
|
|
|
|
/// Loaded once per collection (`pins.list` is collection-scoped) rather
|
|
/// than per-row — a per-row `pins.list`/lookup would be an N+1 call for
|
|
/// every document in the tree.
|
|
@State private var pinsByDocumentID: [String: OutlinePin] = [:]
|
|
|
|
private var tree: [DocumentNode] {
|
|
buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
|
|
}
|
|
|
|
init(
|
|
apiClient: OutlineAPIClient,
|
|
collection: OutlineCollection,
|
|
sortOption: SidebarSortOption,
|
|
refreshToken: Int,
|
|
externalRefreshToken: Int,
|
|
selectedDocumentID: String?,
|
|
onSelectDocument: @escaping ([OutlineDocument]) -> Void
|
|
) {
|
|
self.apiClient = apiClient
|
|
self.collection = collection
|
|
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
|
self.sortOption = sortOption
|
|
self.refreshToken = refreshToken
|
|
self.externalRefreshToken = externalRefreshToken
|
|
self.selectedDocumentID = selectedDocumentID
|
|
self.onSelectDocument = onSelectDocument
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if viewModel.isLoading && viewModel.documents.isEmpty {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
.padding(.vertical, 6)
|
|
} else if viewModel.documents.isEmpty {
|
|
Text("No documents")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.vertical, 6)
|
|
} else {
|
|
ForEach(tree) { node in
|
|
DocumentNodeRow(
|
|
apiClient: apiClient,
|
|
node: node,
|
|
depth: 0,
|
|
ancestors: [],
|
|
selectedDocumentID: selectedDocumentID,
|
|
pinsByDocumentID: pinsByDocumentID,
|
|
onSelectDocument: onSelectDocument,
|
|
onDocumentsChanged: { await viewModel.load() },
|
|
onPinsChanged: { await loadPins() }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
// Combined into one identity rather than two separate `.task(id:)`
|
|
// modifiers — each of those fires once unconditionally on first
|
|
// appear, so two of them would double the initial load.
|
|
.task(id: "\(refreshToken)-\(externalRefreshToken)") {
|
|
await viewModel.load()
|
|
await loadPins()
|
|
}
|
|
}
|
|
|
|
private func loadPins() async {
|
|
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collection.id)) else { return }
|
|
pinsByDocumentID = Dictionary(uniqueKeysWithValues: pins.map { ($0.documentId, $0) })
|
|
}
|
|
}
|
|
|
|
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
|
|
/// below must run on the main thread — without pinning this to `@MainActor`,
|
|
/// a `Task` launched from a menu action can resume on a background executor
|
|
/// after its first `await`, and calling those APIs off-main is what produced
|
|
/// the ViewBridge/`nw_connection` console spam (and worse, silent failures).
|
|
@MainActor
|
|
private struct DocumentNodeRow: View {
|
|
@Environment(StarStore.self) private var starStore
|
|
|
|
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 pinsByDocumentID: [String: OutlinePin]
|
|
let onSelectDocument: ([OutlineDocument]) -> Void
|
|
let onDocumentsChanged: () async -> Void
|
|
let onPinsChanged: () 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 isShowingNewDocumentSheet = false
|
|
@State private var actionErrorMessage: String?
|
|
|
|
private var isSelected: Bool {
|
|
node.id == selectedDocumentID
|
|
}
|
|
|
|
private func containsSelected(_ node: DocumentNode) -> Bool {
|
|
guard let selectedDocumentID else { return false }
|
|
return node.children.contains {
|
|
$0.id == selectedDocumentID || containsSelected($0)
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack(spacing: 6) {
|
|
if node.children.isEmpty {
|
|
Color.clear.frame(width: 12)
|
|
} else {
|
|
Button {
|
|
isExpanded.toggle()
|
|
} label: {
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
|
.frame(width: 12)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
Button {
|
|
onSelectDocument(ancestors + [node.document])
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
if let emoji = node.document.emoji {
|
|
Text(emoji)
|
|
.font(.body)
|
|
} else {
|
|
Image(systemName: "doc.text")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Text(node.document.title.isEmpty ? "Untitled" : node.document.title)
|
|
.font(.body)
|
|
.lineLimit(1)
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
if starStore.isStarred(documentId: node.document.id) {
|
|
Image(systemName: "star.fill")
|
|
.font(.caption2)
|
|
.foregroundStyle(.yellow)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.vertical, 6)
|
|
.padding(.horizontal, 4)
|
|
.padding(.leading, CGFloat(depth) * 16)
|
|
.background(
|
|
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,
|
|
pinsByDocumentID: pinsByDocumentID,
|
|
onSelectDocument: onSelectDocument,
|
|
onDocumentsChanged: onDocumentsChanged,
|
|
onPinsChanged: onPinsChanged
|
|
)
|
|
}
|
|
}
|
|
}
|
|
// Reveals the selected document if it's nested under this node —
|
|
// both on first appearance and whenever the selection changes — but
|
|
// never auto-collapses on the way out, so manual expand/collapse
|
|
// elsewhere in the tree isn't fought.
|
|
.task(id: selectedDocumentID) {
|
|
if containsSelected(node) {
|
|
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)
|
|
}
|
|
.sheet(isPresented: $isShowingNewDocumentSheet) {
|
|
NewDocumentSheet(apiClient: apiClient, initialParentDocument: node.document) { _ in
|
|
Task { await onDocumentsChanged() }
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var contextMenuContent: some View {
|
|
Button(starStore.isStarred(documentId: node.document.id) ? "Unstar" : "Star") {
|
|
Task { await star() }
|
|
}
|
|
// subscriptions.* does exist and works (confirmed against a live
|
|
// server via the reader's menu) — not shown here because
|
|
// subscriptions.list is per-document, so reflecting accurate
|
|
// per-row state for every document in the tree would mean an N+1
|
|
// call storm. Use the reader's ⋯ menu instead.
|
|
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
|
|
}
|
|
// DocumentShareSheet now has a real "People with access" section —
|
|
// this row just doesn't have a sheet wired up to present it yet
|
|
// (only the reader toolbar does). Use the reader's ⋯ menu instead.
|
|
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") {
|
|
isShowingNewDocumentSheet = true
|
|
}
|
|
// Scoped to this collection (`collection.id`) — "Pin to Collection",
|
|
// distinct from the reader toolbar's "Pin to Home" (collectionId: nil).
|
|
Button(pinsByDocumentID[node.document.id] != nil ? "Unpin from Collection" : "Pin to Collection") {
|
|
Task { await togglePin() }
|
|
}
|
|
|
|
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 starStore.toggleDocument(node.document.id, apiClient: apiClient)
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update the star on this document.")
|
|
}
|
|
}
|
|
|
|
private func togglePin() async {
|
|
do {
|
|
if let pin = pinsByDocumentID[node.document.id] {
|
|
try await apiClient.deletePin(id: pin.id)
|
|
} else {
|
|
_ = try await apiClient.createPin(CreatePinRequest(documentId: node.document.id, collectionId: node.document.collectionId))
|
|
}
|
|
await onPinsChanged()
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update pin state.")
|
|
}
|
|
}
|
|
|
|
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 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
|