Merge pull request 'Phase 1: macOS read/browse + REST editing client (0.0.1-alpha)' (#1) from feature/macos-app into main
Reviewed-on: #1
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "docs/reference/outline-openapi"]
|
||||||
|
path = docs/reference/outline-openapi
|
||||||
|
url = https://github.com/outline/openapi.git
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
.DS_Store
|
||||||
@@ -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"]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Connection lifecycle for Outline's Hocuspocus collaboration socket
|
||||||
|
/// (`wss://<host>/collaboration/document.<id>`) — 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<Void, Never>?
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Talks to Outline's RPC-style REST API (`POST /api/<namespace>.<method>`, 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<Body: Encodable, Response: Decodable>(_ 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<Response>.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<Body: Encodable>(_ 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 {}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Outline wraps every successful RPC response as `{ "data": ..., "pagination": ... }`.
|
||||||
|
struct OutlineEnvelope<T: Decodable>: 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?
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct ListViewsRequest: Encodable, Sendable {
|
||||||
|
public let documentId: String
|
||||||
|
|
||||||
|
public init(documentId: String) {
|
||||||
|
self.documentId = documentId
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct ShareInfoRequest: Encodable, Sendable {
|
||||||
|
public let documentId: String?
|
||||||
|
|
||||||
|
public init(documentId: String?) {
|
||||||
|
self.documentId = documentId
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct StarCollectionRequest: Encodable, Sendable {
|
||||||
|
public let collectionId: String
|
||||||
|
|
||||||
|
public init(collectionId: String) {
|
||||||
|
self.collectionId = collectionId
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct StarDocumentRequest: Encodable, Sendable {
|
||||||
|
public let documentId: String
|
||||||
|
|
||||||
|
public init(documentId: String) {
|
||||||
|
self.documentId = documentId
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,13 @@
|
|||||||
objectVersion = 110;
|
objectVersion = 110;
|
||||||
objects = {
|
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 */
|
/* Begin PBXContainerItemProxy section */
|
||||||
FAF99C28302CE96200C9949F /* PBXContainerItemProxy */ = {
|
FAF99C28302CE96200C9949F /* PBXContainerItemProxy */ = {
|
||||||
isa = PBXContainerItemProxy;
|
isa = PBXContainerItemProxy;
|
||||||
@@ -51,6 +58,10 @@
|
|||||||
FAF99C15302CE96100C9949F /* Frameworks */ = {
|
FAF99C15302CE96100C9949F /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
|
FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */,
|
||||||
|
FAF99CB1302D120500C9949F /* MarkdownEngineCodeBlocks in Frameworks */,
|
||||||
|
FAF99CB3302D120500C9949F /* MarkdownEngineLatex in Frameworks */,
|
||||||
|
FAF99CAF302D120500C9949F /* MarkdownEngine in Frameworks */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
FAF99C24302CE96200C9949F /* Frameworks */ = {
|
FAF99C24302CE96200C9949F /* Frameworks */ = {
|
||||||
@@ -103,6 +114,12 @@
|
|||||||
FAF99C1A302CE96100C9949F /* Outpost */,
|
FAF99C1A302CE96100C9949F /* Outpost */,
|
||||||
);
|
);
|
||||||
name = Outpost;
|
name = Outpost;
|
||||||
|
packageProductDependencies = (
|
||||||
|
FAF99C43302CF1BD00C9949F /* OutlineKit */,
|
||||||
|
FAF99CAE302D120500C9949F /* MarkdownEngine */,
|
||||||
|
FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */,
|
||||||
|
FAF99CB2302D120500C9949F /* MarkdownEngineLatex */,
|
||||||
|
);
|
||||||
productName = Outpost;
|
productName = Outpost;
|
||||||
productReference = FAF99C18302CE96100C9949F /* Outpost.app */;
|
productReference = FAF99C18302CE96100C9949F /* Outpost.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
@@ -181,6 +198,10 @@
|
|||||||
);
|
);
|
||||||
mainGroup = FAF99C0F302CE96100C9949F;
|
mainGroup = FAF99C0F302CE96100C9949F;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
packageReferences = (
|
||||||
|
FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */,
|
||||||
|
FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */,
|
||||||
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = FAF99C19302CE96100C9949F /* Products */;
|
productRefGroup = FAF99C19302CE96100C9949F /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -364,6 +385,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||||
@@ -386,7 +408,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 0.0.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -408,6 +430,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||||
@@ -430,7 +453,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 0.0.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -585,6 +608,46 @@
|
|||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* 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 */;
|
rootObject = FAF99C10302CE96100C9949F /* Project object */;
|
||||||
validationLevel = 1;
|
validationLevel = 1;
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<key>Outpost.xcscheme_^#shared#^_</key>
|
<key>Outpost.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>0</integer>
|
<integer>1</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "outpost-ios-1024.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
@@ -28,51 +29,61 @@
|
|||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "outpost 9.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "16x16"
|
"size" : "16x16"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "outpost 8.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "16x16"
|
"size" : "16x16"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "outpost 7.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "32x32"
|
"size" : "32x32"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "outpost 6.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "32x32"
|
"size" : "32x32"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "outpost 5.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "128x128"
|
"size" : "128x128"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "outpost 4.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "128x128"
|
"size" : "128x128"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "outpost.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "256x256"
|
"size" : "256x256"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "outpost 1.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "256x256"
|
"size" : "256x256"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "outpost 2.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "512x512"
|
"size" : "512x512"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "outpost 3.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "512x512"
|
"size" : "512x512"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 573 KiB |
|
After Width: | Height: | Size: 71 KiB |
@@ -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)
|
#if os(macOS)
|
||||||
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
|
typealias ContentView = ContentView_macOS
|
||||||
#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<Content: View>: View {
|
|
||||||
let content: () -> Content
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
#if os(macOS)
|
|
||||||
NavigationSplitView {
|
|
||||||
content()
|
|
||||||
} detail: {
|
|
||||||
Text("Select an item")
|
|
||||||
}
|
|
||||||
#else
|
#else
|
||||||
content()
|
typealias ContentView = ContentView_iOS
|
||||||
#endif
|
#endif
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
ContentView()
|
|
||||||
.modelContainer(for: Item.self, inMemory: true)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
typealias AuthView = AuthView_macOS
|
||||||
|
#else
|
||||||
|
typealias AuthView = AuthView_iOS
|
||||||
|
#endif
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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<String>,
|
||||||
|
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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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<String> = []
|
||||||
|
let onSelectDocument: (OutlineCollection, [OutlineDocument]) -> Void
|
||||||
|
let onSearchInCollection: (OutlineCollection) -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
apiClient: OutlineAPIClient,
|
||||||
|
selectedCollection: Binding<OutlineCollection?>,
|
||||||
|
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
|
||||||
@@ -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: "|")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||