diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..8c99c45 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "docs/reference/outline-openapi"] + path = docs/reference/outline-openapi + url = https://github.com/outline/openapi.git diff --git a/OutlineKit/.gitignore b/OutlineKit/.gitignore new file mode 100644 index 0000000..29a1e94 --- /dev/null +++ b/OutlineKit/.gitignore @@ -0,0 +1,3 @@ +.build/ +.swiftpm/ +.DS_Store diff --git a/OutlineKit/Package.swift b/OutlineKit/Package.swift new file mode 100644 index 0000000..8a929b8 --- /dev/null +++ b/OutlineKit/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "OutlineKit", + platforms: [ + .iOS(.v17), + .macOS(.v14) + ], + products: [ + .library(name: "OutlineKit", targets: ["OutlineKit"]) + ], + targets: [ + .target( + name: "OutlineKit" + ), + .testTarget( + name: "OutlineKitTests", + dependencies: ["OutlineKit"] + ) + ] +) diff --git a/OutlineKit/Sources/OutlineKit/Auth/KeychainTokenStore.swift b/OutlineKit/Sources/OutlineKit/Auth/KeychainTokenStore.swift new file mode 100644 index 0000000..83c283f --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Auth/KeychainTokenStore.swift @@ -0,0 +1,56 @@ +import Foundation +import Security + +public final class KeychainTokenStore: TokenStoring, @unchecked Sendable { + private let service: String + private let account: String + + public init(service: String = "com.outpost.outlinekit", account: String = "outline-api-token") { + self.service = service + self.account = account + } + + public func token() throws -> String { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data, let token = String(data: data, encoding: .utf8) else { + throw TokenStoreError.notFound + } + return token + } + + public func store(_ token: String) throws { + let data = Data(token.utf8) + let query = baseQuery() + + let existsStatus = SecItemCopyMatching(query as CFDictionary, nil) + if existsStatus == errSecSuccess { + let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) + guard updateStatus == errSecSuccess else { throw TokenStoreError.storeFailed(updateStatus) } + } else { + var addQuery = query + addQuery[kSecValueData as String] = data + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + guard addStatus == errSecSuccess else { throw TokenStoreError.storeFailed(addStatus) } + } + } + + public func clear() throws { + let status = SecItemDelete(baseQuery() as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw TokenStoreError.deleteFailed(status) + } + } + + private func baseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + } +} diff --git a/OutlineKit/Sources/OutlineKit/Auth/TokenStoring.swift b/OutlineKit/Sources/OutlineKit/Auth/TokenStoring.swift new file mode 100644 index 0000000..90a6c1e --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Auth/TokenStoring.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Boundary over wherever the API bearer token lives. Tokens never touch UserDefaults or logs. +public protocol TokenStoring: Sendable { + func token() throws -> String + func store(_ token: String) throws + func clear() throws +} + +public enum TokenStoreError: Error, Sendable { + case notFound + case storeFailed(OSStatus) + case deleteFailed(OSStatus) +} diff --git a/OutlineKit/Sources/OutlineKit/Collaboration/HocuspocusConnection.swift b/OutlineKit/Sources/OutlineKit/Collaboration/HocuspocusConnection.swift new file mode 100644 index 0000000..962b24b --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Collaboration/HocuspocusConnection.swift @@ -0,0 +1,103 @@ +import Foundation + +/// Connection lifecycle for Outline's Hocuspocus collaboration socket +/// (`wss:///collaboration/document.`) — see `docs/ARCHITECTURE.md` +/// section 2. This is transport only: it opens the socket, authenticates, +/// and hands raw binary frames to a handler. It does **not** speak the Yjs +/// sync/awareness wire protocol or touch a `Y.Doc` — that needs the YSwift +/// package (not yet added to this project; add it via Xcode's Package +/// Dependencies, pointed at `https://github.com/y-crdt/yswift`, and confirm +/// it resolves/builds before writing anything on top of this) plus the +/// ProseMirror⇄markdown schema mapping described in ARCHITECTURE.md +/// section 4, neither of which exist yet. Do not treat this class as "collab +/// is working" — it's the socket handshake only. +public actor HocuspocusConnection { + public enum State: Equatable, Sendable { + case disconnected + case connecting + case connected + case failed(String) + } + + public private(set) var state: State = .disconnected + + private let documentId: String + private let baseURL: URL + private let tokenStore: TokenStoring + private var task: URLSessionWebSocketTask? + private var receiveLoopTask: Task? + private var onFrame: (@Sendable (Data) -> Void)? + + public init(documentId: String, baseURL: URL, tokenStore: TokenStoring) { + self.documentId = documentId + self.baseURL = baseURL + self.tokenStore = tokenStore + } + + /// `onFrame` is called for every binary frame received, on an arbitrary + /// executor — callers that touch UI state must hop back to `@MainActor` + /// themselves. + public func connect(onFrame: @escaping @Sendable (Data) -> Void) { + guard state != .connected, state != .connecting else { return } + self.onFrame = onFrame + + guard let token = try? tokenStore.token(), let wsURL = webSocketURL() else { + state = .failed("Missing token or invalid server URL.") + return + } + + state = .connecting + var request = URLRequest(url: wsURL) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let session = URLSession(configuration: .default) + let webSocketTask = session.webSocketTask(with: request) + task = webSocketTask + webSocketTask.resume() + state = .connected + + receiveLoopTask = Task { [weak self] in + await self?.receiveLoop() + } + } + + public func disconnect() { + receiveLoopTask?.cancel() + receiveLoopTask = nil + task?.cancel(with: .goingAway, reason: nil) + task = nil + state = .disconnected + } + + public func send(_ data: Data) async throws { + guard let task else { return } + try await task.send(.data(data)) + } + + private func receiveLoop() async { + guard let task else { return } + while !Task.isCancelled { + do { + let message = try await task.receive() + switch message { + case .data(let data): + onFrame?(data) + case .string(let text): + onFrame?(Data(text.utf8)) + @unknown default: + break + } + } catch { + state = .failed(error.localizedDescription) + return + } + } + } + + private func webSocketURL() -> URL? { + guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { return nil } + components.scheme = components.scheme == "http" ? "ws" : "wss" + components.path = "/collaboration/document.\(documentId)" + return components.url + } +} diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift new file mode 100644 index 0000000..3604545 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift @@ -0,0 +1,58 @@ +import Foundation + +/// REST-layer boundary. The CRDT/sync layer and editor UI layer depend only on this +/// protocol, never on `LiveOutlineAPIClient`, so the transport can be swapped or mocked +/// without touching callers. +public protocol OutlineAPIClient: Sendable { + /// Validates the current token and identifies the signed-in user/workspace. Backed by `auth.info`. + func authInfo() async throws -> OutlineAuthInfo + + func documentInfo(id: String) async throws -> OutlineDocument + func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument] + /// Full-text search with snippets/ranking. Backed by `documents.search`. + func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] + /// Title-only search — faster, no snippets. Backed by `documents.search_titles`. + 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 createShare(_ request: CreateShareRequest) async throws -> OutlineShare + func shareInfo(documentId: String) async throws -> OutlineShare? + func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare + /// See `OutlinePin` — best-effort, not in the vendored spec. + func createPin(_ request: CreatePinRequest) async throws -> OutlinePin + func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] + func deletePin(id: String) async throws + /// See `OutlineSubscription` — best-effort, not in the vendored spec. + func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription + func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] + func deleteSubscription(id: String) async throws + /// Historical view records, not live presence. Backed by `views.list`. + func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] + + func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] + func collectionInfo(id: String) async throws -> OutlineCollection + func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection + func deleteCollection(id: String) async throws + /// Kicks off an async export job — this only wraps the trigger, not polling + /// for completion or downloading the resulting file. + func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation + func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar + /// Every star across both documents and collections for the current user. Backed by `stars.list`. + func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] + func deleteStar(id: String) async throws + + func currentUser() async throws -> OutlineUser +} diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIError.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIError.swift new file mode 100644 index 0000000..be4a50a --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIError.swift @@ -0,0 +1,10 @@ +import Foundation + +public enum OutlineAPIError: Error, Sendable { + case unauthorized + case notFound + case server(status: Int, message: String?) + case decoding(Error) + case transport(Error) + case tokenUnavailable +} diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineConfiguration.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineConfiguration.swift new file mode 100644 index 0000000..8bdc965 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineConfiguration.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Points at one self-hosted Outline instance's API root, e.g. `https://outline.example.com`. +public struct OutlineConfiguration: Sendable { + public let baseURL: URL + + public init(baseURL: URL) { + self.baseURL = baseURL + } +} diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift new file mode 100644 index 0000000..19a3725 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -0,0 +1,326 @@ +import Foundation + +/// Talks to Outline's RPC-style REST API (`POST /api/.`, Bearer auth). +public actor LiveOutlineAPIClient: OutlineAPIClient { + private let baseURL: URL + private let httpClient: HTTPClient + private let tokenStore: TokenStoring + private let decoder: JSONDecoder + private let encoder: JSONEncoder + + public init( + configuration: OutlineConfiguration, + tokenStore: TokenStoring, + httpClient: HTTPClient = URLSessionHTTPClient() + ) { + self.baseURL = configuration.baseURL + self.tokenStore = tokenStore + self.httpClient = httpClient + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + self.decoder = decoder + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + self.encoder = encoder + } + + public func authInfo() async throws -> OutlineAuthInfo { + try await post("auth.info", body: EmptyParams()) + } + + public func documentInfo(id: String) async throws -> OutlineDocument { + try await post("documents.info", body: DocumentInfoParams(id: id)) + } + + public func listDocuments( + collectionId: String?, + parentDocumentId: String?, + offset: Int, + limit: Int + ) async throws -> [OutlineDocument] { + try await post( + "documents.list", + body: DocumentListParams( + collectionId: collectionId, + parentDocumentId: parentDocumentId, + offset: offset, + limit: limit + ) + ) + } + + public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { + try await post("documents.search", body: request) + } + + public func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { + try await post("documents.search_titles", body: request) + } + + public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { + try await post("documents.create", body: request) + } + + public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument { + 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 createShare(_ request: CreateShareRequest) async throws -> OutlineShare { + try await post("shares.create", body: request) + } + + public func shareInfo(documentId: String) async throws -> OutlineShare? { + do { + return try await post("shares.info", body: ShareInfoRequest(documentId: documentId)) + } catch OutlineAPIError.notFound { + return nil + } + } + + public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare { + try await post("shares.update", body: request) + } + + public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin { + try await post("pins.create", body: request) + } + + public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { + try await post("pins.list", body: request) + } + + public func deletePin(id: String) async throws { + try await postForSuccess("pins.delete", body: StarIDParams(id: id)) + } + + public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription { + try await post("subscriptions.create", body: request) + } + + public func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { + try await post("subscriptions.list", body: request) + } + + public func deleteSubscription(id: String) async throws { + try await postForSuccess("subscriptions.delete", body: StarIDParams(id: id)) + } + + public func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { + try await post("views.list", body: request) + } + + public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] { + try await post("collections.list", body: CollectionListParams(offset: offset, limit: limit)) + } + + public func collectionInfo(id: String) async throws -> OutlineCollection { + try await post("collections.info", body: CollectionInfoParams(id: id)) + } + + public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection { + try await post("collections.update", body: request) + } + + public func deleteCollection(id: String) async throws { + try await postForSuccess("collections.delete", body: CollectionInfoParams(id: id)) + } + + public func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation { + let result: ExportCollectionResponse = try await post("collections.export", body: request) + return result.fileOperation + } + + public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar { + try await post("stars.create", body: request) + } + + public func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { + let result: ListStarsResponse = try await post("stars.list", body: request) + return result.stars + } + + public func deleteStar(id: String) async throws { + try await postForSuccess("stars.delete", body: StarIDParams(id: id)) + } + + public func currentUser() async throws -> OutlineUser { + try await post("users.info", body: EmptyParams()) + } + + private func post(_ path: String, body: Body) async throws -> Response { + guard let token = try? tokenStore.token() else { + throw OutlineAPIError.tokenUnavailable + } + + var request = URLRequest(url: baseURL.appendingPathComponent("api/\(path)")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.httpBody = try encoder.encode(body) + + let data: Data + let response: HTTPURLResponse + do { + (data, response) = try await httpClient.send(request) + } catch let error as OutlineAPIError { + throw error + } catch { + throw OutlineAPIError.transport(error) + } + + guard (200...299).contains(response.statusCode) else { + let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data) + switch response.statusCode { + case 401: + throw OutlineAPIError.unauthorized + case 404: + throw OutlineAPIError.notFound + default: + throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error) + } + } + + do { + return try decoder.decode(OutlineEnvelope.self, from: data).data + } catch { + throw OutlineAPIError.decoding(error) + } + } + + /// For endpoints shaped `{ "success": true }` instead of `{ "data": ... }` + /// (e.g. `collections.delete`) — `post(_:body:)`'s envelope decode doesn't fit. + private func postForSuccess(_ path: String, body: Body) async throws { + guard let token = try? tokenStore.token() else { + throw OutlineAPIError.tokenUnavailable + } + + var request = URLRequest(url: baseURL.appendingPathComponent("api/\(path)")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.httpBody = try encoder.encode(body) + + let data: Data + let response: HTTPURLResponse + do { + (data, response) = try await httpClient.send(request) + } catch let error as OutlineAPIError { + throw error + } catch { + throw OutlineAPIError.transport(error) + } + + guard (200...299).contains(response.statusCode) else { + let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data) + switch response.statusCode { + case 401: + throw OutlineAPIError.unauthorized + case 404: + throw OutlineAPIError.notFound + default: + throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error) + } + } + + do { + let envelope = try decoder.decode(SuccessEnvelope.self, from: data) + guard envelope.success else { + throw OutlineAPIError.server(status: response.statusCode, message: nil) + } + } catch let error as OutlineAPIError { + throw error + } catch { + throw OutlineAPIError.decoding(error) + } + } +} + +private struct ExportCollectionResponse: Decodable { + let fileOperation: OutlineFileOperation +} + +private struct DuplicateDocumentResponse: Decodable { + let documents: [OutlineDocument] +} + +private struct ListStarsResponse: Decodable { + let stars: [OutlineStar] +} + +private struct StarIDParams: Encodable { + let id: String +} + +private struct MoveDocumentResponse: Decodable { + let documents: [OutlineDocument]? + let collections: [OutlineCollection]? +} + +private struct SuccessEnvelope: Decodable { + let success: Bool +} + +private struct DocumentInfoParams: Encodable { + let id: String +} + +private struct DocumentListParams: Encodable { + let collectionId: String? + let parentDocumentId: String? + let offset: Int + let limit: Int +} + +private struct CollectionListParams: Encodable { + let offset: Int + let limit: Int +} + +private struct CollectionInfoParams: Encodable { + let id: String +} + +private struct EmptyParams: Encodable {} diff --git a/OutlineKit/Sources/OutlineKit/Models/DocumentSearchTypes.swift b/OutlineKit/Sources/OutlineKit/Models/DocumentSearchTypes.swift new file mode 100644 index 0000000..203ca6d --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/DocumentSearchTypes.swift @@ -0,0 +1,36 @@ +import Foundation + +public enum DocumentSearchDateFilter: String, Codable, Sendable, CaseIterable { + case day + case week + case month + case year +} + +public enum DocumentSearchSort: String, Codable, Sendable, CaseIterable { + case relevance + case createdAt + case updatedAt + case title +} + +public enum DocumentSearchDirection: String, Codable, Sendable, CaseIterable { + case ascending = "ASC" + case descending = "DESC" +} + +public enum DocumentStatusFilter: String, Codable, Sendable, CaseIterable { + case draft + case archived + case published +} + +/// One hit from `documents.search` — includes the matched snippet and ranking +/// that `documents.search_titles` doesn't provide. +public struct OutlineDocumentSearchResult: Decodable, Sendable, Identifiable { + public let context: String + public let ranking: Double + public let document: OutlineDocument + + public var id: String { document.id } +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineAuthInfo.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineAuthInfo.swift new file mode 100644 index 0000000..7ee726b --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineAuthInfo.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Result of `auth.info` — confirms a token is valid and identifies the signed-in user/workspace. +public struct OutlineAuthInfo: Codable, Sendable { + public let user: OutlineUser + public let team: OutlineTeam + + public init(user: OutlineUser, team: OutlineTeam) { + self.user = user + self.team = team + } +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineCollection.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineCollection.swift new file mode 100644 index 0000000..e842e1a --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineCollection.swift @@ -0,0 +1,29 @@ +import Foundation + +public struct OutlineCollection: Codable, Identifiable, Hashable, Sendable { + public let id: String + public let name: String + public let description: String? + public let color: String? + public let icon: String? + public let createdAt: Date + public let updatedAt: Date + + public init( + id: String, + name: String, + description: String? = nil, + color: String? = nil, + icon: String? = nil, + createdAt: Date, + updatedAt: Date + ) { + self.id = id + self.name = name + self.description = description + self.color = color + self.icon = icon + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineDocument.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineDocument.swift new file mode 100644 index 0000000..8b44c6f --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineDocument.swift @@ -0,0 +1,50 @@ +import Foundation + +public struct OutlineDocument: Codable, Identifiable, Hashable, Sendable { + public let id: String + public let title: String + public let text: String + public let emoji: String? + public let collectionId: String? + public let parentDocumentId: String? + public let url: String + public let revision: Int? + public let fullWidth: Bool? + public let createdAt: Date + public let updatedAt: Date + public let publishedAt: Date? + public let archivedAt: Date? + public let deletedAt: Date? + + public init( + id: String, + title: String, + text: String, + emoji: String? = nil, + collectionId: String? = nil, + parentDocumentId: String? = nil, + url: String, + revision: Int? = nil, + fullWidth: Bool? = nil, + createdAt: Date, + updatedAt: Date, + publishedAt: Date? = nil, + archivedAt: Date? = nil, + deletedAt: Date? = nil + ) { + self.id = id + self.title = title + self.text = text + self.emoji = emoji + self.collectionId = collectionId + self.parentDocumentId = parentDocumentId + self.url = url + self.revision = revision + self.fullWidth = fullWidth + self.createdAt = createdAt + self.updatedAt = updatedAt + self.publishedAt = publishedAt + self.archivedAt = archivedAt + self.deletedAt = deletedAt + } +} 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/OutlineEnvelope.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineEnvelope.swift new file mode 100644 index 0000000..5be0bed --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineEnvelope.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Outline wraps every successful RPC response as `{ "data": ..., "pagination": ... }`. +struct OutlineEnvelope: Decodable { + let data: T + let pagination: OutlinePagination? +} + +public struct OutlinePagination: Decodable, Sendable { + public let offset: Int + public let limit: Int + public let nextPath: String? +} + +/// Outline error responses: `{ "ok": false, "error": "...", "message": "..." }`. +struct OutlineErrorEnvelope: Decodable { + let ok: Bool + let error: String? + let message: String? +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineFileOperation.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineFileOperation.swift new file mode 100644 index 0000000..8e5b3cc --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineFileOperation.swift @@ -0,0 +1,8 @@ +import Foundation + +/// A tracked async job — collection/document export runs server-side and +/// this is the handle to check on it (progress polling isn't wrapped yet). +public struct OutlineFileOperation: Decodable, Sendable { + public let id: String + public let state: String? +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlinePin.swift b/OutlineKit/Sources/OutlineKit/Models/OutlinePin.swift new file mode 100644 index 0000000..1637c40 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlinePin.swift @@ -0,0 +1,14 @@ +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. +public struct OutlinePin: Decodable, Identifiable, Sendable { + public let id: String + public let documentId: String + public let collectionId: String? + public let index: String? +} 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/OutlineShare.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineShare.swift new file mode 100644 index 0000000..1cb4660 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineShare.swift @@ -0,0 +1,10 @@ +import Foundation + +/// A public share link for a document or collection, backed by `shares.*`. +public struct OutlineShare: Decodable, Identifiable, Sendable { + public let id: String + public let documentId: String? + public let collectionId: String? + public let url: String + public let published: Bool +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineStar.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineStar.swift new file mode 100644 index 0000000..09f759d --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineStar.swift @@ -0,0 +1,9 @@ +import Foundation + +/// A bookmark on a document or collection, shown in the user's sidebar. +public struct OutlineStar: Decodable, Sendable { + public let id: String + public let index: String? + public let documentId: String? + public let collectionId: String? +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineSubscription.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineSubscription.swift new file mode 100644 index 0000000..9731f1d --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineSubscription.swift @@ -0,0 +1,15 @@ +import Foundation + +/// A notification subscription on a document (or collection), backed by +/// `subscriptions.*`. Same caveat as `OutlinePin`: not in the vendored +/// OpenAPI spec — no `Subscriptions` tag exists there — but the endpoint +/// exists on Outline's actual server (`server/routes/api/subscriptions.ts` +/// upstream). Shape reconstructed from general knowledge, not verified +/// against this spec; treat field names as best-effort until confirmed +/// against a live server. +public struct OutlineSubscription: Decodable, Identifiable, Sendable { + public let id: String + public let documentId: String? + public let collectionId: String? + public let event: String +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineTeam.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineTeam.swift new file mode 100644 index 0000000..47e6249 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineTeam.swift @@ -0,0 +1,26 @@ +import Foundation + +public struct OutlineTeam: Codable, Identifiable, Hashable, Sendable { + public let id: String + public let name: String + public let avatarUrl: String? + public let url: String? + public let subdomain: String? + public let domain: String? + + public init( + id: String, + name: String, + avatarUrl: String? = nil, + url: String? = nil, + subdomain: String? = nil, + domain: String? = nil + ) { + self.id = id + self.name = name + self.avatarUrl = avatarUrl + self.url = url + self.subdomain = subdomain + self.domain = domain + } +} 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/Models/OutlineUser.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineUser.swift new file mode 100644 index 0000000..b85a6ca --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineUser.swift @@ -0,0 +1,23 @@ +import Foundation + +public struct OutlineUser: Codable, Identifiable, Hashable, Sendable { + public let id: String + public let name: String + public let email: String? + public let avatarUrl: String? + public let role: String? + + public init( + id: String, + name: String, + email: String? = nil, + avatarUrl: String? = nil, + role: String? = nil + ) { + self.id = id + self.name = name + self.email = email + self.avatarUrl = avatarUrl + self.role = role + } +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineView.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineView.swift new file mode 100644 index 0000000..7e87283 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineView.swift @@ -0,0 +1,15 @@ +import Foundation + +/// A record of a user having viewed a document, backed by `views.list`. +/// This is historical/aggregated (`firstViewedAt`/`lastViewedAt`/`count`), +/// not live presence — there's no realtime "viewing right now" signal over +/// REST, only over the Hocuspocus collaboration socket's awareness protocol. +public struct OutlineView: Decodable, Identifiable, Sendable { + public let id: String + public let documentId: String + public let userId: String + public let user: OutlineUser + public let count: Int + public let firstViewedAt: Date? + public let lastViewedAt: Date? +} diff --git a/OutlineKit/Sources/OutlineKit/Networking/HTTPClient.swift b/OutlineKit/Sources/OutlineKit/Networking/HTTPClient.swift new file mode 100644 index 0000000..f0f8024 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Networking/HTTPClient.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Thin seam over `URLSession` so `LiveOutlineAPIClient` can be tested without network access. +public protocol HTTPClient: Sendable { + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) +} + +public final class URLSessionHTTPClient: HTTPClient, Sendable { + private let session: URLSession + + public init(session: URLSession = .shared) { + self.session = session + } + + public func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw OutlineAPIError.transport(URLError(.badServerResponse)) + } + return (data, httpResponse) + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift new file mode 100644 index 0000000..07de5ea --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/CreateDocumentRequest.swift @@ -0,0 +1,23 @@ +import Foundation + +public struct CreateDocumentRequest: Encodable, Sendable { + public let title: String + public let text: String + public let collectionId: String + public let parentDocumentId: String? + public let publish: Bool + + public init( + title: String, + text: String, + collectionId: String, + parentDocumentId: String? = nil, + publish: Bool = true + ) { + self.title = title + self.text = text + self.collectionId = collectionId + self.parentDocumentId = parentDocumentId + self.publish = publish + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift new file mode 100644 index 0000000..e8bc0d7 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/CreatePinRequest.swift @@ -0,0 +1,12 @@ +import Foundation + +/// See `OutlinePin` — best-effort shape, not in the vendored spec. +public struct CreatePinRequest: Encodable, Sendable { + public let documentId: String + public let collectionId: String? + + public init(documentId: String, collectionId: String? = nil) { + self.documentId = documentId + self.collectionId = collectionId + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreateShareRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreateShareRequest.swift new file mode 100644 index 0000000..d6e918b --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/CreateShareRequest.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct CreateShareRequest: Encodable, Sendable { + public let documentId: String? + public let collectionId: String? + + public init(documentId: String? = nil, collectionId: String? = nil) { + self.documentId = documentId + self.collectionId = collectionId + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreateSubscriptionRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreateSubscriptionRequest.swift new file mode 100644 index 0000000..c2cf5f2 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/CreateSubscriptionRequest.swift @@ -0,0 +1,12 @@ +import Foundation + +/// See `OutlineSubscription` — best-effort shape, not in the vendored spec. +public struct CreateSubscriptionRequest: Encodable, Sendable { + public let documentId: String + public let event: String + + public init(documentId: String, event: String = "documents.update") { + self.documentId = documentId + self.event = event + } +} 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/DocumentSearchRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/DocumentSearchRequest.swift new file mode 100644 index 0000000..60ee656 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/DocumentSearchRequest.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Full-text search across a workspace, backed by `documents.search`. +public struct DocumentSearchRequest: Encodable, Sendable { + public let query: String + public let collectionId: String? + public let documentId: String? + public let userId: String? + public let statusFilter: [DocumentStatusFilter]? + public let dateFilter: DocumentSearchDateFilter? + public let sort: DocumentSearchSort? + public let direction: DocumentSearchDirection? + public let offset: Int + public let limit: Int + public let snippetMinWords: Int? + public let snippetMaxWords: Int? + + public init( + query: String, + collectionId: String? = nil, + documentId: String? = nil, + userId: String? = nil, + statusFilter: [DocumentStatusFilter]? = nil, + dateFilter: DocumentSearchDateFilter? = nil, + sort: DocumentSearchSort? = nil, + direction: DocumentSearchDirection? = nil, + offset: Int = 0, + limit: Int = 25, + snippetMinWords: Int? = nil, + snippetMaxWords: Int? = nil + ) { + self.query = query + self.collectionId = collectionId + self.documentId = documentId + self.userId = userId + self.statusFilter = statusFilter + self.dateFilter = dateFilter + self.sort = sort + self.direction = direction + self.offset = offset + self.limit = limit + self.snippetMinWords = snippetMinWords + self.snippetMaxWords = snippetMaxWords + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/DocumentSearchTitlesRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/DocumentSearchTitlesRequest.swift new file mode 100644 index 0000000..f798a34 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/DocumentSearchTitlesRequest.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Fast title-only search, backed by `documents.search_titles`. +public struct DocumentSearchTitlesRequest: Encodable, Sendable { + public let query: String + public let collectionId: String? + public let documentId: String? + public let userId: String? + public let statusFilter: [DocumentStatusFilter]? + public let dateFilter: DocumentSearchDateFilter? + public let sort: DocumentSearchSort? + public let direction: DocumentSearchDirection? + public let offset: Int + public let limit: Int + + public init( + query: String, + collectionId: String? = nil, + documentId: String? = nil, + userId: String? = nil, + statusFilter: [DocumentStatusFilter]? = nil, + dateFilter: DocumentSearchDateFilter? = nil, + sort: DocumentSearchSort? = nil, + direction: DocumentSearchDirection? = nil, + offset: Int = 0, + limit: Int = 25 + ) { + self.query = query + self.collectionId = collectionId + self.documentId = documentId + self.userId = userId + self.statusFilter = statusFilter + self.dateFilter = dateFilter + self.sort = sort + self.direction = direction + self.offset = offset + self.limit = limit + } +} 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/ExportCollectionRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ExportCollectionRequest.swift new file mode 100644 index 0000000..ebc55af --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ExportCollectionRequest.swift @@ -0,0 +1,17 @@ +import Foundation + +public enum CollectionExportFormat: String, Encodable, Sendable, CaseIterable { + case markdown = "outline-markdown" + case json + case html +} + +public struct ExportCollectionRequest: Encodable, Sendable { + public let id: String + public let format: CollectionExportFormat + + public init(id: String, format: CollectionExportFormat = .markdown) { + self.id = id + self.format = format + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/ListPinsRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListPinsRequest.swift new file mode 100644 index 0000000..29dc61f --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ListPinsRequest.swift @@ -0,0 +1,10 @@ +import Foundation + +/// See `OutlinePin` — best-effort shape, not in the vendored spec. +public struct ListPinsRequest: Encodable, Sendable { + public let collectionId: String? + + public init(collectionId: String? = nil) { + self.collectionId = collectionId + } +} 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/ListStarsRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListStarsRequest.swift new file mode 100644 index 0000000..b27f770 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ListStarsRequest.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct ListStarsRequest: Encodable, Sendable { + public let offset: Int + public let limit: Int + + public init(offset: Int = 0, limit: Int = 100) { + self.offset = offset + self.limit = limit + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/ListSubscriptionsRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListSubscriptionsRequest.swift new file mode 100644 index 0000000..5348078 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ListSubscriptionsRequest.swift @@ -0,0 +1,12 @@ +import Foundation + +/// See `OutlineSubscription` — best-effort shape, not in the vendored spec. +public struct ListSubscriptionsRequest: Encodable, Sendable { + public let documentId: String + public let event: String + + public init(documentId: String, event: String = "documents.update") { + self.documentId = documentId + self.event = event + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/ListViewsRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListViewsRequest.swift new file mode 100644 index 0000000..b46e51e --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ListViewsRequest.swift @@ -0,0 +1,9 @@ +import Foundation + +public struct ListViewsRequest: Encodable, Sendable { + public let documentId: String + + public init(documentId: String) { + self.documentId = documentId + } +} 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/ShareInfoRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ShareInfoRequest.swift new file mode 100644 index 0000000..37aa05a --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ShareInfoRequest.swift @@ -0,0 +1,9 @@ +import Foundation + +public struct ShareInfoRequest: Encodable, Sendable { + public let documentId: String? + + public init(documentId: String?) { + self.documentId = documentId + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/StarCollectionRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/StarCollectionRequest.swift new file mode 100644 index 0000000..b3f3bd8 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/StarCollectionRequest.swift @@ -0,0 +1,9 @@ +import Foundation + +public struct StarCollectionRequest: Encodable, Sendable { + public let collectionId: String + + public init(collectionId: String) { + self.collectionId = collectionId + } +} 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/Sources/OutlineKit/Requests/UpdateCollectionRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateCollectionRequest.swift new file mode 100644 index 0000000..7b094f1 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateCollectionRequest.swift @@ -0,0 +1,17 @@ +import Foundation + +public struct UpdateCollectionRequest: Encodable, Sendable { + public let id: String + public let name: String? + public let description: String? + + public init( + id: String, + name: String? = nil, + description: String? = nil + ) { + self.id = id + self.name = name + self.description = description + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift new file mode 100644 index 0000000..b7a5611 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateDocumentRequest.swift @@ -0,0 +1,34 @@ +import Foundation + +public struct UpdateDocumentRequest: Encodable, Sendable { + public let id: String + public let title: String? + public let text: String? + public let append: Bool? + public let fullWidth: Bool? + public let insightsEnabled: Bool? + /// Not in the vendored spec's `Document`/`documents.update` shape at all + /// (only a workspace-level `documentEmbeds` flag exists there) — included + /// speculatively since the field may exist on newer self-hosted servers. + /// Unrecognized fields are typically ignored server-side rather than + /// rejected, so this is low-risk even if unsupported. + public let documentEmbeds: Bool? + + public init( + id: String, + title: String? = nil, + text: String? = nil, + append: Bool? = nil, + fullWidth: Bool? = nil, + insightsEnabled: Bool? = nil, + documentEmbeds: Bool? = nil + ) { + self.id = id + self.title = title + self.text = text + self.append = append + self.fullWidth = fullWidth + self.insightsEnabled = insightsEnabled + self.documentEmbeds = documentEmbeds + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateShareRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateShareRequest.swift new file mode 100644 index 0000000..9b9abc5 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateShareRequest.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct UpdateShareRequest: Encodable, Sendable { + public let id: String + public let published: Bool + + public init(id: String, published: Bool) { + self.id = id + self.published = published + } +} diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift new file mode 100644 index 0000000..82c518f --- /dev/null +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -0,0 +1,712 @@ +import XCTest +@testable import OutlineKit + +private final class MockHTTPClient: HTTPClient, @unchecked Sendable { + var lastRequest: URLRequest? + var responseData: Data = Data() + var statusCode: Int = 200 + + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + lastRequest = request + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + )! + return (responseData, response) + } +} + +private final class StaticTokenStore: TokenStoring, @unchecked Sendable { + private var stored: String? + + init(token: String? = "test-token") { + self.stored = token + } + + func token() throws -> String { + guard let stored else { throw TokenStoreError.notFound } + return stored + } + + func store(_ token: String) throws { stored = token } + func clear() throws { stored = nil } +} + +final class LiveOutlineAPIClientTests: XCTestCase { + func testDeleteCollectionSucceedsOnSuccessTrue() 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.deleteCollection(id: "col-1") + + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/collections.delete") + } + + func testDeleteCollectionThrowsOnSuccessFalse() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { "success": false } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + do { + try await client.deleteCollection(id: "col-1") + XCTFail("Expected an error when success is false") + } catch { + // expected + } + } + + func testExportCollectionDecodesFileOperation() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "fileOperation": { "id": "op-1", "state": "creating" } + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let operation = try await client.exportCollection(ExportCollectionRequest(id: "col-1")) + + XCTAssertEqual(operation.id, "op-1") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/collections.export") + } + + func testStarCollectionDecodesStar() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "id": "star-1", + "collectionId": "col-1", + "documentId": null + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let star = try await client.starCollection(StarCollectionRequest(collectionId: "col-1")) + + XCTAssertEqual(star.id, "star-1") + XCTAssertEqual(star.collectionId, "col-1") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/stars.create") + } + + func testSearchDocumentsDecodesContextAndRanking() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": [ + { + "context": "At Acme Inc our hiring practices are inclusive", + "ranking": 1.1844109, + "document": { + "id": "doc-1", + "title": "Welcome to Acme Inc", + "text": "…", + "url": "/doc/welcome-doc-1", + "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 results = try await client.searchDocuments(DocumentSearchRequest(query: "hiring")) + + XCTAssertEqual(results.count, 1) + XCTAssertEqual(results[0].context, "At Acme Inc our hiring practices are inclusive") + XCTAssertEqual(results[0].document.title, "Welcome to Acme Inc") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.search") + } + + func testSearchDocumentTitlesReturnsDocumentsDirectly() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": [ + { + "id": "doc-1", + "title": "Welcome to Acme Inc", + "text": "…", + "url": "/doc/welcome-doc-1", + "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 results = try await client.searchDocumentTitles(DocumentSearchTitlesRequest(query: "Welcome")) + + XCTAssertEqual(results.count, 1) + XCTAssertEqual(results[0].title, "Welcome to Acme Inc") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.search_titles") + } + + func testAuthInfoDecodesUserAndTeam() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "user": { + "id": "123e4567-e89b-12d3-a456-426614174000", + "name": "Jane Doe", + "email": "hello@example.com", + "role": "admin" + }, + "team": { + "id": "123e4567-e89b-12d3-a456-426614174000", + "name": "Acme Inc", + "url": "https://acme-inc.getoutline.com", + "subdomain": "acme-inc" + } + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let auth = try await client.authInfo() + + XCTAssertEqual(auth.user.name, "Jane Doe") + XCTAssertEqual(auth.team.name, "Acme Inc") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/auth.info") + XCTAssertEqual(httpClient.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer test-token") + } + + func testUpdateCollectionSendsDescriptionAndDecodesResult() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "id": "col-1", + "name": "Engineering", + "description": "Updated overview text.", + "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 collection = try await client.updateCollection( + UpdateCollectionRequest(id: "col-1", description: "Updated overview text.") + ) + + XCTAssertEqual(collection.description, "Updated overview text.") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/collections.update") + + struct SentBody: Decodable { + let id: String + let description: String? + } + + let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody) + let decodedBody = try JSONDecoder().decode(SentBody.self, from: sentBody) + XCTAssertEqual(decodedBody.id, "col-1") + XCTAssertEqual(decodedBody.description, "Updated overview text.") + } + + func testListDocumentsSendsParentDocumentIdFilter() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { "data": [] } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + _ = try await client.listDocuments(collectionId: nil, parentDocumentId: "doc-1", offset: 0, limit: 100) + + struct SentBody: Decodable { + let parentDocumentId: String? + } + + let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody) + let decodedBody = try JSONDecoder().decode(SentBody.self, from: sentBody) + XCTAssertEqual(decodedBody.parentDocumentId, "doc-1") + } + + func testDocumentInfoDecodesEnvelopeAndSetsAuthHeader() 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" + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let document = try await client.documentInfo(id: "doc-1") + + XCTAssertEqual(document.id, "doc-1") + XCTAssertEqual(document.title, "Hello") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.info") + XCTAssertEqual(httpClient.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer test-token") + } + + func testUnauthorizedStatusMapsToUnauthorizedError() async throws { + let httpClient = MockHTTPClient() + httpClient.statusCode = 401 + httpClient.responseData = """ + { "ok": false, "error": "authentication_required", "message": "Authentication required" } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + do { + _ = try await client.documentInfo(id: "doc-1") + XCTFail("Expected unauthorized error") + } catch OutlineAPIError.unauthorized { + // expected + } + } + + 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 testListStarsDecodesStarsArray() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "stars": [ + { "id": "star-1", "collectionId": "col-1", "documentId": null }, + { "id": "star-2", "collectionId": null, "documentId": "doc-1" } + ], + "documents": [] + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let stars = try await client.listStars(ListStarsRequest()) + + XCTAssertEqual(stars.count, 2) + XCTAssertEqual(stars.first?.collectionId, "col-1") + XCTAssertEqual(stars.last?.documentId, "doc-1") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/stars.list") + } + + func testDeleteStarSucceedsOnSuccessTrue() 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.deleteStar(id: "star-1") + + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/stars.delete") + } + + func testCreateShareDecodesShare() 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": 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.createShare(CreateShareRequest(documentId: "doc-1")) + + XCTAssertEqual(share.url, "https://outline.example.com/s/share-1") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.create") + } + + func testShareInfoReturnsNilOnNotFound() async throws { + let httpClient = MockHTTPClient() + httpClient.statusCode = 404 + httpClient.responseData = """ + { "ok": false, "error": "not_found", "message": "Not Found" } + """.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") + + XCTAssertNil(share) + } + + func testListViewsDecodesViewsAndFiltersNilLastViewedAt() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": [ + { + "id": "view-1", + "documentId": "doc-1", + "userId": "user-1", + "user": { "id": "user-1", "name": "Jane Doe" }, + "count": 3, + "firstViewedAt": "2026-01-01T00:00:00.000Z", + "lastViewedAt": "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 views = try await client.listViews(ListViewsRequest(documentId: "doc-1")) + + XCTAssertEqual(views.first?.user.name, "Jane Doe") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/views.list") + } + + func testCreatePinDecodesPin() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { "data": { "id": "pin-1", "documentId": "doc-1", "collectionId": "col-1", "index": "a" } } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let pin = try await client.createPin(CreatePinRequest(documentId: "doc-1", collectionId: "col-1")) + + XCTAssertEqual(pin.id, "pin-1") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.create") + } + + func testCreateSubscriptionDecodesSubscription() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { "data": { "id": "sub-1", "documentId": "doc-1", "collectionId": null, "event": "documents.update" } } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let subscription = try await client.createSubscription(CreateSubscriptionRequest(documentId: "doc-1")) + + XCTAssertEqual(subscription.id, "sub-1") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/subscriptions.create") + } + + func testMissingTokenThrowsTokenUnavailable() async throws { + let httpClient = MockHTTPClient() + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(token: nil), + httpClient: httpClient + ) + + do { + _ = try await client.documentInfo(id: "doc-1") + XCTFail("Expected tokenUnavailable error") + } catch OutlineAPIError.tokenUnavailable { + // expected + } + } +} diff --git a/Outpost.xcodeproj/project.pbxproj b/Outpost.xcodeproj/project.pbxproj index 044723d..81fd07e 100644 --- a/Outpost.xcodeproj/project.pbxproj +++ b/Outpost.xcodeproj/project.pbxproj @@ -6,6 +6,13 @@ objectVersion = 110; objects = { +/* Begin PBXBuildFile section */ + FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99C43302CF1BD00C9949F /* OutlineKit */; }; + FAF99CAF302D120500C9949F /* MarkdownEngine in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CAE302D120500C9949F /* MarkdownEngine */; }; + FAF99CB1302D120500C9949F /* MarkdownEngineCodeBlocks in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */; }; + FAF99CB3302D120500C9949F /* MarkdownEngineLatex in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CB2302D120500C9949F /* MarkdownEngineLatex */; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ FAF99C28302CE96200C9949F /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -51,6 +58,10 @@ FAF99C15302CE96100C9949F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; files = ( + FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */, + FAF99CB1302D120500C9949F /* MarkdownEngineCodeBlocks in Frameworks */, + FAF99CB3302D120500C9949F /* MarkdownEngineLatex in Frameworks */, + FAF99CAF302D120500C9949F /* MarkdownEngine in Frameworks */, ); }; FAF99C24302CE96200C9949F /* Frameworks */ = { @@ -103,6 +114,12 @@ FAF99C1A302CE96100C9949F /* Outpost */, ); name = Outpost; + packageProductDependencies = ( + FAF99C43302CF1BD00C9949F /* OutlineKit */, + FAF99CAE302D120500C9949F /* MarkdownEngine */, + FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */, + FAF99CB2302D120500C9949F /* MarkdownEngineLatex */, + ); productName = Outpost; productReference = FAF99C18302CE96100C9949F /* Outpost.app */; productType = "com.apple.product-type.application"; @@ -181,6 +198,10 @@ ); mainGroup = FAF99C0F302CE96100C9949F; minimizedProjectReferenceProxies = 1; + packageReferences = ( + FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */, + FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */, + ); preferredProjectObjectVersion = 77; productRefGroup = FAF99C19302CE96100C9949F /* Products */; projectDirPath = ""; @@ -364,6 +385,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = B95H74ZDY6; @@ -386,7 +408,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 27.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -408,6 +430,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = B95H74ZDY6; @@ -430,7 +453,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 27.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -585,6 +608,46 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = OutlineKit; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCRemoteSwiftPackageReference section */ + FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/nodes-app/swift-markdown-engine"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.12.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + FAF99C43302CF1BD00C9949F /* OutlineKit */ = { + isa = XCSwiftPackageProductDependency; + productName = OutlineKit; + }; + FAF99CAE302D120500C9949F /* MarkdownEngine */ = { + isa = XCSwiftPackageProductDependency; + package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */; + productName = MarkdownEngine; + }; + FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */ = { + isa = XCSwiftPackageProductDependency; + package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */; + productName = MarkdownEngineCodeBlocks; + }; + FAF99CB2302D120500C9949F /* MarkdownEngineLatex */ = { + isa = XCSwiftPackageProductDependency; + package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */; + productName = MarkdownEngineLatex; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = FAF99C10302CE96100C9949F /* Project object */; validationLevel = 1; diff --git a/Outpost.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Outpost.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..0ad3948 --- /dev/null +++ b/Outpost.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "f233fa96f0c6bdcdbf87f726af38f25704f6d46a156a27dfac47541baa63bf97", + "pins" : [ + { + "identity" : "highlighterswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/smittytone/HighlighterSwift", + "state" : { + "revision" : "fe7aae9c9b31d3b296fd3d2dd575e1a207bb29e0", + "version" : "3.1.0" + } + }, + { + "identity" : "swift-markdown-engine", + "kind" : "remoteSourceControl", + "location" : "https://github.com/nodes-app/swift-markdown-engine", + "state" : { + "revision" : "e5f7607fc4021181056ef7a09dbb7573dc0237d9", + "version" : "0.12.0" + } + }, + { + "identity" : "swiftmath", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mgriebling/SwiftMath", + "state" : { + "revision" : "fa8244ed032f4a1ade4cb0571bf87d2f1a9fd2d7", + "version" : "1.7.3" + } + } + ], + "version" : 3 +} diff --git a/Outpost.xcodeproj/xcuserdata/psmattas.xcuserdatad/xcschemes/xcschememanagement.plist b/Outpost.xcodeproj/xcuserdata/psmattas.xcuserdatad/xcschemes/xcschememanagement.plist index 25c5dd0..e060b9d 100644 --- a/Outpost.xcodeproj/xcuserdata/psmattas.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Outpost.xcodeproj/xcuserdata/psmattas.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ Outpost.xcscheme_^#shared#^_ orderHint - 0 + 1 diff --git a/Outpost/Assets.xcassets/AppIcon.appiconset/Contents.json b/Outpost/Assets.xcassets/AppIcon.appiconset/Contents.json index ffdfe15..6b366ba 100644 --- a/Outpost/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Outpost/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "outpost-ios-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -28,51 +29,61 @@ "size" : "1024x1024" }, { + "filename" : "outpost 9.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { + "filename" : "outpost 8.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { + "filename" : "outpost 7.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { + "filename" : "outpost 6.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { + "filename" : "outpost 5.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { + "filename" : "outpost 4.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { + "filename" : "outpost.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { + "filename" : "outpost 1.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { + "filename" : "outpost 2.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { + "filename" : "outpost 3.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 1.png b/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 1.png new file mode 100644 index 0000000..d43057b Binary files /dev/null and b/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 1.png differ diff --git a/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 2.png b/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 2.png new file mode 100644 index 0000000..d43057b Binary files /dev/null and b/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 2.png differ diff --git a/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 3.png b/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 3.png new file mode 100644 index 0000000..5101a9b Binary files /dev/null and b/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 3.png differ diff --git a/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 4.png b/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 4.png new file mode 100644 index 0000000..6181ef8 Binary files /dev/null and b/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 4.png differ diff --git a/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 5.png b/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 5.png new file mode 100644 index 0000000..d6b369d Binary files /dev/null and b/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 5.png differ diff --git a/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 6.png b/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 6.png new file mode 100644 index 0000000..82bc7d2 Binary files /dev/null and b/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 6.png differ diff --git a/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 7.png b/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 7.png new file mode 100644 index 0000000..76ef4cc Binary files /dev/null and b/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 7.png differ diff --git a/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 8.png b/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 8.png new file mode 100644 index 0000000..76ef4cc Binary files /dev/null and b/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 8.png differ diff --git a/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 9.png b/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 9.png new file mode 100644 index 0000000..8fc5124 Binary files /dev/null and b/Outpost/Assets.xcassets/AppIcon.appiconset/outpost 9.png differ diff --git a/Outpost/Assets.xcassets/AppIcon.appiconset/outpost-ios-1024.png b/Outpost/Assets.xcassets/AppIcon.appiconset/outpost-ios-1024.png new file mode 100644 index 0000000..fea8615 Binary files /dev/null and b/Outpost/Assets.xcassets/AppIcon.appiconset/outpost-ios-1024.png differ diff --git a/Outpost/Assets.xcassets/AppIcon.appiconset/outpost.png b/Outpost/Assets.xcassets/AppIcon.appiconset/outpost.png new file mode 100644 index 0000000..6181ef8 Binary files /dev/null and b/Outpost/Assets.xcassets/AppIcon.appiconset/outpost.png differ diff --git a/Outpost/ContentView.swift b/Outpost/ContentView.swift index 77b60b9..57f4f38 100644 --- a/Outpost/ContentView.swift +++ b/Outpost/ContentView.swift @@ -1,80 +1,5 @@ -// -// ContentView.swift -// Outpost -// -// Created by Puranjay Savar Mattas on 12/08/26. -// - -import SwiftUI -import SwiftData - -struct ContentView: View { - @Environment(\.modelContext) private var modelContext - @Query private var items: [Item] - - var body: some View { - NavigationViewWrapper { - List { - ForEach(items) { item in - NavigationLink { - Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))") - } label: { - Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard)) - } - } - .onDelete(perform: deleteItems) - } #if os(macOS) - .navigationSplitViewColumnWidth(min: 180, ideal: 200) -#endif - .toolbar { -#if os(iOS) - ToolbarItem(placement: .navigationBarTrailing) { - EditButton() - } -#endif - ToolbarItem { - Button(action: addItem) { - Label("Add Item", systemImage: "plus") - } - } - } - } - } - - private func addItem() { - withAnimation { - let newItem = Item(timestamp: Date()) - modelContext.insert(newItem) - } - } - - private func deleteItems(offsets: IndexSet) { - withAnimation { - for index in offsets { - modelContext.delete(items[index]) - } - } - } -} - -fileprivate struct NavigationViewWrapper: View { - let content: () -> Content - - var body: some View { -#if os(macOS) - NavigationSplitView { - content() - } detail: { - Text("Select an item") - } +typealias ContentView = ContentView_macOS #else - content() +typealias ContentView = ContentView_iOS #endif - } -} - -#Preview { - ContentView() - .modelContainer(for: Item.self, inMemory: true) -} diff --git a/Outpost/Features/About/AboutView.swift b/Outpost/Features/About/AboutView.swift new file mode 100644 index 0000000..a797b19 --- /dev/null +++ b/Outpost/Features/About/AboutView.swift @@ -0,0 +1,79 @@ +#if os(macOS) +import AppKit +import SwiftUI + +struct AboutView: View { + private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")! + private let releasesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/releases")! + + private var appName: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost" + } + + /// Bumped alongside `MARKETING_VERSION` in the Xcode project — kept out + /// of the bundle version itself since `CFBundleShortVersionString` is + /// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`. + private let releaseStage = "ALPHA" + + private var versionString: String { + let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1" + let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1" + let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)" + return "Version \(shortVersion)\(stageSuffix) (\(buildNumber))" + } + + private var copyrightYear: String { + String(Calendar.current.component(.year, from: Date())) + } + + var body: some View { + VStack(spacing: 16) { + // `NSImage(named: "AppIcon")` doesn't reliably resolve an App + // Icon asset-catalog entry on macOS — `NSApp.applicationIconImage` + // is the API that's actually guaranteed to match what the Dock + // and Finder show for the running app. + Image(nsImage: NSApp.applicationIconImage) + .resizable() + .frame(width: 80, height: 80) + + VStack(spacing: 4) { + Text(appName) + .font(.title2.bold()) + Text(versionString) + .font(.caption) + .foregroundStyle(.secondary) + } + + Text("A native Apple client for self-hosted Outline, built for full editing parity with the web app.") + .font(.callout) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + + VStack(spacing: 10) { + Link(destination: repositoryURL) { + Label("View Source on Git", systemImage: "link") + } + .font(.callout) + + Button("Check for Updates…") { + checkForUpdates() + } + } + + Text("© \(copyrightYear) Puranjay Savar Mattas") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(32) + .frame(width: 320) + } + + // No Sparkle-style in-app updater yet — this just opens the releases page + // on the self-hosted Gitea instance so the user can check/download manually. + private func checkForUpdates() { + NSWorkspace.shared.open(releasesURL) + } +} +#endif diff --git a/Outpost/Features/Account/AccountFooter.swift b/Outpost/Features/Account/AccountFooter.swift new file mode 100644 index 0000000..fdc932f --- /dev/null +++ b/Outpost/Features/Account/AccountFooter.swift @@ -0,0 +1,122 @@ +#if os(macOS) +import SwiftUI + +/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label +/// contains the avatar image reliably broke its own sizing on click (even after +/// several hardening attempts) — that's specifically `Menu`'s AppKit-bridged +/// label rendering, which a `Button` doesn't go through. +struct AccountFooter: View { + @Environment(SessionStore.self) private var session + @Environment(\.openURL) private var openURL + @Environment(\.openWindow) private var openWindow + @Environment(\.openSettings) private var openSettings + @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system + @State private var isMenuPresented = false + @State private var isShowingLogoutConfirmation = false + @State private var isShowingProfile = false + + private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")! + private let issuesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/issues")! + + private var apiDocumentationURL: URL? { + session.serverURL?.appendingPathComponent("developers") + } + + var body: some View { + Button { + isMenuPresented = true + } label: { + HStack(spacing: 10) { + AvatarBadge(avatarURL: session.userAvatarURL, size: 28, placeholderSystemImage: "person.crop.circle.fill") + + VStack(alignment: .leading, spacing: 1) { + Text(session.userName ?? "Signed In") + .font(.subheadline.weight(.medium)) + .lineLimit(1) + if let email = session.userEmail { + Text(email) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity) + .padding(.bottom, 16) + .popover(isPresented: $isMenuPresented, arrowEdge: .trailing) { + menuContent + } + .logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session) + .sheet(isPresented: $isShowingProfile) { + ProfileView() + } + } + + private var menuContent: some View { + VStack(alignment: .leading, spacing: 2) { + menuItem("Keyboard Shortcuts…") { openWindow(id: "keyboard-shortcuts") } + + Divider() + + menuItem("Documentation") { openURL(repositoryURL) } + if let apiDocumentationURL { + menuItem("API Documentation") { openURL(apiDocumentationURL) } + } + menuItem("Changelog") { openURL(repositoryURL) } + + Divider() + + menuItem("Send Us Feedback") { openURL(issuesURL) } + menuItem("Report a Bug") { openURL(issuesURL) } + + Divider() + + VStack(alignment: .leading, spacing: 4) { + Text("Appearance") + .font(.caption) + .foregroundStyle(.secondary) + Picker("Appearance", selection: $appearance) { + ForEach(AppAppearance.allCases) { option in + Text(option.label).tag(option) + } + } + .pickerStyle(.segmented) + .labelsHidden() + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + + menuItem("Profile…") { isShowingProfile = true } + menuItem("Preferences…") { openSettings() } + + Divider() + + menuItem("Log Out", role: .destructive) { isShowingLogoutConfirmation = true } + } + .padding(8) + .frame(width: 240) + } + + private func menuItem(_ title: String, role: ButtonRole? = nil, action: @escaping () -> Void) -> some View { + Button(role: role) { + isMenuPresented = false + action() + } label: { + Text(title) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .foregroundStyle(role == .destructive ? Color.red : Color.primary) + .padding(.vertical, 4) + .padding(.horizontal, 6) + .contentShape(Rectangle()) + } +} +#endif diff --git a/Outpost/Features/Account/KeyboardShortcutsView.swift b/Outpost/Features/Account/KeyboardShortcutsView.swift new file mode 100644 index 0000000..2e98c6c --- /dev/null +++ b/Outpost/Features/Account/KeyboardShortcutsView.swift @@ -0,0 +1,39 @@ +#if os(macOS) +import SwiftUI + +struct KeyboardShortcutsView: View { + private struct Shortcut: Identifiable { + let id = UUID() + let action: String + let keys: String + } + + private let shortcuts: [Shortcut] = [ + Shortcut(action: "Sign In", keys: "⏎"), + Shortcut(action: "Preferences", keys: "⌘ ,"), + Shortcut(action: "Close Window", keys: "⌘ W"), + Shortcut(action: "Quit Outpost", keys: "⌘ Q") + ] + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Keyboard Shortcuts") + .font(.title3.bold()) + + VStack(spacing: 10) { + ForEach(shortcuts) { shortcut in + HStack { + Text(shortcut.action) + Spacer() + Text(shortcut.keys) + .foregroundStyle(.secondary) + .monospaced() + } + } + } + } + .padding(24) + .frame(width: 280) + } +} +#endif diff --git a/Outpost/Features/Account/PreferencesView.swift b/Outpost/Features/Account/PreferencesView.swift new file mode 100644 index 0000000..cfe27e9 --- /dev/null +++ b/Outpost/Features/Account/PreferencesView.swift @@ -0,0 +1,40 @@ +#if os(macOS) +import SwiftUI + +struct PreferencesView: View { + @Environment(SessionStore.self) private var session + @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system + @State private var isShowingLogoutConfirmation = false + + var body: some View { + Form { + Section("Appearance") { + Picker("Appearance", selection: $appearance) { + ForEach(AppAppearance.allCases) { option in + Text(option.label).tag(option) + } + } + .pickerStyle(.segmented) + .labelsHidden() + } + + Section("Account") { + LabeledContent("Signed in as", value: session.userName ?? "—") + if let email = session.userEmail { + LabeledContent("Email", value: email) + } + if let teamName = session.teamName { + LabeledContent("Workspace", value: teamName) + } + + Button("Log Out…", role: .destructive) { + isShowingLogoutConfirmation = true + } + } + } + .formStyle(.grouped) + .frame(width: 380, height: 300) + .logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session) + } +} +#endif diff --git a/Outpost/Features/Account/ProfileView.swift b/Outpost/Features/Account/ProfileView.swift new file mode 100644 index 0000000..d4907be --- /dev/null +++ b/Outpost/Features/Account/ProfileView.swift @@ -0,0 +1,37 @@ +#if os(macOS) +import SwiftUI + +struct ProfileView: View { + @Environment(SessionStore.self) private var session + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 16) { + AvatarBadge(avatarURL: session.userAvatarURL, size: 64, placeholderSystemImage: "person.crop.circle.fill") + + VStack(spacing: 4) { + Text(session.userName ?? "Unknown") + .font(.title3.bold()) + if let email = session.userEmail { + Text(email) + .font(.callout) + .foregroundStyle(.secondary) + } + } + + if let teamName = session.teamName { + Label(teamName, systemImage: "building.2") + .font(.callout) + .foregroundStyle(.secondary) + } + + Button("Done") { + dismiss() + } + .keyboardShortcut(.defaultAction) + } + .padding(32) + .frame(width: 300) + } +} +#endif diff --git a/Outpost/Features/Auth/AuthHeaderView.swift b/Outpost/Features/Auth/AuthHeaderView.swift new file mode 100644 index 0000000..efa4249 --- /dev/null +++ b/Outpost/Features/Auth/AuthHeaderView.swift @@ -0,0 +1,31 @@ +import SwiftUI + +struct AuthHeaderView: View { + var body: some View { + VStack(spacing: 12) { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color.accentColor, Color.accentColor.opacity(0.6)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 64, height: 64) + Image(systemName: "text.book.closed.fill") + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(.white) + } + .shadow(color: Color.accentColor.opacity(0.35), radius: 12, y: 6) + + VStack(spacing: 4) { + Text("Welcome to Outpost") + .font(.title2.bold()) + Text("Sign in to your Outline workspace") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } +} diff --git a/Outpost/Features/Auth/AuthView.swift b/Outpost/Features/Auth/AuthView.swift new file mode 100644 index 0000000..5700d7d --- /dev/null +++ b/Outpost/Features/Auth/AuthView.swift @@ -0,0 +1,5 @@ +#if os(macOS) +typealias AuthView = AuthView_macOS +#else +typealias AuthView = AuthView_iOS +#endif diff --git a/Outpost/Features/Auth/AuthViewModel.swift b/Outpost/Features/Auth/AuthViewModel.swift new file mode 100644 index 0000000..a2e9dc8 --- /dev/null +++ b/Outpost/Features/Auth/AuthViewModel.swift @@ -0,0 +1,104 @@ +import Foundation +import Observation +import OutlineKit + +enum AuthValidationError: Error { + case emptyURL + case httpNotAllowed + case invalidURL +} + +@MainActor +@Observable +final class AuthViewModel { + struct AuthResult { + let serverURL: URL + let user: OutlineUser + let team: OutlineTeam + } + + static let httpsHint = "Outline requires HTTPS. We'll add it automatically if you don't include a scheme." + + var serverURLString = "" + var apiToken = "" + var isValidating = false + var errorMessage: String? + + private let tokenStore: TokenStoring + + init(tokenStore: TokenStoring = KeychainTokenStore()) { + self.tokenStore = tokenStore + } + + var canSubmit: Bool { + !serverURLString.trimmingCharacters(in: .whitespaces).isEmpty + && !apiToken.trimmingCharacters(in: .whitespaces).isEmpty + && !isValidating + } + + func signIn() async -> AuthResult? { + errorMessage = nil + + let url: URL + do { + url = try Self.resolvedURL(from: serverURLString) + } catch { + errorMessage = Self.message(for: error) + return nil + } + + isValidating = true + defer { isValidating = false } + + do { + try tokenStore.store(apiToken) + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: url), + tokenStore: tokenStore + ) + let auth = try await client.authInfo() + return AuthResult(serverURL: url, user: auth.user, team: auth.team) + } catch { + try? tokenStore.clear() + errorMessage = Self.message(for: error) + return nil + } + } + + private static func resolvedURL(from raw: String) throws -> URL { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw AuthValidationError.emptyURL + } + + if trimmed.lowercased().hasPrefix("http://") { + throw AuthValidationError.httpNotAllowed + } + + let withScheme = trimmed.lowercased().hasPrefix("https://") ? trimmed : "https://\(trimmed)" + + guard let url = URL(string: withScheme), let host = url.host, !host.isEmpty else { + throw AuthValidationError.invalidURL + } + return url + } + + private static func message(for error: Error) -> String { + switch error { + case AuthValidationError.httpNotAllowed: + return "HTTP is not supported. Outline requires HTTPS to protect your API token." + case AuthValidationError.emptyURL: + return "Enter your Outline server address." + case AuthValidationError.invalidURL: + return "That doesn't look like a valid server address." + case OutlineAPIError.unauthorized: + return "Invalid API token." + case OutlineAPIError.tokenUnavailable: + return "Could not access Keychain." + case OutlineAPIError.transport: + return "Could not reach server. Check the address and try again." + default: + return "Sign in failed. Please try again." + } + } +} diff --git a/Outpost/Features/Auth/AuthView_iOS.swift b/Outpost/Features/Auth/AuthView_iOS.swift new file mode 100644 index 0000000..42e2684 --- /dev/null +++ b/Outpost/Features/Auth/AuthView_iOS.swift @@ -0,0 +1,108 @@ +#if os(iOS) +import SwiftUI + +struct AuthView_iOS: View { + @State private var viewModel = AuthViewModel() + @FocusState private var focusedField: Field? + var onSigningIn: (AuthViewModel.AuthResult) -> Void + + private enum Field { + case serverURL, apiToken + } + + var body: some View { + ScrollView { + VStack(spacing: 28) { + AuthHeaderView() + .padding(.top, 48) + + VStack(spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + fieldLabel("Server", systemImage: "globe") + + TextField("outline.example.com", text: $viewModel.serverURLString) + .textFieldStyle(.plain) + .padding(14) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 12)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .focused($focusedField, equals: .serverURL) + .submitLabel(.next) + .onSubmit { focusedField = .apiToken } + + Label(AuthViewModel.httpsHint, systemImage: "lock.fill") + .font(.caption) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 6) { + fieldLabel("API Token", systemImage: "key.fill") + + SecureField("Personal API token", text: $viewModel.apiToken) + .textFieldStyle(.plain) + .padding(14) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 12)) + .focused($focusedField, equals: .apiToken) + .submitLabel(.go) + .onSubmit { submit() } + } + + if let errorMessage = viewModel.errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle.fill") + .font(.footnote) + .foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .leading) + .transition(.opacity.combined(with: .move(edge: .top))) + } + + Button(action: submit) { + HStack { + if viewModel.isValidating { + ProgressView() + .tint(.white) + } else { + Text("Sign In") + .fontWeight(.semibold) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } + .buttonStyle(.borderedProminent) + .disabled(!viewModel.canSubmit) + } + .padding(20) + .background(.background.secondary, in: RoundedRectangle(cornerRadius: 24)) + .padding(.horizontal, 20) + + Spacer(minLength: 24) + } + } + .background( + LinearGradient( + colors: [Color.accentColor.opacity(0.12), Color(.systemBackground)], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + ) + .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage) + .animation(.easeInOut(duration: 0.2), value: viewModel.isValidating) + } + + private func fieldLabel(_ text: String, systemImage: String) -> some View { + Label(text, systemImage: systemImage) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + + private func submit() { + focusedField = nil + Task { + guard let result = await viewModel.signIn() else { return } + onSigningIn(result) + } + } +} +#endif diff --git a/Outpost/Features/Auth/AuthView_macOS.swift b/Outpost/Features/Auth/AuthView_macOS.swift new file mode 100644 index 0000000..294591d --- /dev/null +++ b/Outpost/Features/Auth/AuthView_macOS.swift @@ -0,0 +1,90 @@ +#if os(macOS) +import SwiftUI + +struct AuthView_macOS: View { + @State private var viewModel = AuthViewModel() + @FocusState private var focusedField: Field? + @State private var isShowingServerHint = false + var onSigningIn: (AuthViewModel.AuthResult) -> Void + + private enum Field { + case serverURL, apiToken + } + + var body: some View { + VStack(spacing: 24) { + AuthHeaderView() + + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 4) { + Text("Server") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Button { + isShowingServerHint.toggle() + } label: { + Image(systemName: "info.circle") + .font(.caption) + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + .help(AuthViewModel.httpsHint) + .popover(isPresented: $isShowingServerHint, arrowEdge: .bottom) { + Text(AuthViewModel.httpsHint) + .font(.callout) + .padding() + .frame(width: 240) + } + } + + TextField("outline.example.com", text: $viewModel.serverURLString, prompt: Text("outline.example.com")) + .textFieldStyle(.roundedBorder) + .focused($focusedField, equals: .serverURL) + .onSubmit { focusedField = .apiToken } + } + + VStack(alignment: .leading, spacing: 6) { + Text("API Token") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + SecureField("Personal API token", text: $viewModel.apiToken, prompt: Text("Personal API token")) + .textFieldStyle(.roundedBorder) + .focused($focusedField, equals: .apiToken) + .onSubmit { submit() } + } + + if let errorMessage = viewModel.errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle.fill") + .font(.callout) + .foregroundStyle(.red) + .transition(.opacity) + } + + HStack { + Spacer() + if viewModel.isValidating { + ProgressView() + .controlSize(.small) + } + Button("Sign In", action: submit) + .keyboardShortcut(.defaultAction) + .disabled(!viewModel.canSubmit) + } + } + } + .padding(32) + .frame(width: 380) + .animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage) + } + + private func submit() { + focusedField = nil + Task { + guard let result = await viewModel.signIn() else { return } + onSigningIn(result) + } + } +} +#endif diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift new file mode 100644 index 0000000..6db03f2 --- /dev/null +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -0,0 +1,471 @@ +#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 + 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 + + private var tree: [DocumentNode] { + buildDocumentTree(from: viewModel.documents, sortedBy: sortOption) + } + + init( + apiClient: OutlineAPIClient, + collection: OutlineCollection, + sortOption: SidebarSortOption, + refreshToken: Int, + selectedDocumentID: String?, + onSelectDocument: @escaping ([OutlineDocument]) -> Void + ) { + self.apiClient = apiClient + _viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection)) + self.sortOption = sortOption + self.refreshToken = refreshToken + 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, + onSelectDocument: onSelectDocument, + onDocumentsChanged: { await viewModel.load() } + ) + } + } + } + .task(id: refreshToken) { await viewModel.load() } + } +} + +/// `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 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 + } + + 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, + onSelectDocument: onSelectDocument, + onDocumentsChanged: onDocumentsChanged + ) + } + } + } + // 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) + } + } + + @ViewBuilder + private var contextMenuContent: some View { + Button(starStore.isStarred(documentId: node.document.id) ? "Unstar" : "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 starStore.toggleDocument(node.document.id, apiClient: apiClient) + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update the star on 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/CollectionListContent.swift b/Outpost/Features/Collections/CollectionListContent.swift new file mode 100644 index 0000000..c2399ab --- /dev/null +++ b/Outpost/Features/Collections/CollectionListContent.swift @@ -0,0 +1,42 @@ +import SwiftUI +import OutlineKit + +struct CollectionListContent: View { + var viewModel: CollectionsViewModel + let onSelect: (OutlineCollection) -> Void + + var body: some View { + Group { + if viewModel.isLoading && viewModel.collections.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage = viewModel.errorMessage, viewModel.collections.isEmpty { + ContentUnavailableView { + Label("Couldn't Load Collections", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } actions: { + Button("Retry") { + Task { await viewModel.load() } + } + } + } else if viewModel.collections.isEmpty { + ContentUnavailableView( + "No Collections", + systemImage: "folder", + description: Text("Collections you have access to will appear here.") + ) + } else { + List(viewModel.collections) { collection in + Button { + onSelect(collection) + } label: { + CollectionRowView(collection: collection) + } + .buttonStyle(.plain) + } + } + } + .task { await viewModel.load() } + } +} diff --git a/Outpost/Features/Collections/CollectionOverviewContent.swift b/Outpost/Features/Collections/CollectionOverviewContent.swift new file mode 100644 index 0000000..ad2fcbf --- /dev/null +++ b/Outpost/Features/Collections/CollectionOverviewContent.swift @@ -0,0 +1,29 @@ +#if os(macOS) +import SwiftUI +import MarkdownEngine +import OutlineKit + +/// Read-only for now — document/overview editing isn't wired up yet. Uses +/// `NativeTextViewWrapper`'s own `isEditable: false`, not `.disabled(true)`: +/// the latter blocks ALL interaction including link clicks, since +/// `isSelectable` and link-opening both still need to work. +struct CollectionOverviewContent: View { + @State private var markdown: String + + init(collection: OutlineCollection) { + _markdown = State(initialValue: collection.description ?? "") + } + + var body: some View { + ScrollView { + NativeTextViewWrapper( + text: $markdown, + configuration: .init(heightBehavior: .fitsContent), + isEditable: false + ) + .padding() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} +#endif diff --git a/Outpost/Features/Collections/CollectionOverviewView.swift b/Outpost/Features/Collections/CollectionOverviewView.swift new file mode 100644 index 0000000..2436a06 --- /dev/null +++ b/Outpost/Features/Collections/CollectionOverviewView.swift @@ -0,0 +1,189 @@ +#if os(macOS) +import SwiftUI +import OutlineKit + +struct CollectionOverviewView: View { + let collection: OutlineCollection + @State private var viewModel: DocumentsViewModel + @State private var selectedTab: CollectionTab = .overview + + // Contextual search — "search what you're looking at" — scoped to this + // collection's documents. The field itself lives on the parent + // NavigationStack (so it survives pushing into a document); this owns + // only the resulting search state. + @State private var searchViewModel: DocumentTitleSearchViewModel + @Binding var searchQuery: String + let onOpenDocument: (OutlineDocument) -> Void + + private var trimmedSearchQuery: String { + searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + } + + init( + apiClient: OutlineAPIClient, + collection: OutlineCollection, + searchQuery: Binding, + onOpenDocument: @escaping (OutlineDocument) -> Void + ) { + self.collection = collection + _viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection)) + _searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id)) + _searchQuery = searchQuery + self.onOpenDocument = onOpenDocument + } + + private var sortedDocuments: [OutlineDocument] { + selectedTab.sorted(viewModel.documents) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // No in-content header — the collection's icon/title live in the + // window toolbar now (via `ContentView_macOS`), so this doesn't + // duplicate it directly below. + if viewModel.hasRemoteChanges { + RemoteChangesBanner { + Task { await viewModel.load() } + } + } + + if trimmedSearchQuery.isEmpty { + tabBar + } + + Divider() + + // All branches get the same frame so switching tabs/search doesn't + // visibly resize/jump the content area to its own ideal size. + Group { + if !trimmedSearchQuery.isEmpty { + searchResultsList + } else if selectedTab == .overview { + CollectionOverviewContent(collection: collection) + } else { + documentList + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + // Corner spinner for reloads with stale data already on screen + // (remote-changes refresh, tab switch after the first load) — + // `documentList`'s own spinner only covers the empty-state case. + .overlay(alignment: .topTrailing) { + if viewModel.isLoading && !viewModel.documents.isEmpty { + ProgressView() + .controlSize(.small) + .padding(12) + } + } + } + // No `.navigationTitle` here — it renders its own native title bubble, + // duplicating the leading toolbar item `ContentView_macOS` already + // shows (which also carries the collection > document hierarchy). + .task { await viewModel.load() } + .task(id: trimmedSearchQuery) { + try? await Task.sleep(for: .milliseconds(250)) + guard !Task.isCancelled else { return } + await searchViewModel.search(query: trimmedSearchQuery) + } + .task { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(45)) + guard !Task.isCancelled else { break } + await viewModel.checkForRemoteChanges() + } + } + } + + // Spans the full window width, centered, directly under the toolbar — + // not a scrolling, left-aligned row anymore. + private var tabBar: some View { + HStack(spacing: 4) { + Spacer(minLength: 0) + ForEach(CollectionTab.allCases) { tab in + Button { + selectedTab = tab + } label: { + Text(tab.label) + .font(.callout.weight(selectedTab == tab ? .semibold : .regular)) + .foregroundStyle(selectedTab == tab ? Color.primary : Color.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + selectedTab == tab ? Color.accentColor.opacity(0.15) : Color.clear, + in: RoundedRectangle(cornerRadius: 6) + ) + } + .buttonStyle(.plain) + } + Spacer(minLength: 0) + } + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + } + + @ViewBuilder + private var documentList: some View { + if viewModel.isLoading && viewModel.documents.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage = viewModel.errorMessage, viewModel.documents.isEmpty { + ContentUnavailableView { + Label("Couldn't Load Documents", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } actions: { + Button("Retry") { + Task { await viewModel.load() } + } + } + } else if viewModel.documents.isEmpty { + ContentUnavailableView( + "No Documents", + systemImage: "doc.text", + description: Text("Documents in \(collection.name) will appear here.") + ) + } else { + List(sortedDocuments) { document in + Button { + onOpenDocument(document) + } label: { + DocumentRowView(document: document) + } + .buttonStyle(.plain) + } + } + } + + @ViewBuilder + private var searchResultsList: some View { + if searchViewModel.isSearching && searchViewModel.results.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage = searchViewModel.errorMessage { + ContentUnavailableView { + Label("Search Failed", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } + } else if searchViewModel.results.isEmpty { + ContentUnavailableView.search(text: trimmedSearchQuery) + } else { + List(searchViewModel.results) { result in + Button { + onOpenDocument(result.document) + } label: { + VStack(alignment: .leading, spacing: 4) { + DocumentRowView(document: result.document) + Text(result.context) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(.vertical, 2) + } + .buttonStyle(.plain) + } + } + } +} +#endif diff --git a/Outpost/Features/Collections/CollectionRowView.swift b/Outpost/Features/Collections/CollectionRowView.swift new file mode 100644 index 0000000..913b9e0 --- /dev/null +++ b/Outpost/Features/Collections/CollectionRowView.swift @@ -0,0 +1,44 @@ +import SwiftUI +import OutlineKit + +struct CollectionRowView: View { + let collection: OutlineCollection + + var body: some View { + Label { + Text(collection.name) + } icon: { + if let emoji = collection.emojiIcon { + Text(emoji) + } else if let symbolName = collection.icon.flatMap(OutlineIconMapping.sfSymbolName) { + Image(systemName: symbolName) + .foregroundStyle(tintColor) + } else { + Image(systemName: "folder.fill") + .foregroundStyle(tintColor) + } + } + } + + private var tintColor: Color { + collection.color.flatMap { Color(hex: $0) } ?? .accentColor + } +} + +private extension OutlineCollection { + /// Outline's `icon` field holds either a literal emoji, or a string key + /// into Outline's own icon set (see OutlineIconMapping) — only the former + /// is renderable directly, the latter needs the lookup table. + var emojiIcon: String? { + // `isEmojiPresentation` misses symbol-class emoji that default to text + // presentation (e.g. some picked without a variation selector) — `isEmoji` + // is the broader, correct check for "this scalar can be an emoji at all". + // Plain ASCII digits/`#`/`*` also report `isEmoji == true` (they're valid + // keycap-sequence bases), so exclude the ASCII range to avoid treating a + // literal digit or punctuation icon name as an emoji. + guard let icon, icon.unicodeScalars.contains(where: { $0.properties.isEmoji && $0.value > 0x7F }) else { + return nil + } + return icon + } +} diff --git a/Outpost/Features/Collections/CollectionTab.swift b/Outpost/Features/Collections/CollectionTab.swift new file mode 100644 index 0000000..71f7a0d --- /dev/null +++ b/Outpost/Features/Collections/CollectionTab.swift @@ -0,0 +1,45 @@ +import Foundation +import OutlineKit + +/// Mirrors Outline's own top tab bar on a collection's page. +enum CollectionTab: String, CaseIterable, Identifiable { + case overview + case documents + case popular + case recentlyUpdated + case recentlyPublished + case leastRecentlyUpdated + case alphabetical + + var id: String { rawValue } + + var label: String { + switch self { + case .overview: return "Overview" + case .documents: return "Documents" + case .popular: return "Popular" + case .recentlyUpdated: return "Recently Updated" + case .recentlyPublished: return "Recently Published" + case .leastRecentlyUpdated: return "Least Recently Updated" + case .alphabetical: return "A-Z" + } + } + + /// `.popular` has no real ranking signal yet — it would need view-count / + /// analytics data the REST layer doesn't expose — so it falls back to the + /// same order as `.documents` rather than fabricating a ranking. + func sorted(_ documents: [OutlineDocument]) -> [OutlineDocument] { + switch self { + case .overview, .documents, .popular: + return documents + case .recentlyUpdated: + return documents.sorted { $0.updatedAt > $1.updatedAt } + case .recentlyPublished: + return documents.sorted { ($0.publishedAt ?? $0.createdAt) > ($1.publishedAt ?? $1.createdAt) } + case .leastRecentlyUpdated: + return documents.sorted { $0.updatedAt < $1.updatedAt } + case .alphabetical: + return documents.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } + } + } +} diff --git a/Outpost/Features/Collections/CollectionTreeRow.swift b/Outpost/Features/Collections/CollectionTreeRow.swift new file mode 100644 index 0000000..1bd159e --- /dev/null +++ b/Outpost/Features/Collections/CollectionTreeRow.swift @@ -0,0 +1,189 @@ +#if os(macOS) +import SwiftUI +import OutlineKit + +@MainActor +struct CollectionTreeRow: View { + @Environment(StarStore.self) private var starStore + + let apiClient: OutlineAPIClient + let collection: OutlineCollection + let isExpanded: Bool + let isSelected: Bool + let selectedDocumentID: String? + let onToggle: () -> Void + let onSelectDocument: ([OutlineDocument]) -> Void + let onSearchInCollection: (OutlineCollection) -> Void + let onCollectionsChanged: () async -> Void + + @State private var sortOption: SidebarSortOption = .manual + @State private var documentsRefreshToken = 0 + + @State private var isShowingRenameAlert = false + @State private var renameText = "" + @State private var isShowingDeleteConfirmation = false + @State private var actionErrorMessage: String? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button(action: onToggle) { + HStack(spacing: 6) { + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + .frame(width: 12) + + CollectionRowView(collection: collection) + + Spacer(minLength: 0) + + if starStore.isStarred(collectionId: collection.id) { + Image(systemName: "star.fill") + .font(.caption2) + .foregroundStyle(.yellow) + } + } + .padding(.vertical, 4) + .padding(.horizontal, 6) + .contentShape(Rectangle()) + .background( + isSelected ? Color.accentColor.opacity(0.15) : Color.clear, + in: RoundedRectangle(cornerRadius: 6) + ) + } + .buttonStyle(.plain) + .animation(.easeInOut(duration: 0.15), value: isExpanded) + .contextMenu { contextMenuContent } + + if isExpanded { + CollectionDocumentsOutline( + apiClient: apiClient, + collection: collection, + sortOption: sortOption, + refreshToken: documentsRefreshToken, + selectedDocumentID: selectedDocumentID, + onSelectDocument: onSelectDocument + ) + .padding(.leading, 18) + } + } + .alert("Rename Collection", isPresented: $isShowingRenameAlert) { + TextField("Name", text: $renameText) + Button("Cancel", role: .cancel) {} + Button("Rename") { + Task { await rename() } + } + } + .confirmationDialog( + "Delete \"\(collection.name)\"?", + isPresented: $isShowingDeleteConfirmation, + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + Task { await delete() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This deletes the collection and all of its documents. This can't be undone.") + } + .alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) { + Button("OK") { actionErrorMessage = nil } + } message: { + Text(actionErrorMessage ?? "") + } + } + + @ViewBuilder + private var contextMenuContent: some View { + Button(starStore.isStarred(collectionId: collection.id) ? "Unstar" : "Star") { + Task { await star() } + } + + Divider() + + Button("New Document") { + Task { await createDocument() } + } + + Divider() + + Button("Rename…") { + renameText = collection.name + isShowingRenameAlert = true + } + + Divider() + + Menu("Sort in Sidebar") { + Picker("Sort", selection: $sortOption) { + ForEach(SidebarSortOption.allCases) { option in + Text(option.label).tag(option) + } + } + } + + Divider() + + Button("Export…") { + Task { await export() } + } + + Button("Search in Collection") { + onSearchInCollection(collection) + } + + Divider() + + Button("Delete…", role: .destructive) { + isShowingDeleteConfirmation = true + } + } + + private func star() async { + do { + try await starStore.toggleCollection(collection.id, apiClient: apiClient) + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update the star on this collection.") + } + } + + private func createDocument() async { + do { + _ = try await apiClient.createDocument( + CreateDocumentRequest(title: "Untitled", text: "", collectionId: collection.id) + ) + documentsRefreshToken += 1 + await onCollectionsChanged() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new document.") + } + } + + private func rename() async { + do { + _ = try await apiClient.updateCollection(UpdateCollectionRequest(id: collection.id, name: renameText)) + await onCollectionsChanged() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't rename this collection.") + } + } + + private func export() async { + do { + _ = try await apiClient.exportCollection(ExportCollectionRequest(id: collection.id)) + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't start the export.") + } + } + + private func delete() async { + do { + try await apiClient.deleteCollection(id: collection.id) + await onCollectionsChanged() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete this collection.") + } + } +} +#endif diff --git a/Outpost/Features/Collections/CollectionsTreeView.swift b/Outpost/Features/Collections/CollectionsTreeView.swift new file mode 100644 index 0000000..fe6bf27 --- /dev/null +++ b/Outpost/Features/Collections/CollectionsTreeView.swift @@ -0,0 +1,124 @@ +#if os(macOS) +import SwiftUI +import OutlineKit + +struct CollectionsTreeView: View { + @State private var viewModel: CollectionsViewModel + @Binding private var selectedCollection: OutlineCollection? + let selectedDocumentID: String? + @State private var expandedCollectionIDs: Set = [] + let onSelectDocument: (OutlineCollection, [OutlineDocument]) -> Void + let onSearchInCollection: (OutlineCollection) -> Void + + init( + apiClient: OutlineAPIClient, + selectedCollection: Binding, + selectedDocumentID: String?, + onSelectDocument: @escaping (OutlineCollection, [OutlineDocument]) -> Void, + onSearchInCollection: @escaping (OutlineCollection) -> Void + ) { + _viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient)) + _selectedCollection = selectedCollection + self.selectedDocumentID = selectedDocumentID + self.onSelectDocument = onSelectDocument + self.onSearchInCollection = onSearchInCollection + } + + var body: some View { + VStack(spacing: 0) { + if viewModel.hasRemoteChanges { + RemoteChangesBanner { + Task { await viewModel.load() } + } + } + content + } + .task { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(45)) + guard !Task.isCancelled else { break } + await viewModel.checkForRemoteChanges() + } + } + } + + @ViewBuilder + private var content: some View { + Group { + if viewModel.isLoading && viewModel.collections.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage = viewModel.errorMessage, viewModel.collections.isEmpty { + ContentUnavailableView { + Label("Couldn't Load Collections", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } actions: { + Button("Retry") { + Task { await viewModel.load() } + } + } + } else if viewModel.collections.isEmpty { + ContentUnavailableView( + "No Collections", + systemImage: "folder", + description: Text("Collections you have access to will appear here.") + ) + } else { + // Plain `ScrollView`/`LazyVStack`, not `List`: on macOS, a + // `List` row's context menu wins over any `.contextMenu` + // nested deeper inside that row's content (right-clicking a + // document row inside an expanded `CollectionTreeRow` showed + // the collection's menu instead of the document's) — every + // row here already does its own selection highlighting, so + // `List` wasn't buying anything but that bug. + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(viewModel.collections) { collection in + CollectionTreeRow( + apiClient: viewModel.apiClient, + collection: collection, + isExpanded: expandedCollectionIDs.contains(collection.id), + isSelected: selectedCollection?.id == collection.id, + selectedDocumentID: selectedDocumentID, + onToggle: { toggle(collection) }, + onSelectDocument: { chain in onSelectDocument(collection, chain) }, + onSearchInCollection: onSearchInCollection, + onCollectionsChanged: { await viewModel.load() } + ) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + } + } + .task { + await viewModel.load() + // Stand-in for Outline's own configured "Start view" — we don't have + // a confirmed schema for `team.preferences` to read the actual + // setting, so this defaults to the first collection instead of + // landing on an empty "No Collection Selected" placeholder. + if selectedCollection == nil, let first = viewModel.collections.first { + selectedCollection = first + } + } + // A document opened from outside the sidebar (detail pane's list, + // global search) wouldn't otherwise expand its collection here, so + // the highlighted row would stay hidden. + .task(id: selectedDocumentID) { + guard selectedDocumentID != nil, let selectedCollection else { return } + expandedCollectionIDs.insert(selectedCollection.id) + } + } + + private func toggle(_ collection: OutlineCollection) { + selectedCollection = collection + if expandedCollectionIDs.contains(collection.id) { + expandedCollectionIDs.remove(collection.id) + } else { + expandedCollectionIDs.insert(collection.id) + } + } +} +#endif diff --git a/Outpost/Features/Collections/CollectionsViewModel.swift b/Outpost/Features/Collections/CollectionsViewModel.swift new file mode 100644 index 0000000..981706f --- /dev/null +++ b/Outpost/Features/Collections/CollectionsViewModel.swift @@ -0,0 +1,49 @@ +import Foundation +import Observation +import OutlineKit + +@MainActor +@Observable +final class CollectionsViewModel { + private(set) var collections: [OutlineCollection] = [] + var isLoading = false + var errorMessage: String? + var hasRemoteChanges = false + + let apiClient: OutlineAPIClient + + init(apiClient: OutlineAPIClient) { + self.apiClient = apiClient + } + + func load() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + collections = try await apiClient.listCollections(offset: 0, limit: 100) + hasRemoteChanges = false + } catch { + errorMessage = "Couldn't load collections. Check your connection and try again." + } + } + + /// Fetches fresh data to compare against what's displayed, without + /// replacing it — `hasRemoteChanges` drives a "Refresh" banner instead of + /// silently swapping content (and losing scroll position/expanded state) + /// out from under whoever's looking at it. + func checkForRemoteChanges() async { + guard let fresh = try? await apiClient.listCollections(offset: 0, limit: 100) else { return } + if Self.fingerprint(fresh) != Self.fingerprint(collections) { + hasRemoteChanges = true + } + } + + private static func fingerprint(_ collections: [OutlineCollection]) -> String { + collections + .map { "\($0.id):\($0.updatedAt.timeIntervalSince1970)" } + .sorted() + .joined(separator: "|") + } +} diff --git a/Outpost/Features/Collections/ContentView_iOS.swift b/Outpost/Features/Collections/ContentView_iOS.swift new file mode 100644 index 0000000..ced40d3 --- /dev/null +++ b/Outpost/Features/Collections/ContentView_iOS.swift @@ -0,0 +1,46 @@ +#if os(iOS) +import SwiftUI +import OutlineKit + +struct ContentView_iOS: View { + @Environment(SessionStore.self) private var session + + var body: some View { + NavigationStack { + Group { + if let apiClient = session.apiClient { + CollectionsScreen(apiClient: apiClient) + } else { + ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark") + } + } + .navigationTitle(session.teamName ?? "Outpost") + .toolbar { + ToolbarItem(placement: .navigation) { + AvatarBadge(avatarURL: session.teamAvatarURL, placeholderSystemImage: "text.book.closed.fill") + } + } + } + } +} + +private struct CollectionsScreen: View { + @State private var viewModel: CollectionsViewModel + @State private var pushedCollection: OutlineCollection? + private let apiClient: OutlineAPIClient + + init(apiClient: OutlineAPIClient) { + self.apiClient = apiClient + _viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient)) + } + + var body: some View { + CollectionListContent(viewModel: viewModel) { collection in + pushedCollection = collection + } + .navigationDestination(item: $pushedCollection) { collection in + DocumentsPane(apiClient: apiClient, collection: collection) + } + } +} +#endif diff --git a/Outpost/Features/Collections/ContentView_macOS.swift b/Outpost/Features/Collections/ContentView_macOS.swift new file mode 100644 index 0000000..4d650f8 --- /dev/null +++ b/Outpost/Features/Collections/ContentView_macOS.swift @@ -0,0 +1,242 @@ +#if os(macOS) +import SwiftUI +import OutlineKit + +struct ContentView_macOS: View { + @Environment(SessionStore.self) private var session + @State private var selectedCollection: OutlineCollection? + /// The real navigation stack, root to leaf — also the source of truth for + /// the toolbar breadcrumb, so the two can't drift out of sync. + @State private var documentPath: [OutlineDocument] = [] + @State private var globalSearchQuery = "" + @State private var contextualSearchQuery = "" + @State private var isContextualSearchExpanded = false + @FocusState private var isContextualSearchFocused: Bool + + private var trimmedGlobalQuery: String { + globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + NavigationSplitView { + VStack(spacing: 0) { + SidebarSearchField(text: $globalSearchQuery) + Divider() + sidebar + AccountFooter() + } + .navigationSplitViewColumnWidth(min: 220, ideal: 260) + } detail: { + detail + } + // Empty rather than a real value: with one, the native title rendered + // in the same toolbar row as the custom `.navigation` pill below, + // visibly duplicating it. `.windowToolbarStyle(showsTitle: false)` was + // tried to suppress just the native text, but it also suppressed the + // custom `.navigation` item itself — an empty title sidesteps both + // problems (Window-menu entry is blank as a result). + .navigationTitle("") + .toolbar { + // Per HIG: "sidebar-toggle/back controls appear at the far leading + // edge, followed by the view title" — this is that title, not + // centered. It doesn't collide with the back button because they're + // mutually exclusive: whenever a document's pushed (back button + // visible), this shows the document hierarchy instead of falling + // back to the workspace badge. + ToolbarItem(placement: .navigation) { + leadingToolbarContent + } + // Custom instead of `.searchable`: that modifier always renders a + // full-width field, but this is meant to sit alongside the other + // per-document toolbar buttons as a plain icon that only expands + // into a field once clicked. + ToolbarItem(placement: .primaryAction) { + contextualSearchField + } + } + } + + @ViewBuilder + private var contextualSearchField: some View { + if isContextualSearchExpanded || !contextualSearchQuery.isEmpty { + HStack(spacing: 4) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField( + selectedCollection.map { "Search \($0.name)" } ?? "Search", + text: $contextualSearchQuery + ) + .textFieldStyle(.plain) + .focused($isContextualSearchFocused) + .frame(width: 180) + + if !contextualSearchQuery.isEmpty { + Button { + contextualSearchQuery = "" + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.fill.tertiary, in: Capsule()) + .onChange(of: isContextualSearchFocused) { _, focused in + if !focused && contextualSearchQuery.isEmpty { + isContextualSearchExpanded = false + } + } + } else { + Button { + isContextualSearchExpanded = true + Task { @MainActor in isContextualSearchFocused = true } + } label: { + Image(systemName: "magnifyingglass") + } + } + } + + @ViewBuilder + private var leadingToolbarContent: some View { + Group { + if !trimmedGlobalQuery.isEmpty { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + Text("Search") + } + } else if !documentPath.isEmpty, let selectedCollection { + // Collection → every ancestor (icon only) → current document + // (icon + full title) — ancestors stay icon-only so a deep + // chain doesn't blow out the toolbar width. + HStack(spacing: 6) { + CollectionRowView(collection: selectedCollection) + + ForEach(Array(documentPath.enumerated()), id: \.element.id) { index, document in + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.tertiary) + + if let emoji = document.emoji { + Text(emoji) + } else { + Image(systemName: "doc.text") + } + + if index == documentPath.count - 1 { + Text(document.title.isEmpty ? "Untitled" : document.title) + .lineLimit(1) + } + } + } + } else if let selectedCollection { + CollectionRowView(collection: selectedCollection) + } else { + HStack(spacing: 8) { + AvatarBadge(avatarURL: session.teamAvatarURL, placeholderSystemImage: "text.book.closed.fill") + Text(session.teamName ?? "Outpost") + .lineLimit(1) + } + } + } + .font(.headline) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(.fill.tertiary, in: Capsule()) + } + + @ViewBuilder + private var sidebar: some View { + if let apiClient = session.apiClient { + CollectionsTreeView( + apiClient: apiClient, + selectedCollection: $selectedCollection, + selectedDocumentID: documentPath.last?.id, + onSelectDocument: selectDocumentChain, + onSearchInCollection: searchInCollection + ) + } else { + ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark") + } + } + + private func searchInCollection(_ collection: OutlineCollection) { + globalSearchQuery = "" + selectedCollection = collection + isContextualSearchExpanded = true + Task { @MainActor in + isContextualSearchFocused = true + } + } + + /// Sidebar clicks (which know the real ancestor chain via DocumentNode): + /// replaces the whole stack with `chain`, so the toolbar shows full + /// hierarchy immediately rather than just the leaf. + private func selectDocumentChain(_ collection: OutlineCollection, _ chain: [OutlineDocument]) { + selectedCollection = collection + replaceDocumentPath(with: chain) + } + + /// Flat-list / search-result clicks: no known ancestors, single-level push. + private func openDocument(_ document: OutlineDocument) { + replaceDocumentPath(with: [document]) + } + + /// Replacing `path` with a same-size array can silently fail to update + /// NavigationStack on macOS — popping to empty and pushing again as two + /// separate state updates (so SwiftUI processes them as two render + /// passes) is the reliable version of the same operation. Appending + /// (e.g. opening a sub-document from within the reader) doesn't hit this + /// and can mutate `documentPath` directly. + private func replaceDocumentPath(with chain: [OutlineDocument]) { + documentPath = [] + Task { @MainActor in + documentPath = chain + } + } + + @ViewBuilder + private var detail: some View { + if let apiClient = session.apiClient { + // A fresh NavigationStack per collection (or when entering/leaving + // search), so switching either also clears any pushed document. + NavigationStack(path: $documentPath) { + Group { + if !trimmedGlobalQuery.isEmpty { + GlobalSearchResultsView(apiClient: apiClient, query: trimmedGlobalQuery, onOpenDocument: openDocument) + } else if let selectedCollection { + CollectionOverviewView( + apiClient: apiClient, + collection: selectedCollection, + searchQuery: $contextualSearchQuery, + onOpenDocument: openDocument + ) + } else { + ContentUnavailableView( + "No Collection Selected", + systemImage: "sidebar.left", + description: Text("Choose a collection to see its documents.") + ) + } + } + .navigationDestination(for: OutlineDocument.self) { document in + DocumentReaderView( + apiClient: apiClient, + document: document, + onOpenChild: { child in documentPath.append(child) }, + onDeleted: { + if !documentPath.isEmpty { + documentPath.removeLast() + } + } + ) + } + } + .id(trimmedGlobalQuery.isEmpty ? (selectedCollection?.id ?? "none") : "search") + } else { + ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark") + } + } +} +#endif diff --git a/Outpost/Features/Collections/DocumentHistorySheet.swift b/Outpost/Features/Collections/DocumentHistorySheet.swift new file mode 100644 index 0000000..1e370eb --- /dev/null +++ b/Outpost/Features/Collections/DocumentHistorySheet.swift @@ -0,0 +1,76 @@ +#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. +@MainActor +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..e401596 --- /dev/null +++ b/Outpost/Features/Collections/DocumentInsightsSheet.swift @@ -0,0 +1,100 @@ +#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. +@MainActor +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/DocumentListContent.swift b/Outpost/Features/Collections/DocumentListContent.swift new file mode 100644 index 0000000..3ee4c9d --- /dev/null +++ b/Outpost/Features/Collections/DocumentListContent.swift @@ -0,0 +1,37 @@ +import SwiftUI +import OutlineKit + +struct DocumentListContent: View { + var viewModel: DocumentsViewModel + + var body: some View { + Group { + if viewModel.isLoading && viewModel.documents.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage = viewModel.errorMessage, viewModel.documents.isEmpty { + ContentUnavailableView { + Label("Couldn't Load Documents", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } actions: { + Button("Retry") { + Task { await viewModel.load() } + } + } + } else if viewModel.documents.isEmpty { + ContentUnavailableView( + "No Documents", + systemImage: "doc.text", + description: Text("Documents in \(viewModel.collection.name) will appear here.") + ) + } else { + List(viewModel.documents) { document in + DocumentRowView(document: document) + } + } + } + .navigationTitle(viewModel.collection.name) + .task { await viewModel.load() } + } +} diff --git a/Outpost/Features/Collections/DocumentNode.swift b/Outpost/Features/Collections/DocumentNode.swift new file mode 100644 index 0000000..d66d7b2 --- /dev/null +++ b/Outpost/Features/Collections/DocumentNode.swift @@ -0,0 +1,26 @@ +import Foundation +import OutlineKit + +/// Reconstructs the real document hierarchy client-side from `parentDocumentId`. +/// `documents.list` already returns the full flat set for a collection +/// (including children, not just roots), so no separate tree-shaped endpoint +/// is needed — `collections.documents` exists and returns exactly this shape, +/// but its `NavigationNode` carries no emoji/timestamps, which would break +/// per-row icons and date-based sidebar sorting. +struct DocumentNode: Identifiable { + let document: OutlineDocument + let children: [DocumentNode] + var id: String { document.id } +} + +func buildDocumentTree(from documents: [OutlineDocument], sortedBy sort: SidebarSortOption) -> [DocumentNode] { + let byParent = Dictionary(grouping: documents, by: { $0.parentDocumentId }) + + func makeNodes(parentId: String?) -> [DocumentNode] { + sort.sorted(byParent[parentId] ?? []).map { document in + DocumentNode(document: document, children: makeNodes(parentId: document.id)) + } + } + + return makeNodes(parentId: nil) +} diff --git a/Outpost/Features/Collections/DocumentPresentSheet.swift b/Outpost/Features/Collections/DocumentPresentSheet.swift new file mode 100644 index 0000000..5c1fc5a --- /dev/null +++ b/Outpost/Features/Collections/DocumentPresentSheet.swift @@ -0,0 +1,83 @@ +#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). +@MainActor +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/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift new file mode 100644 index 0000000..cbd8786 --- /dev/null +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -0,0 +1,483 @@ +#if os(macOS) +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import MarkdownEngine +import OutlineKit + +/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions +/// below must run on the main thread — see the identical note on +/// `DocumentNodeRow` in `CollectionDocumentsOutline.swift`. +@MainActor +struct DocumentReaderView: View { + @Environment(SessionStore.self) private var session + @Environment(StarStore.self) private var starStore + + @State private var viewModel: DocumentReaderViewModel + let apiClient: OutlineAPIClient + let document: OutlineDocument + /// Pushes a genuine new stack entry (not a reset+replace) — the back + /// button then correctly returns to this document, not the collection. + let onOpenChild: (OutlineDocument) -> Void + /// Called after Delete/Archive/Unpublish succeed — the document is no + /// longer visible in the collection it was opened from, so the reader + /// pops itself off the navigation stack. + let onDeleted: () -> Void + + @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 isShowingShareSheet = false + @State private var actionErrorMessage: String? + + init( + apiClient: OutlineAPIClient, + document: OutlineDocument, + onOpenChild: @escaping (OutlineDocument) -> Void, + onDeleted: @escaping () -> Void + ) { + self.apiClient = apiClient + self.document = document + _viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document)) + self.onOpenChild = onOpenChild + self.onDeleted = onDeleted + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if viewModel.isEditing { + TextField("Title", text: $viewModel.title) + .font(.largeTitle.weight(.bold)) + .textFieldStyle(.plain) + } + + if viewModel.isLoading && viewModel.text.isEmpty { + ProgressView() + .frame(maxWidth: .infinity) + } else if let errorMessage = viewModel.errorMessage { + ContentUnavailableView { + Label("Couldn't Load Document", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } actions: { + Button("Retry") { + Task { await viewModel.loadFullContent() } + } + } + } else { + NativeTextViewWrapper( + text: $viewModel.text, + configuration: .init(heightBehavior: .fitsContent), + isEditable: viewModel.isEditing + ) + + if !viewModel.children.isEmpty { + childrenSection + } + } + } + .padding() + .frame(maxWidth: viewModel.isFullWidth ? .infinity : 900) + .frame(maxWidth: .infinity) + } + .overlay(alignment: .topTrailing) { + if viewModel.isLoading && !viewModel.text.isEmpty { + ProgressView() + .controlSize(.small) + .padding(12) + } + } + // No `.navigationTitle` here either — same reason as CollectionOverviewView. + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + viewerAvatars + Button { + isShowingShareSheet = true + } label: { + Image(systemName: "square.and.arrow.up") + } + .help("Share") + + Button { + Task { await viewModel.toggleEditing() } + } label: { + if viewModel.isSaving { + ProgressView().controlSize(.small) + } else { + Text(viewModel.isEditing ? "Done" : "Edit") + } + } + .disabled(viewModel.isSaving) + + Button { + Task { await createChildDocument() } + } label: { + Image(systemName: "doc.badge.plus") + } + .help("New Document") + + Menu { + menuContent + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .task { await viewModel.loadFullContent() } + .task { + await viewModel.loadPinAndSubscriptionState() + } + .task { + while !Task.isCancelled { + await viewModel.loadViewers() + try? await Task.sleep(for: .seconds(20)) + } + } + .confirmationDialog( + "Unpublish \"\(viewModel.title.isEmpty ? "Untitled" : viewModel.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 \"\(viewModel.title.isEmpty ? "Untitled" : viewModel.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 \"\(viewModel.title.isEmpty ? "Untitled" : viewModel.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: document) { + onDeleted() + } + } + .sheet(isPresented: $isShowingHistorySheet) { + DocumentHistorySheet(apiClient: apiClient, document: document) + } + .sheet(isPresented: $isShowingInsightsSheet) { + DocumentInsightsSheet(apiClient: apiClient, document: document) + } + .sheet(isPresented: $isShowingPresentSheet) { + DocumentPresentSheet(apiClient: apiClient, document: document) + } + .sheet(isPresented: $isShowingSearchSheet) { + DocumentSearchSheet(apiClient: apiClient, document: document) + } + .sheet(isPresented: $isShowingShareSheet) { + DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId) + } + } + + @ViewBuilder + private var viewerAvatars: some View { + if !viewModel.viewers.isEmpty { + HStack(spacing: -6) { + ForEach(viewModel.viewers.prefix(5)) { viewer in + AvatarBadge( + avatarURL: viewer.user.avatarUrl.flatMap { URL(string: $0, relativeTo: session.serverURL)?.absoluteURL }, + size: 20 + ) + .overlay(Circle().stroke(.background, lineWidth: 1.5)) + .help(Text(viewer.user.name)) + } + } + .padding(.trailing, 4) + } + } + + private var childrenSection: some View { + VStack(alignment: .leading, spacing: 8) { + Divider() + .padding(.vertical, 4) + + Text("Sub-documents") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + ForEach(viewModel.children) { child in + Button { + onOpenChild(child) + } label: { + DocumentRowView(document: child) + } + .buttonStyle(.plain) + .padding(.vertical, 4) + + if child.id != viewModel.children.last?.id { + Divider() + } + } + } + } + + @ViewBuilder + private var menuContent: some View { + Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") { + Task { await star() } + } + Button(viewModel.isSubscribed ? "Unsubscribe" : "Subscribe") { + Task { await toggleSubscription() } + } + + Divider() + + Button(viewModel.isEditing ? "Done Editing" : "Edit") { + Task { await viewModel.toggleEditing() } + } + // 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 — deferred rather than + // half-built here. + Button("Import Document…") {} + .disabled(true) + Button("New Document") { + Task { await createChildDocument() } + } + Button(viewModel.isPinned ? "Unpin" : "Pin") { + 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("Enable Viewer Insights") { + Task { await enableInsights() } + } + Button("Enable Embeds") { + Task { await enableEmbeds() } + } + Button(viewModel.isFullWidth ? "Default Width" : "Full Width") { + Task { await toggleFullWidth() } + } + + Divider() + + Button("Delete…", role: .destructive) { + isShowingDeleteConfirmation = true + } + } + + private func star() async { + do { + try await starStore.toggleDocument(viewModel.documentId, apiClient: apiClient) + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update the star on this document.") + } + } + + private func togglePin() async { + do { + try await viewModel.togglePin() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update pin state.") + } + } + + private func toggleSubscription() async { + do { + try await viewModel.toggleSubscription() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update subscription state.") + } + } + + private func toggleFullWidth() async { + do { + try await viewModel.toggleFullWidth() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't change document width.") + } + } + + private func enableInsights() async { + do { + try await viewModel.enableViewerInsights() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't enable viewer insights.") + } + } + + private func enableEmbeds() async { + do { + try await viewModel.enableEmbeds() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't enable embeds.") + } + } + + private func templatize() async { + do { + _ = try await apiClient.templatizeDocument(TemplatizeDocumentRequest(id: viewModel.documentId)) + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't templatize this document.") + } + } + + private func duplicate() async { + do { + _ = try await apiClient.duplicateDocument(DuplicateDocumentRequest(id: viewModel.documentId)) + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't duplicate this document.") + } + } + + private func unpublish() async { + do { + _ = try await apiClient.unpublishDocument(UnpublishDocumentRequest(id: viewModel.documentId)) + onDeleted() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't unpublish this document.") + } + } + + private func archive() async { + do { + _ = try await apiClient.archiveDocument(id: viewModel.documentId) + onDeleted() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't archive this document.") + } + } + + private func delete() async { + do { + try await apiClient.deleteDocument(DeleteDocumentRequest(id: viewModel.documentId)) + onDeleted() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete this document.") + } + } + + private func createChildDocument() async { + guard let collectionId = viewModel.collectionId else { + actionErrorMessage = "This document isn't in a collection." + return + } + do { + let child = try await apiClient.createDocument( + CreateDocumentRequest(title: "Untitled", text: "", collectionId: collectionId, parentDocumentId: viewModel.documentId) + ) + onOpenChild(child) + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new document.") + } + } + + private func download() async { + do { + let markdown = try await apiClient.exportDocument(id: viewModel.documentId) + let panel = NSSavePanel() + panel.nameFieldStringValue = "\(viewModel.title.isEmpty ? "Untitled" : viewModel.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 { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(viewModel.text, forType: .string) + } + + /// 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 { + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 612, height: 792)) + textView.string = viewModel.text + textView.font = .monospacedSystemFont(ofSize: 11, weight: .regular) + + let operation = NSPrintOperation(view: textView) + operation.printInfo.horizontalPagination = .fit + operation.printInfo.verticalPagination = .automatic + operation.run() + } +} +#endif diff --git a/Outpost/Features/Collections/DocumentReaderViewModel.swift b/Outpost/Features/Collections/DocumentReaderViewModel.swift new file mode 100644 index 0000000..47bc3c2 --- /dev/null +++ b/Outpost/Features/Collections/DocumentReaderViewModel.swift @@ -0,0 +1,181 @@ +import Foundation +import Observation +import OutlineKit + +@MainActor +@Observable +final class DocumentReaderViewModel { + var title: String + var emoji: String? + var text: String + var collectionId: String? + var isFullWidth = false + var children: [OutlineDocument] = [] + var isLoading = false + var errorMessage: String? + + var isEditing = false + var isSaving = false + var saveErrorMessage: String? + + /// Recent viewers, `views.list` filtered to entries that actually have a + /// `lastViewedAt` — this is historical/aggregated view data, not live + /// "viewing right now" presence (that needs the Hocuspocus collaboration + /// socket's awareness protocol, which isn't wired up yet). + private(set) var viewers: [OutlineView] = [] + + private(set) var isPinned = false + private var pinId: String? + private(set) var isSubscribed = false + private var subscriptionId: String? + private(set) var share: OutlineShare? + + let documentId: String + private let apiClient: OutlineAPIClient + + init(apiClient: OutlineAPIClient, document: OutlineDocument) { + self.apiClient = apiClient + self.documentId = document.id + self.title = document.title + self.emoji = document.emoji + self.text = document.text + self.collectionId = document.collectionId + self.isFullWidth = document.fullWidth ?? false + } + + /// The list endpoint's copy of a document isn't guaranteed to be the full, + /// current body — always re-fetch via `documents.info` when actually opened. + func loadFullContent() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + let full = try await apiClient.documentInfo(id: documentId) + title = full.title + emoji = full.emoji + text = full.text + collectionId = full.collectionId + isFullWidth = full.fullWidth ?? false + } catch { + errorMessage = "Couldn't load this document. Check your connection and try again." + } + + children = (try? await apiClient.listDocuments( + collectionId: nil, + parentDocumentId: documentId, + offset: 0, + limit: 100 + )) ?? [] + } + + func loadViewers() async { + guard let views = try? await apiClient.listViews(ListViewsRequest(documentId: documentId)) else { return } + viewers = views.filter { $0.lastViewedAt != nil } + } + + func loadPinAndSubscriptionState() async { + if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collectionId)), + let match = pins.first(where: { $0.documentId == documentId }) { + isPinned = true + pinId = match.id + } else { + isPinned = false + pinId = nil + } + + if let subscriptions = try? await apiClient.listSubscriptions(ListSubscriptionsRequest(documentId: documentId)), + let match = subscriptions.first { + isSubscribed = true + subscriptionId = match.id + } else { + isSubscribed = false + subscriptionId = nil + } + } + + func loadShare() async { + share = try? await apiClient.shareInfo(documentId: documentId) + } + + func togglePin() async throws { + if let pinId { + self.pinId = nil + isPinned = false + do { + try await apiClient.deletePin(id: pinId) + } catch { + self.pinId = pinId + isPinned = true + throw error + } + } else { + let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: collectionId)) + pinId = pin.id + isPinned = true + } + } + + func toggleSubscription() async throws { + if let subscriptionId { + self.subscriptionId = nil + isSubscribed = false + do { + try await apiClient.deleteSubscription(id: subscriptionId) + } catch { + self.subscriptionId = subscriptionId + isSubscribed = true + throw error + } + } else { + let subscription = try await apiClient.createSubscription(CreateSubscriptionRequest(documentId: documentId)) + subscriptionId = subscription.id + isSubscribed = true + } + } + + 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 { + isEditing = true + return + } + isSaving = true + saveErrorMessage = nil + defer { isSaving = false } + do { + let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: title, text: text)) + title = updated.title + text = updated.text + isEditing = false + } catch { + saveErrorMessage = outlineErrorMessage(error, fallback: "Couldn't save this document.") + } + } + + func toggleFullWidth() async throws { + let newValue = !isFullWidth + isFullWidth = newValue + do { + _ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, fullWidth: newValue)) + } catch { + isFullWidth = !newValue + throw error + } + } + + /// Fire-and-forget: `insightsEnabled` isn't readable back off `Document` + /// in the vendored spec, so there's no state to reflect as a checkmark. + func enableViewerInsights() async throws { + _ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, insightsEnabled: true)) + } + + /// Fire-and-forget, speculative field — see `UpdateDocumentRequest.documentEmbeds`. + func enableEmbeds() async throws { + _ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, documentEmbeds: true)) + } +} diff --git a/Outpost/Features/Collections/DocumentRowView.swift b/Outpost/Features/Collections/DocumentRowView.swift new file mode 100644 index 0000000..4164307 --- /dev/null +++ b/Outpost/Features/Collections/DocumentRowView.swift @@ -0,0 +1,30 @@ +import SwiftUI +import OutlineKit + +struct DocumentRowView: View { + @Environment(StarStore.self) private var starStore + + let document: OutlineDocument + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + if let emoji = document.emoji { + Text(emoji) + } + Text(document.title.isEmpty ? "Untitled" : document.title) + .font(.body) + .lineLimit(1) + + if starStore.isStarred(documentId: document.id) { + Image(systemName: "star.fill") + .font(.caption2) + .foregroundStyle(.yellow) + } + } + Text(document.updatedAt, format: .relative(presentation: .named)) + .font(.caption) + .foregroundStyle(.secondary) + } + } +} diff --git a/Outpost/Features/Collections/DocumentSearchSheet.swift b/Outpost/Features/Collections/DocumentSearchSheet.swift new file mode 100644 index 0000000..849e8cc --- /dev/null +++ b/Outpost/Features/Collections/DocumentSearchSheet.swift @@ -0,0 +1,145 @@ +#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. +@MainActor +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/DocumentShareSheet.swift b/Outpost/Features/Collections/DocumentShareSheet.swift new file mode 100644 index 0000000..1cae350 --- /dev/null +++ b/Outpost/Features/Collections/DocumentShareSheet.swift @@ -0,0 +1,112 @@ +#if os(macOS) +import AppKit +import SwiftUI +import OutlineKit + +@MainActor +struct DocumentShareSheet: View { + @Environment(\.dismiss) private var dismiss + + let apiClient: OutlineAPIClient + let documentId: String + + @State private var share: OutlineShare? + @State private var isLoading = false + @State private var isUpdating = false + @State private var errorMessage: String? + @State private var didCopy = false + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Share") + .font(.headline) + Spacer() + Button("Done") { dismiss() } + } + + if isLoading { + ProgressView().frame(maxWidth: .infinity) + } else if let errorMessage { + Text(errorMessage) + .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") + } + .buttonStyle(.plain) + } + .padding(8) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6)) + + Toggle( + "Published — accessible without sign-in", + isOn: Binding( + get: { share.published }, + set: { newValue in Task { await setPublished(newValue) } } + ) + ) + .disabled(isUpdating) + } else { + Button("Create Share Link") { + Task { await create() } + } + } + } + .padding(20) + .frame(width: 380) + .task { await load() } + } + + private func copyLink(_ url: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(url, forType: .string) + didCopy = true + Task { + try? await Task.sleep(for: .seconds(1.5)) + didCopy = false + } + } + + private func load() async { + isLoading = true + defer { isLoading = false } + do { + share = try await apiClient.shareInfo(documentId: documentId) + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.") + } + } + + private func create() async { + isLoading = true + defer { isLoading = false } + do { + share = try await apiClient.createShare(CreateShareRequest(documentId: documentId)) + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't create a share link.") + } + } + + private func setPublished(_ published: Bool) async { + guard let share else { return } + isUpdating = true + defer { isUpdating = false } + do { + self.share = try await apiClient.updateShare(UpdateShareRequest(id: share.id, published: published)) + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.") + } + } +} +#endif diff --git a/Outpost/Features/Collections/DocumentsPane.swift b/Outpost/Features/Collections/DocumentsPane.swift new file mode 100644 index 0000000..7bc2b0e --- /dev/null +++ b/Outpost/Features/Collections/DocumentsPane.swift @@ -0,0 +1,17 @@ +import SwiftUI +import OutlineKit + +/// Holds `DocumentsViewModel` in `@State` so callers that construct this from a +/// computed/closure context (`NavigationSplitView.detail`, `navigationDestination`) +/// don't rebuild — and reset — it on every unrelated re-render. +struct DocumentsPane: View { + @State private var viewModel: DocumentsViewModel + + init(apiClient: OutlineAPIClient, collection: OutlineCollection) { + _viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection)) + } + + var body: some View { + DocumentListContent(viewModel: viewModel) + } +} diff --git a/Outpost/Features/Collections/DocumentsViewModel.swift b/Outpost/Features/Collections/DocumentsViewModel.swift new file mode 100644 index 0000000..0c5181d --- /dev/null +++ b/Outpost/Features/Collections/DocumentsViewModel.swift @@ -0,0 +1,50 @@ +import Foundation +import Observation +import OutlineKit + +@MainActor +@Observable +final class DocumentsViewModel { + private(set) var documents: [OutlineDocument] = [] + var isLoading = false + var errorMessage: String? + var hasRemoteChanges = false + + let collection: OutlineCollection + private let apiClient: OutlineAPIClient + + init(apiClient: OutlineAPIClient, collection: OutlineCollection) { + self.apiClient = apiClient + self.collection = collection + } + + func load() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + documents = try await apiClient.listDocuments(collectionId: collection.id, parentDocumentId: nil, offset: 0, limit: 100) + hasRemoteChanges = false + } catch { + errorMessage = "Couldn't load documents. Check your connection and try again." + } + } + + /// See CollectionsViewModel.checkForRemoteChanges — same reasoning. + func checkForRemoteChanges() async { + guard let fresh = try? await apiClient.listDocuments(collectionId: collection.id, parentDocumentId: nil, offset: 0, limit: 100) else { + return + } + if Self.fingerprint(fresh) != Self.fingerprint(documents) { + hasRemoteChanges = true + } + } + + private static func fingerprint(_ documents: [OutlineDocument]) -> String { + documents + .map { "\($0.id):\($0.updatedAt.timeIntervalSince1970)" } + .sorted() + .joined(separator: "|") + } +} diff --git a/Outpost/Features/Collections/MoveDocumentSheet.swift b/Outpost/Features/Collections/MoveDocumentSheet.swift new file mode 100644 index 0000000..996701f --- /dev/null +++ b/Outpost/Features/Collections/MoveDocumentSheet.swift @@ -0,0 +1,123 @@ +#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. +@MainActor +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 diff --git a/Outpost/Features/Collections/RemoteChangesBanner.swift b/Outpost/Features/Collections/RemoteChangesBanner.swift new file mode 100644 index 0000000..4bd8046 --- /dev/null +++ b/Outpost/Features/Collections/RemoteChangesBanner.swift @@ -0,0 +1,26 @@ +#if os(macOS) +import SwiftUI + +/// Shown when a periodic background check finds the server has changes we +/// don't have — doesn't auto-refresh, since that would silently replace +/// what's on screen (losing scroll position, expanded rows) without asking. +struct RemoteChangesBanner: View { + let onRefresh: () -> Void + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "arrow.triangle.2.circlepath") + .foregroundStyle(Color.accentColor) + Text("New changes available") + .font(.callout) + Spacer(minLength: 8) + Button("Refresh", action: onRefresh) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.12)) + } +} +#endif diff --git a/Outpost/Features/Collections/SidebarSortOption.swift b/Outpost/Features/Collections/SidebarSortOption.swift new file mode 100644 index 0000000..bd043ed --- /dev/null +++ b/Outpost/Features/Collections/SidebarSortOption.swift @@ -0,0 +1,39 @@ +import Foundation +import OutlineKit + +/// Ordering for the nested document outline under a sidebar collection. +/// +/// Outline's `Collection.sort` field exists for this, but its own OpenAPI spec +/// notes it's "left as a frontend concern to implement" and `collections.update` +/// doesn't document a `sort` param to persist it — so this stays a local, +/// per-session preference rather than a synced setting. +enum SidebarSortOption: String, CaseIterable, Identifiable { + case manual + case alphabetical + case dateCreated + case dateUpdated + + var id: String { rawValue } + + var label: String { + switch self { + case .manual: return "Manual" + case .alphabetical: return "A–Z" + case .dateCreated: return "Date Created" + case .dateUpdated: return "Date Updated" + } + } + + func sorted(_ documents: [OutlineDocument]) -> [OutlineDocument] { + switch self { + case .manual: + return documents + case .alphabetical: + return documents.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } + case .dateCreated: + return documents.sorted { $0.createdAt > $1.createdAt } + case .dateUpdated: + return documents.sorted { $0.updatedAt > $1.updatedAt } + } + } +} diff --git a/Outpost/Features/Search/DocumentTitleSearchViewModel.swift b/Outpost/Features/Search/DocumentTitleSearchViewModel.swift new file mode 100644 index 0000000..15da66f --- /dev/null +++ b/Outpost/Features/Search/DocumentTitleSearchViewModel.swift @@ -0,0 +1,48 @@ +import Foundation +import Observation +import OutlineKit + +/// Backs the contextual toolbar search — matches scoped to whichever collection +/// is currently open ("search what you're looking at"). +/// +/// Uses `documents.search` rather than `documents.search_titles`: the latter +/// consistently failed against the connected server (likely unavailable on +/// this Outline version — see ARCHITECTURE.md's note on server version drift), +/// while `documents.search` is the same, longer-established endpoint the +/// sidebar's global search already relies on. +@MainActor +@Observable +final class DocumentTitleSearchViewModel { + var results: [OutlineDocumentSearchResult] = [] + var isSearching = false + var errorMessage: String? + + private let apiClient: OutlineAPIClient + private let collectionId: String + + init(apiClient: OutlineAPIClient, collectionId: String) { + self.apiClient = apiClient + self.collectionId = collectionId + } + + func search(query: String) async { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + results = [] + errorMessage = nil + return + } + + isSearching = true + errorMessage = nil + defer { isSearching = false } + + do { + results = try await apiClient.searchDocuments( + DocumentSearchRequest(query: trimmed, collectionId: collectionId) + ) + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Search failed. Try again.") + } + } +} diff --git a/Outpost/Features/Search/GlobalSearchResultsView.swift b/Outpost/Features/Search/GlobalSearchResultsView.swift new file mode 100644 index 0000000..5780e4e --- /dev/null +++ b/Outpost/Features/Search/GlobalSearchResultsView.swift @@ -0,0 +1,141 @@ +#if os(macOS) +import SwiftUI +import OutlineKit + +struct GlobalSearchResultsView: View { + @State private var viewModel: GlobalSearchViewModel + let query: String + let onOpenDocument: (OutlineDocument) -> Void + + init(apiClient: OutlineAPIClient, query: String, onOpenDocument: @escaping (OutlineDocument) -> Void) { + _viewModel = State(initialValue: GlobalSearchViewModel(apiClient: apiClient)) + self.query = query + self.onOpenDocument = onOpenDocument + } + + private var searchKey: String { + [ + query, + viewModel.collectionFilter?.id ?? "", + viewModel.dateFilter?.rawValue ?? "", + viewModel.sort.rawValue, + viewModel.direction.rawValue, + viewModel.statusFilter.map(\.rawValue).sorted().joined(separator: ",") + ].joined(separator: "|") + } + + var body: some View { + // No `.navigationTitle` — `ContentView_macOS`'s leading toolbar item + // already shows a "Search" pill while global search is active. + results + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + collectionPicker + sortPicker + dateFilterPicker + statusFilterMenu + } + } + .task { await viewModel.loadCollectionsIfNeeded() } + .task(id: searchKey) { + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + await viewModel.search(query: query) + } + } + + // `documents.search`'s `collectionId` is a single UUID, not an array — this + // stays single-select because that's genuinely all the endpoint supports, + // not an arbitrary UI limitation. + private var collectionPicker: some View { + Picker("Collection", selection: $viewModel.collectionFilter) { + Text("All Collections").tag(OutlineCollection?.none) + ForEach(viewModel.availableCollections) { collection in + Text(collection.name).tag(Optional(collection)) + } + } + .padding(.horizontal, 4) + } + + private var sortPicker: some View { + Picker("Sort", selection: $viewModel.sort) { + Text("Relevance").tag(DocumentSearchSort.relevance) + Text("Updated").tag(DocumentSearchSort.updatedAt) + Text("Created").tag(DocumentSearchSort.createdAt) + Text("Title").tag(DocumentSearchSort.title) + } + .padding(.horizontal, 4) + } + + private var dateFilterPicker: some View { + Picker("Date", selection: $viewModel.dateFilter) { + Text("Any Time").tag(DocumentSearchDateFilter?.none) + Text("Past Day").tag(Optional(DocumentSearchDateFilter.day)) + Text("Past Week").tag(Optional(DocumentSearchDateFilter.week)) + Text("Past Month").tag(Optional(DocumentSearchDateFilter.month)) + Text("Past Year").tag(Optional(DocumentSearchDateFilter.year)) + } + .padding(.horizontal, 4) + } + + private var statusFilterMenu: some View { + Menu { + ForEach(DocumentStatusFilter.allCases, id: \.self) { status in + Button { + if viewModel.statusFilter.contains(status) { + viewModel.statusFilter.remove(status) + } else { + viewModel.statusFilter.insert(status) + } + } label: { + // `Label(_:systemImage:)` with an empty string logs "No + // symbol named ''" — use the icon-closure init instead so + // the unselected case can render no icon at all. + Label { + Text(status.rawValue.capitalized) + } icon: { + if viewModel.statusFilter.contains(status) { + Image(systemName: "checkmark") + } + } + } + } + } label: { + Text(viewModel.statusFilter.isEmpty ? "Any Status" : "\(viewModel.statusFilter.count) Status") + } + .padding(.horizontal, 4) + } + + @ViewBuilder + private var results: some View { + if viewModel.isSearching && viewModel.results.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage = viewModel.errorMessage { + ContentUnavailableView { + Label("Search Failed", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } + } else if viewModel.results.isEmpty { + ContentUnavailableView.search(text: query) + } else { + List(viewModel.results) { result in + Button { + onOpenDocument(result.document) + } label: { + VStack(alignment: .leading, spacing: 4) { + DocumentRowView(document: result.document) + Text(result.context) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(.vertical, 2) + } + .buttonStyle(.plain) + } + } + } +} +#endif diff --git a/Outpost/Features/Search/GlobalSearchViewModel.swift b/Outpost/Features/Search/GlobalSearchViewModel.swift new file mode 100644 index 0000000..a97324e --- /dev/null +++ b/Outpost/Features/Search/GlobalSearchViewModel.swift @@ -0,0 +1,62 @@ +import Foundation +import Observation +import OutlineKit + +/// Backs the sidebar's whole-workspace search — full-text via `documents.search`, +/// with filters mirroring what that endpoint actually supports. +@MainActor +@Observable +final class GlobalSearchViewModel { + var results: [OutlineDocumentSearchResult] = [] + var availableCollections: [OutlineCollection] = [] + var isSearching = false + var errorMessage: String? + + var collectionFilter: OutlineCollection? + var dateFilter: DocumentSearchDateFilter? + var sort: DocumentSearchSort = .relevance + var direction: DocumentSearchDirection = .descending + var statusFilter: Set = [] + + private let apiClient: OutlineAPIClient + + init(apiClient: OutlineAPIClient) { + self.apiClient = apiClient + } + + func loadCollectionsIfNeeded() async { + guard availableCollections.isEmpty else { return } + availableCollections = (try? await apiClient.listCollections(offset: 0, limit: 100)) ?? [] + } + + func search(query: String) async { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + results = [] + errorMessage = nil + return + } + + isSearching = true + errorMessage = nil + defer { isSearching = false } + + do { + results = try await apiClient.searchDocuments( + DocumentSearchRequest( + query: trimmed, + collectionId: collectionFilter?.id, + statusFilter: statusFilter.isEmpty ? nil : Array(statusFilter), + dateFilter: dateFilter, + // The server rejects `sort: "relevance"` outright — it's + // apparently only valid as the *implicit* default when the + // field is omitted entirely, not as an explicit value. + sort: sort == .relevance ? nil : sort, + direction: direction + ) + ) + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Search failed. Try again.") + } + } +} diff --git a/Outpost/Features/Search/SidebarSearchField.swift b/Outpost/Features/Search/SidebarSearchField.swift new file mode 100644 index 0000000..213867f --- /dev/null +++ b/Outpost/Features/Search/SidebarSearchField.swift @@ -0,0 +1,30 @@ +#if os(macOS) +import SwiftUI + +struct SidebarSearchField: View { + @Binding var text: String + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Search workspace…", text: $text) + .textFieldStyle(.plain) + if !text.isEmpty { + Button { + text = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8)) + .padding(.horizontal, 10) + .padding(.vertical, 8) + } +} +#endif diff --git a/Outpost/Item.swift b/Outpost/Item.swift deleted file mode 100644 index 344e52f..0000000 --- a/Outpost/Item.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Item.swift -// Outpost -// -// Created by Puranjay Savar Mattas on 12/08/26. -// - -import Foundation -import SwiftData - -@Model -final class Item { - var timestamp: Date - - init(timestamp: Date) { - self.timestamp = timestamp - } -} diff --git a/Outpost/Outpost.entitlements b/Outpost/Outpost.entitlements new file mode 100644 index 0000000..ee95ab7 --- /dev/null +++ b/Outpost/Outpost.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/Outpost/OutpostApp.swift b/Outpost/OutpostApp.swift index ab99140..49caa0c 100644 --- a/Outpost/OutpostApp.swift +++ b/Outpost/OutpostApp.swift @@ -6,27 +6,85 @@ // import SwiftUI -import SwiftData + +#if os(macOS) +import AppKit +#endif @main struct OutpostApp: App { - var sharedModelContainer: ModelContainer = { - let schema = Schema([ - Item.self, - ]) - let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + @State private var session = SessionStore() + @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system - do { - return try ModelContainer(for: schema, configurations: [modelConfiguration]) - } catch { - fatalError("Could not create ModelContainer: \(error)") - } - }() + #if os(macOS) + @Environment(\.openWindow) private var openWindow + @State private var isShowingLogoutConfirmation = false + #endif var body: some Scene { WindowGroup { - ContentView() + RootView() + .environment(session) + #if os(iOS) + .preferredColorScheme(appearance.colorScheme) + #endif + #if os(macOS) + .onAppear { applyMacAppearance() } + .onChange(of: appearance) { _, _ in applyMacAppearance() } + .logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session) + #endif } - .modelContainer(sharedModelContainer) + #if os(macOS) + .commands { + CommandGroup(replacing: .appInfo) { + Button("About Outpost") { + openWindow(id: "about") + } + } + CommandGroup(after: .appSettings) { + Divider() + Button("Log Out…") { + isShowingLogoutConfirmation = true + } + .disabled(!session.isSignedIn) + } + } + #endif + + #if os(macOS) + Window("About Outpost", id: "about") { + AboutView() + .disablesFullScreen() + } + .windowResizability(.contentSize) + + Window("Keyboard Shortcuts", id: "keyboard-shortcuts") { + KeyboardShortcutsView() + .disablesFullScreen() + } + .windowResizability(.contentSize) + + Settings { + PreferencesView() + .environment(session) + } + #endif } + + #if os(macOS) + /// `.preferredColorScheme(nil)` doesn't reliably reset the window's real + /// `NSAppearance` back to "follow system" after an explicit override — this + /// sets the actual AppKit-level appearance directly instead, which every + /// window (present and future) inherits. + private func applyMacAppearance() { + switch appearance { + case .system: + NSApp.appearance = nil + case .light: + NSApp.appearance = NSAppearance(named: .aqua) + case .dark: + NSApp.appearance = NSAppearance(named: .darkAqua) + } + } + #endif } diff --git a/Outpost/Root/RootView.swift b/Outpost/Root/RootView.swift new file mode 100644 index 0000000..5b25277 --- /dev/null +++ b/Outpost/Root/RootView.swift @@ -0,0 +1,48 @@ +import SwiftUI +import OutlineKit + +struct RootView: View { + @Environment(SessionStore.self) private var session + @State private var welcomeName: String? + @State private var starStore = StarStore() + + var body: some View { + ZStack { + if session.isSignedIn { + ContentView() + } else { + AuthView(onSigningIn: startWelcomeTransition) + } + + if let welcomeName { + WelcomeSplashView(name: welcomeName) + .transition(.opacity) + .zIndex(1) + } + } + .environment(starStore) + .animation(.easeInOut(duration: 0.45), value: welcomeName != nil) + .task { + await session.refreshTeamInfoIfNeeded() + } + .task(id: session.isSignedIn) { + if session.isSignedIn, let apiClient = session.apiClient { + await starStore.load(apiClient: apiClient) + } else { + starStore.reset() + } + } + } + + private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) { + welcomeName = result.user.name + session.signIn(serverURL: result.serverURL, user: result.user, team: result.team) + + Task { + try? await Task.sleep(for: .seconds(1.1)) + withAnimation(.easeInOut(duration: 0.45)) { + welcomeName = nil + } + } + } +} diff --git a/Outpost/Root/SessionStore.swift b/Outpost/Root/SessionStore.swift new file mode 100644 index 0000000..d8563fe --- /dev/null +++ b/Outpost/Root/SessionStore.swift @@ -0,0 +1,78 @@ +import Foundation +import Observation +import OutlineKit + +@MainActor +@Observable +final class SessionStore { + private static let serverURLDefaultsKey = "outline.serverURL" + + private let tokenStore: TokenStoring + private let defaults: UserDefaults + + var isSignedIn: Bool + var userName: String? + var userEmail: String? + var userAvatarURL: URL? + var teamName: String? + var teamAvatarURL: URL? + private(set) var apiClient: OutlineAPIClient? + + var serverURL: URL? { + defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:)) + } + + init(tokenStore: TokenStoring = KeychainTokenStore(), defaults: UserDefaults = .standard) { + self.tokenStore = tokenStore + self.defaults = defaults + self.isSignedIn = (try? tokenStore.token()) != nil + + if isSignedIn, let serverURL { + apiClient = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: serverURL), + tokenStore: tokenStore + ) + } + } + + func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) { + defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey) + apiClient = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: serverURL), + tokenStore: tokenStore + ) + apply(user: user, team: team, serverURL: serverURL) + isSignedIn = true + } + + func signOut() { + try? tokenStore.clear() + defaults.removeObject(forKey: Self.serverURLDefaultsKey) + isSignedIn = false + userName = nil + userEmail = nil + userAvatarURL = nil + teamName = nil + teamAvatarURL = nil + apiClient = nil + } + + /// Re-fetches user/workspace name/logo on relaunch, when the token survived but this + /// in-memory state didn't. + func refreshTeamInfoIfNeeded() async { + guard isSignedIn, teamName == nil, let apiClient, let serverURL else { return } + guard let auth = try? await apiClient.authInfo() else { return } + apply(user: auth.user, team: auth.team, serverURL: serverURL) + } + + private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) { + userName = user.name + userEmail = user.email + // Outline can return either an absolute URL or a server-relative path + // (e.g. `/api/files.get?key=...`) for avatarUrl — resolve against the + // configured server so relative paths don't fail as "unsupported URL". + userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL } + teamName = team.name + teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL } + } +} diff --git a/Outpost/Root/WelcomeSplashView.swift b/Outpost/Root/WelcomeSplashView.swift new file mode 100644 index 0000000..c00203b --- /dev/null +++ b/Outpost/Root/WelcomeSplashView.swift @@ -0,0 +1,41 @@ +import SwiftUI + +/// Full-window cover shown between a successful sign-in and `RootView` revealing the +/// signed-in content beneath it, so the login form never has to visibly tear down. +struct WelcomeSplashView: View { + let name: String + + var body: some View { + ZStack { + Rectangle() + .fill(.background) + .ignoresSafeArea() + + VStack(spacing: 20) { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color.accentColor, Color.accentColor.opacity(0.6)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 88, height: 88) + Image(systemName: "checkmark") + .font(.system(size: 36, weight: .bold)) + .foregroundStyle(.white) + } + .shadow(color: Color.accentColor.opacity(0.35), radius: 16, y: 8) + + VStack(spacing: 6) { + Text("Welcome, \(name)") + .font(.title2.bold()) + Text("Signing you in…") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + } +} diff --git a/Outpost/Support/AppAppearance.swift b/Outpost/Support/AppAppearance.swift new file mode 100644 index 0000000..2405e97 --- /dev/null +++ b/Outpost/Support/AppAppearance.swift @@ -0,0 +1,23 @@ +import SwiftUI + +enum AppAppearance: String, CaseIterable, Identifiable { + case system, light, dark + + var id: String { rawValue } + + var label: String { + switch self { + case .system: return "System" + case .light: return "Light" + case .dark: return "Dark" + } + } + + var colorScheme: ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } +} diff --git a/Outpost/Support/AvatarBadge.swift b/Outpost/Support/AvatarBadge.swift new file mode 100644 index 0000000..bf35d89 --- /dev/null +++ b/Outpost/Support/AvatarBadge.swift @@ -0,0 +1,62 @@ +import SwiftUI + +#if os(macOS) +import AppKit +typealias PlatformImage = NSImage +#else +import UIKit +typealias PlatformImage = UIImage +#endif + +/// Loads the avatar manually instead of using `AsyncImage`, and flattens the result +/// with `.drawingGroup()`. Hosted inside AppKit-backed controls (a macOS toolbar +/// item, a `Menu` label), this content was observed blowing past its `.frame`/ +/// `.clipShape` constraints once the host re-measured it after the image loaded — +/// `.drawingGroup()` rasterizes it to a fixed bitmap at the constrained size first, +/// so there's nothing left for the host to re-measure. +struct AvatarBadge: View { + let avatarURL: URL? + var size: CGFloat = 22 + var placeholderSystemImage: String = "person.crop.circle.fill" + + @State private var loadedImage: PlatformImage? + + var body: some View { + ZStack { + Circle() + .fill(Color.accentColor.opacity(0.2)) + + if let loadedImage { + platformImage(loadedImage) + .resizable() + .scaledToFill() + } else { + placeholderIcon + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + .compositingGroup() + .drawingGroup() + .task(id: avatarURL) { + loadedImage = nil + guard let avatarURL else { return } + guard let (data, _) = try? await URLSession.shared.data(from: avatarURL) else { return } + loadedImage = PlatformImage(data: data) + } + } + + private func platformImage(_ image: PlatformImage) -> Image { + #if os(macOS) + Image(nsImage: image) + #else + Image(uiImage: image) + #endif + } + + private var placeholderIcon: some View { + Image(systemName: placeholderSystemImage) + .font(.system(size: size * 0.5, weight: .semibold)) + .foregroundStyle(Color.accentColor) + } +} diff --git a/Outpost/Support/Color+Hex.swift b/Outpost/Support/Color+Hex.swift new file mode 100644 index 0000000..be81b49 --- /dev/null +++ b/Outpost/Support/Color+Hex.swift @@ -0,0 +1,14 @@ +import SwiftUI + +extension Color { + init?(hex: String) { + var sanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) + sanitized.removeAll { $0 == "#" } + guard sanitized.count == 6, let value = UInt32(sanitized, radix: 16) else { return nil } + self.init( + red: Double((value >> 16) & 0xFF) / 255, + green: Double((value >> 8) & 0xFF) / 255, + blue: Double(value & 0xFF) / 255 + ) + } +} diff --git a/Outpost/Support/OutlineAPIError+Message.swift b/Outpost/Support/OutlineAPIError+Message.swift new file mode 100644 index 0000000..012960f --- /dev/null +++ b/Outpost/Support/OutlineAPIError+Message.swift @@ -0,0 +1,29 @@ +import Foundation +import OutlineKit + +extension OutlineAPIError { + /// User-facing message, distinguishing failure causes instead of one blanket + /// "try again" — a 404 (e.g. an endpoint missing on an older server version) + /// looks nothing like a dropped connection, and surfacing that difference is + /// what actually helps diagnose it. + var userFacingMessage: String { + switch self { + case .unauthorized: + return "Sign-in expired. Try signing in again." + case .notFound: + return "That isn't available on this server." + case .transport: + return "Couldn't reach the server. Check your connection." + case .server(let status, let message): + return message ?? "The server returned an error (\(status))." + case .decoding: + return "Got an unexpected response from the server." + case .tokenUnavailable: + return "Couldn't access your saved sign-in." + } + } +} + +func outlineErrorMessage(_ error: Error, fallback: String) -> String { + (error as? OutlineAPIError)?.userFacingMessage ?? fallback +} diff --git a/Outpost/Support/OutlineIconMapping.swift b/Outpost/Support/OutlineIconMapping.swift new file mode 100644 index 0000000..41ec8c5 --- /dev/null +++ b/Outpost/Support/OutlineIconMapping.swift @@ -0,0 +1,96 @@ +import Foundation + +/// Best-effort SF Symbol equivalents for Outline's non-emoji collection icons. +/// +/// `Collection.icon` is either a literal emoji or a string key into Outline's +/// own icon set — a mix of custom `outline-icons` components (camelCase keys) +/// and FontAwesome icons (kebab-case keys, e.g. `faCake` → `"cake"`). The +/// canonical key list lives in `outline/outline`'s `shared/utils/IconLibrary.tsx`, +/// not in the `outline-icons` package itself (that's just the raw component +/// library, keyed by PascalCase component name — a different naming scheme). +/// +/// SF Symbols has no exact match for everything in that set, so this only +/// covers keys with a confident, unambiguous equivalent. Anything else falls +/// back to the generic folder glyph — better than a blank/invalid symbol name +/// from a wrong guess. +enum OutlineIconMapping { + static func sfSymbolName(for outlineIconKey: String) -> String? { + mapping[outlineIconKey] + } + + private static let mapping: [String: String] = [ + // Outline's own custom icon set (camelCase keys). + "academicCap": "graduationcap.fill", + "bicycle": "bicycle", + "beaker": "testtube.2", + "buildingBlocks": "cube.fill", + "bookmark": "bookmark.fill", + "browser": "globe", + "collection": "square.grid.2x2.fill", + "coins": "dollarsign.circle.fill", + "camera": "camera.fill", + "carrot": "leaf.fill", + "clock": "clock.fill", + "cloud": "cloud.fill", + "code": "chevron.left.forwardslash.chevron.right", + "database": "cylinder.fill", + "done": "checkmark.circle.fill", + "email": "envelope.fill", + "eye": "eye.fill", + "feedback": "bubble.left.and.bubble.right.fill", + "flame": "flame.fill", + "graph": "chart.line.uptrend.xyaxis", + "globe": "globe", + "hashtag": "number", + "info": "info.circle.fill", + "image": "photo.fill", + "internet": "network", + "leaf": "leaf.fill", + "library": "books.vertical.fill", + "lightbulb": "lightbulb.fill", + "lightning": "bolt.fill", + "letter": "envelope.fill", + "math": "function", + "moon": "moon.fill", + "notepad": "note.text", + "padlock": "lock.fill", + "palette": "paintpalette.fill", + "pencil": "pencil", + "plane": "airplane", + "promote": "megaphone.fill", + "ramen": "fork.knife", + "question": "questionmark.circle.fill", + "server": "server.rack", + "sun": "sun.max.fill", + "shapes": "square.on.circle", + "sport": "sportscourt.fill", + "smiley": "face.smiling.fill", + "target": "target", + "team": "person.3.fill", + "terminal": "terminal.fill", + "thumbsup": "hand.thumbsup.fill", + "truck": "shippingbox.fill", + "tools": "wrench.and.screwdriver.fill", + "vehicle": "car.fill", + "warning": "exclamationmark.triangle.fill", + + // FontAwesome icons (kebab-case keys) — high-confidence subset only. + "book": "book.fill", + "cake": "birthday.cake.fill", + "cat": "cat.fill", + "dog": "dog.fill", + "cube": "cube.fill", + "heart": "heart.fill", + "gift": "gift.fill", + "hammer": "hammer.fill", + "laptop": "laptopcomputer", + "map": "map.fill", + "newspaper": "newspaper.fill", + "paw": "pawprint.fill", + "shield": "shield.fill", + "tree": "tree.fill", + "trophy": "trophy.fill", + "umbrella": "umbrella.fill", + "fish": "fish.fill" + ] +} diff --git a/Outpost/Support/StarStore.swift b/Outpost/Support/StarStore.swift new file mode 100644 index 0000000..05bac17 --- /dev/null +++ b/Outpost/Support/StarStore.swift @@ -0,0 +1,76 @@ +import Foundation +import Observation +import OutlineKit + +/// Shared starred-state cache, one `stars.list` fetch shared across every +/// sidebar row and reader instead of each row independently guessing at its +/// own starred state (the API has no `isStarred` field on `Document`/ +/// `Collection` themselves — the only source of truth is the stars list). +@MainActor +@Observable +final class StarStore { + private var documentStars: [String: OutlineStar] = [:] + private var collectionStars: [String: OutlineStar] = [:] + private(set) var isLoaded = false + + func isStarred(documentId: String) -> Bool { + documentStars[documentId] != nil + } + + func isStarred(collectionId: String) -> Bool { + collectionStars[collectionId] != nil + } + + func load(apiClient: OutlineAPIClient) async { + guard let stars = try? await apiClient.listStars(ListStarsRequest(offset: 0, limit: 250)) else { return } + documentStars = Dictionary(uniqueKeysWithValues: stars.compactMap { star in + star.documentId.map { ($0, star) } + }) + collectionStars = Dictionary(uniqueKeysWithValues: stars.compactMap { star in + star.collectionId.map { ($0, star) } + }) + isLoaded = true + } + + func reset() { + documentStars = [:] + collectionStars = [:] + isLoaded = false + } + + /// Optimistic — the row shows the new state immediately rather than + /// waiting on a re-fetch, then rolls back if the request actually fails. + func toggleDocument(_ documentId: String, apiClient: OutlineAPIClient) async throws { + if let star = documentStars[documentId] { + documentStars.removeValue(forKey: documentId) + do { + try await apiClient.deleteStar(id: star.id) + } catch { + documentStars[documentId] = star + throw error + } + } else { + do { + let star = try await apiClient.starDocument(StarDocumentRequest(documentId: documentId)) + documentStars[documentId] = star + } catch { + throw error + } + } + } + + func toggleCollection(_ collectionId: String, apiClient: OutlineAPIClient) async throws { + if let star = collectionStars[collectionId] { + collectionStars.removeValue(forKey: collectionId) + do { + try await apiClient.deleteStar(id: star.id) + } catch { + collectionStars[collectionId] = star + throw error + } + } else { + let star = try await apiClient.starCollection(StarCollectionRequest(collectionId: collectionId)) + collectionStars[collectionId] = star + } + } +} diff --git a/Outpost/Support/View+LogoutConfirmation.swift b/Outpost/Support/View+LogoutConfirmation.swift new file mode 100644 index 0000000..6675154 --- /dev/null +++ b/Outpost/Support/View+LogoutConfirmation.swift @@ -0,0 +1,18 @@ +import SwiftUI + +extension View { + func logoutConfirmationDialog(isPresented: Binding, session: SessionStore) -> some View { + confirmationDialog( + "Log Out of Outpost?", + isPresented: isPresented, + titleVisibility: .visible + ) { + Button("Log Out", role: .destructive) { + session.signOut() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("You'll need your server address and API token to sign in again.") + } + } +} diff --git a/Outpost/Support/WindowConfigurator.swift b/Outpost/Support/WindowConfigurator.swift new file mode 100644 index 0000000..412325a --- /dev/null +++ b/Outpost/Support/WindowConfigurator.swift @@ -0,0 +1,38 @@ +#if os(macOS) +import SwiftUI +import AppKit + +/// Grabs the hosting `NSWindow` once it's attached to a screen, for the +/// handful of things SwiftUI's `Window` scene doesn't expose a modifier for. +private struct WindowConfigurator: NSViewRepresentable { + let configure: (NSWindow) -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { + if let window = view.window { + configure(window) + } + } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) {} +} + +extension View { + /// `Window` scenes default to a full standard titlebar, fullscreen + /// (green) button included — not appropriate for fixed-size reference + /// panels like About or Keyboard Shortcuts, which have no reason to + /// support fullscreen at all. + func disablesFullScreen() -> some View { + background( + WindowConfigurator { window in + window.collectionBehavior.remove(.fullScreenPrimary) + window.collectionBehavior.insert(.fullScreenNone) + window.standardWindowButton(.zoomButton)?.isHidden = true + } + ) + } +} +#endif diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 60d8d05..4fea49f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,6 +8,9 @@ Deeper technical reference backing `CLAUDE.md`. Update this as decisions are mad - **Auth**: `Authorization: Bearer ` header. Keys are created per-user under Settings → API Keys on the Outline instance. Store in Keychain, scope a dedicated key to this app rather than reusing a personal one. - **Core endpoints to wrap first**: `documents.info`, `documents.list`, `documents.search`, `documents.create`, `documents.update`, `collections.list`, `collections.info`, `users.info`. - Full reference: your instance's `/developers` page (same docs as getoutline.com/developers, versioned per Outline release — check against your self-hosted version, not just the public docs). +- **Full OpenAPI spec, vendored as a git submodule**: [`docs/reference/outline-openapi`](reference/outline-openapi) → [`outline/openapi`](https://github.com/outline/openapi), spec at `docs/reference/outline-openapi/spec3.yml`. Check this before adding any new `OutlineAPIClient` method — read the exact request/response shape here rather than guessing from prose docs. It's already caught real mismatches twice: `documents.search`'s `sort` field rejects `"relevance"` as an explicit value even though the field is documented as accepting it (server only treats it as the implicit default when omitted), and `documents.search_titles` doesn't work against this self-hosted instance at all despite being in the spec (see `DocumentTitleSearchViewModel`'s doc comment — likely server version drift, matching the risk already called out below). + - Fresh clone: `git submodule update --init --recursive`. + - Refresh to upstream's latest: `git submodule update --remote docs/reference/outline-openapi`. ## 2. Realtime collaboration transport diff --git a/docs/reference/outline-openapi b/docs/reference/outline-openapi new file mode 160000 index 0000000..43d990d --- /dev/null +++ b/docs/reference/outline-openapi @@ -0,0 +1 @@ +Subproject commit 43d990d5ec250e9f8daa05962d68b099128d2895 diff --git a/scripts/package-dmg.sh b/scripts/package-dmg.sh new file mode 100755 index 0000000..babd9eb --- /dev/null +++ b/scripts/package-dmg.sh @@ -0,0 +1,142 @@ +#!/bin/bash +# Packages an already-exported Outpost.app into a distributable DMG with an +# Applications-folder symlink for drag-to-install, then prints a Markdown +# changelog to the terminal for pasting into the Gitea release notes — +# nothing is written to the repo, this is generated fresh from git log +# every time. +# +# Usage: scripts/package-dmg.sh /path/to/Outpost.app [output-dir] +# +# Produces: /Outpost-.dmg +# Version/build number are read directly from the app's own Info.plist, so +# this always matches whatever MARKETING_VERSION was set to at archive time. + +set -euo pipefail + +APP_PATH="${1:?Usage: package-dmg.sh /path/to/Outpost.app [output-dir]}" +OUT_DIR="${2:-$(pwd)/dist}" + +if [ ! -d "$APP_PATH" ]; then + echo "error: '$APP_PATH' not found or not a directory" >&2 + exit 1 +fi + +APP_NAME="$(basename "$APP_PATH" .app)" +VERSION="$(defaults read "$APP_PATH/Contents/Info" CFBundleShortVersionString)" +BUILD="$(defaults read "$APP_PATH/Contents/Info" CFBundleVersion)" + +mkdir -p "$OUT_DIR" + +STAGING_DIR="$(mktemp -d)" +trap 'rm -rf "$STAGING_DIR"' EXIT + +echo "Staging '$APP_NAME.app' ($VERSION build $BUILD)..." +cp -R "$APP_PATH" "$STAGING_DIR/" +ln -s /Applications "$STAGING_DIR/Applications" + +# Not notarized (no paid Apple Developer Program membership) — Gatekeeper +# blocks it on a downloader's first launch. This is the standard bypass, +# shipped in the DMG itself instead of repeated in every release's notes. +cat > "$STAGING_DIR/Read Me First.txt" <<'EOF' +Outpost isn't notarized by Apple (that requires a paid Apple Developer +Program membership), so macOS Gatekeeper will block it on first launch +with "cannot be opened because the developer cannot be verified." + +To open it anyway: + 1. Drag Outpost.app to Applications. + 2. Right-click (or Control-click) Outpost.app and choose "Open". + 3. Click "Open" in the dialog that appears. + +You only need to do this once — after that it launches normally. + +If macOS instead shows "Outpost is damaged and can't be opened," that's +the same Gatekeeper quarantine flag, just a different message on newer +macOS versions. Fix it from Terminal: + xattr -cr /Applications/Outpost.app +EOF + +DMG_NAME="${APP_NAME}-${VERSION}.dmg" +DMG_PATH="$OUT_DIR/$DMG_NAME" +rm -f "$DMG_PATH" + +echo "Building $DMG_NAME..." +hdiutil create \ + -volname "$APP_NAME $VERSION" \ + -srcfolder "$STAGING_DIR" \ + -ov \ + -format UDZO \ + "$DMG_PATH" + +echo "Done: $DMG_PATH" + +# --- Changelog ------------------------------------------------------------- +# Groups commits by conventional-commit type (feat/fix/.../other) between the +# previous tag and whatever's being released. Two workflows are supported: +# - already tagged (HEAD == latest tag): diff against the tag before it. +# - not tagged yet (HEAD ahead of latest tag): diff since that tag. +# Falls back to the full log if there are no tags at all yet. + +print_section() { + title="$1" + content="$2" + if [ -n "$content" ]; then + printf '### %s\n\n%s\n' "$title" "$content" + fi +} + +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [ -z "$REPO_ROOT" ]; then + echo "" >&2 + echo "warning: not inside a git repository, skipping changelog" >&2 + exit 0 +fi + +LATEST_TAG="$(git -C "$REPO_ROOT" describe --tags --abbrev=0 2>/dev/null || true)" + +if [ -z "$LATEST_TAG" ]; then + RANGE="HEAD" + HEADING="Changelog" +else + HEAD_COMMIT="$(git -C "$REPO_ROOT" rev-parse HEAD)" + TAG_COMMIT="$(git -C "$REPO_ROOT" rev-parse "$LATEST_TAG")" + if [ "$HEAD_COMMIT" = "$TAG_COMMIT" ]; then + PREV_TAG="$(git -C "$REPO_ROOT" describe --tags --abbrev=0 "${LATEST_TAG}^" 2>/dev/null || true)" + RANGE="${PREV_TAG:+$PREV_TAG..}$LATEST_TAG" + HEADING="Changelog ($LATEST_TAG)" + else + RANGE="$LATEST_TAG..HEAD" + HEADING="Changelog (since $LATEST_TAG)" + fi +fi + +FEAT="" ; FIX="" ; PERF="" ; REFACTOR="" ; DOCS="" ; TESTS="" ; CHORE="" ; OTHER="" + +while IFS='|' read -r hash subject; do + [ -z "${hash:-}" ] && continue + case "$subject" in + feat*) FEAT="${FEAT}- ${subject#*: } (${hash})"$'\n' ;; + fix*) FIX="${FIX}- ${subject#*: } (${hash})"$'\n' ;; + perf*) PERF="${PERF}- ${subject#*: } (${hash})"$'\n' ;; + refactor*) REFACTOR="${REFACTOR}- ${subject#*: } (${hash})"$'\n' ;; + docs*) DOCS="${DOCS}- ${subject#*: } (${hash})"$'\n' ;; + test*) TESTS="${TESTS}- ${subject#*: } (${hash})"$'\n' ;; + chore*) CHORE="${CHORE}- ${subject#*: } (${hash})"$'\n' ;; + *) OTHER="${OTHER}- ${subject} (${hash})"$'\n' ;; + esac +done </dev/null) +EOF + +echo "" +echo "---" +echo "" +echo "## $HEADING" +echo "" +print_section "Features" "$FEAT" +print_section "Fixes" "$FIX" +print_section "Performance" "$PERF" +print_section "Refactors" "$REFACTOR" +print_section "Documentation" "$DOCS" +print_section "Tests" "$TESTS" +print_section "Chores" "$CHORE" +print_section "Other" "$OTHER"