Compare commits
68
Commits
@@ -335,6 +335,62 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await live.currentUser()
|
try await live.currentUser()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
|
||||||
|
try await live.createAttachment(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
|
||||||
|
try await live.uploadAttachmentFile(result, fileData: fileData)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteAttachment(id: String) async throws {
|
||||||
|
try await live.deleteAttachment(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
|
||||||
|
try await live.updateUserAvatar(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser {
|
||||||
|
try await live.updateUserName(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
|
||||||
|
try await live.updateUserLanguage(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
|
||||||
|
try await live.updateUserPreferences(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteAccount() async throws {
|
||||||
|
try await live.deleteAccount()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||||
|
try await live.subscribeToNotifications(eventType: eventType)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||||
|
try await live.unsubscribeFromNotifications(eventType: eventType)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
|
||||||
|
try await live.listApiKeys(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
|
||||||
|
try await live.createApiKey(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteApiKey(id: String) async throws {
|
||||||
|
try await live.deleteApiKey(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func installationInfo() async throws -> OutlineInstallationInfo {
|
||||||
|
try await live.installationInfo()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Sync management (Settings surface)
|
// MARK: - Sync management (Settings surface)
|
||||||
|
|
||||||
public func pendingOperations() async -> [PendingOperationSummary] {
|
public func pendingOperations() async -> [PendingOperationSummary] {
|
||||||
@@ -377,6 +433,15 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
/// existing cached-read methods already do the caching as a side effect,
|
/// existing cached-read methods already do the caching as a side effect,
|
||||||
/// this just has to drive the walk and separately cache each document by
|
/// this just has to drive the walk and separately cache each document by
|
||||||
/// id (`listDocuments`'s cache key is the list, not the individual doc).
|
/// id (`listDocuments`'s cache key is the list, not the individual doc).
|
||||||
|
///
|
||||||
|
/// Recurses into every document's children, not just collections' own
|
||||||
|
/// root-level documents — a document with sub-documents used to leave
|
||||||
|
/// them uncached entirely (only reachable if something else happened to
|
||||||
|
/// open them individually first). Also caches each collection under its
|
||||||
|
/// own `"collection:<id>"` key (previously only cached as part of the
|
||||||
|
/// paginated list blob), so both are individually enumerable afterward
|
||||||
|
/// via `OfflineCacheStore.loadAll(keyPrefix:)` — see
|
||||||
|
/// `cachedDocumentsIndex()`/`cachedCollectionsIndex()`.
|
||||||
public func performFullSync() async -> FullSyncSummary {
|
public func performFullSync() async -> FullSyncSummary {
|
||||||
var documentsCount = 0
|
var documentsCount = 0
|
||||||
var errors: [String] = []
|
var errors: [String] = []
|
||||||
@@ -401,28 +466,64 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for collection in collections {
|
for collection in collections {
|
||||||
var offset = 0
|
await cacheCollection(collection)
|
||||||
let limit = 100
|
let result = await cacheDocumentTree(collectionId: collection.id, parentDocumentId: nil, collectionName: collection.name)
|
||||||
while true {
|
documentsCount += result.count
|
||||||
let documents: [OutlineDocument]
|
errors.append(contentsOf: result.errors)
|
||||||
do {
|
|
||||||
documents = try await listDocuments(collectionId: collection.id, parentDocumentId: nil, offset: offset, limit: limit)
|
|
||||||
} catch {
|
|
||||||
errors.append("\(collection.name): \(errorDescription(error))")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
for document in documents {
|
|
||||||
await cacheDocument(document)
|
|
||||||
}
|
|
||||||
documentsCount += documents.count
|
|
||||||
guard documents.count == limit else { break }
|
|
||||||
offset += limit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return FullSyncSummary(collectionsCount: collections.count, documentsCount: documentsCount, errors: errors, finishedAt: Date())
|
return FullSyncSummary(collectionsCount: collections.count, documentsCount: documentsCount, errors: errors, finishedAt: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Caches every document under `parentDocumentId` (`nil` = a
|
||||||
|
/// collection's root level) and recurses into each one's own children,
|
||||||
|
/// depth-first, until a branch runs out of sub-documents. Returns a
|
||||||
|
/// plain `(count, errors)` pair rather than mutating shared state across
|
||||||
|
/// `await` boundaries, since this calls itself recursively.
|
||||||
|
private func cacheDocumentTree(
|
||||||
|
collectionId: String,
|
||||||
|
parentDocumentId: String?,
|
||||||
|
collectionName: String
|
||||||
|
) async -> (count: Int, errors: [String]) {
|
||||||
|
var count = 0
|
||||||
|
var errors: [String] = []
|
||||||
|
var offset = 0
|
||||||
|
let limit = 100
|
||||||
|
while true {
|
||||||
|
let documents: [OutlineDocument]
|
||||||
|
do {
|
||||||
|
documents = try await listDocuments(collectionId: collectionId, parentDocumentId: parentDocumentId, offset: offset, limit: limit)
|
||||||
|
} catch {
|
||||||
|
errors.append("\(collectionName): \(errorDescription(error))")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for document in documents {
|
||||||
|
await cacheDocument(document)
|
||||||
|
count += 1
|
||||||
|
let childResult = await cacheDocumentTree(collectionId: collectionId, parentDocumentId: document.id, collectionName: collectionName)
|
||||||
|
count += childResult.count
|
||||||
|
errors.append(contentsOf: childResult.errors)
|
||||||
|
}
|
||||||
|
guard documents.count == limit else { break }
|
||||||
|
offset += limit
|
||||||
|
}
|
||||||
|
return (count, errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every individually cached document from the last Full Local Sync —
|
||||||
|
/// empty if a sync has never run (or found nothing). Purely a local
|
||||||
|
/// SwiftData read, no network involved.
|
||||||
|
public func cachedDocumentsIndex() async -> [OutlineDocument] {
|
||||||
|
let payloads = await cache.loadAll(keyPrefix: "document:")
|
||||||
|
return payloads.compactMap { try? decoder.decode(OutlineDocument.self, from: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every individually cached collection from the last Full Local Sync.
|
||||||
|
public func cachedCollectionsIndex() async -> [OutlineCollection] {
|
||||||
|
let payloads = await cache.loadAll(keyPrefix: "collection:")
|
||||||
|
return payloads.compactMap { try? decoder.decode(OutlineCollection.self, from: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T {
|
private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T {
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ public actor OfflineCacheStore {
|
|||||||
return try? modelContext.fetch(descriptor).first?.payload
|
return try? modelContext.fetch(descriptor).first?.payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Everything cached under a key prefix — e.g. every individually
|
||||||
|
/// cached document (`"document:<id>"`) or collection
|
||||||
|
/// (`"collection:<id>"`) after a Full Local Sync, for building a local
|
||||||
|
/// search index without a per-item exact-key lookup.
|
||||||
|
public func loadAll(keyPrefix: String) -> [Data] {
|
||||||
|
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key.starts(with: keyPrefix) })
|
||||||
|
return ((try? modelContext.fetch(descriptor)) ?? []).map(\.payload)
|
||||||
|
}
|
||||||
|
|
||||||
/// Used to drop a temporary `pending-*` document's cache entry once a
|
/// Used to drop a temporary `pending-*` document's cache entry once a
|
||||||
/// queued create syncs and the server hands back the real id — the
|
/// queued create syncs and the server hands back the real id — the
|
||||||
/// placeholder key would otherwise sit around as a dead orphan forever.
|
/// placeholder key would otherwise sit around as a dead orphan forever.
|
||||||
|
|||||||
@@ -69,4 +69,40 @@ public protocol OutlineAPIClient: Sendable {
|
|||||||
func deleteStar(id: String) async throws
|
func deleteStar(id: String) async throws
|
||||||
|
|
||||||
func currentUser() async throws -> OutlineUser
|
func currentUser() async throws -> OutlineUser
|
||||||
|
|
||||||
|
/// Two-step presigned upload: this requests where/how to upload,
|
||||||
|
/// `uploadAttachmentFile` performs the actual multipart POST to that
|
||||||
|
/// target. See `OutlineAttachment`/`CreateAttachmentResult`.
|
||||||
|
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult
|
||||||
|
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws
|
||||||
|
/// Best-effort — matches the shape every other simple `id`-only delete
|
||||||
|
/// in this API uses (`pins.delete`, `stars.delete`, …), not confirmed
|
||||||
|
/// against a live server specifically for attachments yet.
|
||||||
|
func deleteAttachment(id: String) async throws
|
||||||
|
/// `users.update`, avatar only. See `UpdateUserAvatarRequest`.
|
||||||
|
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser
|
||||||
|
/// `users.update`, name only. See `UpdateUserNameRequest`.
|
||||||
|
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser
|
||||||
|
/// `users.update`, language only. See `UpdateUserLanguageRequest`.
|
||||||
|
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser
|
||||||
|
/// `users.update`, preferences only. See `UpdateUserPreferencesRequest`.
|
||||||
|
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser
|
||||||
|
/// Backed by `users.delete` — self-service account deletion, no
|
||||||
|
/// confirmation code param confirmed live, matches every other simple
|
||||||
|
/// no-body delete in this API.
|
||||||
|
func deleteAccount() async throws
|
||||||
|
/// `nil` targets every notification event. See `NotificationEventType`.
|
||||||
|
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
|
||||||
|
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
|
||||||
|
|
||||||
|
/// Settings → API & Access.
|
||||||
|
func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey]
|
||||||
|
/// The returned `OutlineAPIKey.value` is the only time the full
|
||||||
|
/// plaintext key is ever available — the caller is responsible for
|
||||||
|
/// displaying it once and then discarding it.
|
||||||
|
func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey
|
||||||
|
func deleteApiKey(id: String) async throws
|
||||||
|
|
||||||
|
/// Settings → Installation. Self-hosted server version info.
|
||||||
|
func installationInfo() async throws -> OutlineInstallationInfo
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,103 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await post("users.info", body: EmptyParams())
|
try await post("users.info", body: EmptyParams())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
|
||||||
|
try await post("attachments.create", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No Bearer/CSRF header attached here, deliberately — the presigned
|
||||||
|
/// `form.sig` field (short-lived, scoped to this exact upload key) is
|
||||||
|
/// what authorizes this specific request, the same way an S3 presigned
|
||||||
|
/// POST works. Best-effort against a live server: if it turns out the
|
||||||
|
/// self-hosted local-storage backend also wants a bearer token here,
|
||||||
|
/// that's a one-line addition once confirmed, not a design change.
|
||||||
|
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
|
||||||
|
let (body, contentType) = MultipartFormDataBuilder.build(
|
||||||
|
fields: result.form,
|
||||||
|
fileFieldName: "file",
|
||||||
|
fileName: result.attachment.name ?? "avatar",
|
||||||
|
fileData: fileData,
|
||||||
|
fileContentType: result.form["Content-Type"] ?? "application/octet-stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
// `uploadUrl` is a host-relative path (e.g. "/api/files.create") on
|
||||||
|
// a self-hosted local-storage backend, not an absolute S3 URL —
|
||||||
|
// resolving against `baseURL` handles both: `URL(string:relativeTo:)`
|
||||||
|
// replaces the whole path for a leading-slash relative string per
|
||||||
|
// RFC 3986, same resolution already used for user/team avatar URLs.
|
||||||
|
guard let uploadURL = URL(string: result.uploadUrl, relativeTo: baseURL)?.absoluteURL else {
|
||||||
|
throw OutlineAPIError.transport(URLError(.badURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: uploadURL)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = 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)
|
||||||
|
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteAttachment(id: String) async throws {
|
||||||
|
try await postForSuccess("attachments.delete", body: StarIDParams(id: id))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
|
||||||
|
try await post("users.update", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser {
|
||||||
|
try await post("users.update", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
|
||||||
|
try await post("users.update", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
|
||||||
|
try await post("users.update", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteAccount() async throws {
|
||||||
|
try await postForSuccess("users.delete", body: EmptyParams())
|
||||||
|
}
|
||||||
|
|
||||||
|
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||||
|
try await post("users.notificationsSubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||||
|
try await post("users.notificationsUnsubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
|
||||||
|
try await post("apiKeys.list", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
|
||||||
|
try await post("apiKeys.create", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteApiKey(id: String) async throws {
|
||||||
|
try await postForSuccess("apiKeys.delete", body: StarIDParams(id: id))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func installationInfo() async throws -> OutlineInstallationInfo {
|
||||||
|
try await post("installation.info", body: EmptyParams())
|
||||||
|
}
|
||||||
|
|
||||||
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
|
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
|
||||||
guard let token = try? tokenStore.token() else {
|
guard let token = try? tokenStore.token() else {
|
||||||
throw OutlineAPIError.tokenUnavailable
|
throw OutlineAPIError.tokenUnavailable
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The subset of Outline's notification event types this app exposes a
|
||||||
|
/// toggle for. Wire values confirmed live (captured `users.update`
|
||||||
|
/// responses showing the full `notificationSettings` dictionary after
|
||||||
|
/// toggling each one in Outline's own web app). The server tracks more
|
||||||
|
/// event types than this app has UI for (`revisions.create`,
|
||||||
|
/// `emails.onboarding`, `emails.features` were also seen live) — those are
|
||||||
|
/// left alone since `users.notificationsSubscribe`/`Unsubscribe` are
|
||||||
|
/// per-event, not a whole-object replace like `preferences`, so there's no
|
||||||
|
/// clobbering risk in only covering a subset.
|
||||||
|
public enum NotificationEventType: String, CaseIterable, Sendable {
|
||||||
|
case documentPublish = "documents.publish"
|
||||||
|
case documentUpdate = "documents.update"
|
||||||
|
case commentCreate = "comments.create"
|
||||||
|
case commentMentioned = "comments.mentioned"
|
||||||
|
case documentMentioned = "documents.mentioned"
|
||||||
|
case commentGroupMentioned = "comments.group_mentioned"
|
||||||
|
case documentGroupMentioned = "documents.group_mentioned"
|
||||||
|
case commentResolve = "comments.resolve"
|
||||||
|
case reactionCreate = "reactions.create"
|
||||||
|
case collectionCreate = "collections.create"
|
||||||
|
case emailsInviteAccepted = "emails.invite_accepted"
|
||||||
|
case documentAddUser = "documents.add_user"
|
||||||
|
case collectionAddUser = "collections.add_user"
|
||||||
|
case emailsExportCompleted = "emails.export_completed"
|
||||||
|
case accessRequestCreate = "access_requests.create"
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A personal API key (Settings → API & Access). Only the last 4 characters
|
||||||
|
/// of the actual token are ever returned by the server — there's no way to
|
||||||
|
/// see a full key again after creation, matching every other API-key UI
|
||||||
|
/// convention.
|
||||||
|
public struct OutlineAPIKey: Codable, Identifiable, Hashable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let name: String
|
||||||
|
public let last4: String?
|
||||||
|
public let scope: [String]?
|
||||||
|
public let createdAt: Date
|
||||||
|
public let expiresAt: Date?
|
||||||
|
public let lastActiveAt: Date?
|
||||||
|
/// The full plaintext key — present *only* in `apiKeys.create`'s
|
||||||
|
/// response, confirmed live: `apiKeys.list` never includes it, matching
|
||||||
|
/// "shown once at creation" being enforced server-side, not just a
|
||||||
|
/// client-side UI convention this app has to uphold on its own.
|
||||||
|
public let value: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
last4: String? = nil,
|
||||||
|
scope: [String]? = nil,
|
||||||
|
createdAt: Date,
|
||||||
|
expiresAt: Date? = nil,
|
||||||
|
lastActiveAt: Date? = nil,
|
||||||
|
value: String? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.last4 = last4
|
||||||
|
self.scope = scope
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.expiresAt = expiresAt
|
||||||
|
self.lastActiveAt = lastActiveAt
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// One uploaded file — created via a two-step presigned upload
|
||||||
|
/// (`attachments.create` for the upload target, then a direct POST to
|
||||||
|
/// `uploadUrl`/`form`). Confirmed live against a self-hosted instance's
|
||||||
|
/// local-storage backend; `size` comes back as a string there, not a
|
||||||
|
/// number — same "don't trust the vendored spec's implied types" lesson as
|
||||||
|
/// everywhere else this codebase has hit it.
|
||||||
|
public struct OutlineAttachment: Decodable, Identifiable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let documentId: String?
|
||||||
|
public let contentType: String?
|
||||||
|
public let name: String?
|
||||||
|
public let url: String?
|
||||||
|
public let size: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `attachments.create`'s response — everything needed to perform the
|
||||||
|
/// actual upload. `form` fields (Content-Type, key, acl, sig, `_csrf`, …)
|
||||||
|
/// must all be included as their own multipart parts, in the same request
|
||||||
|
/// as the file itself, POSTed to `uploadUrl`.
|
||||||
|
public struct CreateAttachmentResult: Decodable, Sendable {
|
||||||
|
public let attachment: OutlineAttachment
|
||||||
|
public let uploadUrl: String
|
||||||
|
public let form: [String: String]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `installation.info` — the self-hosted server's own version, confirmed
|
||||||
|
/// live. `policies` (a separate top-level array alongside `data` in the raw
|
||||||
|
/// response) isn't modeled here — not used by this app's Installation
|
||||||
|
/// settings page.
|
||||||
|
public struct OutlineInstallationInfo: Decodable, Sendable {
|
||||||
|
public let version: String
|
||||||
|
public let latestVersion: String
|
||||||
|
public let versionsBehind: Int
|
||||||
|
}
|
||||||
@@ -6,18 +6,30 @@ public struct OutlineUser: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public let email: String?
|
public let email: String?
|
||||||
public let avatarUrl: String?
|
public let avatarUrl: String?
|
||||||
public let role: String?
|
public let role: String?
|
||||||
|
public let language: String?
|
||||||
|
public let preferences: OutlineUserPreferences?
|
||||||
|
/// Keyed by `NotificationEventType`'s raw values, plus event types this
|
||||||
|
/// app has no UI for — kept as a flexible dictionary rather than a
|
||||||
|
/// fixed struct for that reason. `true` means subscribed.
|
||||||
|
public let notificationSettings: [String: Bool]?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
email: String? = nil,
|
email: String? = nil,
|
||||||
avatarUrl: String? = nil,
|
avatarUrl: String? = nil,
|
||||||
role: String? = nil
|
role: String? = nil,
|
||||||
|
language: String? = nil,
|
||||||
|
preferences: OutlineUserPreferences? = nil,
|
||||||
|
notificationSettings: [String: Bool]? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
self.email = email
|
self.email = email
|
||||||
self.avatarUrl = avatarUrl
|
self.avatarUrl = avatarUrl
|
||||||
self.role = role
|
self.role = role
|
||||||
|
self.language = language
|
||||||
|
self.preferences = preferences
|
||||||
|
self.notificationSettings = notificationSettings
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `User.preferences` — a free-form JSON blob on Outline's own `User` row,
|
||||||
|
/// not a fixed-shape API resource. Wire key names below are confirmed
|
||||||
|
/// against a live server's own web app traffic (captured toggling every
|
||||||
|
/// Preferences setting one at a time), not guessed — see the `CodingKeys`
|
||||||
|
/// mapping for the two that don't match this struct's own property names.
|
||||||
|
///
|
||||||
|
/// The server also validates `preferences` against a known key allowlist
|
||||||
|
/// and rejects the whole `users.update` call (not just the bad field) if
|
||||||
|
/// any key it doesn't recognize is present — confirmed live via a
|
||||||
|
/// `"notificationBadge: Invalid Input"` error when an earlier, wrong value
|
||||||
|
/// was sent. That's also why `fullWidthDocuments` is kept here even though
|
||||||
|
/// this app has no UI for it yet: this app always sends the *whole*
|
||||||
|
/// preferences object back on every save (see
|
||||||
|
/// `UpdateUserPreferencesRequest`), so silently dropping an unknown key
|
||||||
|
/// during decode would permanently clear it the next time any other
|
||||||
|
/// preference here gets saved.
|
||||||
|
public struct OutlineUserPreferences: Codable, Hashable, Sendable {
|
||||||
|
public var rememberLastPath: Bool?
|
||||||
|
/// App-facing polarity: `true` means "separate editing mode is on" —
|
||||||
|
/// the opposite of the wire's own `seamlessEdit` (seamless editing and
|
||||||
|
/// separate editing modes are each other's negation), inverted in
|
||||||
|
/// `init(from:)`/`encode(to:)` so nothing outside this file has to
|
||||||
|
/// remember that.
|
||||||
|
public var separateEditing: Bool?
|
||||||
|
public var useCursorPointer: Bool?
|
||||||
|
public var codeBlockLineNumbers: Bool?
|
||||||
|
public var showCommentMarker: Bool?
|
||||||
|
public var smartText: Bool?
|
||||||
|
/// One of `NotificationBadgeStyle`'s raw values.
|
||||||
|
public var notificationBadge: String?
|
||||||
|
/// No UI in this app yet — preserved purely so saving any other
|
||||||
|
/// preference here doesn't clobber it. See the type doc comment.
|
||||||
|
public var fullWidthDocuments: Bool?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
rememberLastPath: Bool? = nil,
|
||||||
|
separateEditing: Bool? = nil,
|
||||||
|
useCursorPointer: Bool? = nil,
|
||||||
|
codeBlockLineNumbers: Bool? = nil,
|
||||||
|
showCommentMarker: Bool? = nil,
|
||||||
|
smartText: Bool? = nil,
|
||||||
|
notificationBadge: String? = nil,
|
||||||
|
fullWidthDocuments: Bool? = nil
|
||||||
|
) {
|
||||||
|
self.rememberLastPath = rememberLastPath
|
||||||
|
self.separateEditing = separateEditing
|
||||||
|
self.useCursorPointer = useCursorPointer
|
||||||
|
self.codeBlockLineNumbers = codeBlockLineNumbers
|
||||||
|
self.showCommentMarker = showCommentMarker
|
||||||
|
self.smartText = smartText
|
||||||
|
self.notificationBadge = notificationBadge
|
||||||
|
self.fullWidthDocuments = fullWidthDocuments
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case rememberLastPath
|
||||||
|
case seamlessEdit
|
||||||
|
case useCursorPointer
|
||||||
|
case codeBlockLineNumbers
|
||||||
|
case commentsInGutter
|
||||||
|
case enableSmartText
|
||||||
|
case notificationBadge
|
||||||
|
case fullWidthDocuments
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
rememberLastPath = try container.decodeIfPresent(Bool.self, forKey: .rememberLastPath)
|
||||||
|
separateEditing = try container.decodeIfPresent(Bool.self, forKey: .seamlessEdit).map { !$0 }
|
||||||
|
useCursorPointer = try container.decodeIfPresent(Bool.self, forKey: .useCursorPointer)
|
||||||
|
codeBlockLineNumbers = try container.decodeIfPresent(Bool.self, forKey: .codeBlockLineNumbers)
|
||||||
|
showCommentMarker = try container.decodeIfPresent(Bool.self, forKey: .commentsInGutter)
|
||||||
|
smartText = try container.decodeIfPresent(Bool.self, forKey: .enableSmartText)
|
||||||
|
notificationBadge = try container.decodeIfPresent(String.self, forKey: .notificationBadge)
|
||||||
|
fullWidthDocuments = try container.decodeIfPresent(Bool.self, forKey: .fullWidthDocuments)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encodeIfPresent(rememberLastPath, forKey: .rememberLastPath)
|
||||||
|
try container.encodeIfPresent(separateEditing.map { !$0 }, forKey: .seamlessEdit)
|
||||||
|
try container.encodeIfPresent(useCursorPointer, forKey: .useCursorPointer)
|
||||||
|
try container.encodeIfPresent(codeBlockLineNumbers, forKey: .codeBlockLineNumbers)
|
||||||
|
try container.encodeIfPresent(showCommentMarker, forKey: .commentsInGutter)
|
||||||
|
try container.encodeIfPresent(smartText, forKey: .enableSmartText)
|
||||||
|
try container.encodeIfPresent(notificationBadge, forKey: .notificationBadge)
|
||||||
|
try container.encodeIfPresent(fullWidthDocuments, forKey: .fullWidthDocuments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App-icon unread indicator style. Wire values confirmed live (captured
|
||||||
|
/// setting all three from Outline's own web app).
|
||||||
|
public enum NotificationBadgeStyle: String, CaseIterable, Identifiable, Sendable {
|
||||||
|
case none = "disabled"
|
||||||
|
case unreadIndicator = "indicator"
|
||||||
|
case unreadCount = "count"
|
||||||
|
|
||||||
|
public var id: String { rawValue }
|
||||||
|
|
||||||
|
public var label: String {
|
||||||
|
switch self {
|
||||||
|
case .none: return "None"
|
||||||
|
case .unreadIndicator: return "Unread Indicator"
|
||||||
|
case .unreadCount: return "Unread Count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Builds the multipart/form-data body for Outline's presigned-upload
|
||||||
|
/// targets (`attachments.create`'s `uploadUrl`/`form`) — an S3-style
|
||||||
|
/// presigned POST: every `form` field has to ride along as its own part in
|
||||||
|
/// the same request as the file, not as query params or headers.
|
||||||
|
enum MultipartFormDataBuilder {
|
||||||
|
static func build(
|
||||||
|
fields: [String: String],
|
||||||
|
fileFieldName: String,
|
||||||
|
fileName: String,
|
||||||
|
fileData: Data,
|
||||||
|
fileContentType: String,
|
||||||
|
boundary: String = "Boundary-\(UUID().uuidString)"
|
||||||
|
) -> (body: Data, contentType: String) {
|
||||||
|
var body = Data()
|
||||||
|
|
||||||
|
for (key, value) in fields {
|
||||||
|
body.append("--\(boundary)\r\n".utf8Data)
|
||||||
|
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".utf8Data)
|
||||||
|
body.append(value.utf8Data)
|
||||||
|
body.append("\r\n".utf8Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
body.append("--\(boundary)\r\n".utf8Data)
|
||||||
|
body.append(
|
||||||
|
"Content-Disposition: form-data; name=\"\(fileFieldName)\"; filename=\"\(fileName)\"\r\n".utf8Data
|
||||||
|
)
|
||||||
|
body.append("Content-Type: \(fileContentType)\r\n\r\n".utf8Data)
|
||||||
|
body.append(fileData)
|
||||||
|
body.append("\r\n".utf8Data)
|
||||||
|
body.append("--\(boundary)--\r\n".utf8Data)
|
||||||
|
|
||||||
|
return (body, "multipart/form-data; boundary=\(boundary)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
var utf8Data: Data { Data(utf8) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `apiKeys.create`. `expiresAt: nil` (the key omitted entirely, not sent as
|
||||||
|
/// literal `null`) confirmed live to mean no expiration — Swift's
|
||||||
|
/// synthesized `Encodable` already omits `nil` optionals via
|
||||||
|
/// `encodeIfPresent`, so no custom `encode(to:)` is needed here the way
|
||||||
|
/// `UpdateUserAvatarRequest` needed one for the opposite case.
|
||||||
|
public struct CreateApiKeyRequest: Encodable, Sendable {
|
||||||
|
public let name: String
|
||||||
|
public let expiresAt: Date?
|
||||||
|
/// `nil`/omitted grants full access — confirmed live (every key created
|
||||||
|
/// without a scope came back with unrestricted access). A specific
|
||||||
|
/// scope is a list of allowed API paths, e.g. `["/api/documents.info"]`.
|
||||||
|
public let scope: [String]?
|
||||||
|
|
||||||
|
public init(name: String, expiresAt: Date? = nil, scope: [String]? = nil) {
|
||||||
|
self.name = name
|
||||||
|
self.expiresAt = expiresAt
|
||||||
|
self.scope = scope
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Requests an upload target for a new file — not the upload itself, see
|
||||||
|
/// `OutlineAPIClient.uploadAttachmentFile`. `documentId: nil` is what a
|
||||||
|
/// user-avatar upload sends (confirmed live); a real value scopes the
|
||||||
|
/// attachment to a document instead (e.g. an inline image embed).
|
||||||
|
public struct CreateAttachmentRequest: Encodable, Sendable {
|
||||||
|
public let name: String
|
||||||
|
public let contentType: String
|
||||||
|
public let size: Int
|
||||||
|
public let documentId: String?
|
||||||
|
|
||||||
|
public init(name: String, contentType: String, size: Int, documentId: String? = nil) {
|
||||||
|
self.name = name
|
||||||
|
self.contentType = contentType
|
||||||
|
self.size = size
|
||||||
|
self.documentId = documentId
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `apiKeys.list` — matches every other paginated `.list` endpoint's flat
|
||||||
|
/// offset/limit convention (e.g. `ListSharesRequest`), not the nested
|
||||||
|
/// `pagination` object that only appears in list *responses*.
|
||||||
|
public struct ListApiKeysRequest: Encodable, Sendable {
|
||||||
|
public let offset: Int
|
||||||
|
public let limit: Int
|
||||||
|
|
||||||
|
public init(offset: Int = 0, limit: Int = 25) {
|
||||||
|
self.offset = offset
|
||||||
|
self.limit = limit
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `users.notificationsSubscribe` / `users.notificationsUnsubscribe`. A
|
||||||
|
/// `nil` `eventType` targets every notification event at once — confirmed
|
||||||
|
/// live via Outline's own "All notifications" master toggle, which sends
|
||||||
|
/// no `eventType` at all.
|
||||||
|
public struct NotificationSubscriptionRequest: Encodable, Sendable {
|
||||||
|
public let eventType: String?
|
||||||
|
|
||||||
|
public init(eventType: String? = nil) {
|
||||||
|
self.eventType = eventType
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `users.update`, scoped to just the avatar. Custom `encode(to:)` because
|
||||||
|
/// Swift's synthesized `Encodable` uses `encodeIfPresent` for `Optional`
|
||||||
|
/// properties, which *omits* the key entirely when the value is `nil` —
|
||||||
|
/// removing the avatar needs a literal `"avatarUrl": null` in the request
|
||||||
|
/// body, not the key missing. `id` is required: confirmed live (the
|
||||||
|
/// captured request body's length only matches `{"id": "...", "avatarUrl":
|
||||||
|
/// ...}`, not a shorter shape without it).
|
||||||
|
public struct UpdateUserAvatarRequest: Encodable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let avatarUrl: String?
|
||||||
|
|
||||||
|
public init(id: String, avatarUrl: String?) {
|
||||||
|
self.id = id
|
||||||
|
self.avatarUrl = avatarUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, avatarUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(avatarUrl, forKey: .avatarUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `users.update`, language only.
|
||||||
|
public struct UpdateUserLanguageRequest: Encodable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let language: String
|
||||||
|
|
||||||
|
public init(id: String, language: String) {
|
||||||
|
self.id = id
|
||||||
|
self.language = language
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `users.update`, name only.
|
||||||
|
public struct UpdateUserNameRequest: Encodable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let name: String
|
||||||
|
|
||||||
|
public init(id: String, name: String) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `users.update`, preferences only. Sends the *whole* preferences object
|
||||||
|
/// back (not a single changed key) — avoids needing to know whether the
|
||||||
|
/// server deep-merges a partial `preferences` body or replaces it outright.
|
||||||
|
public struct UpdateUserPreferencesRequest: Encodable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let preferences: OutlineUserPreferences
|
||||||
|
|
||||||
|
public init(id: String, preferences: OutlineUserPreferences) {
|
||||||
|
self.id = id
|
||||||
|
self.preferences = preferences
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,6 +89,20 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
|
|||||||
func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { throw NotStubbed() }
|
func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { throw NotStubbed() }
|
||||||
func deleteStar(id: String) async throws { throw NotStubbed() }
|
func deleteStar(id: String) async throws { throw NotStubbed() }
|
||||||
func currentUser() async throws -> OutlineUser { throw NotStubbed() }
|
func currentUser() async throws -> OutlineUser { throw NotStubbed() }
|
||||||
|
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() }
|
||||||
|
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws { throw NotStubbed() }
|
||||||
|
func deleteAttachment(id: String) async throws { throw NotStubbed() }
|
||||||
|
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
|
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
|
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
|
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
|
func deleteAccount() async throws { throw NotStubbed() }
|
||||||
|
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
|
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
|
func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] { throw NotStubbed() }
|
||||||
|
func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey { throw NotStubbed() }
|
||||||
|
func deleteApiKey(id: String) async throws { throw NotStubbed() }
|
||||||
|
func installationInfo() async throws -> OutlineInstallationInfo { throw NotStubbed() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct StubTransportError: Error {}
|
private struct StubTransportError: Error {}
|
||||||
@@ -407,8 +421,13 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
func testPerformFullSyncCachesEachDocumentIndividually() async throws {
|
func testPerformFullSyncCachesEachDocumentIndividually() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
|
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
|
||||||
stub.listDocumentsHandler = { _, _, offset, _ in
|
// Must return empty for any non-nil parentDocumentId (no children) —
|
||||||
offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
|
// performFullSync now recurses into every document's own children,
|
||||||
|
// so a stub that ignores parentDocumentId and always returns the
|
||||||
|
// same root documents regardless would recurse into itself forever.
|
||||||
|
stub.listDocumentsHandler = { _, parentDocumentId, offset, _ in
|
||||||
|
guard parentDocumentId == nil else { return [] }
|
||||||
|
return offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
|
||||||
}
|
}
|
||||||
let cache = try makeCache()
|
let cache = try makeCache()
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
|
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
|
||||||
@@ -423,6 +442,34 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(cachedDoc.id, "doc-2")
|
XCTAssertEqual(cachedDoc.id, "doc-2")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPerformFullSyncRecursesIntoNestedDocuments() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
|
||||||
|
// doc-1 (root) -> doc-2 (child of doc-1) -> doc-3 (grandchild) —
|
||||||
|
// regression test for the real gap this fixed: only root-level
|
||||||
|
// documents were ever cached before, so a document's own
|
||||||
|
// sub-documents were never reachable offline at all unless
|
||||||
|
// something else happened to open them individually first.
|
||||||
|
stub.listDocumentsHandler = { _, parentDocumentId, offset, _ in
|
||||||
|
guard offset == 0 else { return [] }
|
||||||
|
switch parentDocumentId {
|
||||||
|
case nil: return [self.makeDocument(id: "doc-1")]
|
||||||
|
case "doc-1": return [self.makeDocument(id: "doc-2")]
|
||||||
|
case "doc-2": return [self.makeDocument(id: "doc-3")]
|
||||||
|
default: return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cache = try makeCache()
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
|
||||||
|
|
||||||
|
let summary = await sut.performFullSync()
|
||||||
|
|
||||||
|
XCTAssertEqual(summary.documentsCount, 3)
|
||||||
|
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||||
|
let cachedGrandchild = try await sut.documentInfo(id: "doc-3")
|
||||||
|
XCTAssertEqual(cachedGrandchild.id, "doc-3")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Offline document creation
|
// MARK: - Offline document creation
|
||||||
|
|
||||||
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
|
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
|
||||||
|
|||||||
@@ -993,6 +993,490 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.list")
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.list")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testUpdateUserAvatarSendsExplicitNullWhenRemoving() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "user-1",
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"avatarUrl": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let user = try await client.updateUserAvatar(UpdateUserAvatarRequest(id: "user-1", avatarUrl: nil))
|
||||||
|
|
||||||
|
XCTAssertNil(user.avatarUrl)
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
|
||||||
|
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
|
||||||
|
let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8))
|
||||||
|
// The whole point of UpdateUserAvatarRequest's custom encode(to:) —
|
||||||
|
// Swift's synthesized Encodable would have omitted the key entirely
|
||||||
|
// for a nil Optional instead of sending a literal null.
|
||||||
|
XCTAssertTrue(sentJSON.contains("\"avatarUrl\":null"), "expected an explicit null, got: \(sentJSON)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUpdateUserAvatarSendsTheNewURLWhenSet() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "user-1",
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"avatarUrl": "/api/attachments.redirect?id=abc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let user = try await client.updateUserAvatar(
|
||||||
|
UpdateUserAvatarRequest(id: "user-1", avatarUrl: "/api/attachments.redirect?id=abc")
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(user.avatarUrl, "/api/attachments.redirect?id=abc")
|
||||||
|
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
|
||||||
|
let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8))
|
||||||
|
XCTAssertTrue(sentJSON.contains("\"id\":\"user-1\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUpdateUserNameSendsRequestAndDecodesResult() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "user-1",
|
||||||
|
"name": "New Name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let user = try await client.updateUserName(UpdateUserNameRequest(id: "user-1", name: "New Name"))
|
||||||
|
|
||||||
|
XCTAssertEqual(user.name, "New Name")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUpdateUserLanguageSendsRequestAndDecodesResult() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "user-1",
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"language": "fr_FR"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let user = try await client.updateUserLanguage(UpdateUserLanguageRequest(id: "user-1", language: "fr_FR"))
|
||||||
|
|
||||||
|
XCTAssertEqual(user.language, "fr_FR")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUpdateUserPreferencesSendsWholeObjectAndDecodesResult() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "user-1",
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"preferences": { "rememberLastPath": true, "useCursorPointer": true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let preferences = OutlineUserPreferences(rememberLastPath: true, useCursorPointer: true)
|
||||||
|
let user = try await client.updateUserPreferences(UpdateUserPreferencesRequest(id: "user-1", preferences: preferences))
|
||||||
|
|
||||||
|
XCTAssertEqual(user.preferences?.rememberLastPath, true)
|
||||||
|
XCTAssertEqual(user.preferences?.useCursorPointer, true)
|
||||||
|
|
||||||
|
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||||
|
let sentPreferences = sentBody?["preferences"] as? [String: Any]
|
||||||
|
XCTAssertEqual(sentPreferences?["rememberLastPath"] as? Bool, true)
|
||||||
|
XCTAssertEqual(sentPreferences?["useCursorPointer"] as? Bool, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locks in the wire mapping confirmed against a live server's own web
|
||||||
|
/// app traffic: `seamlessEdit`/`commentsInGutter`/`enableSmartText` are
|
||||||
|
/// the real keys (not `separateEditing`/`showCommentMarker`/`smartText`
|
||||||
|
/// this struct exposes), and `seamlessEdit` is the *negation* of this
|
||||||
|
/// app's `separateEditing`.
|
||||||
|
func testOutlineUserPreferencesDecodesRealWireKeys() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"seamlessEdit": false,
|
||||||
|
"commentsInGutter": true,
|
||||||
|
"enableSmartText": true,
|
||||||
|
"rememberLastPath": true,
|
||||||
|
"useCursorPointer": true,
|
||||||
|
"codeBlockLineNumbers": false,
|
||||||
|
"notificationBadge": "indicator",
|
||||||
|
"fullWidthDocuments": true
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let preferences = try JSONDecoder().decode(OutlineUserPreferences.self, from: json)
|
||||||
|
|
||||||
|
XCTAssertEqual(preferences.separateEditing, true, "seamlessEdit: false means separate editing is ON")
|
||||||
|
XCTAssertEqual(preferences.showCommentMarker, true)
|
||||||
|
XCTAssertEqual(preferences.smartText, true)
|
||||||
|
XCTAssertEqual(preferences.rememberLastPath, true)
|
||||||
|
XCTAssertEqual(preferences.useCursorPointer, true)
|
||||||
|
XCTAssertEqual(preferences.codeBlockLineNumbers, false)
|
||||||
|
XCTAssertEqual(preferences.notificationBadge, "indicator")
|
||||||
|
XCTAssertEqual(preferences.fullWidthDocuments, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOutlineUserPreferencesEncodesRealWireKeysAndInvertsSeparateEditing() throws {
|
||||||
|
var preferences = OutlineUserPreferences()
|
||||||
|
preferences.separateEditing = true
|
||||||
|
preferences.showCommentMarker = false
|
||||||
|
preferences.smartText = true
|
||||||
|
preferences.fullWidthDocuments = true
|
||||||
|
|
||||||
|
let data = try JSONEncoder().encode(preferences)
|
||||||
|
let object = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
|
||||||
|
XCTAssertEqual(object?["seamlessEdit"] as? Bool, false, "separateEditing: true must encode as seamlessEdit: false")
|
||||||
|
XCTAssertEqual(object?["commentsInGutter"] as? Bool, false)
|
||||||
|
XCTAssertEqual(object?["enableSmartText"] as? Bool, true)
|
||||||
|
XCTAssertEqual(object?["fullWidthDocuments"] as? Bool, true)
|
||||||
|
XCTAssertNil(object?["separateEditing"], "must not leak this app's own field name onto the wire")
|
||||||
|
XCTAssertNil(object?["showCommentMarker"])
|
||||||
|
XCTAssertNil(object?["smartText"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSubscribeToNotificationsSendsEventTypeAndDecodesResult() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "user-1",
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"notificationSettings": { "documents.publish": true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let user = try await client.subscribeToNotifications(eventType: .documentPublish)
|
||||||
|
|
||||||
|
XCTAssertEqual(user.notificationSettings?["documents.publish"], true)
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsSubscribe")
|
||||||
|
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||||
|
XCTAssertEqual(sentBody?["eventType"] as? String, "documents.publish")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnsubscribeFromNotificationsWithNilEventTypeTargetsAll() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "user-1",
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"notificationSettings": { "documents.publish": false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = try await client.unsubscribeFromNotifications(eventType: nil)
|
||||||
|
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsUnsubscribe")
|
||||||
|
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||||
|
XCTAssertNil(sentBody?["eventType"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListApiKeysDecodesResult() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"pagination": { "limit": 25, "offset": 0 },
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "c3eec545-6d38-4065-90dc-b6c96a551445",
|
||||||
|
"name": "Outpost",
|
||||||
|
"scope": null,
|
||||||
|
"last4": "dl1h",
|
||||||
|
"createdAt": "2026-08-12T18:44:32.467Z",
|
||||||
|
"updatedAt": "2026-08-12T18:44:32.467Z",
|
||||||
|
"expiresAt": null,
|
||||||
|
"lastActiveAt": "2026-08-18T01:01:36.553Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let keys = try await client.listApiKeys(ListApiKeysRequest())
|
||||||
|
|
||||||
|
XCTAssertEqual(keys.count, 1)
|
||||||
|
XCTAssertEqual(keys.first?.name, "Outpost")
|
||||||
|
XCTAssertEqual(keys.first?.last4, "dl1h")
|
||||||
|
XCTAssertNil(keys.first?.expiresAt)
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.list")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInstallationInfoDecodesResult() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": { "version": "1.9.2", "latestVersion": "1.9.2", "versionsBehind": 0 },
|
||||||
|
"policies": []
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let info = try await client.installationInfo()
|
||||||
|
|
||||||
|
XCTAssertEqual(info.version, "1.9.2")
|
||||||
|
XCTAssertEqual(info.versionsBehind, 0)
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/installation.info")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCreateApiKeyOmitsExpiresAtWhenNilAndDecodesValue() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683",
|
||||||
|
"name": "test",
|
||||||
|
"scope": null,
|
||||||
|
"value": "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv",
|
||||||
|
"last4": "0DGv",
|
||||||
|
"createdAt": "2026-08-18T15:39:29.872Z",
|
||||||
|
"expiresAt": null,
|
||||||
|
"lastActiveAt": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let key = try await client.createApiKey(CreateApiKeyRequest(name: "test"))
|
||||||
|
|
||||||
|
XCTAssertEqual(key.value, "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv")
|
||||||
|
XCTAssertNil(key.expiresAt)
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.create")
|
||||||
|
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||||
|
XCTAssertEqual(sentBody?["name"] as? String, "test")
|
||||||
|
XCTAssertNil(sentBody?["expiresAt"], "omitting expiresAt (not sending null) is what produces a non-expiring key")
|
||||||
|
XCTAssertNil(sentBody?["scope"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCreateApiKeySendsExpiresAtWhenProvided() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "04e204fb-51a4-4b54-aed1-2056dfd576d7",
|
||||||
|
"name": "Test",
|
||||||
|
"scope": null,
|
||||||
|
"value": "ol_api_aeYcOts7I2sJXw3zaztjydRM3W3W89TBAtcLts",
|
||||||
|
"last4": "cLts",
|
||||||
|
"createdAt": "2026-08-18T15:38:22.928Z",
|
||||||
|
"expiresAt": "2026-11-16T23:59:59.999Z",
|
||||||
|
"lastActiveAt": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let expiresAt = Date(timeIntervalSince1970: 1_795_000_000)
|
||||||
|
_ = try await client.createApiKey(CreateApiKeyRequest(name: "Test", expiresAt: expiresAt))
|
||||||
|
|
||||||
|
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||||
|
XCTAssertNotNil(sentBody?["expiresAt"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeleteApiKeySendsRequest() 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.deleteApiKey(id: "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683")
|
||||||
|
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.delete")
|
||||||
|
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||||
|
XCTAssertEqual(sentBody?["id"] as? String, "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeleteAccountSendsRequest() 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.deleteAccount()
|
||||||
|
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.delete")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeleteAttachmentSendsRequest() 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.deleteAttachment(id: "attach-1")
|
||||||
|
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.delete")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCreateAttachmentDecodesUploadTargetAndFormFields() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"attachment": {
|
||||||
|
"id": "attach-1",
|
||||||
|
"documentId": null,
|
||||||
|
"contentType": "image/jpeg",
|
||||||
|
"name": "avatar.jpg",
|
||||||
|
"url": "/api/attachments.redirect?id=attach-1",
|
||||||
|
"size": "59304"
|
||||||
|
},
|
||||||
|
"uploadUrl": "/api/files.create",
|
||||||
|
"form": {
|
||||||
|
"Content-Type": "image/jpeg",
|
||||||
|
"key": "uploads/user-1/attach-1/avatar.jpg",
|
||||||
|
"acl": "public-read"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = try await client.createAttachment(
|
||||||
|
CreateAttachmentRequest(name: "avatar.jpg", contentType: "image/jpeg", size: 59304)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(result.attachment.id, "attach-1")
|
||||||
|
XCTAssertEqual(result.attachment.size, "59304")
|
||||||
|
XCTAssertEqual(result.uploadUrl, "/api/files.create")
|
||||||
|
XCTAssertEqual(result.form["acl"], "public-read")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.create")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUploadAttachmentFilePostsToTheResolvedRelativeURL() 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
|
||||||
|
)
|
||||||
|
|
||||||
|
let uploadTarget = CreateAttachmentResult(
|
||||||
|
attachment: OutlineAttachment(
|
||||||
|
id: "attach-1",
|
||||||
|
documentId: nil,
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
name: "avatar.jpg",
|
||||||
|
url: "/api/attachments.redirect?id=attach-1",
|
||||||
|
size: "3"
|
||||||
|
),
|
||||||
|
uploadUrl: "/api/files.create",
|
||||||
|
form: ["Content-Type": "image/jpeg", "key": "uploads/attach-1"]
|
||||||
|
)
|
||||||
|
|
||||||
|
try await client.uploadAttachmentFile(uploadTarget, fileData: Data("abc".utf8))
|
||||||
|
|
||||||
|
// Resolved against baseURL's host, not appended onto "/api/<baseURL-relative-path>".
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.absoluteString, "https://outline.example.com/api/files.create")
|
||||||
|
XCTAssertNil(httpClient.lastRequest?.value(forHTTPHeaderField: "Authorization"))
|
||||||
|
let contentType = httpClient.lastRequest?.value(forHTTPHeaderField: "Content-Type")
|
||||||
|
XCTAssertTrue(contentType?.hasPrefix("multipart/form-data; boundary=") ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
func testMissingTokenThrowsTokenUnavailable() async throws {
|
func testMissingTokenThrowsTokenUnavailable() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
let client = LiveOutlineAPIClient(
|
let client = LiveOutlineAPIClient(
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import OutlineKit
|
||||||
|
|
||||||
|
final class MultipartFormDataBuilderTests: XCTestCase {
|
||||||
|
func testBuildIncludesEveryFieldAndTheFile() throws {
|
||||||
|
let fileData = Data("fake-jpeg-bytes".utf8)
|
||||||
|
let (body, contentType) = MultipartFormDataBuilder.build(
|
||||||
|
fields: ["key": "uploads/abc", "acl": "public-read", "Content-Type": "image/jpeg"],
|
||||||
|
fileFieldName: "file",
|
||||||
|
fileName: "avatar.jpg",
|
||||||
|
fileData: fileData,
|
||||||
|
fileContentType: "image/jpeg",
|
||||||
|
boundary: "TestBoundary"
|
||||||
|
)
|
||||||
|
|
||||||
|
let bodyString = String(decoding: body, as: UTF8.self)
|
||||||
|
|
||||||
|
XCTAssertEqual(contentType, "multipart/form-data; boundary=TestBoundary")
|
||||||
|
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"key\""))
|
||||||
|
XCTAssertTrue(bodyString.contains("uploads/abc"))
|
||||||
|
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"acl\""))
|
||||||
|
XCTAssertTrue(bodyString.contains("public-read"))
|
||||||
|
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"file\"; filename=\"avatar.jpg\""))
|
||||||
|
XCTAssertTrue(bodyString.contains("fake-jpeg-bytes"))
|
||||||
|
XCTAssertTrue(bodyString.hasPrefix("--TestBoundary\r\n"))
|
||||||
|
XCTAssertTrue(bodyString.hasSuffix("--TestBoundary--\r\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFileFieldComesAfterAllFormFields() throws {
|
||||||
|
let (body, _) = MultipartFormDataBuilder.build(
|
||||||
|
fields: ["a": "1", "b": "2"],
|
||||||
|
fileFieldName: "file",
|
||||||
|
fileName: "x.jpg",
|
||||||
|
fileData: Data("bytes".utf8),
|
||||||
|
fileContentType: "image/jpeg",
|
||||||
|
boundary: "B"
|
||||||
|
)
|
||||||
|
let bodyString = String(decoding: body, as: UTF8.self)
|
||||||
|
|
||||||
|
let fieldsRange = bodyString.range(of: "name=\"a\"")
|
||||||
|
let fileRange = bodyString.range(of: "name=\"file\"")
|
||||||
|
XCTAssertNotNil(fieldsRange)
|
||||||
|
XCTAssertNotNil(fileRange)
|
||||||
|
if let fieldsRange, let fileRange {
|
||||||
|
XCTAssertTrue(fieldsRange.lowerBound < fileRange.lowerBound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -299,7 +299,7 @@
|
|||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -361,7 +361,7 @@
|
|||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -385,15 +385,20 @@
|
|||||||
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;
|
||||||
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Outpost;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||||
@@ -408,9 +413,10 @@
|
|||||||
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 = 0.0.3;
|
MARKETING_VERSION = 0.0.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -430,15 +436,20 @@
|
|||||||
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;
|
||||||
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Outpost;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||||
@@ -453,9 +464,10 @@
|
|||||||
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 = 0.0.3;
|
MARKETING_VERSION = 0.0.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -476,7 +488,7 @@
|
|||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||||
@@ -502,7 +514,7 @@
|
|||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||||
@@ -527,7 +539,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||||
@@ -552,7 +564,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "outpost-ios-1024.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 440 KiB |
@@ -2,29 +2,17 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Bare content (icon, name, version, links) with no window chrome — reused
|
/// Bare content (icon, name, version, links) with no window chrome — the
|
||||||
/// by both the standalone "About Outpost" window (`AboutView`, the standard
|
/// macOS "About Outpost" app-menu command now opens Settings' own About
|
||||||
/// macOS app-menu affordance) and the Settings page's own About section, so
|
/// section directly (no separate popup window), so this is its only caller.
|
||||||
/// the two can't drift out of sync.
|
|
||||||
struct AboutInfoView: View {
|
struct AboutInfoView: View {
|
||||||
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
|
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
|
||||||
private let releasesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/releases")!
|
|
||||||
|
|
||||||
var appName: String {
|
var appName: String {
|
||||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost"
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bumped alongside `MARKETING_VERSION` in the Xcode project — kept out
|
var versionString: String { OutpostVersion.fullVersionString }
|
||||||
/// 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"
|
|
||||||
|
|
||||||
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 {
|
private var copyrightYear: String {
|
||||||
String(Calendar.current.component(.year, from: Date()))
|
String(Calendar.current.component(.year, from: Date()))
|
||||||
@@ -55,35 +43,15 @@ struct AboutInfoView: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
VStack(spacing: 10) {
|
Link(destination: repositoryURL) {
|
||||||
Link(destination: repositoryURL) {
|
Label("View Source on Git", systemImage: "link")
|
||||||
Label("View Source on Git", systemImage: "link")
|
|
||||||
}
|
|
||||||
.font(.callout)
|
|
||||||
|
|
||||||
Button("Check for Updates…") {
|
|
||||||
checkForUpdates()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.font(.callout)
|
||||||
|
|
||||||
Text("© \(copyrightYear) Puranjay Savar Mattas")
|
Text("© \(copyrightYear) Puranjay Savar Mattas")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AboutView: View {
|
|
||||||
var body: some View {
|
|
||||||
AboutInfoView()
|
|
||||||
.padding(32)
|
|
||||||
.frame(width: 320)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Crop/rotate/zoom editor shown after picking a photo, before it's
|
||||||
|
/// uploaded — pan (drag), zoom (pinch or the slider), and 90°-increment
|
||||||
|
/// rotate, all inside a circular mask matching how the avatar actually
|
||||||
|
/// renders everywhere else in the app.
|
||||||
|
///
|
||||||
|
/// The on-screen preview and the final exported image are built from the
|
||||||
|
/// exact same view composition (`avatarContent`), just instantiated once
|
||||||
|
/// for display and once inside an `ImageRenderer` — that's deliberate:
|
||||||
|
/// hand-deriving a separate set of crop-math for a higher-resolution
|
||||||
|
/// render would risk it silently disagreeing with what the user actually
|
||||||
|
/// saw and confirmed, and there's no way to visually verify that
|
||||||
|
/// agreement without running the app. Reusing the identical view tree
|
||||||
|
/// makes the export WYSIWYG by construction instead of by careful math.
|
||||||
|
struct AvatarCropperView: View {
|
||||||
|
let sourceImage: NSImage
|
||||||
|
let onConfirm: (Data) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
@State private var scale: CGFloat = 1
|
||||||
|
@State private var offset: CGSize = .zero
|
||||||
|
@State private var rotationDegrees: Double = 0
|
||||||
|
@GestureState private var dragTranslation: CGSize = .zero
|
||||||
|
|
||||||
|
/// Used for both the live preview and the exported image — see the
|
||||||
|
/// type-level doc comment for why that's the same size, not two.
|
||||||
|
private let diameter: CGFloat = 320
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Text("Edit Photo")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
avatarContent
|
||||||
|
.clipShape(Circle())
|
||||||
|
Circle()
|
||||||
|
.strokeBorder(Color.primary.opacity(0.15), lineWidth: 1)
|
||||||
|
}
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
.contentShape(Circle())
|
||||||
|
.gesture(
|
||||||
|
DragGesture()
|
||||||
|
.updating($dragTranslation) { value, state, _ in state = value.translation }
|
||||||
|
.onEnded { value in
|
||||||
|
offset.width += value.translation.width
|
||||||
|
offset.height += value.translation.height
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees -= 90 }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "rotate.left")
|
||||||
|
}
|
||||||
|
.help("Rotate left")
|
||||||
|
|
||||||
|
Slider(value: $scale, in: 1...4)
|
||||||
|
.frame(width: 140)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees += 90 }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "rotate.right")
|
||||||
|
}
|
||||||
|
.help("Rotate right")
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Cancel", role: .cancel, action: onCancel)
|
||||||
|
Spacer()
|
||||||
|
Button("Use Photo") {
|
||||||
|
if let data = renderFinalImage() {
|
||||||
|
onConfirm(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.frame(width: 360)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aspect-fills `sourceImage` into a `diameter`×`diameter` square, then
|
||||||
|
/// applies the user's pan/zoom/rotation on top — identical between the
|
||||||
|
/// live preview and the final render (see the type-level doc comment).
|
||||||
|
private var avatarContent: some View {
|
||||||
|
Image(nsImage: sourceImage)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
.scaleEffect(scale)
|
||||||
|
.rotationEffect(.degrees(rotationDegrees))
|
||||||
|
.offset(x: offset.width + dragTranslation.width, y: offset.height + dragTranslation.height)
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
.clipped()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func renderFinalImage() -> Data? {
|
||||||
|
let content = avatarContent
|
||||||
|
.clipShape(Circle())
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
let renderer = ImageRenderer(content: content)
|
||||||
|
renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays
|
||||||
|
guard let nsImage = renderer.nsImage else { return nil }
|
||||||
|
return nsImage.jpegData(compressionQuality: 0.9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -1,15 +1,34 @@
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import OutlineKit
|
||||||
|
|
||||||
/// Swapped into the real sidebar's content slot (search field, collections
|
/// Swapped into the real sidebar's content slot (search field, collections
|
||||||
/// tree, account footer) while Settings is open — same sidebar, different
|
/// tree, account footer) while Settings is open — same sidebar, different
|
||||||
/// content, rather than a separate mini sidebar nested inside a page. "Done"
|
/// content, rather than a separate mini sidebar nested inside a page. "Done"
|
||||||
/// clears `AppNavigation.isShowingSettings`, which puts the collections tree
|
/// clears `AppNavigation.isShowingSettings`, which puts the collections tree
|
||||||
/// back.
|
/// back.
|
||||||
|
///
|
||||||
|
/// Grouped by `SettingsCategory` — `general` (ours) sits under an "Outpost"
|
||||||
|
/// header at the top, then Outline's own Account/Workspace groups, matching
|
||||||
|
/// the settings page structure of the Outline web app. Outline's own server
|
||||||
|
/// version has no dedicated section (there used to be an Integrations &
|
||||||
|
/// Installation category for just that) — it's cheap enough to show
|
||||||
|
/// unconditionally in the footer here instead, alongside Outpost's own
|
||||||
|
/// version.
|
||||||
struct SettingsSidebarList: View {
|
struct SettingsSidebarList: View {
|
||||||
@Binding var selection: SettingsSection?
|
@Binding var selection: SettingsSection?
|
||||||
let onDone: () -> Void
|
let onDone: () -> Void
|
||||||
|
|
||||||
|
@Environment(SessionStore.self) private var session
|
||||||
|
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||||
|
@State private var outlineVersion: String?
|
||||||
|
|
||||||
|
/// Mirrors `SettingsView`'s own check — a real dropped connection or
|
||||||
|
/// the manual Offline Mode toggle both mean there's no server to ask.
|
||||||
|
private var isEffectivelyOnline: Bool {
|
||||||
|
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -22,20 +41,75 @@ struct SettingsSidebarList: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
List(SettingsSection.allCases, selection: $selection) { section in
|
List(selection: $selection) {
|
||||||
Label(section.title, systemImage: section.icon)
|
ForEach(SettingsCategory.allCases) { category in
|
||||||
.tag(section)
|
let sections = SettingsSection.allCases.filter { $0.category == category }
|
||||||
|
Section {
|
||||||
|
ForEach(sections) { section in
|
||||||
|
Label(section.title, systemImage: section.icon)
|
||||||
|
.tag(section)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
if let title = category.title {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(title)
|
||||||
|
// Not hardcoded to `.workspace` specifically —
|
||||||
|
// stays correct on its own as sections get
|
||||||
|
// built, only shows while every section in
|
||||||
|
// the category is still `!isImplemented`.
|
||||||
|
if sections.allSatisfy({ !$0.isImplemented }) {
|
||||||
|
Text("Coming Soon")
|
||||||
|
.font(.system(size: 9, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(.secondary.opacity(0.15), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.sidebar)
|
.listStyle(.sidebar)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
versionFooter
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
Button("Done", action: onDone)
|
Button("Done", action: onDone)
|
||||||
.keyboardShortcut(.cancelAction)
|
.keyboardShortcut(.cancelAction)
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(12)
|
.padding(12)
|
||||||
}
|
}
|
||||||
|
// Keyed to connectivity, not a one-shot `.task {}` — reconnecting
|
||||||
|
// (or turning the manual Offline Mode toggle back off) re-fires
|
||||||
|
// this automatically instead of leaving the footer stuck on
|
||||||
|
// whatever it last knew, or blank, until Settings is reopened.
|
||||||
|
.task(id: isEffectivelyOnline) { await refreshOutlineVersion() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var versionFooter: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Outpost \(OutpostVersion.displayString)")
|
||||||
|
if let outlineVersion {
|
||||||
|
Text("Outline \(outlineVersion)")
|
||||||
|
} else if !isEffectivelyOnline {
|
||||||
|
Text("Outline — offline")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshOutlineVersion() async {
|
||||||
|
guard isEffectivelyOnline, let apiClient = session.apiClient else { return }
|
||||||
|
outlineVersion = try? await apiClient.installationInfo().version
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,21 +3,17 @@ import SwiftUI
|
|||||||
struct AuthHeaderView: View {
|
struct AuthHeaderView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
ZStack {
|
// A plain Image Set, not the AppIcon *app icon* asset — App Icon
|
||||||
Circle()
|
// sets aren't reliably resolvable through Image(_:)/UIImage
|
||||||
.fill(
|
// (named:) at runtime (confirmed live: showed nothing). This is
|
||||||
LinearGradient(
|
// the same source artwork (outpost-ios-1024.png) duplicated
|
||||||
colors: [Color.accentColor, Color.accentColor.opacity(0.6)],
|
// into a normal image set so SwiftUI can actually load it.
|
||||||
startPoint: .topLeading,
|
Image("AppLogo")
|
||||||
endPoint: .bottomTrailing
|
.resizable()
|
||||||
)
|
.scaledToFit()
|
||||||
)
|
.frame(width: 72, height: 72)
|
||||||
.frame(width: 64, height: 64)
|
.clipShape(RoundedRectangle(cornerRadius: 72 * 0.2237, style: .continuous))
|
||||||
Image(systemName: "text.book.closed.fill")
|
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||||
.font(.system(size: 26, weight: .semibold))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
}
|
|
||||||
.shadow(color: Color.accentColor.opacity(0.35), radius: 12, y: 6)
|
|
||||||
|
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
Text("Welcome to Outpost")
|
Text("Welcome to Outpost")
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import SwiftUI
|
||||||
|
import OutlineKit
|
||||||
|
|
||||||
|
/// ⌘K. Settings → Editor → Command Palette. Always searches locally, never a
|
||||||
|
/// per-keystroke network request. Two data-source modes:
|
||||||
|
///
|
||||||
|
/// - Lightweight (default): a live `listCollections` + `listViewedDocuments`
|
||||||
|
/// fetch once when the palette opens — two small requests, near-instant,
|
||||||
|
/// works with no setup.
|
||||||
|
/// - Full Workspace: reads `CachingOutlineAPIClient`'s local SwiftData cache
|
||||||
|
/// directly (`cachedDocumentsIndex()`/`cachedCollectionsIndex()`) — zero
|
||||||
|
/// network calls at all, and includes every nested sub-document, not just
|
||||||
|
/// collection roots. Requires Full Local Sync to actually have populated
|
||||||
|
/// that cache first (gated in Settings — the toggle here is disabled
|
||||||
|
/// without it); this view doesn't trigger a sync itself.
|
||||||
|
struct CommandPaletteView: View {
|
||||||
|
let apiClient: OutlineAPIClient
|
||||||
|
let cachingClient: CachingOutlineAPIClient?
|
||||||
|
let fullWorkspaceSearch: Bool
|
||||||
|
let onSelectDocument: (OutlineDocument) -> Void
|
||||||
|
let onSelectCollection: (OutlineCollection) -> Void
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
@State private var query = ""
|
||||||
|
@State private var collections: [OutlineCollection] = []
|
||||||
|
@State private var documents: [OutlineDocument] = []
|
||||||
|
@State private var isLoading = true
|
||||||
|
@State private var selectedIndex = 0
|
||||||
|
@FocusState private var isSearchFieldFocused: Bool
|
||||||
|
|
||||||
|
private enum Result: Identifiable {
|
||||||
|
case collection(OutlineCollection)
|
||||||
|
case document(OutlineDocument)
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .collection(let collection): return "collection-\(collection.id)"
|
||||||
|
case .document(let document): return "document-\(document.id)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var results: [Result] {
|
||||||
|
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else {
|
||||||
|
// No query yet: surface collections first, then the most
|
||||||
|
// recent/full-workspace documents as-is, capped so the panel
|
||||||
|
// doesn't dump the entire workspace with nothing typed.
|
||||||
|
return (collections.map(Result.collection) + documents.map(Result.document))
|
||||||
|
.prefix(20)
|
||||||
|
.map { $0 }
|
||||||
|
}
|
||||||
|
let scored: [(Result, Int)] = collections.compactMap { collection in
|
||||||
|
matchScore(collection.name, query: trimmed).map { (Result.collection(collection), $0) }
|
||||||
|
} + documents.compactMap { document in
|
||||||
|
matchScore(document.title, query: trimmed).map { (Result.document(document), $0) }
|
||||||
|
}
|
||||||
|
return scored.sorted { $0.1 < $1.1 }.prefix(30).map(\.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lower is better — exact match, then prefix match, then earliest
|
||||||
|
/// contiguous-substring position, then (for multi-word queries) every
|
||||||
|
/// word present somewhere in the title in any order. That last tier is
|
||||||
|
/// what makes "test document" find a title like "Test Plan Document" —
|
||||||
|
/// requiring the exact phrase contiguously (the previous behavior)
|
||||||
|
/// meant a title with anything between the words never matched at all,
|
||||||
|
/// which looked like "documents never show up, only collections" any
|
||||||
|
/// time the real title didn't happen to contain the typed phrase
|
||||||
|
/// verbatim. `nil` means no match at all. Still deliberately not a full
|
||||||
|
/// fuzzy/Levenshtein algorithm — good enough for document/collection
|
||||||
|
/// titles without the unpredictability that brings.
|
||||||
|
private func matchScore(_ title: String, query: String) -> Int? {
|
||||||
|
let haystack = title.lowercased()
|
||||||
|
let needle = query.lowercased()
|
||||||
|
if haystack == needle { return 0 }
|
||||||
|
if haystack.hasPrefix(needle) { return 1 }
|
||||||
|
if let range = haystack.range(of: needle) {
|
||||||
|
return 2 + haystack.distance(from: haystack.startIndex, to: range.lowerBound)
|
||||||
|
}
|
||||||
|
let words = needle.split(separator: " ").map(String.init)
|
||||||
|
guard words.count > 1, words.allSatisfy({ haystack.contains($0) }) else { return nil }
|
||||||
|
let totalPosition = words.reduce(0) { partial, word in
|
||||||
|
guard let range = haystack.range(of: word) else { return partial }
|
||||||
|
return partial + haystack.distance(from: haystack.startIndex, to: range.lowerBound)
|
||||||
|
}
|
||||||
|
return 100 + totalPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black.opacity(0.001) // catches clicks outside the card to dismiss
|
||||||
|
.onTapGesture { onDismiss() }
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("Search documents and collections…", text: $query)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.font(.title3)
|
||||||
|
.focused($isSearchFieldFocused)
|
||||||
|
.onChange(of: query) { selectedIndex = 0 }
|
||||||
|
.onSubmit { selectCurrent() }
|
||||||
|
// Attached directly on the field itself, not an
|
||||||
|
// ancestor — confirmed live that .onKeyPress on the
|
||||||
|
// outer card never saw arrow-key events at all while
|
||||||
|
// this TextField actually held focus, the up/down
|
||||||
|
// presses just went nowhere. Escape still needs its
|
||||||
|
// own handler below since this one only covers
|
||||||
|
// whichever view is actually focused.
|
||||||
|
.onKeyPress(.downArrow) { moveSelection(by: 1); return .handled }
|
||||||
|
.onKeyPress(.upArrow) { moveSelection(by: -1); return .handled }
|
||||||
|
.onKeyPress(.escape) { onDismiss(); return .handled }
|
||||||
|
if isLoading {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
if results.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
isLoading ? "Loading…" : "No Results",
|
||||||
|
systemImage: isLoading ? "ellipsis" : "magnifyingglass"
|
||||||
|
)
|
||||||
|
.frame(height: 160)
|
||||||
|
} else {
|
||||||
|
ScrollViewReader { scrollProxy in
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
|
ForEach(Array(results.enumerated()), id: \.element.id) { index, result in
|
||||||
|
resultRow(result, isSelected: index == selectedIndex)
|
||||||
|
.id(index)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
selectedIndex = index
|
||||||
|
selectCurrent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(6)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 360)
|
||||||
|
.onChange(of: selectedIndex) { _, newValue in
|
||||||
|
scrollProxy.scrollTo(newValue, anchor: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(.separator))
|
||||||
|
.frame(width: 560)
|
||||||
|
.shadow(color: .black.opacity(0.3), radius: 24, y: 12)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
// The window/responder chain isn't always ready to accept a
|
||||||
|
// first-responder change in the same instant this view is
|
||||||
|
// inserted — confirmed live: setting this synchronously on
|
||||||
|
// appear left the field unfocused until manually clicked
|
||||||
|
// (also the likely source of several "entangle context after
|
||||||
|
// pre-commit" / CA-transaction warnings in the console, which
|
||||||
|
// are exactly what fighting AppKit for first-responder status
|
||||||
|
// mid-commit looks like). A one-frame-ish delay is enough for
|
||||||
|
// the overlay's insertion to settle first.
|
||||||
|
try? await Task.sleep(for: .milliseconds(50))
|
||||||
|
isSearchFieldFocused = true
|
||||||
|
await loadResults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resultRow(_ result: Result, isSelected: Bool) -> some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
switch result {
|
||||||
|
case .collection(let collection):
|
||||||
|
// Reuses the sidebar's own icon logic (emoji vs Outline's
|
||||||
|
// icon-key-to-SF-Symbol mapping vs fallback) instead of
|
||||||
|
// guessing — `collection.icon` isn't a raw SF Symbol name.
|
||||||
|
CollectionRowView(collection: collection)
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.frame(width: 20)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(collection.name)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text("Collection")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
case .document(let document):
|
||||||
|
if let emoji = document.emoji {
|
||||||
|
Text(emoji).frame(width: 20)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "doc.text")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 20)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(document.title.isEmpty ? "Untitled" : document.title)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text("Document")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(isSelected ? Color.accentColor.opacity(0.15) : .clear, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func moveSelection(by delta: Int) {
|
||||||
|
guard !results.isEmpty else { return }
|
||||||
|
selectedIndex = max(0, min(results.count - 1, selectedIndex + delta))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectCurrent() {
|
||||||
|
guard results.indices.contains(selectedIndex) else { return }
|
||||||
|
switch results[selectedIndex] {
|
||||||
|
case .collection(let collection): onSelectCollection(collection)
|
||||||
|
case .document(let document): onSelectDocument(document)
|
||||||
|
}
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadResults() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
if fullWorkspaceSearch {
|
||||||
|
// Purely local SwiftData reads — no network at all, and (since
|
||||||
|
// Full Local Sync now recurses into every document's children)
|
||||||
|
// this includes nested sub-documents the live per-collection
|
||||||
|
// fetch never could. Empty if a sync has never actually run.
|
||||||
|
collections = await cachingClient?.cachedCollectionsIndex() ?? []
|
||||||
|
documents = await cachingClient?.cachedDocumentsIndex() ?? []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
async let fetchedCollections = (try? apiClient.listCollections(offset: 0, limit: 250)) ?? []
|
||||||
|
async let fetchedRecent = (try? apiClient.listViewedDocuments(offset: 0, limit: 30)) ?? []
|
||||||
|
collections = await fetchedCollections
|
||||||
|
documents = await fetchedRecent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -6,6 +6,7 @@ struct ContentView_macOS: View {
|
|||||||
@Environment(SessionStore.self) private var session
|
@Environment(SessionStore.self) private var session
|
||||||
@Environment(AppNavigation.self) private var navigation
|
@Environment(AppNavigation.self) private var navigation
|
||||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||||
|
@AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false
|
||||||
/// The landing state — no collection selected yet is what Home actually
|
/// The landing state — no collection selected yet is what Home actually
|
||||||
/// means, so this starts `true` rather than auto-selecting the first
|
/// means, so this starts `true` rather than auto-selecting the first
|
||||||
/// collection the way this used to work.
|
/// collection the way this used to work.
|
||||||
@@ -23,6 +24,11 @@ struct ContentView_macOS: View {
|
|||||||
/// Home's "New Document" buttons) — every expanded sidebar row reloads
|
/// Home's "New Document" buttons) — every expanded sidebar row reloads
|
||||||
/// itself in response. See `CollectionDocumentsOutline.externalRefreshToken`.
|
/// itself in response. See `CollectionDocumentsOutline.externalRefreshToken`.
|
||||||
@State private var documentsChangedToken = 0
|
@State private var documentsChangedToken = 0
|
||||||
|
/// Guards the restore-on-launch attempt to exactly once per app launch
|
||||||
|
/// — without this, `mainContent`'s `.task` would re-run (and
|
||||||
|
/// re-navigate out from under the user) every time it reappears, e.g.
|
||||||
|
/// after a trip through Settings.
|
||||||
|
@State private var hasAttemptedLocationRestore = false
|
||||||
|
|
||||||
private var trimmedGlobalQuery: String {
|
private var trimmedGlobalQuery: String {
|
||||||
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -147,6 +153,38 @@ struct ContentView_macOS: View {
|
|||||||
if newValue != nil {
|
if newValue != nil {
|
||||||
isShowingHome = false
|
isShowingHome = false
|
||||||
}
|
}
|
||||||
|
persistLastLocationIfEnabled()
|
||||||
|
}
|
||||||
|
.onChange(of: documentPath) { _, _ in
|
||||||
|
persistLastLocationIfEnabled()
|
||||||
|
}
|
||||||
|
.onChange(of: isShowingHome) { _, _ in
|
||||||
|
persistLastLocationIfEnabled()
|
||||||
|
}
|
||||||
|
// Once per launch, before the user has a chance to navigate
|
||||||
|
// manually — restores whatever `restoreLastLocationIfEnabled`
|
||||||
|
// finds, or leaves today's Home default alone if there's nothing
|
||||||
|
// to restore (preference off, nothing stored yet, or resolution
|
||||||
|
// fails e.g. a deleted document/collection or being offline).
|
||||||
|
.task {
|
||||||
|
guard !hasAttemptedLocationRestore else { return }
|
||||||
|
hasAttemptedLocationRestore = true
|
||||||
|
await restoreLastLocationIfEnabled()
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if navigation.isShowingCommandPalette, let apiClient = session.apiClient {
|
||||||
|
CommandPaletteView(
|
||||||
|
apiClient: apiClient,
|
||||||
|
cachingClient: session.cachingClient,
|
||||||
|
fullWorkspaceSearch: isCommandPaletteFullWorkspaceSearch,
|
||||||
|
onSelectDocument: openDocument,
|
||||||
|
onSelectCollection: { collection in
|
||||||
|
selectedCollection = collection
|
||||||
|
replaceDocumentPath(with: [])
|
||||||
|
},
|
||||||
|
onDismiss: { navigation.isShowingCommandPalette = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +198,63 @@ struct ContentView_macOS: View {
|
|||||||
replaceDocumentPath(with: [])
|
replaceDocumentPath(with: [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Remember previous location (Preferences → Remember previous location)
|
||||||
|
|
||||||
|
private static let lastLocationDefaultsKey = "outline.lastLocation"
|
||||||
|
|
||||||
|
/// What gets persisted — `isHome` disambiguates "was on Home" from "no
|
||||||
|
/// collection selected yet" (the latter only otherwise happens on the
|
||||||
|
/// brief `ContentUnavailableView` placeholder state), since both would
|
||||||
|
/// otherwise look identical (`collectionId == nil`).
|
||||||
|
private struct LastLocation: Codable {
|
||||||
|
var isHome: Bool
|
||||||
|
var collectionId: String?
|
||||||
|
var documentIds: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called from every navigation-changing `.onChange` — cheap to persist
|
||||||
|
/// on every change rather than debouncing, this is just a small JSON
|
||||||
|
/// blob in `UserDefaults`, not a network call.
|
||||||
|
private func persistLastLocationIfEnabled() {
|
||||||
|
guard session.userPreferences?.rememberLastPath == true else { return }
|
||||||
|
let location = LastLocation(isHome: isShowingHome, collectionId: selectedCollection?.id, documentIds: documentPath.map(\.id))
|
||||||
|
guard let data = try? JSONEncoder().encode(location) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: Self.lastLocationDefaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves IDs back into real `OutlineCollection`/`OutlineDocument`
|
||||||
|
/// objects via the API — stored IDs alone aren't enough to populate
|
||||||
|
/// `selectedCollection`/`documentPath` directly. Resolves the document
|
||||||
|
/// chain in order and stops at the first failure (deleted document,
|
||||||
|
/// offline, etc.) rather than aborting the whole restore — whatever
|
||||||
|
/// prefix of the chain resolved successfully is still a better landing
|
||||||
|
/// spot than falling all the way back to Home.
|
||||||
|
private func restoreLastLocationIfEnabled() async {
|
||||||
|
guard session.userPreferences?.rememberLastPath == true,
|
||||||
|
let apiClient = session.apiClient,
|
||||||
|
let data = UserDefaults.standard.data(forKey: Self.lastLocationDefaultsKey),
|
||||||
|
let location = try? JSONDecoder().decode(LastLocation.self, from: data)
|
||||||
|
else { return }
|
||||||
|
// A pure "was on Home, nothing pushed" location needs no action —
|
||||||
|
// Home is already the default state before this ever runs.
|
||||||
|
guard location.collectionId != nil || !location.documentIds.isEmpty else { return }
|
||||||
|
|
||||||
|
if let collectionId = location.collectionId {
|
||||||
|
guard let collection = try? await apiClient.collectionInfo(id: collectionId) else { return }
|
||||||
|
selectedCollection = collection
|
||||||
|
isShowingHome = false
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedChain: [OutlineDocument] = []
|
||||||
|
for documentId in location.documentIds {
|
||||||
|
guard let document = try? await apiClient.documentInfo(id: documentId) else { break }
|
||||||
|
resolvedChain.append(document)
|
||||||
|
}
|
||||||
|
if !resolvedChain.isEmpty {
|
||||||
|
replaceDocumentPath(with: resolvedChain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var contextualSearchField: some View {
|
private var contextualSearchField: some View {
|
||||||
if isContextualSearchExpanded || !contextualSearchQuery.isEmpty {
|
if isContextualSearchExpanded || !contextualSearchQuery.isEmpty {
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ struct DocumentReaderView: View {
|
|||||||
@Environment(SessionStore.self) private var session
|
@Environment(SessionStore.self) private var session
|
||||||
@Environment(StarStore.self) private var starStore
|
@Environment(StarStore.self) private var starStore
|
||||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||||
|
/// Local-only Outpost setting (Settings → Editor), not synced to
|
||||||
|
/// Outline — see `SettingsView.editorDetail`.
|
||||||
|
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
|
||||||
|
|
||||||
@State private var viewModel: DocumentReaderViewModel
|
@State private var viewModel: DocumentReaderViewModel
|
||||||
let apiClient: OutlineAPIClient
|
let apiClient: OutlineAPIClient
|
||||||
@@ -51,6 +54,11 @@ struct DocumentReaderView: View {
|
|||||||
) {
|
) {
|
||||||
self.apiClient = apiClient
|
self.apiClient = apiClient
|
||||||
self.document = document
|
self.document = document
|
||||||
|
// `separateEditingEnabled` can't be read from `@Environment` here —
|
||||||
|
// environment values aren't populated yet inside a view's `init`,
|
||||||
|
// only from `body` onward. Defaults to `true` (today's only
|
||||||
|
// behavior) and gets set for real in `.task` below once `session`
|
||||||
|
// is actually available.
|
||||||
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
|
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
|
||||||
self.onOpenChild = onOpenChild
|
self.onOpenChild = onOpenChild
|
||||||
self.onDeleted = onDeleted
|
self.onDeleted = onDeleted
|
||||||
@@ -66,44 +74,26 @@ struct DocumentReaderView: View {
|
|||||||
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Split View needs the full window height (each pane scrolls itself),
|
||||||
|
/// which an unbounded page-level `ScrollView` can't give it — a
|
||||||
|
/// `minHeight` inside one just resolves to exactly that minimum, not
|
||||||
|
/// "fill available space", since there's no bounded space to fill.
|
||||||
|
/// Only switches over once there's real content to show; loading/error
|
||||||
|
/// states still go through the normal scrolling layout.
|
||||||
|
private var canShowSplitView: Bool {
|
||||||
|
isSplitViewEnabled
|
||||||
|
&& viewModel.isEffectivelyEditable
|
||||||
|
&& viewModel.errorMessage == nil
|
||||||
|
&& !(viewModel.isLoading && viewModel.text.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
Group {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
if canShowSplitView {
|
||||||
if viewModel.isEditing {
|
splitViewContent
|
||||||
TextField("Title", text: $viewModel.title)
|
} else {
|
||||||
.font(.largeTitle.weight(.bold))
|
scrollingReaderContent
|
||||||
.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),
|
|
||||||
documentId: viewModel.documentId,
|
|
||||||
isEditable: viewModel.isEditing
|
|
||||||
)
|
|
||||||
|
|
||||||
if !viewModel.children.isEmpty {
|
|
||||||
childrenSection
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
}
|
||||||
.overlay(alignment: .topTrailing) {
|
.overlay(alignment: .topTrailing) {
|
||||||
if viewModel.isLoading && !viewModel.text.isEmpty {
|
if viewModel.isLoading && !viewModel.text.isEmpty {
|
||||||
@@ -127,16 +117,24 @@ struct DocumentReaderView: View {
|
|||||||
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
if viewModel.separateEditingEnabled {
|
||||||
Task { await viewModel.toggleEditing() }
|
Button {
|
||||||
} label: {
|
Task { await viewModel.toggleEditing() }
|
||||||
if viewModel.isSaving {
|
} label: {
|
||||||
ProgressView().controlSize(.small)
|
if viewModel.isSaving {
|
||||||
} else {
|
ProgressView().controlSize(.small)
|
||||||
Text(viewModel.isEditing ? "Done" : "Edit")
|
} else {
|
||||||
|
Text(viewModel.isEditing ? "Done" : "Edit")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(viewModel.isSaving)
|
||||||
|
} else if viewModel.isSaving {
|
||||||
|
// No Edit/Done affordance when documents are always
|
||||||
|
// editable — this is the only feedback that an autosave
|
||||||
|
// is actually happening.
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
.help("Saving…")
|
||||||
}
|
}
|
||||||
.disabled(viewModel.isSaving)
|
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
isShowingNewDocumentSheet = true
|
isShowingNewDocumentSheet = true
|
||||||
@@ -160,6 +158,17 @@ struct DocumentReaderView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task { await viewModel.loadFullContent() }
|
.task { await viewModel.loadFullContent() }
|
||||||
|
// See the doc comment on `DocumentReaderViewModel.separateEditingEnabled`
|
||||||
|
// for why this can't just be read at `init` time.
|
||||||
|
.task { viewModel.separateEditingEnabled = session.userPreferences?.separateEditing ?? true }
|
||||||
|
.onChange(of: viewModel.text) {
|
||||||
|
guard !viewModel.separateEditingEnabled else { return }
|
||||||
|
viewModel.scheduleAutosave()
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.title) {
|
||||||
|
guard !viewModel.separateEditingEnabled else { return }
|
||||||
|
viewModel.scheduleAutosave()
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
await viewModel.loadPinAndSubscriptionState()
|
await viewModel.loadPinAndSubscriptionState()
|
||||||
}
|
}
|
||||||
@@ -272,31 +281,99 @@ struct DocumentReaderView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var childrenSection: some View {
|
/// Today's single-pane layout — page-level `ScrollView` wrapping title +
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
/// content, used for the normal reading/editing view, and for every
|
||||||
Divider()
|
/// loading/error state regardless of Split View.
|
||||||
.padding(.vertical, 4)
|
private var scrollingReaderContent: some View {
|
||||||
|
ScrollView {
|
||||||
Text("Sub-documents")
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
.font(.caption.weight(.semibold))
|
if viewModel.isEffectivelyEditable {
|
||||||
.foregroundStyle(.secondary)
|
TextField("Title", text: $viewModel.title)
|
||||||
|
.font(.largeTitle.weight(.bold))
|
||||||
ForEach(viewModel.children) { child in
|
.textFieldStyle(.plain)
|
||||||
Button {
|
|
||||||
onOpenChild(child)
|
|
||||||
} label: {
|
|
||||||
DocumentRowView(document: child)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
|
|
||||||
if child.id != viewModel.children.last?.id {
|
if viewModel.isLoading && viewModel.text.isEmpty {
|
||||||
Divider()
|
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),
|
||||||
|
documentId: viewModel.documentId,
|
||||||
|
isEditable: viewModel.isEffectivelyEditable
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Split View's layout — title fixed at the top (not part of either
|
||||||
|
/// scrolling pane), `splitEditorView` filling every remaining pixel of
|
||||||
|
/// the window below it. No outer `ScrollView` here on purpose: each
|
||||||
|
/// pane already scrolls itself, and nesting that inside another
|
||||||
|
/// unbounded scroll container is exactly what was capping both panes
|
||||||
|
/// at a fixed height instead of spanning the window.
|
||||||
|
private var splitViewContent: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
TextField("Title", text: $viewModel.title)
|
||||||
|
.font(.largeTitle.weight(.bold))
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.padding([.horizontal, .top])
|
||||||
|
|
||||||
|
splitEditorView
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Left is a plain, unrendered raw-text editor (deliberately not
|
||||||
|
/// `NativeTextViewWrapper` — just the literal Markdown source); right
|
||||||
|
/// is the same rich rendering used everywhere else in the app,
|
||||||
|
/// read-only, bound to the same `viewModel.text` so it updates live as
|
||||||
|
/// the left side is typed into.
|
||||||
|
///
|
||||||
|
/// Scroll position between the two panes is **not** synchronized — the
|
||||||
|
/// only way to do that would be reaching into `NativeTextViewWrapper`'s
|
||||||
|
/// private internal view hierarchy to find its scroll view (the package
|
||||||
|
/// exposes no scroll position/delegate hook at all), which is fragile
|
||||||
|
/// enough to break silently on a package update. Flagged as a known
|
||||||
|
/// follow-up, not attempted here.
|
||||||
|
private var splitEditorView: some View {
|
||||||
|
HSplitView {
|
||||||
|
TextEditor(text: $viewModel.text)
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.padding(8)
|
||||||
|
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
NativeTextViewWrapper(
|
||||||
|
text: $viewModel.text,
|
||||||
|
configuration: .init(heightBehavior: .fitsContent),
|
||||||
|
documentId: viewModel.documentId,
|
||||||
|
isEditable: false
|
||||||
|
)
|
||||||
|
.padding(8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var menuContent: some View {
|
private var menuContent: some View {
|
||||||
Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") {
|
Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") {
|
||||||
@@ -309,8 +386,10 @@ struct DocumentReaderView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
|
if viewModel.separateEditingEnabled {
|
||||||
Task { await viewModel.toggleEditing() }
|
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
|
||||||
|
Task { await viewModel.toggleEditing() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Membership management now lives in DocumentShareSheet's "People
|
// Membership management now lives in DocumentShareSheet's "People
|
||||||
// with access" section, alongside the share link — same sheet,
|
// with access" section, alongside the share link — same sheet,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ final class DocumentReaderViewModel {
|
|||||||
var text: String
|
var text: String
|
||||||
var collectionId: String?
|
var collectionId: String?
|
||||||
var isFullWidth = false
|
var isFullWidth = false
|
||||||
var children: [OutlineDocument] = []
|
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
|
|
||||||
@@ -18,6 +17,38 @@ final class DocumentReaderViewModel {
|
|||||||
var isSaving = false
|
var isSaving = false
|
||||||
var saveErrorMessage: String?
|
var saveErrorMessage: String?
|
||||||
|
|
||||||
|
/// Snapshot of the preference, set once via `.task` right after the
|
||||||
|
/// view appears (can't be read from `@Environment` inside the view's
|
||||||
|
/// own `init`) rather than a live binding to `SessionStore` — matches
|
||||||
|
/// how `isFullWidth` etc. are already seeded from the document at init
|
||||||
|
/// rather than observed reactively. A change made in Settings while a
|
||||||
|
/// document is already open takes effect the next document opened, not
|
||||||
|
/// mid-session; an acceptable tradeoff for how rarely this gets
|
||||||
|
/// toggled versus the complexity of threading a live preference
|
||||||
|
/// reference through every reader instance.
|
||||||
|
var separateEditingEnabled: Bool
|
||||||
|
|
||||||
|
/// The single source of truth the view reads for both "show the title
|
||||||
|
/// field" and "is the text view editable" — when separate editing is
|
||||||
|
/// off there's no Edit/Done mode at all, the document is just always
|
||||||
|
/// editable (assuming permission; there's no per-document permission
|
||||||
|
/// field to pre-check against, so an unauthorized edit simply fails to
|
||||||
|
/// save rather than being blocked client-side up front).
|
||||||
|
var isEffectivelyEditable: Bool {
|
||||||
|
separateEditingEnabled ? isEditing : true
|
||||||
|
}
|
||||||
|
|
||||||
|
private var autosaveTask: Task<Void, Never>?
|
||||||
|
/// Tracks the last known-synced-with-the-server values so
|
||||||
|
/// `scheduleAutosave()` can no-op when called just because `text`/
|
||||||
|
/// `title` were reassigned *from* a server response (initial load, or
|
||||||
|
/// a completed save) rather than actually edited — without this, every
|
||||||
|
/// document open in the always-editable mode would fire one pointless
|
||||||
|
/// autosave round-trip immediately, re-sending exactly what was just
|
||||||
|
/// received.
|
||||||
|
private var lastSyncedText: String
|
||||||
|
private var lastSyncedTitle: String
|
||||||
|
|
||||||
/// Recent viewers, `views.list` filtered to entries that actually have a
|
/// Recent viewers, `views.list` filtered to entries that actually have a
|
||||||
/// `lastViewedAt` — this is historical/aggregated view data, not live
|
/// `lastViewedAt` — this is historical/aggregated view data, not live
|
||||||
/// "viewing right now" presence (that needs the Hocuspocus collaboration
|
/// "viewing right now" presence (that needs the Hocuspocus collaboration
|
||||||
@@ -38,7 +69,7 @@ final class DocumentReaderViewModel {
|
|||||||
let documentId: String
|
let documentId: String
|
||||||
private let apiClient: OutlineAPIClient
|
private let apiClient: OutlineAPIClient
|
||||||
|
|
||||||
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
|
init(apiClient: OutlineAPIClient, document: OutlineDocument, separateEditingEnabled: Bool = true) {
|
||||||
self.apiClient = apiClient
|
self.apiClient = apiClient
|
||||||
self.documentId = document.id
|
self.documentId = document.id
|
||||||
self.title = document.title
|
self.title = document.title
|
||||||
@@ -46,6 +77,9 @@ final class DocumentReaderViewModel {
|
|||||||
self.text = document.text
|
self.text = document.text
|
||||||
self.collectionId = document.collectionId
|
self.collectionId = document.collectionId
|
||||||
self.isFullWidth = document.fullWidth ?? false
|
self.isFullWidth = document.fullWidth ?? false
|
||||||
|
self.separateEditingEnabled = separateEditingEnabled
|
||||||
|
self.lastSyncedText = document.text
|
||||||
|
self.lastSyncedTitle = document.title
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The list endpoint's copy of a document isn't guaranteed to be the full,
|
/// The list endpoint's copy of a document isn't guaranteed to be the full,
|
||||||
@@ -62,16 +96,11 @@ final class DocumentReaderViewModel {
|
|||||||
text = full.text
|
text = full.text
|
||||||
collectionId = full.collectionId
|
collectionId = full.collectionId
|
||||||
isFullWidth = full.fullWidth ?? false
|
isFullWidth = full.fullWidth ?? false
|
||||||
|
lastSyncedText = full.text
|
||||||
|
lastSyncedTitle = full.title
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Couldn't load this document. Check your connection and try again."
|
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 {
|
func loadViewers() async {
|
||||||
@@ -146,20 +175,55 @@ final class DocumentReaderViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Turning editing off saves; turning it on is just a mode switch.
|
/// Turning editing off saves; turning it on is just a mode switch. Only
|
||||||
|
/// meaningful when `separateEditingEnabled` — the always-editable path
|
||||||
|
/// uses `scheduleAutosave()` instead.
|
||||||
func toggleEditing() async {
|
func toggleEditing() async {
|
||||||
guard isEditing else {
|
guard isEditing else {
|
||||||
isEditing = true
|
isEditing = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
await save()
|
||||||
|
if saveErrorMessage == nil {
|
||||||
|
isEditing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debounced save for the always-editable (separate editing off) path —
|
||||||
|
/// cancels any pending save and starts a fresh countdown on every call,
|
||||||
|
/// so a save only actually fires once typing pauses, not on every
|
||||||
|
/// keystroke. Goes through the same `updateDocument` call the explicit
|
||||||
|
/// Done-button save uses, which is already offline-queue-aware
|
||||||
|
/// (`CachingOutlineAPIClient`), so autosave while offline just queues
|
||||||
|
/// like any other edit instead of needing separate handling here.
|
||||||
|
func scheduleAutosave() {
|
||||||
|
guard text != lastSyncedText || title != lastSyncedTitle else { return }
|
||||||
|
autosaveTask?.cancel()
|
||||||
|
autosaveTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: .seconds(1.5))
|
||||||
|
guard let self, !Task.isCancelled else { return }
|
||||||
|
await self.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() async {
|
||||||
isSaving = true
|
isSaving = true
|
||||||
saveErrorMessage = nil
|
saveErrorMessage = nil
|
||||||
defer { isSaving = false }
|
defer { isSaving = false }
|
||||||
|
let sentTitle = title
|
||||||
|
let sentText = text
|
||||||
do {
|
do {
|
||||||
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: title, text: text))
|
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: sentTitle, text: sentText))
|
||||||
title = updated.title
|
// Only reconcile with the server's response if nothing changed
|
||||||
text = updated.text
|
// locally while the request was in flight — otherwise this
|
||||||
isEditing = false
|
// would clobber keystrokes typed during a debounced autosave's
|
||||||
|
// round trip. Whatever's newer goes out on the next autosave
|
||||||
|
// cycle regardless, since `scheduleAutosave()` keeps getting
|
||||||
|
// re-triggered by continued typing.
|
||||||
|
if title == sentTitle { title = updated.title }
|
||||||
|
if text == sentText { text = updated.text }
|
||||||
|
lastSyncedTitle = sentTitle
|
||||||
|
lastSyncedText = sentText
|
||||||
} catch {
|
} catch {
|
||||||
saveErrorMessage = outlineErrorMessage(error, fallback: "Couldn't save this document.")
|
saveErrorMessage = outlineErrorMessage(error, fallback: "Couldn't save this document.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ struct OutpostApp: App {
|
|||||||
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@Environment(\.openWindow) private var openWindow
|
|
||||||
@State private var isShowingLogoutConfirmation = false
|
@State private var isShowingLogoutConfirmation = false
|
||||||
|
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
@@ -40,7 +40,8 @@ struct OutpostApp: App {
|
|||||||
.commands {
|
.commands {
|
||||||
CommandGroup(replacing: .appInfo) {
|
CommandGroup(replacing: .appInfo) {
|
||||||
Button("About Outpost") {
|
Button("About Outpost") {
|
||||||
openWindow(id: "about")
|
navigation.selectedSettingsSection = .about
|
||||||
|
navigation.isShowingSettings = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// No `Settings {}` scene anymore — Settings renders inside the
|
// No `Settings {}` scene anymore — Settings renders inside the
|
||||||
@@ -60,16 +61,21 @@ struct OutpostApp: App {
|
|||||||
}
|
}
|
||||||
.disabled(!session.isSignedIn)
|
.disabled(!session.isSignedIn)
|
||||||
}
|
}
|
||||||
|
// Settings → Editor → Command Palette gates this — disabled
|
||||||
|
// (not just a no-op) when the user's turned it off, matching
|
||||||
|
// how Settings…/Log Out already disable rather than silently
|
||||||
|
// do nothing.
|
||||||
|
CommandGroup(after: .newItem) {
|
||||||
|
Button("Command Palette…") {
|
||||||
|
navigation.isShowingCommandPalette = true
|
||||||
|
}
|
||||||
|
.keyboardShortcut("k")
|
||||||
|
.disabled(!session.isSignedIn || !isCommandPaletteEnabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Window("About Outpost", id: "about") {
|
|
||||||
AboutView()
|
|
||||||
.disablesFullScreen()
|
|
||||||
}
|
|
||||||
.windowResizability(.contentSize)
|
|
||||||
|
|
||||||
Window("Keyboard Shortcuts", id: "keyboard-shortcuts") {
|
Window("Keyboard Shortcuts", id: "keyboard-shortcuts") {
|
||||||
KeyboardShortcutsView()
|
KeyboardShortcutsView()
|
||||||
.disablesFullScreen()
|
.disablesFullScreen()
|
||||||
|
|||||||
@@ -1,27 +1,126 @@
|
|||||||
import Observation
|
import Observation
|
||||||
|
|
||||||
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
/// Top-level groupings shown as section headers in `SettingsSidebarList`.
|
||||||
case appearance, account, offlineSync, advanced, about
|
/// `general` holds everything that's ours, not Outline's own settings
|
||||||
|
/// categories — labeled "Outpost" so it reads as clearly distinct from the
|
||||||
|
/// Outline-sourced groups below it.
|
||||||
|
enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||||
|
case general
|
||||||
|
case account
|
||||||
|
case workspace
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var title: String? {
|
||||||
|
switch self {
|
||||||
|
case .general: return "Outpost"
|
||||||
|
case .account: return "Account"
|
||||||
|
case .workspace: return "Workspace"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry in the Settings sidebar. Mirrors Outline's own settings
|
||||||
|
/// categories (Account/Workspace) so this app's settings read as a native
|
||||||
|
/// counterpart to the web app's, plus a `general` group for things that are
|
||||||
|
/// ours and don't map onto Outline's structure (offline/sync, advanced,
|
||||||
|
/// about, appearance). Outline's own version info moved to the sidebar
|
||||||
|
/// footer (`SettingsSidebarList`) instead of a standalone
|
||||||
|
/// Integrations & Installation section.
|
||||||
|
///
|
||||||
|
/// Most of the Account/Workspace cases are navigation-only for now —
|
||||||
|
/// `SettingsView` renders a "Coming Soon" placeholder for anything not
|
||||||
|
/// explicitly built yet. Content lands section by section.
|
||||||
|
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||||
|
// General (ours)
|
||||||
|
case appearance, editor, offlineSync, advanced, about
|
||||||
|
|
||||||
|
// Account
|
||||||
|
case profile, preferences, notifications, passkeys, apiAccess
|
||||||
|
|
||||||
|
// Workspace
|
||||||
|
case details, authentication, security, ai, members, groups, templates, emojis, applications, shared, links, webhooks, importData, exportData
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var category: SettingsCategory {
|
||||||
|
switch self {
|
||||||
|
case .appearance, .editor, .offlineSync, .advanced, .about:
|
||||||
|
return .general
|
||||||
|
case .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
||||||
|
return .account
|
||||||
|
case .details, .authentication, .security, .ai, .members, .groups, .templates, .emojis, .applications, .shared, .links, .webhooks, .importData, .exportData:
|
||||||
|
return .workspace
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .appearance: return "Appearance"
|
case .appearance: return "Appearance"
|
||||||
case .account: return "Account"
|
case .editor: return "Editor"
|
||||||
case .offlineSync: return "Offline & Sync"
|
case .offlineSync: return "Offline & Sync"
|
||||||
case .advanced: return "Advanced"
|
case .advanced: return "Advanced"
|
||||||
case .about: return "About"
|
case .about: return "About"
|
||||||
|
case .profile: return "Profile"
|
||||||
|
case .preferences: return "Preferences"
|
||||||
|
case .notifications: return "Notifications"
|
||||||
|
case .passkeys: return "Passkeys"
|
||||||
|
case .apiAccess: return "API & Access"
|
||||||
|
case .details: return "Details"
|
||||||
|
case .authentication: return "Authentication"
|
||||||
|
case .security: return "Security"
|
||||||
|
case .ai: return "AI"
|
||||||
|
case .members: return "Members"
|
||||||
|
case .groups: return "Groups"
|
||||||
|
case .templates: return "Templates"
|
||||||
|
case .emojis: return "Emojis"
|
||||||
|
case .applications: return "Applications"
|
||||||
|
case .shared: return "Shared"
|
||||||
|
case .links: return "Links"
|
||||||
|
case .webhooks: return "Webhooks"
|
||||||
|
case .importData: return "Import"
|
||||||
|
case .exportData: return "Export"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var icon: String {
|
var icon: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .appearance: return "paintbrush"
|
case .appearance: return "paintbrush"
|
||||||
case .account: return "person.crop.circle"
|
case .editor: return "square.split.2x1"
|
||||||
case .offlineSync: return "arrow.triangle.2.circlepath"
|
case .offlineSync: return "arrow.triangle.2.circlepath"
|
||||||
case .advanced: return "wrench.and.screwdriver"
|
case .advanced: return "wrench.and.screwdriver"
|
||||||
case .about: return "info.circle"
|
case .about: return "info.circle"
|
||||||
|
case .profile: return "person.crop.circle"
|
||||||
|
case .preferences: return "gearshape"
|
||||||
|
case .notifications: return "bell"
|
||||||
|
case .passkeys: return "key"
|
||||||
|
case .apiAccess: return "chevron.left.forwardslash.chevron.right"
|
||||||
|
case .details: return "building.2"
|
||||||
|
case .authentication: return "lock"
|
||||||
|
case .security: return "shield"
|
||||||
|
case .ai: return "sparkles"
|
||||||
|
case .members: return "person.2"
|
||||||
|
case .groups: return "person.3"
|
||||||
|
case .templates: return "doc.on.doc"
|
||||||
|
case .emojis: return "face.smiling"
|
||||||
|
case .applications: return "app.badge"
|
||||||
|
case .shared: return "square.and.arrow.up.on.square"
|
||||||
|
case .links: return "link"
|
||||||
|
case .webhooks: return "bolt.horizontal"
|
||||||
|
case .importData: return "square.and.arrow.down"
|
||||||
|
case .exportData: return "square.and.arrow.up"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything actually built so far — everything else in Account/
|
||||||
|
/// Workspace renders a "Coming Soon" placeholder until its content is
|
||||||
|
/// specified and built.
|
||||||
|
var isImplemented: Bool {
|
||||||
|
switch self {
|
||||||
|
case .appearance, .editor, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,4 +136,6 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
final class AppNavigation {
|
final class AppNavigation {
|
||||||
var isShowingSettings = false
|
var isShowingSettings = false
|
||||||
var selectedSettingsSection: SettingsSection? = .appearance
|
var selectedSettingsSection: SettingsSection? = .appearance
|
||||||
|
/// ⌘K, see `OutpostApp`'s `CommandGroup` and `CommandPaletteView`.
|
||||||
|
var isShowingCommandPalette = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,25 @@ import OutlineKit
|
|||||||
@Observable
|
@Observable
|
||||||
final class SessionStore {
|
final class SessionStore {
|
||||||
private static let serverURLDefaultsKey = "outline.serverURL"
|
private static let serverURLDefaultsKey = "outline.serverURL"
|
||||||
|
/// Preferences now drive real editor behavior (separate editing, etc.),
|
||||||
|
/// not just a settings screen — they need to survive a cold launch with
|
||||||
|
/// no network, not just live in memory from the last successful fetch.
|
||||||
|
/// Still read-only while offline (Settings already gates every toggle
|
||||||
|
/// on `isEffectivelyOnline`) — this only makes the *last known* values
|
||||||
|
/// available, never lets them be changed without a server round-trip.
|
||||||
|
private static let userPreferencesDefaultsKey = "outline.userPreferences"
|
||||||
|
|
||||||
private let tokenStore: TokenStoring
|
private let tokenStore: TokenStoring
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
var isSignedIn: Bool
|
var isSignedIn: Bool
|
||||||
|
private(set) var userId: String?
|
||||||
var userName: String?
|
var userName: String?
|
||||||
var userEmail: String?
|
var userEmail: String?
|
||||||
var userAvatarURL: URL?
|
var userAvatarURL: URL?
|
||||||
|
var userLanguage: String?
|
||||||
|
var userPreferences: OutlineUserPreferences?
|
||||||
|
var userNotificationSettings: [String: Bool]?
|
||||||
var teamName: String?
|
var teamName: String?
|
||||||
var teamAvatarURL: URL?
|
var teamAvatarURL: URL?
|
||||||
private(set) var apiClient: OutlineAPIClient?
|
private(set) var apiClient: OutlineAPIClient?
|
||||||
@@ -35,11 +46,28 @@ final class SessionStore {
|
|||||||
init(tokenStore: TokenStoring = KeychainTokenStore(), defaults: UserDefaults = .standard) {
|
init(tokenStore: TokenStoring = KeychainTokenStore(), defaults: UserDefaults = .standard) {
|
||||||
self.tokenStore = tokenStore
|
self.tokenStore = tokenStore
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
self.isSignedIn = (try? tokenStore.token()) != nil
|
|
||||||
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
|
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
|
||||||
|
|
||||||
if isSignedIn, let serverURL {
|
let hasToken = (try? tokenStore.token()) != nil
|
||||||
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore)
|
let storedServerURL = defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
|
||||||
|
|
||||||
|
if hasToken, let storedServerURL {
|
||||||
|
isSignedIn = true
|
||||||
|
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore)
|
||||||
|
userPreferences = Self.loadCachedPreferences(defaults: defaults)
|
||||||
|
} else {
|
||||||
|
// Keychain and the sandboxed UserDefaults container don't
|
||||||
|
// always survive together — a Keychain item written by an
|
||||||
|
// older-signed build can outlive a reinstall that wipes the
|
||||||
|
// container (or vice versa), leaving a token with no server or
|
||||||
|
// a server with no token. Clear whichever half survived rather
|
||||||
|
// than showing a broken "signed in" UI with no working
|
||||||
|
// apiClient — a fresh sign-in rewrites both consistently.
|
||||||
|
if hasToken {
|
||||||
|
try? tokenStore.clear()
|
||||||
|
}
|
||||||
|
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
|
||||||
|
isSignedIn = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +78,24 @@ final class SessionStore {
|
|||||||
isSignedIn = true
|
isSignedIn = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `static` (not an instance method) so `init` can call it before every
|
||||||
|
/// stored property has a value — same reason `makeAPIClient` is static.
|
||||||
|
private static func loadCachedPreferences(defaults: UserDefaults) -> OutlineUserPreferences? {
|
||||||
|
guard let data = defaults.data(forKey: userPreferencesDefaultsKey) else { return nil }
|
||||||
|
return try? JSONDecoder().decode(OutlineUserPreferences.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `nil` clears the cache instead of writing a `null` — happens whenever
|
||||||
|
/// a fresh fetch legitimately comes back with no preferences set, so a
|
||||||
|
/// stale cached value from a previous account/state can't linger.
|
||||||
|
private func cachePreferences(_ preferences: OutlineUserPreferences?) {
|
||||||
|
guard let preferences, let data = try? JSONEncoder().encode(preferences) else {
|
||||||
|
defaults.removeObject(forKey: Self.userPreferencesDefaultsKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defaults.set(data, forKey: Self.userPreferencesDefaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
private static func makeAPIClient(
|
private static func makeAPIClient(
|
||||||
serverURL: URL,
|
serverURL: URL,
|
||||||
tokenStore: TokenStoring,
|
tokenStore: TokenStoring,
|
||||||
@@ -68,13 +114,22 @@ final class SessionStore {
|
|||||||
try? tokenStore.clear()
|
try? tokenStore.clear()
|
||||||
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
|
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
|
||||||
isSignedIn = false
|
isSignedIn = false
|
||||||
|
userId = nil
|
||||||
userName = nil
|
userName = nil
|
||||||
userEmail = nil
|
userEmail = nil
|
||||||
userAvatarURL = nil
|
userAvatarURL = nil
|
||||||
|
userLanguage = nil
|
||||||
|
userPreferences = nil
|
||||||
|
userNotificationSettings = nil
|
||||||
teamName = nil
|
teamName = nil
|
||||||
teamAvatarURL = nil
|
teamAvatarURL = nil
|
||||||
apiClient = nil
|
apiClient = nil
|
||||||
cachingClient = nil
|
cachingClient = nil
|
||||||
|
defaults.removeObject(forKey: Self.userPreferencesDefaultsKey)
|
||||||
|
// Same key `ContentView_macOS` persists "Remember previous
|
||||||
|
// location" under — cleared here too so switching accounts/servers
|
||||||
|
// can't restore a stale location that belongs to a different sign-in.
|
||||||
|
defaults.removeObject(forKey: "outline.lastLocation")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-fetches user/workspace name/logo on relaunch, when the token survived but this
|
/// Re-fetches user/workspace name/logo on relaunch, when the token survived but this
|
||||||
@@ -85,13 +140,32 @@ final class SessionStore {
|
|||||||
apply(user: auth.user, team: auth.team, serverURL: serverURL)
|
apply(user: auth.user, team: auth.team, serverURL: serverURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Settings calls this after a successful name/avatar change so the
|
||||||
|
/// sidebar's account footer and everywhere else reading these reflect
|
||||||
|
/// it immediately, without waiting for the next `auth.info` refresh.
|
||||||
|
func applyUpdatedProfile(_ user: OutlineUser) {
|
||||||
|
guard let serverURL else { return }
|
||||||
|
userId = user.id
|
||||||
|
userName = user.name
|
||||||
|
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||||
|
userLanguage = user.language
|
||||||
|
userPreferences = user.preferences
|
||||||
|
cachePreferences(user.preferences)
|
||||||
|
userNotificationSettings = user.notificationSettings
|
||||||
|
}
|
||||||
|
|
||||||
private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) {
|
private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) {
|
||||||
|
userId = user.id
|
||||||
userName = user.name
|
userName = user.name
|
||||||
userEmail = user.email
|
userEmail = user.email
|
||||||
// Outline can return either an absolute URL or a server-relative path
|
// Outline can return either an absolute URL or a server-relative path
|
||||||
// (e.g. `/api/files.get?key=...`) for avatarUrl — resolve against the
|
// (e.g. `/api/files.get?key=...`) for avatarUrl — resolve against the
|
||||||
// configured server so relative paths don't fail as "unsupported URL".
|
// configured server so relative paths don't fail as "unsupported URL".
|
||||||
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||||
|
userLanguage = user.language
|
||||||
|
userPreferences = user.preferences
|
||||||
|
cachePreferences(user.preferences)
|
||||||
|
userNotificationSettings = user.notificationSettings
|
||||||
teamName = team.name
|
teamName = team.name
|
||||||
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import OutlineKit
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
@@ -41,11 +42,46 @@ struct AvatarBadge: View {
|
|||||||
.task(id: avatarURL) {
|
.task(id: avatarURL) {
|
||||||
loadedImage = nil
|
loadedImage = nil
|
||||||
guard let avatarURL else { return }
|
guard let avatarURL else { return }
|
||||||
guard let (data, _) = try? await URLSession.shared.data(from: avatarURL) else { return }
|
loadedImage = await Self.loadImage(from: avatarURL)
|
||||||
loadedImage = PlatformImage(data: data)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The real fix, confirmed against a live network capture: every other
|
||||||
|
/// request this app makes attaches `Authorization: Bearer <token>` —
|
||||||
|
/// this one never did, sending a bare unauthenticated GET. Outline's
|
||||||
|
/// browser session authenticates `attachments.redirect` via cookies
|
||||||
|
/// instead, which a native app doesn't have; the API-token equivalent
|
||||||
|
/// is the same Bearer header every RPC call already uses. Almost
|
||||||
|
/// certainly means no avatar image (not just a freshly-uploaded one)
|
||||||
|
/// has ever actually loaded in this app — a 401 and a "no avatar set"
|
||||||
|
/// look identical here, both just fall back to the placeholder icon
|
||||||
|
/// with nothing on screen to flag it as an error.
|
||||||
|
///
|
||||||
|
/// The retry loop is a secondary, independent hardening — cheap
|
||||||
|
/// insurance against a self-hosted reverse-proxied storage backend not
|
||||||
|
/// being instantly consistent right after an upload — kept alongside
|
||||||
|
/// the auth fix rather than instead of it.
|
||||||
|
private static func loadImage(from url: URL) async -> PlatformImage? {
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
if let token = try? KeychainTokenStore().token() {
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
|
||||||
|
for attempt in 0..<3 {
|
||||||
|
if attempt > 0 {
|
||||||
|
try? await Task.sleep(for: .milliseconds(400))
|
||||||
|
}
|
||||||
|
if let (data, response) = try? await URLSession.shared.data(for: request),
|
||||||
|
let httpResponse = response as? HTTPURLResponse,
|
||||||
|
(200...299).contains(httpResponse.statusCode),
|
||||||
|
let image = PlatformImage(data: data) {
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
private func platformImage(_ image: PlatformImage) -> Image {
|
private func platformImage(_ image: PlatformImage) -> Image {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Image(nsImage: image)
|
Image(nsImage: image)
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
extension NSImage {
|
||||||
|
/// `NSImage` has no built-in JPEG encoder (unlike `UIImage`) — routes
|
||||||
|
/// through a bitmap representation to get one.
|
||||||
|
func jpegData(compressionQuality: CGFloat) -> Data? {
|
||||||
|
guard let tiffData = tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiffData) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return bitmap.representation(using: .jpeg, properties: [.compressionFactor: compressionQuality])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Outline's interface-language options. Not exhaustive — Outline accepts
|
||||||
|
/// community translations via its own translation portal, so the real list
|
||||||
|
/// on any given server can be longer than this; this covers the common
|
||||||
|
/// cases and falls back to showing whatever code the server already has
|
||||||
|
/// set even if it isn't in this list.
|
||||||
|
struct OutlineLocale: Identifiable, Hashable {
|
||||||
|
let code: String
|
||||||
|
let label: String
|
||||||
|
|
||||||
|
var id: String { code }
|
||||||
|
|
||||||
|
static let all: [OutlineLocale] = [
|
||||||
|
OutlineLocale(code: "en_US", label: "English (US)"),
|
||||||
|
OutlineLocale(code: "en_GB", label: "English (UK)"),
|
||||||
|
OutlineLocale(code: "de_DE", label: "Deutsch"),
|
||||||
|
OutlineLocale(code: "fr_FR", label: "Français"),
|
||||||
|
OutlineLocale(code: "es_ES", label: "Español"),
|
||||||
|
OutlineLocale(code: "pt_PT", label: "Português"),
|
||||||
|
OutlineLocale(code: "pt_BR", label: "Português (Brasil)"),
|
||||||
|
OutlineLocale(code: "it_IT", label: "Italiano"),
|
||||||
|
OutlineLocale(code: "nl_NL", label: "Nederlands"),
|
||||||
|
OutlineLocale(code: "pl_PL", label: "Polski"),
|
||||||
|
OutlineLocale(code: "ru_RU", label: "Русский"),
|
||||||
|
OutlineLocale(code: "ja_JP", label: "日本語"),
|
||||||
|
OutlineLocale(code: "ko_KR", label: "한국어"),
|
||||||
|
OutlineLocale(code: "zh_CN", label: "中文 (简体)"),
|
||||||
|
OutlineLocale(code: "zh_TW", label: "中文 (繁體)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
static func label(for code: String) -> String {
|
||||||
|
all.first(where: { $0.code == code })?.label ?? code
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Single source of truth for how Outpost's own version is formatted —
|
||||||
|
/// used by both the About page and the Settings sidebar footer, so they
|
||||||
|
/// can't drift out of sync the way `AboutInfoView` was already written to
|
||||||
|
/// avoid for its own two call sites.
|
||||||
|
enum OutpostVersion {
|
||||||
|
/// 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`.
|
||||||
|
static let releaseStage = "ALPHA"
|
||||||
|
|
||||||
|
static var shortVersion: String {
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
|
||||||
|
}
|
||||||
|
|
||||||
|
static var buildNumber: String {
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// e.g. `"0.0.3-ALPHA"` — for compact display (sidebar footer).
|
||||||
|
static var displayString: String {
|
||||||
|
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
|
||||||
|
return "\(shortVersion)\(stageSuffix)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// e.g. `"Version 0.0.3-ALPHA (1)"` — for the About page.
|
||||||
|
static var fullVersionString: String {
|
||||||
|
"Version \(displayString) (\(buildNumber))"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,29 @@
|
|||||||
# Outpost
|
<p align="center">
|
||||||
|
<img src="Outpost/Assets.xcassets/AppLogo.imageset/outpost-ios-1024.png" width="120" alt="Outpost logo">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 align="center">Outpost</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://testflight.apple.com/join/y1mYcYAM">
|
||||||
|
<img src="https://img.shields.io/badge/Download-TestFlight-0D96F6?style=for-the-badge&logo=apple&logoColor=white" alt="Download on TestFlight">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
A native Apple ecosystem client for [Outline](https://github.com/outline/outline) — built for iOS, iPadOS, and macOS from a single SwiftUI codebase, aiming for full editing parity with Outline's web app, including realtime collaborative editing.
|
A native Apple ecosystem client for [Outline](https://github.com/outline/outline) — built for iOS, iPadOS, and macOS from a single SwiftUI codebase, aiming for full editing parity with Outline's web app, including realtime collaborative editing.
|
||||||
|
|
||||||
|
> **Early alpha — macOS only for now.** Expect missing features and rough edges. iOS/iPadOS support is planned but not in the current build. See the [releases page](https://git.psmattas.com/psmattas/Outpost/releases) for changelogs, and [open an issue](https://git.psmattas.com/psmattas/Outpost/issues) if you hit anything.
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
Outline's web app is great, but there's no native Apple client with full editing parity. This project connects to a self-hosted Outline instance over its REST API and realtime collaboration socket to provide a proper native experience across the Apple ecosystem.
|
Outline's web app is great, but there's no native Apple client with full editing parity. This project connects to a self-hosted Outline instance over its REST API and realtime collaboration socket to provide a proper native experience across the Apple ecosystem.
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Early development. See `CLAUDE.md` for the current architecture and phased build plan.
|
|
||||||
|
|
||||||
- [ ] Phase 1 — Auth, browse, search, REST-only editing
|
|
||||||
- [ ] Phase 2 — Realtime collaborative editing (Yjs/Hocuspocus)
|
|
||||||
- [ ] Phase 3 — Offline cache, tables, embeds, comments, macOS polish
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Xcode 16+
|
- Xcode 27+ (currently developed against an Xcode 27 beta — this is a hard minimum, not a suggestion)
|
||||||
- iOS 17+ / iPadOS 17+ / macOS 14+
|
- macOS 27+. iOS/iPadOS support is planned but not in the current build (see the alpha note above) — same 27+ minimum will apply once it lands
|
||||||
- A self-hosted (or hosted) Outline instance with API access
|
- A self-hosted (or hosted) Outline instance with API access
|
||||||
|
|
||||||
> They will be updated. These are old requirements.
|
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. Clone the repo and open the `.xcodeproj` in Xcode.
|
1. Clone the repo and open the `.xcodeproj` in Xcode.
|
||||||
@@ -42,6 +44,16 @@ Parts of this codebase are AI-assisted (built with the help of AI coding tools).
|
|||||||
|
|
||||||
This project is a client only — it does not include, vendor, or redistribute any of Outline's (BSL 1.1 licensed) server source. See [`LICENSE`](./LICENSE) for this repository's own license.
|
This project is a client only — it does not include, vendor, or redistribute any of Outline's (BSL 1.1 licensed) server source. See [`LICENSE`](./LICENSE) for this repository's own license.
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
Outpost collects nothing about you — no analytics, no telemetry, no crash reporting of its own, no age or demographic data, nothing. The only thing stored locally is your Outline server URL and API token (in the device Keychain) and, optionally, a local offline cache of what you've viewed. Everything else goes straight from your device to whatever Outline server you configure — there's no backend in between, and the developer has no access to your data or your server.
|
||||||
|
|
||||||
|
Full policy, terms of service, and data-processing statement are on the [wiki](https://git.psmattas.com/psmattas/Outpost/wiki):
|
||||||
|
|
||||||
|
- [Privacy Policy](https://git.psmattas.com/psmattas/Outpost/wiki/Privacy-Policy.-)
|
||||||
|
- [Terms of Service](https://git.psmattas.com/psmattas/Outpost/wiki/Terms-of-Service.-)
|
||||||
|
- [Data Processing Statement](https://git.psmattas.com/psmattas/Outpost/wiki/Data-Processing-Statement.-)
|
||||||
|
|
||||||
## Not affiliated with Outline
|
## Not affiliated with Outline
|
||||||
|
|
||||||
This is an independent, unofficial client. Not affiliated with or endorsed by General Outline, Inc.
|
This is an independent, unofficial client. Not affiliated with or endorsed by General Outline, Inc.
|
||||||
|
|||||||
Reference in New Issue
Block a user