Compare commits
109
Commits
0.0.3-alpha
..
main
@@ -6,7 +6,7 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Outpost is early alpha — please check the version in About (or your build's commit) is current before filing, and mention which platform (macOS only, for now) and OS version you're on.
|
||||
Outpost is early — please check the version in About (or your build's commit) is current before filing, and mention which platform (macOS only, for now) and OS version you're on.
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
|
||||
@@ -6,7 +6,7 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Outpost is early alpha and tracking Outline's own web app for parity (see [`CLAUDE.md`](../../CLAUDE.md) for the phased build order) — a request that's "just do what web Outline does" is easier to act on than a net-new idea.
|
||||
Outpost is early and tracking Outline's own web app for parity (see [`CLAUDE.md`](../../CLAUDE.md) for the phased build order) — a request that's "just do what web Outline does" is easier to act on than a net-new idea.
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
|
||||
+7
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
Thank you for contributing. Please read this guide before opening issues or PRs.
|
||||
|
||||
Outpost is early alpha (`0.0.x`) — expect the codebase and conventions here to shift as Phase 1 (see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)) settles. If something in this guide is stale, flag it.
|
||||
Outpost is early (`0.1.x`) — expect the codebase and conventions here to keep evolving as later phases (see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)) land. If something in this guide is stale, flag it.
|
||||
|
||||
---
|
||||
|
||||
@@ -69,3 +69,9 @@ Issues and PRs use two label prefixes:
|
||||
## Questions
|
||||
|
||||
Open an issue — this is a single-repo project, there's no separate issue tracker to route to.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Outpost is licensed under the [Business Source License 1.1](./LICENSE), not a traditional OSI open-source license — see the [README's License section](./README.md#license) for what that means in practice. By submitting a PR, you agree your contribution is licensed under the same terms as the rest of the project.
|
||||
|
||||
@@ -1,22 +1,123 @@
|
||||
MIT License
|
||||
Business Source License 1.1
|
||||
|
||||
Copyright (c) 2026 Puranjay Savar Mattas
|
||||
Parameters
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Licensor: Puranjay Savar Mattas
|
||||
Licensed Work: Outpost
|
||||
The Licensed Work is (c) 2026 Puranjay Savar Mattas
|
||||
Additional Use Grant: You may use, copy, modify, and self-host the Licensed
|
||||
Work, and build and distribute your own modified or
|
||||
unmodified copies of it, for personal, educational, or
|
||||
internal non-commercial purposes.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
You may not, without a separate commercial agreement
|
||||
with the Licensor:
|
||||
(a) offer the Licensed Work, or any modified or
|
||||
unmodified version of it, as a hosted or
|
||||
distributed product or service to third parties,
|
||||
whether for a fee or free of charge; or
|
||||
(b) distribute the Licensed Work, or any modified or
|
||||
unmodified version of it, under a name, logo, or
|
||||
branding that states or implies it is an official,
|
||||
endorsed, or affiliated product of Outline, Inc.
|
||||
or of any other third party, or that removes or
|
||||
obscures its origin as an independent,
|
||||
unaffiliated project.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Change Date: 2036-08-20
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
For information about alternative licensing arrangements for the Licensed
|
||||
Work, contact the Licensor.
|
||||
|
||||
Notice
|
||||
|
||||
The Business Source License (this document, or the "License") is not an
|
||||
Open Source license. However, the Licensed Work will eventually be made
|
||||
available under an Open Source License, as stated in this License.
|
||||
|
||||
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
|
||||
"Business Source License" is a trademark of MariaDB Corporation Ab.
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
|
||||
Business Source License 1.1
|
||||
|
||||
Terms
|
||||
|
||||
The Licensor hereby grants you the right to copy, modify, create derivative
|
||||
works, redistribute, and make non-production use of the Licensed Work. The
|
||||
Licensor may make an Additional Use Grant, above, permitting limited
|
||||
production use.
|
||||
|
||||
Effective on the Change Date, or the fourth anniversary of the first
|
||||
publicly available distribution of a specific version of the Licensed Work
|
||||
under this License, whichever comes first, the Licensor hereby grants you
|
||||
rights under the terms of the Change License, and the rights granted in the
|
||||
paragraph above terminate.
|
||||
|
||||
If your use of the Licensed Work does not comply with the requirements
|
||||
currently in effect as described in this License, you must purchase a
|
||||
commercial license from the Licensor, its affiliated entities, or authorized
|
||||
resellers, or you must refrain from using the Licensed Work.
|
||||
|
||||
All copies of the original and modified Licensed Work, and derivative works
|
||||
of the Licensed Work, are subject to this License. This License applies
|
||||
separately for each version of the Licensed Work and the Change Date may
|
||||
vary for each version of the Licensed Work released by Licensor.
|
||||
|
||||
You must conspicuously display this License on each original or modified
|
||||
copy of the Licensed Work. If you receive the Licensed Work in original or
|
||||
modified form from a third party, the terms and conditions set forth in
|
||||
this License apply to your use of that work.
|
||||
|
||||
Any use of the Licensed Work in violation of this License will automatically
|
||||
terminate your rights under this License for the current and all other
|
||||
versions of the Licensed Work.
|
||||
|
||||
This License does not grant you any right in any trademark or logo of
|
||||
Licensor or its affiliates (provided that you may use a trademark or logo
|
||||
of Licensor as expressly required by this License).
|
||||
|
||||
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
||||
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
||||
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
|
||||
TITLE.
|
||||
|
||||
MariaDB hereby grants you permission to use this License's text to license
|
||||
your works, and to refer to it using the trademark "Business Source
|
||||
License", as long as you comply with the Covenants of Licensor below.
|
||||
|
||||
Covenants of Licensor
|
||||
|
||||
In consideration of the right to use this License's text and the "Business
|
||||
Source License" name and trademark, Licensor covenants to MariaDB, and to
|
||||
all other recipients of the licensed work to be provided by Licensor:
|
||||
|
||||
1. To specify as the Change License the GPL Version 2.0 or any later
|
||||
version, or a license that is compatible with GPL Version 2.0 or a later
|
||||
version, where "compatible" means that software provided under the
|
||||
Change License can be included in a program with software provided
|
||||
under GPL Version 2.0 or a later version. Licensor may specify
|
||||
additional Change Licenses without limitation.
|
||||
|
||||
2. To either: (a) specify an additional grant of rights to use that does
|
||||
not impose any additional restriction on the right granted in this
|
||||
License, as the Additional Use Grant; or (b) insert the text "None" to
|
||||
specify a Change License.
|
||||
|
||||
3. To specify a Change Date.
|
||||
|
||||
4. Not to modify this License in any other way.
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
|
||||
Trademark Notice
|
||||
|
||||
"Outpost" and any associated logo are trademarks of the Licensor. This
|
||||
License does not grant permission to use them to identify or market any
|
||||
product, service, or distribution of the Licensed Work — modified or
|
||||
unmodified — that is not published by the Licensor, including forks. See
|
||||
the Additional Use Grant above.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Boundary over wherever the offline cache's symmetric encryption key
|
||||
/// lives. Mirrors `TokenStoring` — same reasoning, different secret.
|
||||
public protocol CacheEncryptionKeyStoring: Sendable {
|
||||
/// Returns the existing key, generating and persisting a new random one
|
||||
/// on first use if none exists yet.
|
||||
func key() throws -> SymmetricKey
|
||||
/// Called on sign-out. Anything still encrypted with the cleared key
|
||||
/// becomes permanently unreadable — that's the point, not a bug: the
|
||||
/// next person signed in on this machine shouldn't be able to read a
|
||||
/// previous account's cached content just because the on-disk rows
|
||||
/// happen to still be there.
|
||||
func clear() throws
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// AES-GCM at the boundary where `CachingOutlineAPIClient` writes to/reads
|
||||
/// from `OfflineCacheStore`. Encryption lives at this layer rather than
|
||||
/// inside `OfflineCacheStore` itself — that stays a dumb opaque-blob store;
|
||||
/// its `key`/`id`/`kind` columns can't be encrypted without breaking the
|
||||
/// `#Predicate` queries built directly against them.
|
||||
enum CachePayloadCryptor {
|
||||
enum CryptoError: Error {
|
||||
case sealingFailed
|
||||
}
|
||||
|
||||
static func encrypt(_ data: Data, key: SymmetricKey) throws -> Data {
|
||||
let sealedBox = try AES.GCM.seal(data, using: key)
|
||||
guard let combined = sealedBox.combined else { throw CryptoError.sealingFailed }
|
||||
return combined
|
||||
}
|
||||
|
||||
static func decrypt(_ data: Data, key: SymmetricKey) throws -> Data {
|
||||
let sealedBox = try AES.GCM.SealedBox(combined: data)
|
||||
return try AES.GCM.open(sealedBox, using: key)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline
|
||||
/// support at the existing protocol boundary, so no view model needs to know
|
||||
@@ -36,11 +37,33 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
private let keyEncoder: JSONEncoder
|
||||
private let encryptionKeyStore: CacheEncryptionKeyStoring
|
||||
/// Resolved lazily and kept for this instance's lifetime — a fresh
|
||||
/// instance is created on every sign-in/sign-out anyway (see
|
||||
/// `SessionStore.makeAPIClient`), so there's no staleness risk, just
|
||||
/// one fewer Keychain round-trip per cache read/write.
|
||||
private var cachedEncryptionKey: SymmetricKey?
|
||||
|
||||
public init(live: OutlineAPIClient, cache: OfflineCacheStore, defaults: UserDefaults = .standard) {
|
||||
/// Recent failure timestamps per category — see `recordFailure` /
|
||||
/// `repeatedFailureSummaries()`. A category only shows up there once it's
|
||||
/// failed `failureThreshold` times within `failureWindow`; a single
|
||||
/// transient blip (which `RetryPolicy` already tries to absorb) never
|
||||
/// reaches this at all.
|
||||
private var failureLog: [String: [Date]] = [:]
|
||||
private var lastFailureMessage: [String: String] = [:]
|
||||
private let failureWindow: TimeInterval = 300
|
||||
private let failureThreshold: Int = 3
|
||||
|
||||
public init(
|
||||
live: OutlineAPIClient,
|
||||
cache: OfflineCacheStore,
|
||||
defaults: UserDefaults = .standard,
|
||||
encryptionKeyStore: CacheEncryptionKeyStoring = KeychainCacheEncryptionKeyStore()
|
||||
) {
|
||||
self.live = live
|
||||
self.cache = cache
|
||||
self.defaults = defaults
|
||||
self.encryptionKeyStore = encryptionKeyStore
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
@@ -62,7 +85,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
// MARK: - Cached reads
|
||||
|
||||
public func documentInfo(id: String) async throws -> OutlineDocument {
|
||||
try await cachedFetch(key: "document:\(id)") { try await self.live.documentInfo(id: id) }
|
||||
try await cachedFetch(key: "document:\(id)", category: "document") { try await self.live.documentInfo(id: id) }
|
||||
}
|
||||
|
||||
public func listDocuments(
|
||||
@@ -72,7 +95,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
limit: Int
|
||||
) async throws -> [OutlineDocument] {
|
||||
let key = "documents:\(collectionId ?? "-"):\(parentDocumentId ?? "-"):\(offset):\(limit)"
|
||||
return try await cachedFetch(key: key) {
|
||||
return try await cachedFetch(key: key, category: "documents") {
|
||||
try await self.live.listDocuments(
|
||||
collectionId: collectionId,
|
||||
parentDocumentId: parentDocumentId,
|
||||
@@ -83,23 +106,29 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
}
|
||||
|
||||
public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] {
|
||||
try await cachedFetch(key: requestKey("documentsList", request)) { try await self.live.documentsList(request) }
|
||||
try await cachedFetch(key: requestKey("documentsList", request), category: "documents") { try await self.live.documentsList(request) }
|
||||
}
|
||||
|
||||
public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] {
|
||||
try await cachedFetch(key: "documentsViewed:\(offset):\(limit)") {
|
||||
try await cachedFetch(key: "documentsViewed:\(offset):\(limit)", category: "documents-viewed") {
|
||||
try await self.live.listViewedDocuments(offset: offset, limit: limit)
|
||||
}
|
||||
}
|
||||
|
||||
public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] {
|
||||
try await cachedFetch(key: "documentsDrafts:\(request.offset):\(request.limit)", category: "drafts") {
|
||||
try await self.live.listDrafts(request)
|
||||
}
|
||||
}
|
||||
|
||||
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
|
||||
try await cachedFetch(key: "collections:\(offset):\(limit)") {
|
||||
try await cachedFetch(key: "collections:\(offset):\(limit)", category: "collections") {
|
||||
try await self.live.listCollections(offset: offset, limit: limit)
|
||||
}
|
||||
}
|
||||
|
||||
public func collectionInfo(id: String) async throws -> OutlineCollection {
|
||||
try await cachedFetch(key: "collection:\(id)") { try await self.live.collectionInfo(id: id) }
|
||||
try await cachedFetch(key: "collection:\(id)", category: "collections") { try await self.live.collectionInfo(id: id) }
|
||||
}
|
||||
|
||||
// MARK: - Queueable writes
|
||||
@@ -115,10 +144,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
let result = try await live.createDocument(request)
|
||||
let result = try await RetryPolicy.withRetry { try await self.live.createDocument(request) }
|
||||
recordSuccess(category: "documents-write")
|
||||
await cacheDocument(result)
|
||||
return result
|
||||
} catch {
|
||||
recordWriteFailureIfStructural(category: "documents-write", error)
|
||||
return await queueDocumentCreate(request)
|
||||
}
|
||||
}
|
||||
@@ -128,10 +159,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
let result = try await live.updateDocument(request)
|
||||
let result = try await RetryPolicy.withRetry { try await self.live.updateDocument(request) }
|
||||
recordSuccess(category: "documents-write")
|
||||
await cacheDocument(result)
|
||||
return result
|
||||
} catch {
|
||||
recordWriteFailureIfStructural(category: "documents-write", error)
|
||||
return try await queueDocumentUpdate(request, dueTo: error)
|
||||
}
|
||||
}
|
||||
@@ -141,10 +174,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
let result = try await live.updateCollection(request)
|
||||
let result = try await RetryPolicy.withRetry { try await self.live.updateCollection(request) }
|
||||
recordSuccess(category: "collections-write")
|
||||
await cacheCollection(result)
|
||||
return result
|
||||
} catch {
|
||||
recordWriteFailureIfStructural(category: "collections-write", error)
|
||||
return try await queueCollectionUpdate(request, dueTo: error)
|
||||
}
|
||||
}
|
||||
@@ -153,7 +188,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
|
||||
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do { return try await live.createPin(request) } catch { return await queuePinCreate(request) }
|
||||
do {
|
||||
let result = try await RetryPolicy.withRetry { try await self.live.createPin(request) }
|
||||
recordSuccess(category: "pins")
|
||||
return result
|
||||
} catch {
|
||||
recordWriteFailureIfStructural(category: "pins", error)
|
||||
return await queuePinCreate(request)
|
||||
}
|
||||
}
|
||||
return await queuePinCreate(request)
|
||||
}
|
||||
@@ -162,9 +204,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
if await cancelIfNeverSynced(id: id) { return }
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
try await live.deletePin(id: id)
|
||||
try await RetryPolicy.withRetry { try await self.live.deletePin(id: id) }
|
||||
recordSuccess(category: "pins")
|
||||
return
|
||||
} catch {
|
||||
recordWriteFailureIfStructural(category: "pins", error)
|
||||
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
|
||||
return
|
||||
}
|
||||
@@ -174,7 +218,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
|
||||
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do { return try await live.createSubscription(request) } catch { return await queueSubscriptionCreate(request) }
|
||||
do {
|
||||
let result = try await RetryPolicy.withRetry { try await self.live.createSubscription(request) }
|
||||
recordSuccess(category: "subscriptions")
|
||||
return result
|
||||
} catch {
|
||||
recordWriteFailureIfStructural(category: "subscriptions", error)
|
||||
return await queueSubscriptionCreate(request)
|
||||
}
|
||||
}
|
||||
return await queueSubscriptionCreate(request)
|
||||
}
|
||||
@@ -183,9 +234,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
if await cancelIfNeverSynced(id: id) { return }
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
try await live.deleteSubscription(id: id)
|
||||
try await RetryPolicy.withRetry { try await self.live.deleteSubscription(id: id) }
|
||||
recordSuccess(category: "subscriptions")
|
||||
return
|
||||
} catch {
|
||||
recordWriteFailureIfStructural(category: "subscriptions", error)
|
||||
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
|
||||
return
|
||||
}
|
||||
@@ -195,14 +248,28 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
|
||||
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do { return try await live.starDocument(request) } catch { return await queueStarDocumentCreate(request) }
|
||||
do {
|
||||
let result = try await RetryPolicy.withRetry { try await self.live.starDocument(request) }
|
||||
recordSuccess(category: "stars")
|
||||
return result
|
||||
} catch {
|
||||
recordWriteFailureIfStructural(category: "stars", error)
|
||||
return await queueStarDocumentCreate(request)
|
||||
}
|
||||
}
|
||||
return await queueStarDocumentCreate(request)
|
||||
}
|
||||
|
||||
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do { return try await live.starCollection(request) } catch { return await queueStarCollectionCreate(request) }
|
||||
do {
|
||||
let result = try await RetryPolicy.withRetry { try await self.live.starCollection(request) }
|
||||
recordSuccess(category: "stars")
|
||||
return result
|
||||
} catch {
|
||||
recordWriteFailureIfStructural(category: "stars", error)
|
||||
return await queueStarCollectionCreate(request)
|
||||
}
|
||||
}
|
||||
return await queueStarCollectionCreate(request)
|
||||
}
|
||||
@@ -211,9 +278,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
if await cancelIfNeverSynced(id: id) { return }
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
try await live.deleteStar(id: id)
|
||||
try await RetryPolicy.withRetry { try await self.live.deleteStar(id: id) }
|
||||
recordSuccess(category: "stars")
|
||||
return
|
||||
} catch {
|
||||
recordWriteFailureIfStructural(category: "stars", error)
|
||||
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
|
||||
return
|
||||
}
|
||||
@@ -303,6 +372,34 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
try await live.listViews(request)
|
||||
}
|
||||
|
||||
public func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] {
|
||||
try await live.listComments(request)
|
||||
}
|
||||
|
||||
public func commentInfo(id: String) async throws -> OutlineComment {
|
||||
try await live.commentInfo(id: id)
|
||||
}
|
||||
|
||||
public func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment {
|
||||
try await live.createComment(request)
|
||||
}
|
||||
|
||||
public func resolveComment(id: String) async throws -> OutlineComment {
|
||||
try await live.resolveComment(id: id)
|
||||
}
|
||||
|
||||
public func unresolveComment(id: String) async throws -> OutlineComment {
|
||||
try await live.unresolveComment(id: id)
|
||||
}
|
||||
|
||||
public func addReaction(commentId: String, emoji: String) async throws {
|
||||
try await live.addReaction(commentId: commentId, emoji: emoji)
|
||||
}
|
||||
|
||||
public func removeReaction(commentId: String, emoji: String) async throws {
|
||||
try await live.removeReaction(commentId: commentId, emoji: emoji)
|
||||
}
|
||||
|
||||
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
|
||||
try await live.addDocumentUser(request)
|
||||
}
|
||||
@@ -335,6 +432,66 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
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 fetchAuthenticatedFile(path: String) async throws -> Data {
|
||||
try await live.fetchAuthenticatedFile(path: path)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
public func pendingOperations() async -> [PendingOperationSummary] {
|
||||
@@ -351,6 +508,15 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
await cache.clearAll()
|
||||
}
|
||||
|
||||
/// Sign-out only — see `OfflineCacheStore.clearEverything()` and
|
||||
/// `CacheEncryptionKeyStoring.clear()`. Callers must clear the
|
||||
/// encryption key too (this actor doesn't own that decision); wiping
|
||||
/// the storage here without it would leave the key to be reused by
|
||||
/// whoever signs in next.
|
||||
public func clearEverythingForSignOut() async {
|
||||
await cache.clearEverything()
|
||||
}
|
||||
|
||||
/// Replays every queued operation against `live`, in the order they were
|
||||
/// queued. Each is independent — one failing doesn't block the rest.
|
||||
public func flushPendingOperations() async -> SyncFlushSummary {
|
||||
@@ -377,6 +543,15 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
/// 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
|
||||
/// 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 {
|
||||
var documentsCount = 0
|
||||
var errors: [String] = []
|
||||
@@ -401,56 +576,136 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
}
|
||||
|
||||
for collection in collections {
|
||||
var offset = 0
|
||||
let limit = 100
|
||||
while true {
|
||||
let documents: [OutlineDocument]
|
||||
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
|
||||
}
|
||||
await cacheCollection(collection)
|
||||
let result = await cacheDocumentTree(collectionId: collection.id, parentDocumentId: nil, collectionName: collection.name)
|
||||
documentsCount += result.count
|
||||
errors.append(contentsOf: result.errors)
|
||||
}
|
||||
|
||||
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 { decryptedDecode(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 { decryptedDecode(OutlineCollection.self, from: $0) }
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T {
|
||||
private func cachedFetch<T: Codable>(key: String, category: String, fetch: () async throws -> T) async throws -> T {
|
||||
// Manual offline mode means "skip the network entirely," not just
|
||||
// "prefer it" — without this check, a read would still hit `live`
|
||||
// (and succeed, showing content beyond whatever's cached) any time
|
||||
// the device actually had a connection, defeating the point of
|
||||
// deliberately testing/working as if offline.
|
||||
if isManualOfflineModeEnabled {
|
||||
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) {
|
||||
if let data = await cache.load(forKey: key), let cached = decryptedDecode(T.self, from: data) {
|
||||
return cached
|
||||
}
|
||||
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||
}
|
||||
do {
|
||||
let result = try await fetch()
|
||||
if let data = try? encoder.encode(result) {
|
||||
let result = try await RetryPolicy.withRetry { try await fetch() }
|
||||
recordSuccess(category: category)
|
||||
if let data = encryptedEncode(result) {
|
||||
await cache.save(data, forKey: key)
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) {
|
||||
// Only a structural failure (decode/auth/server — the server
|
||||
// answered, but something's actually wrong) counts toward the
|
||||
// repeated-failure log. Plain connectivity loss already has its
|
||||
// own offline UI elsewhere; logging it here too would just be a
|
||||
// second banner for the same thing every time Wi-Fi drops.
|
||||
if !RetryPolicy.isRetryable(error) {
|
||||
recordFailure(category: category, message: errorDescription(error))
|
||||
}
|
||||
if let data = await cache.load(forKey: key), let cached = decryptedDecode(T.self, from: data) {
|
||||
return cached
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedEncryptionKey() throws -> SymmetricKey {
|
||||
if let cachedEncryptionKey { return cachedEncryptionKey }
|
||||
let key = try encryptionKeyStore.key()
|
||||
cachedEncryptionKey = key
|
||||
return key
|
||||
}
|
||||
|
||||
/// Encrypts before it ever reaches SwiftData. `nil` on any failure
|
||||
/// (matches the shape of the plain `try? encoder.encode(...)` this
|
||||
/// replaces) — a Keychain hiccup here should behave exactly like an
|
||||
/// encode failure already did: skip caching this one value, not crash.
|
||||
private func encryptedEncode(_ value: some Encodable) -> Data? {
|
||||
guard let plain = try? encoder.encode(value), let key = try? resolvedEncryptionKey() else { return nil }
|
||||
return try? CachePayloadCryptor.encrypt(plain, key: key)
|
||||
}
|
||||
|
||||
/// Decrypts + decodes a value previously written by `encryptedEncode`.
|
||||
/// A row written before this feature shipped (still plaintext JSON, or
|
||||
/// anything encrypted under a key that's since been cleared by
|
||||
/// sign-out) fails to decrypt and returns `nil` here — same as any
|
||||
/// other decode failure, so `cachedFetch` treats it as a cache miss and
|
||||
/// refetches, not a crash.
|
||||
private func decryptedDecode<T: Decodable>(_ type: T.Type, from data: Data) -> T? {
|
||||
guard let key = try? resolvedEncryptionKey(), let plain = try? CachePayloadCryptor.decrypt(data, key: key) else { return nil }
|
||||
return try? decoder.decode(type, from: plain)
|
||||
}
|
||||
|
||||
/// Throwing counterpart for `replay(_:)`, where a genuine decode
|
||||
/// failure needs to propagate (so `flushPendingOperations` records it
|
||||
/// as a failed sync attempt) instead of silently vanishing.
|
||||
private func decryptedDecodeThrowing<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
|
||||
let plain = try CachePayloadCryptor.decrypt(data, key: try resolvedEncryptionKey())
|
||||
return try decoder.decode(type, from: plain)
|
||||
}
|
||||
|
||||
private func requestKey(_ prefix: String, _ request: some Encodable) -> String {
|
||||
guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else {
|
||||
return prefix
|
||||
@@ -459,19 +714,19 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
}
|
||||
|
||||
private func cacheDocument(_ document: OutlineDocument) async {
|
||||
if let data = try? encoder.encode(document) {
|
||||
if let data = encryptedEncode(document) {
|
||||
await cache.save(data, forKey: "document:\(document.id)")
|
||||
}
|
||||
}
|
||||
|
||||
private func cacheCollection(_ collection: OutlineCollection) async {
|
||||
if let data = try? encoder.encode(collection) {
|
||||
if let data = encryptedEncode(collection) {
|
||||
await cache.save(data, forKey: "collection:\(collection.id)")
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async {
|
||||
guard let data = try? encoder.encode(payload) else { return }
|
||||
guard let data = encryptedEncode(payload) else { return }
|
||||
await cache.enqueueOperation(id: id, kind: kind.rawValue, payload: data)
|
||||
}
|
||||
|
||||
@@ -510,7 +765,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
|
||||
private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument {
|
||||
guard let baseData = await cache.load(forKey: "document:\(request.id)"),
|
||||
let base = try? decoder.decode(OutlineDocument.self, from: baseData) else {
|
||||
let base = decryptedDecode(OutlineDocument.self, from: baseData) else {
|
||||
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||
}
|
||||
let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text)
|
||||
@@ -539,7 +794,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
// of it and the update would just fail every retry.
|
||||
if merged.id.hasPrefix("pending-"),
|
||||
let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }),
|
||||
let createRequest = try? decoder.decode(CreateDocumentRequest.self, from: createOp.payload) {
|
||||
let createRequest = decryptedDecode(CreateDocumentRequest.self, from: createOp.payload) {
|
||||
let resolvedCreate = CreateDocumentRequest(
|
||||
title: merged.title,
|
||||
text: merged.text,
|
||||
@@ -561,7 +816,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
|
||||
private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection {
|
||||
guard let baseData = await cache.load(forKey: "collection:\(request.id)"),
|
||||
let base = try? decoder.decode(OutlineCollection.self, from: baseData) else {
|
||||
let base = decryptedDecode(OutlineCollection.self, from: baseData) else {
|
||||
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||
}
|
||||
let merged = OutlineCollection(
|
||||
@@ -611,44 +866,84 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
}
|
||||
switch kind {
|
||||
case .createDocument:
|
||||
let request = try decoder.decode(CreateDocumentRequest.self, from: operation.payload)
|
||||
let request = try decryptedDecodeThrowing(CreateDocumentRequest.self, from: operation.payload)
|
||||
let result = try await live.createDocument(request)
|
||||
await cacheDocument(result)
|
||||
// The placeholder id (== operation.id) is now a dead orphan —
|
||||
// nothing server-side will ever answer to it again.
|
||||
await cache.removeCacheEntry(forKey: "document:\(operation.id)")
|
||||
case .updateDocument:
|
||||
let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload)
|
||||
let request = try decryptedDecodeThrowing(UpdateDocumentRequest.self, from: operation.payload)
|
||||
let result = try await live.updateDocument(request)
|
||||
await cacheDocument(result)
|
||||
case .updateCollection:
|
||||
let request = try decoder.decode(UpdateCollectionRequest.self, from: operation.payload)
|
||||
let request = try decryptedDecodeThrowing(UpdateCollectionRequest.self, from: operation.payload)
|
||||
let result = try await live.updateCollection(request)
|
||||
await cacheCollection(result)
|
||||
case .createPin:
|
||||
let request = try decoder.decode(CreatePinRequest.self, from: operation.payload)
|
||||
let request = try decryptedDecodeThrowing(CreatePinRequest.self, from: operation.payload)
|
||||
_ = try await live.createPin(request)
|
||||
case .deletePin:
|
||||
let request = try decoder.decode(IDPayload.self, from: operation.payload)
|
||||
let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
|
||||
try await live.deletePin(id: request.id)
|
||||
case .createSubscription:
|
||||
let request = try decoder.decode(CreateSubscriptionRequest.self, from: operation.payload)
|
||||
let request = try decryptedDecodeThrowing(CreateSubscriptionRequest.self, from: operation.payload)
|
||||
_ = try await live.createSubscription(request)
|
||||
case .deleteSubscription:
|
||||
let request = try decoder.decode(IDPayload.self, from: operation.payload)
|
||||
let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
|
||||
try await live.deleteSubscription(id: request.id)
|
||||
case .starDocument:
|
||||
let request = try decoder.decode(StarDocumentRequest.self, from: operation.payload)
|
||||
let request = try decryptedDecodeThrowing(StarDocumentRequest.self, from: operation.payload)
|
||||
_ = try await live.starDocument(request)
|
||||
case .deleteStar:
|
||||
let request = try decoder.decode(IDPayload.self, from: operation.payload)
|
||||
let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
|
||||
try await live.deleteStar(id: request.id)
|
||||
case .starCollection:
|
||||
let request = try decoder.decode(StarCollectionRequest.self, from: operation.payload)
|
||||
let request = try decryptedDecodeThrowing(StarCollectionRequest.self, from: operation.payload)
|
||||
_ = try await live.starCollection(request)
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes always queue on any failure (existing behavior, unchanged) —
|
||||
/// this only decides whether the failure is worth logging. A structural
|
||||
/// error (decode/auth/server) queuing for later replay will likely just
|
||||
/// fail the same way again next sync; a transient one might not. Either
|
||||
/// way the queue doesn't change, only whether it's counted toward
|
||||
/// `repeatedFailureSummaries()`.
|
||||
private func recordWriteFailureIfStructural(category: String, _ error: Error) {
|
||||
guard !RetryPolicy.isRetryable(error) else { return }
|
||||
recordFailure(category: category, message: errorDescription(error))
|
||||
}
|
||||
|
||||
private func recordFailure(category: String, message: String) {
|
||||
let now = Date()
|
||||
var timestamps = (failureLog[category] ?? []).filter { now.timeIntervalSince($0) < failureWindow }
|
||||
timestamps.append(now)
|
||||
failureLog[category] = timestamps
|
||||
lastFailureMessage[category] = message
|
||||
}
|
||||
|
||||
private func recordSuccess(category: String) {
|
||||
failureLog[category] = nil
|
||||
lastFailureMessage[category] = nil
|
||||
}
|
||||
|
||||
/// Categories that have failed `failureThreshold`+ times within the last
|
||||
/// `failureWindow` seconds — meant to be polled periodically (see
|
||||
/// `RootView`), not pushed, since this actor has no UI-facing dependency
|
||||
/// of its own. A single blip never shows up here: `RetryPolicy` absorbs
|
||||
/// transient failures before they're ever logged, and only structural
|
||||
/// ones (decode/auth/server) get logged at all — see
|
||||
/// `recordWriteFailureIfStructural` and `cachedFetch`.
|
||||
public func repeatedFailureSummaries() async -> [RepeatedFailure] {
|
||||
let now = Date()
|
||||
return failureLog.compactMap { category, timestamps in
|
||||
let recent = timestamps.filter { now.timeIntervalSince($0) < failureWindow }
|
||||
guard recent.count >= failureThreshold, let message = lastFailureMessage[category] else { return nil }
|
||||
return RepeatedFailure(category: category, message: message, count: recent.count)
|
||||
}
|
||||
}
|
||||
|
||||
private func errorDescription(_ error: Error) -> String {
|
||||
if let apiError = error as? OutlineAPIError {
|
||||
switch apiError {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import Security
|
||||
|
||||
/// Real Keychain-backed `CacheEncryptionKeyStoring`. The key is a plain
|
||||
/// random 256-bit value — deliberately NOT derived from anything guessable
|
||||
/// (bundle id, device id, etc.). It doesn't need deriving to survive an
|
||||
/// uninstall/reinstall of the app either: Keychain items are scoped to the
|
||||
/// requesting app's code signature (bundle id + team id), not its on-disk
|
||||
/// presence, so reinstalling the same app regains access to the same
|
||||
/// Keychain item automatically — exactly how `KeychainTokenStore`'s saved
|
||||
/// token already survives a reinstall today. If the on-disk cache also
|
||||
/// happens to survive (macOS doesn't clean out `~/Library/Containers` just
|
||||
/// because the .app bundle was dragged to the Trash), the reinstalled app
|
||||
/// can still decrypt it; a different app, or the same app after an explicit
|
||||
/// sign-out (`clear()`), can't.
|
||||
public final class KeychainCacheEncryptionKeyStore: CacheEncryptionKeyStoring, @unchecked Sendable {
|
||||
private let service: String
|
||||
private let account: String
|
||||
|
||||
public init(service: String = "com.outpost.outlinekit", account: String = "offline-cache-encryption-key") {
|
||||
self.service = service
|
||||
self.account = account
|
||||
}
|
||||
|
||||
public func key() throws -> SymmetricKey {
|
||||
if let existing = try? readKey() { return existing }
|
||||
let generated = SymmetricKey(size: .bits256)
|
||||
try store(generated)
|
||||
return generated
|
||||
}
|
||||
|
||||
public func clear() throws {
|
||||
let status = SecItemDelete(baseQuery() as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw TokenStoreError.deleteFailed(status)
|
||||
}
|
||||
}
|
||||
|
||||
private func readKey() throws -> SymmetricKey {
|
||||
var query = baseQuery()
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
throw TokenStoreError.notFound
|
||||
}
|
||||
return SymmetricKey(data: data)
|
||||
}
|
||||
|
||||
private func store(_ key: SymmetricKey) throws {
|
||||
let data = key.withUnsafeBytes { Data($0) }
|
||||
let query = baseQuery()
|
||||
|
||||
let existsStatus = SecItemCopyMatching(query as CFDictionary, nil)
|
||||
if existsStatus == errSecSuccess {
|
||||
let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary)
|
||||
guard updateStatus == errSecSuccess else { throw TokenStoreError.storeFailed(updateStatus) }
|
||||
} else {
|
||||
var addQuery = query
|
||||
addQuery[kSecValueData as String] = data
|
||||
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else { throw TokenStoreError.storeFailed(addStatus) }
|
||||
}
|
||||
}
|
||||
|
||||
private func baseQuery() -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,15 @@ public actor OfflineCacheStore {
|
||||
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
|
||||
/// queued create syncs and the server hands back the real id — the
|
||||
/// placeholder key would otherwise sit around as a dead orphan forever.
|
||||
@@ -58,6 +67,22 @@ public actor OfflineCacheStore {
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
/// Wipes both the read-through cache AND the pending write queue —
|
||||
/// used only on sign-out (see `SessionStore.signOut()`), since the
|
||||
/// encryption key backing every row here is about to be cleared too.
|
||||
/// Anything left un-wiped would just become permanently undecryptable
|
||||
/// garbage instead of readable by the next account signed in on this
|
||||
/// machine — deliberately more thorough than `clearAll()` (Settings'
|
||||
/// "Clear All Cache", which never touches pending writes; that button
|
||||
/// shouldn't silently discard someone's unsynced edits).
|
||||
public func clearEverything() {
|
||||
let cachedDescriptor = FetchDescriptor<CachedPayload>()
|
||||
(try? modelContext.fetch(cachedDescriptor))?.forEach { modelContext.delete($0) }
|
||||
let pendingDescriptor = FetchDescriptor<PendingOperation>()
|
||||
(try? modelContext.fetch(pendingDescriptor))?.forEach { modelContext.delete($0) }
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
// MARK: - Offline write queue
|
||||
|
||||
/// Upserts by `id` — a second call with the same id (an edit coalescing
|
||||
|
||||
@@ -13,6 +13,9 @@ public protocol OutlineAPIClient: Sendable {
|
||||
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument]
|
||||
/// Documents the current user has recently viewed. Backed by `documents.viewed`.
|
||||
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument]
|
||||
/// Draft (unpublished) documents belonging to the current user. Backed
|
||||
/// by `documents.drafts`.
|
||||
func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument]
|
||||
/// Full-text search with snippets/ranking. Backed by `documents.search`.
|
||||
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult]
|
||||
/// Title-only search — faster, no snippets. Backed by `documents.search_titles`.
|
||||
@@ -48,6 +51,20 @@ public protocol OutlineAPIClient: Sendable {
|
||||
/// Historical view records, not live presence. Backed by `views.list`.
|
||||
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView]
|
||||
|
||||
/// See `OutlineComment`. `resolve`/`unresolve`/`add_reaction`/
|
||||
/// `remove_reaction` are confirmed real against Outline's own server
|
||||
/// source but aren't in the vendored spec.
|
||||
func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment]
|
||||
func commentInfo(id: String) async throws -> OutlineComment
|
||||
func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment
|
||||
func resolveComment(id: String) async throws -> OutlineComment
|
||||
func unresolveComment(id: String) async throws -> OutlineComment
|
||||
/// Reaction endpoints return `{success: true}`, not the updated
|
||||
/// comment — callers refetch via `commentInfo` for the fresh
|
||||
/// `reactions` array.
|
||||
func addReaction(commentId: String, emoji: String) async throws
|
||||
func removeReaction(commentId: String, emoji: String) async throws
|
||||
|
||||
/// See `OutlineMembership`/`OutlineDocumentMember` — `add` is confirmed
|
||||
/// from Outline's official docs, the rest are best-effort.
|
||||
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership
|
||||
@@ -69,4 +86,48 @@ public protocol OutlineAPIClient: Sendable {
|
||||
func deleteStar(id: String) async throws
|
||||
|
||||
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
|
||||
/// Fetches raw bytes from an authenticated, server-relative GET path —
|
||||
/// e.g. `/api/attachments.redirect?id=<uuid>`, the reference Outline's
|
||||
/// own editor embeds for uploaded images in document Markdown. Unlike
|
||||
/// `post`'s RPC endpoints, this is a GET that 302-redirects to the
|
||||
/// actual (often presigned, cross-host) storage URL; `path` is resolved
|
||||
/// against the client's base URL, same as `uploadAttachmentFile`'s
|
||||
/// `uploadUrl` handling.
|
||||
func fetchAuthenticatedFile(path: String) async throws -> Data
|
||||
/// 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
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
||||
try await post("documents.viewed", body: PaginationParams(offset: offset, limit: limit))
|
||||
}
|
||||
|
||||
public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] {
|
||||
try await post("documents.drafts", body: request)
|
||||
}
|
||||
|
||||
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
|
||||
try await post("documents.search", body: request)
|
||||
}
|
||||
@@ -175,6 +179,34 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
||||
try await post("views.list", body: request)
|
||||
}
|
||||
|
||||
public func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] {
|
||||
try await post("comments.list", body: request)
|
||||
}
|
||||
|
||||
public func commentInfo(id: String) async throws -> OutlineComment {
|
||||
try await post("comments.info", body: StarIDParams(id: id))
|
||||
}
|
||||
|
||||
public func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment {
|
||||
try await post("comments.create", body: request)
|
||||
}
|
||||
|
||||
public func resolveComment(id: String) async throws -> OutlineComment {
|
||||
try await post("comments.resolve", body: StarIDParams(id: id))
|
||||
}
|
||||
|
||||
public func unresolveComment(id: String) async throws -> OutlineComment {
|
||||
try await post("comments.unresolve", body: StarIDParams(id: id))
|
||||
}
|
||||
|
||||
public func addReaction(commentId: String, emoji: String) async throws {
|
||||
try await postForSuccess("comments.add_reaction", body: CommentReactionParams(id: commentId, emoji: emoji))
|
||||
}
|
||||
|
||||
public func removeReaction(commentId: String, emoji: String) async throws {
|
||||
try await postForSuccess("comments.remove_reaction", body: CommentReactionParams(id: commentId, emoji: emoji))
|
||||
}
|
||||
|
||||
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
|
||||
try await post("documents.add_user", body: request)
|
||||
}
|
||||
@@ -229,6 +261,142 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
||||
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 fetchAuthenticatedFile(path: String) async throws -> Data {
|
||||
guard let token = try? tokenStore.token() else {
|
||||
throw OutlineAPIError.tokenUnavailable
|
||||
}
|
||||
// Same host-relative-or-absolute resolution as uploadAttachmentFile's uploadUrl.
|
||||
guard let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else {
|
||||
throw OutlineAPIError.transport(URLError(.badURL))
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
// URLSession's default redirect handling drops Authorization on a
|
||||
// cross-host redirect (same as a browser dropping cookies on one) —
|
||||
// exactly what's wanted here: authorize the request to Outline's own
|
||||
// `attachments.redirect`, not the presigned storage URL it 302s to.
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
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 {
|
||||
switch response.statusCode {
|
||||
case 401:
|
||||
throw OutlineAPIError.unauthorized
|
||||
case 404:
|
||||
throw OutlineAPIError.notFound
|
||||
default:
|
||||
throw OutlineAPIError.server(status: response.statusCode, message: nil)
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
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 {
|
||||
guard let token = try? tokenStore.token() else {
|
||||
throw OutlineAPIError.tokenUnavailable
|
||||
@@ -388,6 +556,11 @@ private struct StarIDParams: Encodable {
|
||||
let id: String
|
||||
}
|
||||
|
||||
private struct CommentReactionParams: Encodable {
|
||||
let id: String
|
||||
let emoji: String
|
||||
}
|
||||
|
||||
private struct MoveDocumentResponse: Decodable {
|
||||
let documents: [OutlineDocument]?
|
||||
let collections: [OutlineCollection]?
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
|
||||
/// Minimal recursive JSON tree for fields the API returns as genuinely
|
||||
/// arbitrary/untyped JSON (`type: object` with no fixed schema in the
|
||||
/// OpenAPI spec) — currently just `Comment.data`, a ProseMirror document.
|
||||
/// Decode-only: nothing in this codebase writes comment bodies back yet.
|
||||
public enum JSONValue: Decodable, Sendable {
|
||||
case string(String)
|
||||
case number(Double)
|
||||
case bool(Bool)
|
||||
case object([String: JSONValue])
|
||||
case array([JSONValue])
|
||||
case null
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
self = .null
|
||||
} else if let value = try? container.decode(Bool.self) {
|
||||
self = .bool(value)
|
||||
} else if let value = try? container.decode(Double.self) {
|
||||
self = .number(value)
|
||||
} else if let value = try? container.decode(String.self) {
|
||||
self = .string(value)
|
||||
} else if let value = try? container.decode([String: JSONValue].self) {
|
||||
self = .object(value)
|
||||
} else if let value = try? container.decode([JSONValue].self) {
|
||||
self = .array(value)
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONValue {
|
||||
/// Best-effort plain-text extraction from a ProseMirror-shaped document —
|
||||
/// walks `content` arrays, concatenates `text` node strings, and adds a
|
||||
/// trailing newline after known block-level node types so paragraphs
|
||||
/// don't run together. Good enough for a read-only comment list; not a
|
||||
/// real ProseMirror renderer (no marks, no lists/tables structure).
|
||||
public func plainText() -> String {
|
||||
switch self {
|
||||
case .object(let fields):
|
||||
if case .string(let text)? = fields["text"] {
|
||||
return text
|
||||
}
|
||||
let childText: String
|
||||
if case .array(let items)? = fields["content"] {
|
||||
childText = items.map { $0.plainText() }.joined()
|
||||
} else {
|
||||
childText = ""
|
||||
}
|
||||
if case .string(let type)? = fields["type"], Self.blockTypes.contains(type) {
|
||||
return childText + "\n"
|
||||
}
|
||||
return childText
|
||||
case .array(let items):
|
||||
return items.map { $0.plainText() }.joined()
|
||||
case .string(let value):
|
||||
return value
|
||||
case .number, .bool, .null:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
private static let blockTypes: Set<String> = [
|
||||
"paragraph", "heading", "listItem", "blockquote", "codeBlock", "list_item", "code_block"
|
||||
]
|
||||
}
|
||||
@@ -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,46 @@
|
||||
import Foundation
|
||||
|
||||
/// A comment (or reply, via `parentCommentId`) on a document. Backed by
|
||||
/// `comments.*` — `create`/`info`/`update`/`delete`/`list` are in the
|
||||
/// vendored OpenAPI spec; `resolve`/`unresolve`/`add_reaction`/
|
||||
/// `remove_reaction` are not (confirmed against Outline's own server
|
||||
/// source instead — same "spec mirror is incomplete" lesson as
|
||||
/// `OutlinePin`). `data` is the comment body as a ProseMirror document,
|
||||
/// not plain text — see `JSONValue.plainText()`.
|
||||
public struct OutlineComment: Decodable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let data: JSONValue
|
||||
public let documentId: String
|
||||
public let parentCommentId: String?
|
||||
public let createdAt: Date
|
||||
public let createdBy: OutlineUser?
|
||||
public let updatedAt: Date?
|
||||
public let resolvedAt: Date?
|
||||
public let resolvedBy: OutlineUser?
|
||||
public let reactions: [ReactionSummary]
|
||||
/// The document text this comment is anchored to — only populated when
|
||||
/// the request set `includeAnchorText: true`; `nil` for a document-level
|
||||
/// (non-anchored) comment either way. No prefix/suffix comes back on
|
||||
/// read (only accepted as create-time disambiguation input), so
|
||||
/// re-finding this text in the rendered document is inherently
|
||||
/// best-effort — first occurrence wins, same as Outline's own create
|
||||
/// behavior when nothing else disambiguates.
|
||||
public let anchorText: String?
|
||||
|
||||
public var isResolved: Bool { resolvedAt != nil }
|
||||
public var bodyText: String {
|
||||
data.plainText().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
/// One emoji's worth of reactions on a comment — grouped by emoji server-side
|
||||
/// (`ReactionSummary` in Outline's own source), not one object per reaction.
|
||||
public struct ReactionSummary: Codable, Sendable, Equatable {
|
||||
public let emoji: String
|
||||
public let userIds: [String]
|
||||
|
||||
public init(emoji: String, userIds: [String]) {
|
||||
self.emoji = emoji
|
||||
self.userIds = userIds
|
||||
}
|
||||
}
|
||||
@@ -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 avatarUrl: 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(
|
||||
id: String,
|
||||
name: String,
|
||||
email: 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.name = name
|
||||
self.email = email
|
||||
self.avatarUrl = avatarUrl
|
||||
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,19 @@
|
||||
import Foundation
|
||||
|
||||
/// A category of API call that's failed repeatedly within a short window —
|
||||
/// see `CachingOutlineAPIClient.repeatedFailureSummaries()`. Deliberately
|
||||
/// carries no request/response payload, document content, or server URL:
|
||||
/// this is meant to be safe to show a user or attach to a bug report as-is.
|
||||
public struct RepeatedFailure: Sendable, Identifiable, Equatable {
|
||||
public let category: String
|
||||
public let message: String
|
||||
public let count: Int
|
||||
|
||||
public var id: String { category }
|
||||
|
||||
public init(category: String, message: String, count: Int) {
|
||||
self.category = category
|
||||
self.message = message
|
||||
self.count = 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,26 @@
|
||||
import Foundation
|
||||
|
||||
/// `parentCommentId: nil` creates a document-level (top-level) comment;
|
||||
/// non-nil creates a reply (Outline supports one level of nesting — a
|
||||
/// reply's own `parentCommentId` should always be a top-level comment's
|
||||
/// id, never another reply's). `anchorText` creates an inline (anchored)
|
||||
/// comment instead of a document-level one — the first occurrence of that
|
||||
/// exact substring in the document's plain text is used, same as Outline's
|
||||
/// own web editor; `anchorPrefix`/`anchorSuffix` aren't sent (this app has
|
||||
/// no UI for choosing between multiple identical occurrences). `text` is
|
||||
/// the documented markdown convenience field for `data` (a ProseMirror
|
||||
/// document) — simplest path for plain-text comment bodies, no need to
|
||||
/// construct ProseMirror JSON by hand.
|
||||
public struct CreateCommentRequest: Encodable, Sendable {
|
||||
public let documentId: String
|
||||
public let parentCommentId: String?
|
||||
public let text: String
|
||||
public let anchorText: String?
|
||||
|
||||
public init(documentId: String, parentCommentId: String? = nil, text: String, anchorText: String? = nil) {
|
||||
self.documentId = documentId
|
||||
self.parentCommentId = parentCommentId
|
||||
self.text = text
|
||||
self.anchorText = anchorText
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,16 @@ import Foundation
|
||||
public struct CreateDocumentRequest: Codable, Sendable {
|
||||
public let title: String
|
||||
public let text: String
|
||||
public let collectionId: String
|
||||
/// `nil` (with `parentDocumentId` also `nil`) creates a draft — Outline
|
||||
/// requires one of the two to publish at all, regardless of `publish`.
|
||||
public let collectionId: String?
|
||||
public let parentDocumentId: String?
|
||||
public let publish: Bool
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
text: String,
|
||||
collectionId: String,
|
||||
collectionId: String? = nil,
|
||||
parentDocumentId: String? = nil,
|
||||
publish: Bool = true
|
||||
) {
|
||||
|
||||
@@ -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,18 @@
|
||||
import Foundation
|
||||
|
||||
public struct ListCommentsRequest: Encodable, Sendable {
|
||||
public let documentId: String
|
||||
public let offset: Int
|
||||
public let limit: Int
|
||||
/// Include each anchored comment's `anchorText` (the document text it's
|
||||
/// attached to) in the response. Off by default — it's extra payload
|
||||
/// only needed when actually positioning inline markers.
|
||||
public let includeAnchorText: Bool
|
||||
|
||||
public init(documentId: String, offset: Int = 0, limit: Int = 100, includeAnchorText: Bool = false) {
|
||||
self.documentId = documentId
|
||||
self.offset = offset
|
||||
self.limit = limit
|
||||
self.includeAnchorText = includeAnchorText
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
public struct ListDraftsRequest: 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
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,13 @@ public struct UpdateDocumentRequest: Codable, Sendable {
|
||||
public let append: Bool?
|
||||
public let fullWidth: Bool?
|
||||
public let insightsEnabled: Bool?
|
||||
/// Moves the document to this collection. Combined with `publish: true`,
|
||||
/// this is how a draft (no `collectionId`, or one it just hasn't left
|
||||
/// yet) gets published into a specific collection in one call.
|
||||
public let collectionId: String?
|
||||
/// Publishes a draft, making it visible to other workspace members.
|
||||
/// Documented as a no-op if the document is already published.
|
||||
public let publish: Bool?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
@@ -14,7 +21,9 @@ public struct UpdateDocumentRequest: Codable, Sendable {
|
||||
text: String? = nil,
|
||||
append: Bool? = nil,
|
||||
fullWidth: Bool? = nil,
|
||||
insightsEnabled: Bool? = nil
|
||||
insightsEnabled: Bool? = nil,
|
||||
collectionId: String? = nil,
|
||||
publish: Bool? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
@@ -22,5 +31,7 @@ public struct UpdateDocumentRequest: Codable, Sendable {
|
||||
self.append = append
|
||||
self.fullWidth = fullWidth
|
||||
self.insightsEnabled = insightsEnabled
|
||||
self.collectionId = collectionId
|
||||
self.publish = publish
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
|
||||
/// Automatic retry-with-backoff for API calls, so a single transient network
|
||||
/// blip doesn't turn into a user-visible failure (or a silently swallowed
|
||||
/// one) the way one `try?` used to.
|
||||
///
|
||||
/// Only retries `OutlineAPIError.transport` — a dropped connection or
|
||||
/// timeout might succeed a second later. Everything else (`.decoding`,
|
||||
/// `.unauthorized`, `.notFound`, `.server`, `.tokenUnavailable`) is retried
|
||||
/// zero times: a response-shape mismatch or a 404 will look exactly the same
|
||||
/// on attempt two, so retrying just burns the cooldown window for nothing —
|
||||
/// callers should treat those as immediate failures instead.
|
||||
public enum RetryPolicy {
|
||||
public static func withRetry<T: Sendable>(
|
||||
maxAttempts: Int = 3,
|
||||
initialDelay: Duration = .seconds(1),
|
||||
_ operation: () async throws -> T
|
||||
) async throws -> T {
|
||||
var attempt = 1
|
||||
var delay = initialDelay
|
||||
while true {
|
||||
do {
|
||||
return try await operation()
|
||||
} catch {
|
||||
guard attempt < maxAttempts, isRetryable(error) else { throw error }
|
||||
attempt += 1
|
||||
try? await Task.sleep(for: delay)
|
||||
delay *= 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func isRetryable(_ error: Error) -> Bool {
|
||||
guard let apiError = error as? OutlineAPIError else { return false }
|
||||
if case .transport = apiError { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import XCTest
|
||||
import CryptoKit
|
||||
@testable import OutlineKit
|
||||
|
||||
private struct NotStubbed: Error {}
|
||||
@@ -29,6 +30,7 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
|
||||
|
||||
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||
func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() }
|
||||
func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||
func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
|
||||
@@ -71,6 +73,13 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
|
||||
func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { throw NotStubbed() }
|
||||
func deleteSubscription(id: String) async throws { throw NotStubbed() }
|
||||
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { throw NotStubbed() }
|
||||
func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] { throw NotStubbed() }
|
||||
func commentInfo(id: String) async throws -> OutlineComment { throw NotStubbed() }
|
||||
func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment { throw NotStubbed() }
|
||||
func resolveComment(id: String) async throws -> OutlineComment { throw NotStubbed() }
|
||||
func unresolveComment(id: String) async throws -> OutlineComment { throw NotStubbed() }
|
||||
func addReaction(commentId: String, emoji: String) async throws { throw NotStubbed() }
|
||||
func removeReaction(commentId: String, emoji: String) async throws { throw NotStubbed() }
|
||||
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { throw NotStubbed() }
|
||||
func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() }
|
||||
func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { throw NotStubbed() }
|
||||
@@ -89,10 +98,41 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
|
||||
func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { throw NotStubbed() }
|
||||
func deleteStar(id: String) async throws { 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 fetchAuthenticatedFile(path: String) async throws -> Data { 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 {}
|
||||
|
||||
/// In-memory `CacheEncryptionKeyStoring` — tests must never touch the real
|
||||
/// Keychain (would pollute the developer's machine and can hang/fail in a
|
||||
/// sandboxed CI runner with no Keychain access).
|
||||
private final class StaticCacheEncryptionKeyStore: CacheEncryptionKeyStoring, @unchecked Sendable {
|
||||
private var stored: SymmetricKey?
|
||||
|
||||
func key() throws -> SymmetricKey {
|
||||
if let stored { return stored }
|
||||
let generated = SymmetricKey(size: .bits256)
|
||||
stored = generated
|
||||
return generated
|
||||
}
|
||||
|
||||
func clear() throws { stored = nil }
|
||||
}
|
||||
|
||||
final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
private func makeCache() throws -> OfflineCacheStore {
|
||||
OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true))
|
||||
@@ -122,7 +162,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let document = makeDocument()
|
||||
stub.documentInfoHandler = { _ in document }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
let result = try await sut.documentInfo(id: "doc-1")
|
||||
|
||||
@@ -138,7 +178,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
if callCount == 1 { return document }
|
||||
throw StubTransportError()
|
||||
}
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
// First call succeeds and populates the cache.
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
@@ -152,7 +192,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
do {
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
@@ -171,7 +211,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
if callCount == 1 { return collections }
|
||||
throw StubTransportError()
|
||||
}
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
_ = try await sut.listCollections(offset: 0, limit: 25)
|
||||
let result = try await sut.listCollections(offset: 0, limit: 25)
|
||||
@@ -184,7 +224,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
let docOne = makeDocument(id: "doc-1", title: "One")
|
||||
let docTwo = makeDocument(id: "doc-2", title: "Two")
|
||||
stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
_ = try await sut.documentInfo(id: "doc-2")
|
||||
@@ -204,7 +244,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
let original = makeDocument(id: "doc-1", title: "Original")
|
||||
stub.documentInfoHandler = { _ in original }
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
// Populate the cache with the base document first (as a real open would).
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
@@ -225,7 +265,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
do {
|
||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x"))
|
||||
@@ -240,7 +280,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
let original = makeDocument(id: "doc-1", title: "Original")
|
||||
stub.documentInfoHandler = { _ in original }
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "First Edit"))
|
||||
@@ -253,7 +293,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.createPinHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
|
||||
XCTAssertTrue(pin.id.hasPrefix("pending-"))
|
||||
@@ -271,7 +311,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
let original = makeDocument(id: "doc-1", title: "Original")
|
||||
stub.documentInfoHandler = { _ in original }
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
|
||||
@@ -295,7 +335,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
let original = makeDocument(id: "doc-1", title: "Original")
|
||||
stub.documentInfoHandler = { _ in original }
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
|
||||
@@ -322,7 +362,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
liveCallCount += 1
|
||||
return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil)
|
||||
}
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults)
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults, encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
|
||||
|
||||
@@ -347,7 +387,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
return [cachedCollection]
|
||||
}
|
||||
let cache = try makeCache()
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults)
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults, encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
// Online first — populates the cache normally.
|
||||
let firstResult = try await sut.listCollections(offset: 0, limit: 25)
|
||||
@@ -366,7 +406,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
func testCacheStorageSummaryReflectsCachedItems() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.documentInfoHandler = { _ in self.makeDocument() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
let summary = await sut.cacheStorageSummary()
|
||||
@@ -395,7 +435,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
return (0..<count).map { self.makeCollection(id: "col-\(offset + $0)") }
|
||||
}
|
||||
stub.listDocumentsHandler = { _, _, _, _ in [] }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
let summary = await sut.performFullSync()
|
||||
|
||||
@@ -407,11 +447,16 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
func testPerformFullSyncCachesEachDocumentIndividually() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
|
||||
stub.listDocumentsHandler = { _, _, offset, _ in
|
||||
offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
|
||||
// Must return empty for any non-nil parentDocumentId (no children) —
|
||||
// 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 sut = CachingOutlineAPIClient(live: stub, cache: cache)
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
let summary = await sut.performFullSync()
|
||||
XCTAssertEqual(summary.documentsCount, 2)
|
||||
@@ -423,12 +468,40 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
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, encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
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
|
||||
|
||||
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
let created = try await sut.createDocument(
|
||||
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
||||
@@ -452,7 +525,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
let created = try await sut.createDocument(
|
||||
CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1")
|
||||
@@ -472,7 +545,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
let created = try await sut.createDocument(
|
||||
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
||||
@@ -497,4 +570,169 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
// expected — nothing left under the old id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Repeated-failure tracking
|
||||
|
||||
/// A structural failure (decode/auth/server) isn't retried — it fails
|
||||
/// the same way every time, so `RetryPolicy` gives up after one attempt
|
||||
/// and it's logged immediately. No cache entry means no fallback either,
|
||||
/// so every call rethrows.
|
||||
func testRepeatedDecodingFailuresSurfaceAfterThreshold() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
for _ in 0..<3 {
|
||||
_ = try? await sut.documentInfo(id: "doc-1")
|
||||
}
|
||||
|
||||
let summaries = await sut.repeatedFailureSummaries()
|
||||
XCTAssertEqual(summaries.count, 1)
|
||||
XCTAssertEqual(summaries.first?.category, "document")
|
||||
XCTAssertEqual(summaries.first?.count, 3)
|
||||
}
|
||||
|
||||
/// Two failures alone shouldn't trip the banner — only three or more
|
||||
/// within the window counts as "repeated."
|
||||
func testFewerThanThresholdFailuresDoNotSurface() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
for _ in 0..<2 {
|
||||
_ = try? await sut.documentInfo(id: "doc-1")
|
||||
}
|
||||
|
||||
let summaries = await sut.repeatedFailureSummaries()
|
||||
XCTAssertTrue(summaries.isEmpty)
|
||||
}
|
||||
|
||||
/// A later success clears the category entirely — a transient run of
|
||||
/// bad luck shouldn't leave a stale banner up after things recover.
|
||||
func testSuccessAfterRepeatedFailuresClearsTheLog() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let document = makeDocument()
|
||||
var callCount = 0
|
||||
stub.documentInfoHandler = { _ in
|
||||
callCount += 1
|
||||
if callCount <= 3 { throw OutlineAPIError.decoding(NotStubbed()) }
|
||||
return document
|
||||
}
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
for _ in 0..<3 {
|
||||
_ = try? await sut.documentInfo(id: "doc-1")
|
||||
}
|
||||
let beforeRecovery = await sut.repeatedFailureSummaries()
|
||||
XCTAssertEqual(beforeRecovery.count, 1)
|
||||
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
|
||||
let afterRecovery = await sut.repeatedFailureSummaries()
|
||||
XCTAssertTrue(afterRecovery.isEmpty)
|
||||
}
|
||||
|
||||
/// Plain connectivity loss (`OutlineAPIError.transport`) already has its
|
||||
/// own offline UI elsewhere — it shouldn't also pile up in the repeated-
|
||||
/// failure log and pop a second, redundant banner.
|
||||
func testTransportFailuresDoNotCountTowardTheRepeatedFailureLog() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.documentInfoHandler = { _ in throw OutlineAPIError.transport(URLError(.notConnectedToInternet)) }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
_ = try? await sut.documentInfo(id: "doc-1")
|
||||
|
||||
let summaries = await sut.repeatedFailureSummaries()
|
||||
XCTAssertTrue(summaries.isEmpty)
|
||||
}
|
||||
|
||||
/// Different categories (document reads vs. pin writes) track
|
||||
/// independently — a broken pins endpoint shouldn't mask, or be masked
|
||||
/// by, unrelated document failures, and one crossing the threshold
|
||||
/// shouldn't drag an unrelated one along with it.
|
||||
func testFailuresInDifferentCategoriesDoNotMix() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
||||
stub.createPinHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
// Document reads cross the threshold...
|
||||
for _ in 0..<3 {
|
||||
_ = try? await sut.documentInfo(id: "doc-1")
|
||||
}
|
||||
// ...pin creates don't.
|
||||
for _ in 0..<2 {
|
||||
_ = try? await sut.createPin(CreatePinRequest(documentId: "doc-1", collectionId: nil))
|
||||
}
|
||||
|
||||
let summaries = await sut.repeatedFailureSummaries()
|
||||
XCTAssertEqual(summaries.map(\.category), ["document"])
|
||||
}
|
||||
|
||||
// MARK: - At-rest encryption
|
||||
|
||||
func testCachedPayloadIsNotStoredAsPlaintextJSON() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let document = makeDocument(title: "Secret Title")
|
||||
stub.documentInfoHandler = { _ in document }
|
||||
let cache = try makeCache()
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
|
||||
let raw = await cache.load(forKey: "document:doc-1")
|
||||
XCTAssertNotNil(raw)
|
||||
// A plain JSON encode would contain the literal title text in the
|
||||
// clear - ciphertext shouldn't, and shouldn't even parse as JSON.
|
||||
XCTAssertNil(String(data: raw!, encoding: .utf8)?.range(of: "Secret Title"))
|
||||
XCTAssertThrowsError(try JSONDecoder().decode(OutlineDocument.self, from: raw!))
|
||||
}
|
||||
|
||||
/// Simulates sign-out (key cleared) followed by a fresh sign-in (a new
|
||||
/// `CachingOutlineAPIClient` instance, same underlying on-disk cache,
|
||||
/// same shape as `SessionStore.makeAPIClient` always creating a new
|
||||
/// instance) — anything still on disk from before is unreadable under
|
||||
/// the new key, which is the entire point of clearing it on sign-out.
|
||||
func testCacheIsUnreadableAfterTheEncryptionKeyIsCleared() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.documentInfoHandler = { _ in self.makeDocument() }
|
||||
let cache = try makeCache()
|
||||
let keyStore = StaticCacheEncryptionKeyStore()
|
||||
let beforeSignOut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: keyStore)
|
||||
_ = try await beforeSignOut.documentInfo(id: "doc-1")
|
||||
|
||||
try keyStore.clear()
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
let afterSignIn = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: keyStore)
|
||||
|
||||
do {
|
||||
_ = try await afterSignIn.documentInfo(id: "doc-1")
|
||||
XCTFail("Expected the now-undecryptable cache entry to be unusable")
|
||||
} catch is StubTransportError {
|
||||
// expected — live fails, and the leftover cache entry can't be
|
||||
// decrypted under the new key either, so there's no fallback.
|
||||
}
|
||||
}
|
||||
|
||||
func testClearEverythingForSignOutWipesBothCacheAndPendingQueue() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.documentInfoHandler = { _ in self.makeDocument() }
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
_ = try? await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Offline edit"))
|
||||
|
||||
let summaryBefore = await sut.cacheStorageSummary()
|
||||
let pendingBefore = await sut.pendingOperations()
|
||||
XCTAssertGreaterThan(summaryBefore.itemCount, 0)
|
||||
XCTAssertFalse(pendingBefore.isEmpty)
|
||||
|
||||
await sut.clearEverythingForSignOut()
|
||||
|
||||
let summaryAfter = await sut.cacheStorageSummary()
|
||||
let pendingAfter = await sut.pendingOperations()
|
||||
XCTAssertEqual(summaryAfter.itemCount, 0)
|
||||
XCTAssertTrue(pendingAfter.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -846,6 +846,137 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/views.list")
|
||||
}
|
||||
|
||||
func testListCommentsDecodesCommentsAndExtractsPlainTextFromProseMirrorData() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "comment-1",
|
||||
"documentId": "doc-1",
|
||||
"parentCommentId": null,
|
||||
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||
"createdBy": { "id": "user-1", "name": "Jane Doe" },
|
||||
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||
"resolvedAt": null,
|
||||
"resolvedBy": null,
|
||||
"reactions": [
|
||||
{ "emoji": "\u{1F44D}", "userIds": ["user-2", "user-3"] }
|
||||
],
|
||||
"data": {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{ "type": "text", "text": "Sounds great" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let comments = try await client.listComments(ListCommentsRequest(documentId: "doc-1"))
|
||||
|
||||
XCTAssertEqual(comments.first?.id, "comment-1")
|
||||
XCTAssertEqual(comments.first?.bodyText, "Sounds great")
|
||||
XCTAssertFalse(comments.first?.isResolved ?? true)
|
||||
XCTAssertEqual(comments.first?.reactions.first?.emoji, "\u{1F44D}")
|
||||
XCTAssertEqual(comments.first?.reactions.first?.userIds, ["user-2", "user-3"])
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.list")
|
||||
}
|
||||
|
||||
func testResolveCommentDecodesResolvedComment() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "comment-1",
|
||||
"documentId": "doc-1",
|
||||
"parentCommentId": null,
|
||||
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||
"createdBy": { "id": "user-1", "name": "Jane Doe" },
|
||||
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||
"resolvedAt": "2026-01-02T00:00:00.000Z",
|
||||
"resolvedBy": { "id": "user-2", "name": "John Roe" },
|
||||
"reactions": [],
|
||||
"data": { "type": "doc", "content": [] }
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let comment = try await client.resolveComment(id: "comment-1")
|
||||
|
||||
XCTAssertTrue(comment.isResolved)
|
||||
XCTAssertEqual(comment.resolvedBy?.name, "John Roe")
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.resolve")
|
||||
}
|
||||
|
||||
func testCreateCommentSendsParentCommentIdForAReply() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "comment-2",
|
||||
"documentId": "doc-1",
|
||||
"parentCommentId": "comment-1",
|
||||
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||
"createdBy": { "id": "user-1", "name": "Jane Doe" },
|
||||
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||
"resolvedAt": null,
|
||||
"resolvedBy": null,
|
||||
"reactions": [],
|
||||
"data": { "type": "doc", "content": [] }
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let reply = try await client.createComment(
|
||||
CreateCommentRequest(documentId: "doc-1", parentCommentId: "comment-1", text: "Agreed")
|
||||
)
|
||||
|
||||
XCTAssertEqual(reply.parentCommentId, "comment-1")
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.create")
|
||||
}
|
||||
|
||||
func testAddReactionPostsIdAndEmoji() 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.addReaction(commentId: "comment-1", emoji: "\u{1F44D}")
|
||||
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.add_reaction")
|
||||
}
|
||||
|
||||
func testCreatePinDecodesPin() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
@@ -993,6 +1124,490 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
||||
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 {
|
||||
let httpClient = MockHTTPClient()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import XCTest
|
||||
@testable import OutlineKit
|
||||
|
||||
private struct PlainError: Error {}
|
||||
|
||||
final class RetryPolicyTests: XCTestCase {
|
||||
func testSucceedsOnFirstAttemptWithoutRetrying() async throws {
|
||||
var callCount = 0
|
||||
let result = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) {
|
||||
callCount += 1
|
||||
return "ok"
|
||||
}
|
||||
XCTAssertEqual(result, "ok")
|
||||
XCTAssertEqual(callCount, 1)
|
||||
}
|
||||
|
||||
func testRetriesTransportErrorsAndSucceedsOnceItStopsFailing() async throws {
|
||||
var callCount = 0
|
||||
let result = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in
|
||||
callCount += 1
|
||||
if callCount < 3 { throw OutlineAPIError.transport(URLError(.timedOut)) }
|
||||
return "ok"
|
||||
}
|
||||
XCTAssertEqual(result, "ok")
|
||||
XCTAssertEqual(callCount, 3)
|
||||
}
|
||||
|
||||
func testGivesUpAfterMaxAttemptsAndRethrowsTheLastError() async throws {
|
||||
var callCount = 0
|
||||
do {
|
||||
_ = try await RetryPolicy.withRetry(maxAttempts: 3, initialDelay: .milliseconds(1)) { () -> String in
|
||||
callCount += 1
|
||||
throw OutlineAPIError.transport(URLError(.timedOut))
|
||||
}
|
||||
XCTFail("Expected the persistent failure to be rethrown")
|
||||
} catch {
|
||||
XCTAssertEqual(callCount, 3)
|
||||
}
|
||||
}
|
||||
|
||||
/// A decode failure means the response is structurally wrong — trying
|
||||
/// again gets the exact same wrong response, so it isn't worth the
|
||||
/// cooldown window the way a network blip is.
|
||||
func testDoesNotRetryNonTransportErrors() async throws {
|
||||
var callCount = 0
|
||||
do {
|
||||
_ = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in
|
||||
callCount += 1
|
||||
throw OutlineAPIError.decoding(PlainError())
|
||||
}
|
||||
XCTFail("Expected the decoding error to be rethrown without retrying")
|
||||
} catch {
|
||||
XCTAssertEqual(callCount, 1)
|
||||
}
|
||||
}
|
||||
|
||||
func testDoesNotRetryErrorsThatAreNotOutlineAPIErrors() async throws {
|
||||
var callCount = 0
|
||||
do {
|
||||
_ = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in
|
||||
callCount += 1
|
||||
throw PlainError()
|
||||
}
|
||||
XCTFail("Expected the error to be rethrown without retrying")
|
||||
} catch {
|
||||
XCTAssertEqual(callCount, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,15 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 110;
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
FA7596B230366A1D0000167E /* MarkdownEngine in Frameworks */ = {isa = PBXBuildFile; productRef = FA7596B130366A1D0000167E /* MarkdownEngine */; };
|
||||
FA7596B430366A1D0000167E /* MarkdownEngineCodeBlocks in Frameworks */ = {isa = PBXBuildFile; productRef = FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */; };
|
||||
FA7596B630366A1D0000167E /* MarkdownEngineLatex in Frameworks */ = {isa = PBXBuildFile; productRef = FA7596B530366A1D0000167E /* MarkdownEngineLatex */; };
|
||||
FADBE087303734FE001E69F0 /* ImagePlayground.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FADBE086303734FE001E69F0 /* ImagePlayground.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
|
||||
FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99C43302CF1BD00C9949F /* OutlineKit */; };
|
||||
FAF99CAF302D120500C9949F /* MarkdownEngine in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CAE302D120500C9949F /* MarkdownEngine */; };
|
||||
FAF99CB1302D120500C9949F /* MarkdownEngineCodeBlocks in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */; };
|
||||
FAF99CB3302D120500C9949F /* MarkdownEngineLatex in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CB2302D120500C9949F /* MarkdownEngineLatex */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -31,6 +32,7 @@
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
FADBE086303734FE001E69F0 /* ImagePlayground.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ImagePlayground.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS27.0.sdk/System/Library/Frameworks/ImagePlayground.framework; sourceTree = DEVELOPER_DIR; };
|
||||
FAF99C18302CE96100C9949F /* Outpost.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Outpost.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FAF99C27302CE96200C9949F /* OutpostTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OutpostTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FAF99C31302CE96200C9949F /* OutpostUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OutpostUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -59,9 +61,10 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
files = (
|
||||
FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */,
|
||||
FAF99CB1302D120500C9949F /* MarkdownEngineCodeBlocks in Frameworks */,
|
||||
FAF99CB3302D120500C9949F /* MarkdownEngineLatex in Frameworks */,
|
||||
FAF99CAF302D120500C9949F /* MarkdownEngine in Frameworks */,
|
||||
FA7596B430366A1D0000167E /* MarkdownEngineCodeBlocks in Frameworks */,
|
||||
FA7596B630366A1D0000167E /* MarkdownEngineLatex in Frameworks */,
|
||||
FADBE087303734FE001E69F0 /* ImagePlayground.framework in Frameworks */,
|
||||
FA7596B230366A1D0000167E /* MarkdownEngine in Frameworks */,
|
||||
);
|
||||
};
|
||||
FAF99C24302CE96200C9949F /* Frameworks */ = {
|
||||
@@ -77,12 +80,21 @@
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
FADBE085303734FE001E69F0 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FADBE086303734FE001E69F0 /* ImagePlayground.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FAF99C0F302CE96100C9949F = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FAF99C1A302CE96100C9949F /* Outpost */,
|
||||
FAF99C2A302CE96200C9949F /* OutpostTests */,
|
||||
FAF99C34302CE96200C9949F /* OutpostUITests */,
|
||||
FADBE085303734FE001E69F0 /* Frameworks */,
|
||||
FAF99C19302CE96100C9949F /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -116,9 +128,9 @@
|
||||
name = Outpost;
|
||||
packageProductDependencies = (
|
||||
FAF99C43302CF1BD00C9949F /* OutlineKit */,
|
||||
FAF99CAE302D120500C9949F /* MarkdownEngine */,
|
||||
FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */,
|
||||
FAF99CB2302D120500C9949F /* MarkdownEngineLatex */,
|
||||
FA7596B130366A1D0000167E /* MarkdownEngine */,
|
||||
FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */,
|
||||
FA7596B530366A1D0000167E /* MarkdownEngineLatex */,
|
||||
);
|
||||
productName = Outpost;
|
||||
productReference = FAF99C18302CE96100C9949F /* Outpost.app */;
|
||||
@@ -200,7 +212,7 @@
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */,
|
||||
FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */,
|
||||
FA7596B030366A1D0000167E /* XCLocalSwiftPackageReference "Vendor/swift-markdown-engine" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = FAF99C19302CE96100C9949F /* Products */;
|
||||
@@ -299,7 +311,7 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -361,7 +373,7 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -385,15 +397,20 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
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=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
@@ -407,20 +424,22 @@
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
MARKETING_VERSION = 0.0.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
XROS_DEPLOYMENT_TARGET = 27.0;
|
||||
};
|
||||
name = Debug;
|
||||
@@ -430,15 +449,20 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
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=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
@@ -452,20 +476,22 @@
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
MARKETING_VERSION = 0.0.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
XROS_DEPLOYMENT_TARGET = 27.0;
|
||||
};
|
||||
name = Release;
|
||||
@@ -476,10 +502,10 @@
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -502,10 +528,10 @@
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -527,10 +553,10 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -552,10 +578,10 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -610,43 +636,33 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
FA7596B030366A1D0000167E /* XCLocalSwiftPackageReference "Vendor/swift-markdown-engine" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = "Vendor/swift-markdown-engine";
|
||||
};
|
||||
FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = OutlineKit;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/nodes-app/swift-markdown-engine";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.12.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
FA7596B130366A1D0000167E /* MarkdownEngine */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = MarkdownEngine;
|
||||
};
|
||||
FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = MarkdownEngineCodeBlocks;
|
||||
};
|
||||
FA7596B530366A1D0000167E /* MarkdownEngineLatex */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = MarkdownEngineLatex;
|
||||
};
|
||||
FAF99C43302CF1BD00C9949F /* OutlineKit */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = OutlineKit;
|
||||
};
|
||||
FAF99CAE302D120500C9949F /* MarkdownEngine */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */;
|
||||
productName = MarkdownEngine;
|
||||
};
|
||||
FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */;
|
||||
productName = MarkdownEngineCodeBlocks;
|
||||
};
|
||||
FAF99CB2302D120500C9949F /* MarkdownEngineLatex */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */;
|
||||
productName = MarkdownEngineLatex;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = FAF99C10302CE96100C9949F /* Project object */;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "f233fa96f0c6bdcdbf87f726af38f25704f6d46a156a27dfac47541baa63bf97",
|
||||
"originHash" : "4127e8224149bef00a33500e8db49748a735e1c07626f2255faea52554afef5e",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "highlighterswift",
|
||||
@@ -10,15 +10,6 @@
|
||||
"version" : "3.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-markdown-engine",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/nodes-app/swift-markdown-engine",
|
||||
"state" : {
|
||||
"revision" : "e5f7607fc4021181056ef7a09dbb7573dc0237d9",
|
||||
"version" : "0.12.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftmath",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2700"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FAF99C17302CE96100C9949F"
|
||||
BuildableName = "Outpost.app"
|
||||
ReferencedContainer = "container:Outpost.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FAF99C26302CE96200C9949F"
|
||||
BuildableName = "OutpostTests.xctest"
|
||||
ReferencedContainer = "container:Outpost.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FAF99C30302CE96200C9949F"
|
||||
BuildableName = "OutpostUITests.xctest"
|
||||
ReferencedContainer = "container:Outpost.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
queueDebuggingEnabled = "No">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FAF99C17302CE96100C9949F"
|
||||
BuildableName = "Outpost.app"
|
||||
ReferencedContainer = "container:Outpost.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../Outpost/Configuration.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FAF99C17302CE96100C9949F"
|
||||
BuildableName = "Outpost.app"
|
||||
ReferencedContainer = "container:Outpost.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -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 |
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"identifier" : "12E4A6D1-6B8A-4C2E-9F3A-0D1B2C3E4F5A",
|
||||
"nonRenewingSubscriptions" : [],
|
||||
"products" : [
|
||||
{
|
||||
"displayPrice" : "0.99",
|
||||
"familyShareable" : false,
|
||||
"internalID" : "992ABE97-F5C4-4F80-8FB0-382BDF47DAB6",
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "A small tip to support Outpost's development.",
|
||||
"displayName" : "Small Tip",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "com.psmattas.OutpostApp.tip.small",
|
||||
"referenceName" : "Small Tip",
|
||||
"type" : "Consumable"
|
||||
},
|
||||
{
|
||||
"displayPrice" : "2.99",
|
||||
"familyShareable" : false,
|
||||
"internalID" : "316DEB73-6BA3-4F6B-BD54-D17A1CE61938",
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "A medium tip to support Outpost's development.",
|
||||
"displayName" : "Medium Tip",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "com.psmattas.OutpostApp.tip.medium",
|
||||
"referenceName" : "Medium Tip",
|
||||
"type" : "Consumable"
|
||||
},
|
||||
{
|
||||
"displayPrice" : "4.99",
|
||||
"familyShareable" : false,
|
||||
"internalID" : "37936F94-AC21-4A39-BC85-CAE6609E170D",
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "A large tip to support Outpost's development.",
|
||||
"displayName" : "Large Tip",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "com.psmattas.OutpostApp.tip.large",
|
||||
"referenceName" : "Large Tip",
|
||||
"type" : "Consumable"
|
||||
},
|
||||
{
|
||||
"displayPrice" : "9.99",
|
||||
"familyShareable" : false,
|
||||
"internalID" : "E072C1AC-2719-483E-8A54-F4924808B724",
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "A generous tip to support Outpost's development.",
|
||||
"displayName" : "Generous Tip",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "com.psmattas.OutpostApp.tip.generous",
|
||||
"referenceName" : "Generous Tip",
|
||||
"type" : "Consumable"
|
||||
}
|
||||
],
|
||||
"settings" : {
|
||||
"_askToBuyEnabled" : false
|
||||
},
|
||||
"subscriptionGroups" : [],
|
||||
"version" : {
|
||||
"major" : 3,
|
||||
"minor" : 0
|
||||
}
|
||||
}
|
||||
@@ -2,29 +2,17 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Bare content (icon, name, version, links) with no window chrome — reused
|
||||
/// by both the standalone "About Outpost" window (`AboutView`, the standard
|
||||
/// macOS app-menu affordance) and the Settings page's own About section, so
|
||||
/// the two can't drift out of sync.
|
||||
/// Bare content (icon, name, version, links) with no window chrome — the
|
||||
/// macOS "About Outpost" app-menu command now opens Settings' own About
|
||||
/// section directly (no separate popup window), so this is its only caller.
|
||||
struct AboutInfoView: View {
|
||||
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 {
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost"
|
||||
}
|
||||
|
||||
/// Bumped alongside `MARKETING_VERSION` in the Xcode project — kept out
|
||||
/// of the bundle version itself since `CFBundleShortVersionString` is
|
||||
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
|
||||
private let releaseStage = "ALPHA"
|
||||
|
||||
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))"
|
||||
}
|
||||
var versionString: String { OutpostVersion.fullVersionString }
|
||||
|
||||
private var copyrightYear: String {
|
||||
String(Calendar.current.component(.year, from: Date()))
|
||||
@@ -55,35 +43,20 @@ struct AboutInfoView: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Link(destination: repositoryURL) {
|
||||
Label("View Source on Git", systemImage: "link")
|
||||
}
|
||||
.font(.callout)
|
||||
|
||||
Button("Check for Updates…") {
|
||||
checkForUpdates()
|
||||
}
|
||||
Link(destination: repositoryURL) {
|
||||
Label("View Source on Git", systemImage: "link")
|
||||
}
|
||||
.font(.callout)
|
||||
|
||||
Divider()
|
||||
.frame(maxWidth: 240)
|
||||
|
||||
TipJarView()
|
||||
|
||||
Text("© \(copyrightYear) Puranjay Savar Mattas")
|
||||
.font(.caption2)
|
||||
.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
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
|
||||
/// One button per consumable tip tier — no "restore purchases" (nothing to
|
||||
/// restore, consumables aren't entitlements) and no manual retry: a failed
|
||||
/// load just shows a message, tapping a tier again re-attempts naturally.
|
||||
struct TipJarView: View {
|
||||
@State private var store = TipJarStore()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
Text("Support Outpost")
|
||||
.font(.callout.weight(.semibold))
|
||||
|
||||
if store.isLoading && store.products.isEmpty {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else if !store.products.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(store.products) { product in
|
||||
tipButton(for: product)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch store.purchaseState {
|
||||
case .thankYou:
|
||||
Label("Thank you!", systemImage: "heart.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.pink)
|
||||
case .failed(let message):
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
case .idle, .purchasing:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.task { await store.loadProductsIfNeeded() }
|
||||
}
|
||||
|
||||
private func tipButton(for product: Product) -> some View {
|
||||
Button {
|
||||
Task { await store.purchase(product) }
|
||||
} label: {
|
||||
VStack(spacing: 2) {
|
||||
if isPurchasing(product) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Text(product.displayPrice)
|
||||
.font(.callout.weight(.semibold))
|
||||
}
|
||||
Text(product.displayName)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(minWidth: 64)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(isAnyPurchaseInFlight)
|
||||
}
|
||||
|
||||
private func isPurchasing(_ product: Product) -> Bool {
|
||||
store.purchaseState == .purchasing(product.id)
|
||||
}
|
||||
|
||||
private var isAnyPurchaseInFlight: Bool {
|
||||
if case .purchasing = store.purchaseState { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,5 +1,6 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
import OutlineKit
|
||||
|
||||
/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label
|
||||
@@ -9,21 +10,13 @@ import OutlineKit
|
||||
struct AccountFooter: View {
|
||||
@Environment(SessionStore.self) private var session
|
||||
@Environment(AppNavigation.self) private var navigation
|
||||
@Environment(\.openURL) private var openURL
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@Environment(\.requestReview) private var requestReview
|
||||
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||
@State private var isMenuPresented = false
|
||||
@State private var isShowingLogoutConfirmation = false
|
||||
@State private var isShowingProfile = false
|
||||
|
||||
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
|
||||
private let issuesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/issues")!
|
||||
|
||||
private var apiDocumentationURL: URL? {
|
||||
session.serverURL?.appendingPathComponent("developers")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
isMenuPresented = true
|
||||
@@ -63,20 +56,10 @@ struct AccountFooter: View {
|
||||
|
||||
private var menuContent: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
menuItem("Keyboard Shortcuts…") { openWindow(id: "keyboard-shortcuts") }
|
||||
|
||||
Divider()
|
||||
|
||||
menuItem("Documentation") { openURL(repositoryURL) }
|
||||
if let apiDocumentationURL {
|
||||
menuItem("API Documentation") { openURL(apiDocumentationURL) }
|
||||
}
|
||||
menuItem("Changelog") { openURL(repositoryURL) }
|
||||
|
||||
Divider()
|
||||
|
||||
menuItem("Send Us Feedback") { openURL(issuesURL) }
|
||||
menuItem("Report a Bug") { openURL(issuesURL) }
|
||||
// Apple's own review/feedback prompt — there's no separate
|
||||
// native channel for "bug" vs. "feedback", so one button covers
|
||||
// both.
|
||||
menuItem("Leave Us Feedback") { requestReview() }
|
||||
|
||||
Divider()
|
||||
|
||||
@@ -106,6 +89,14 @@ struct AccountFooter: View {
|
||||
// as "Invalid attempt to open a new transaction during CA
|
||||
// commit") — letting the popover's dismissal finish first avoids it.
|
||||
menuItem("Settings…") { Task { @MainActor in navigation.isShowingSettings = true } }
|
||||
// Same deferred-Task reasoning as Settings… above — this also
|
||||
// sets isShowingSettings synchronously.
|
||||
menuItem("Support Outpost") {
|
||||
Task { @MainActor in
|
||||
navigation.selectedSettingsSection = .about
|
||||
navigation.isShowingSettings = true
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
#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") {
|
||||
Task {
|
||||
if let data = await 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()
|
||||
}
|
||||
|
||||
/// `ImageRenderer` itself has to run on the main actor (it captures live
|
||||
/// SwiftUI view state), but JPEG compression on the bitmap it produces
|
||||
/// is pure CPU work with no SwiftUI dependency left — hopping off for
|
||||
/// just that part avoids a visible hitch on tapping "Use Photo".
|
||||
/// `tiffRepresentation` (plain `Data`, unlike `NSImage` itself) is what
|
||||
/// actually crosses the actor boundary; mirrors `NSImage.jpegData(
|
||||
/// compressionQuality:)`'s own logic rather than calling it directly, so
|
||||
/// crossing doesn't require handing a non-Sendable `NSImage` to a
|
||||
/// detached task.
|
||||
@MainActor
|
||||
private func renderFinalImage() async -> 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 tiffData = renderer.nsImage?.tiffRepresentation else { return nil }
|
||||
return await Task.detached(priority: .userInitiated) {
|
||||
NSBitmapImageRep(data: tiffData)?.representation(using: .jpeg, properties: [.compressionFactor: 0.9])
|
||||
}.value
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,39 +0,0 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
|
||||
struct KeyboardShortcutsView: View {
|
||||
private struct Shortcut: Identifiable {
|
||||
let id = UUID()
|
||||
let action: String
|
||||
let keys: String
|
||||
}
|
||||
|
||||
private let shortcuts: [Shortcut] = [
|
||||
Shortcut(action: "Sign In", keys: "⏎"),
|
||||
Shortcut(action: "Preferences", keys: "⌘ ,"),
|
||||
Shortcut(action: "Close Window", keys: "⌘ W"),
|
||||
Shortcut(action: "Quit Outpost", keys: "⌘ Q")
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Keyboard Shortcuts")
|
||||
.font(.title3.bold())
|
||||
|
||||
VStack(spacing: 10) {
|
||||
ForEach(shortcuts) { shortcut in
|
||||
HStack {
|
||||
Text(shortcut.action)
|
||||
Spacer()
|
||||
Text(shortcut.keys)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospaced()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 280)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,15 +1,34 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// Swapped into the real sidebar's content slot (search field, collections
|
||||
/// tree, account footer) while Settings is open — same sidebar, different
|
||||
/// content, rather than a separate mini sidebar nested inside a page. "Done"
|
||||
/// clears `AppNavigation.isShowingSettings`, which puts the collections tree
|
||||
/// 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 {
|
||||
@Binding var selection: SettingsSection?
|
||||
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 {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
@@ -22,20 +41,91 @@ struct SettingsSidebarList: View {
|
||||
|
||||
Divider()
|
||||
|
||||
List(SettingsSection.allCases, selection: $selection) { section in
|
||||
Label(section.title, systemImage: section.icon)
|
||||
.tag(section)
|
||||
List(selection: $selection) {
|
||||
// TODO: Workspace is entirely `!isImplemented` placeholders
|
||||
// right now (details/authentication/security/ai/members/
|
||||
// groups/templates/emojis/applications/shared/links/
|
||||
// webhooks/importData/exportData) — App Store review won't
|
||||
// accept a section that's just "Coming Soon" rows, so it's
|
||||
// filtered out of the sidebar below (via `visibleCategories`)
|
||||
// until real content lands. Remove the filter once at least
|
||||
// one Workspace section is built.
|
||||
ForEach(visibleCategories) { category in
|
||||
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)
|
||||
|
||||
Divider()
|
||||
|
||||
versionFooter
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Done", action: onDone)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.frame(maxWidth: .infinity)
|
||||
.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() }
|
||||
}
|
||||
|
||||
/// Categories with at least one built (`isImplemented`) section — see the
|
||||
/// TODO above the `ForEach` that uses this.
|
||||
private var visibleCategories: [SettingsCategory] {
|
||||
SettingsCategory.allCases.filter { category in
|
||||
SettingsSection.allCases.contains { $0.category == category && $0.isImplemented }
|
||||
}
|
||||
}
|
||||
|
||||
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 RetryPolicy.withRetry({ try await apiClient.installationInfo().version })
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,21 +3,17 @@ import SwiftUI
|
||||
struct AuthHeaderView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.accentColor, Color.accentColor.opacity(0.6)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 64, height: 64)
|
||||
Image(systemName: "text.book.closed.fill")
|
||||
.font(.system(size: 26, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.shadow(color: Color.accentColor.opacity(0.35), radius: 12, y: 6)
|
||||
// A plain Image Set, not the AppIcon *app icon* asset — App Icon
|
||||
// sets aren't reliably resolvable through Image(_:)/UIImage
|
||||
// (named:) at runtime (confirmed live: showed nothing). This is
|
||||
// the same source artwork (outpost-ios-1024.png) duplicated
|
||||
// into a normal image set so SwiftUI can actually load it.
|
||||
Image("AppLogo")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 72 * 0.2237, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text("Welcome to Outpost")
|
||||
|
||||
@@ -30,8 +30,15 @@ struct CollectionDocumentsOutline: View {
|
||||
/// every document in the tree.
|
||||
@State private var pinsByDocumentID: [String: OutlinePin] = [:]
|
||||
|
||||
private var tree: [DocumentNode] {
|
||||
buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
|
||||
/// Recomputed only when `viewModel.documents`/`sortOption` actually change
|
||||
/// (below) instead of being a computed property — this rebuilt the whole
|
||||
/// dictionary-grouped, recursively-sorted tree on every `body` evaluation,
|
||||
/// including renders triggered by unrelated state (selection, hover,
|
||||
/// pins) that don't change the tree's shape at all.
|
||||
@State private var tree: [DocumentNode] = []
|
||||
|
||||
private func rebuildTree() {
|
||||
tree = buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
|
||||
}
|
||||
|
||||
init(
|
||||
@@ -86,11 +93,14 @@ struct CollectionDocumentsOutline: View {
|
||||
.task(id: "\(refreshToken)-\(externalRefreshToken)") {
|
||||
await viewModel.load()
|
||||
await loadPins()
|
||||
rebuildTree()
|
||||
}
|
||||
.onChange(of: viewModel.documents) { rebuildTree() }
|
||||
.onChange(of: sortOption) { rebuildTree() }
|
||||
}
|
||||
|
||||
private func loadPins() async {
|
||||
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collection.id)) else { return }
|
||||
guard let pins = try? await RetryPolicy.withRetry({ try await apiClient.listPins(ListPinsRequest(collectionId: collection.id)) }) else { return }
|
||||
pinsByDocumentID = Dictionary(uniqueKeysWithValues: pins.map { ($0.documentId, $0) })
|
||||
}
|
||||
}
|
||||
@@ -157,6 +167,7 @@ private struct DocumentNodeRow: View {
|
||||
.frame(width: 12)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.pointerCursorOnHover()
|
||||
}
|
||||
|
||||
Button {
|
||||
@@ -186,6 +197,7 @@ private struct DocumentNodeRow: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.pointerCursorOnHover()
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
@@ -34,6 +34,7 @@ struct CollectionListContent: View {
|
||||
CollectionRowView(collection: collection)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.pointerCursorOnHover()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import MarkdownEngine
|
||||
import MarkdownEngineCodeBlocks
|
||||
import OutlineKit
|
||||
|
||||
/// Read-only for now — document/overview editing isn't wired up yet. Uses
|
||||
@@ -9,21 +10,34 @@ import OutlineKit
|
||||
/// `isSelectable` and link-opening both still need to work.
|
||||
struct CollectionOverviewContent: View {
|
||||
@State private var markdown: String
|
||||
@State private var imageProvider: OutlineImageProvider
|
||||
/// See `DocumentReaderView`'s `imageReloadTick`.
|
||||
@State private var imageReloadTick = 0
|
||||
|
||||
init(collection: OutlineCollection) {
|
||||
init(apiClient: OutlineAPIClient, collection: OutlineCollection) {
|
||||
_markdown = State(initialValue: collection.description ?? "")
|
||||
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
NativeTextViewWrapper(
|
||||
text: $markdown,
|
||||
configuration: .init(heightBehavior: .fitsContent),
|
||||
configuration: .init(
|
||||
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||
heightBehavior: .fitsContent
|
||||
),
|
||||
isEditable: false
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.animation(nil, value: imageReloadTick)
|
||||
.task {
|
||||
imageProvider.onImageLoaded = {
|
||||
Task { @MainActor in imageReloadTick += 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
struct CollectionOverviewView: View {
|
||||
let apiClient: OutlineAPIClient
|
||||
let collection: OutlineCollection
|
||||
@State private var viewModel: DocumentsViewModel
|
||||
@State private var selectedTab: CollectionTab = .overview
|
||||
@@ -25,6 +26,7 @@ struct CollectionOverviewView: View {
|
||||
searchQuery: Binding<String>,
|
||||
onOpenDocument: @escaping (OutlineDocument) -> Void
|
||||
) {
|
||||
self.apiClient = apiClient
|
||||
self.collection = collection
|
||||
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
||||
_searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id))
|
||||
@@ -32,8 +34,15 @@ struct CollectionOverviewView: View {
|
||||
self.onOpenDocument = onOpenDocument
|
||||
}
|
||||
|
||||
private var sortedDocuments: [OutlineDocument] {
|
||||
selectedTab.sorted(viewModel.documents)
|
||||
/// Recomputed only when `viewModel.documents`/`selectedTab` actually
|
||||
/// change (below) instead of being a computed property re-sorted on
|
||||
/// every render — capped at 100 documents per collection page, so lower
|
||||
/// blast radius than the sidebar/command-palette versions of this same
|
||||
/// pattern, but the same fix.
|
||||
@State private var sortedDocuments: [OutlineDocument] = []
|
||||
|
||||
private func resortDocuments() {
|
||||
sortedDocuments = selectedTab.sorted(viewModel.documents)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -59,7 +68,7 @@ struct CollectionOverviewView: View {
|
||||
if !trimmedSearchQuery.isEmpty {
|
||||
searchResultsList
|
||||
} else if selectedTab == .overview {
|
||||
CollectionOverviewContent(collection: collection)
|
||||
CollectionOverviewContent(apiClient: apiClient, collection: collection)
|
||||
} else {
|
||||
documentList
|
||||
}
|
||||
@@ -92,6 +101,8 @@ struct CollectionOverviewView: View {
|
||||
await viewModel.checkForRemoteChanges()
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.documents) { resortDocuments() }
|
||||
.onChange(of: selectedTab) { resortDocuments() }
|
||||
}
|
||||
|
||||
// Spans the full window width, centered, directly under the toolbar —
|
||||
@@ -114,6 +125,7 @@ struct CollectionOverviewView: View {
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.pointerCursorOnHover()
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
@@ -150,6 +162,7 @@ struct CollectionOverviewView: View {
|
||||
DocumentRowView(document: document)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.pointerCursorOnHover()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,6 +195,7 @@ struct CollectionOverviewView: View {
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.pointerCursorOnHover()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ struct CollectionTreeRow: View {
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.pointerCursorOnHover()
|
||||
.animation(.easeInOut(duration: 0.15), value: isExpanded)
|
||||
.contextMenu { contextMenuContent }
|
||||
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
#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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recomputed only when `query`/`collections`/`documents` actually change
|
||||
/// (below) instead of being a computed property — Full Workspace mode's
|
||||
/// index can be large (every document in the local cache, sub-documents
|
||||
/// included), and this was re-scanning + re-sorting the entire thing on
|
||||
/// every render, including ones triggered by unrelated state like
|
||||
/// `selectedIndex` changing as arrow keys move the selection.
|
||||
@State private var results: [Result] = []
|
||||
|
||||
private func recomputeResults() {
|
||||
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.
|
||||
results = (collections.map(Result.collection) + documents.map(Result.document))
|
||||
.prefix(20)
|
||||
.map { $0 }
|
||||
return
|
||||
}
|
||||
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) }
|
||||
}
|
||||
results = 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
|
||||
recomputeResults()
|
||||
}
|
||||
.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())
|
||||
.pointerCursorOnHover()
|
||||
.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()
|
||||
}
|
||||
.onChange(of: collections) { recomputeResults() }
|
||||
.onChange(of: documents) { recomputeResults() }
|
||||
}
|
||||
|
||||
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(AppNavigation.self) private var navigation
|
||||
@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
|
||||
/// means, so this starts `true` rather than auto-selecting the first
|
||||
/// 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
|
||||
/// itself in response. See `CollectionDocumentsOutline.externalRefreshToken`.
|
||||
@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 {
|
||||
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -147,6 +153,38 @@ struct ContentView_macOS: View {
|
||||
if newValue != nil {
|
||||
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: [])
|
||||
}
|
||||
|
||||
// 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
|
||||
private var contextualSearchField: some View {
|
||||
if isContextualSearchExpanded || !contextualSearchQuery.isEmpty {
|
||||
@@ -350,6 +445,13 @@ struct ContentView_macOS: View {
|
||||
if !documentPath.isEmpty {
|
||||
documentPath.removeLast()
|
||||
}
|
||||
// Delete/Archive/Unpublish/Move all change what
|
||||
// should show in the sidebar tree — this only
|
||||
// popped the reader before, leaving the sidebar
|
||||
// showing the document until some unrelated
|
||||
// trigger (the 45s poll, navigating away and
|
||||
// back) happened to refresh it.
|
||||
documentsChangedToken += 1
|
||||
},
|
||||
onDocumentCreated: { documentsChangedToken += 1 }
|
||||
)
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// Document-level and anchored comments + single-level replies + emoji
|
||||
/// reactions + resolve/unresolve. Composing new comments/replies is still
|
||||
/// plain text only (no bold/italic/lists/etc.) — sent through the `text`
|
||||
/// (markdown) convenience field, not a hand-built ProseMirror `data`
|
||||
/// document.
|
||||
@MainActor
|
||||
struct DocumentCommentsSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let apiClient: OutlineAPIClient
|
||||
let document: OutlineDocument
|
||||
/// Set when opened by tapping an inline anchor marker — scrolls to and
|
||||
/// briefly highlights that comment/reply. `nil` opens unfocused (the
|
||||
/// plain toolbar marker).
|
||||
var focusedCommentId: String? = nil
|
||||
/// Set when opened via "Comment on Selection…" from the editor's
|
||||
/// right-click menu — the selected text to anchor a NEW comment to.
|
||||
/// The first occurrence of this exact substring in the document is
|
||||
/// what the server (and later, this app's own inline marker) anchors
|
||||
/// to; no prefix/suffix disambiguation UI for multiple identical
|
||||
/// occurrences.
|
||||
var pendingAnchorText: String? = nil
|
||||
/// Called after any successful create/reply/resolve/reaction — lets the
|
||||
/// reader refresh its own lightweight copy (inline anchor markers, the
|
||||
/// toolbar badge count) without waiting for the document to be reopened.
|
||||
var onCommentsChanged: (() -> Void)? = nil
|
||||
|
||||
/// A small curated set rather than the full system emoji picker — quick
|
||||
/// taps for the common reactions, matching how most chat apps default.
|
||||
private static let quickReactions = ["👍", "❤️", "😂", "🎉", "😮", "😢"]
|
||||
|
||||
@State private var comments: [OutlineComment] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var actionErrorMessage: String?
|
||||
@State private var currentUserId: String?
|
||||
|
||||
@State private var newCommentText = ""
|
||||
@State private var isPostingNewComment = false
|
||||
/// Mutable mirror of `pendingAnchorText` — lets the user clear it (fall
|
||||
/// back to a plain document-level comment) without touching the init
|
||||
/// param itself.
|
||||
@State private var composingAnchorText: String?
|
||||
|
||||
@State private var replyingToThreadId: String?
|
||||
@State private var replyText = ""
|
||||
@State private var isPostingReply = false
|
||||
|
||||
@State private var resolvingCommentIds: Set<String> = []
|
||||
@State private var reactingCommentIds: Set<String> = []
|
||||
@State private var reactionPickerCommentId: String?
|
||||
|
||||
private struct CommentThread: Identifiable {
|
||||
let top: OutlineComment
|
||||
let replies: [OutlineComment]
|
||||
var id: String { top.id }
|
||||
}
|
||||
|
||||
private var threads: [CommentThread] {
|
||||
let topLevel = comments.filter { $0.parentCommentId == nil }.sorted { $0.createdAt < $1.createdAt }
|
||||
return topLevel.map { top in
|
||||
let replies = comments
|
||||
.filter { $0.parentCommentId == top.id }
|
||||
.sorted { $0.createdAt < $1.createdAt }
|
||||
return CommentThread(top: top, replies: replies)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text("Comments")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
.padding()
|
||||
|
||||
Divider()
|
||||
|
||||
Group {
|
||||
if isLoading && comments.isEmpty {
|
||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let errorMessage {
|
||||
ContentUnavailableView {
|
||||
Label("Couldn't Load Comments", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(errorMessage)
|
||||
} actions: {
|
||||
Button("Retry") { Task { await load() } }
|
||||
}
|
||||
} else if threads.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("No Comments", systemImage: "bubble.left.and.bubble.right")
|
||||
} description: {
|
||||
Text("This document has no comments yet.")
|
||||
}
|
||||
} else {
|
||||
ScrollViewReader { proxy in
|
||||
List(threads) { thread in
|
||||
threadSection(thread)
|
||||
}
|
||||
.task {
|
||||
guard let focusedCommentId else { return }
|
||||
// The list needs a beat to lay out before a
|
||||
// scrollTo lands correctly on first appear.
|
||||
try? await Task.sleep(for: .milliseconds(50))
|
||||
withAnimation {
|
||||
proxy.scrollTo(focusedCommentId, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
Divider()
|
||||
newCommentComposer
|
||||
}
|
||||
.frame(width: 480, height: 560)
|
||||
.task { await load() }
|
||||
.task { currentUserId = try? await RetryPolicy.withRetry({ try await apiClient.currentUser().id }) }
|
||||
.task { composingAnchorText = pendingAnchorText }
|
||||
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
|
||||
Button("OK") { actionErrorMessage = nil }
|
||||
} message: {
|
||||
Text(actionErrorMessage ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Thread
|
||||
|
||||
private func threadSection(_ thread: CommentThread) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
commentRow(thread.top, isReply: false)
|
||||
|
||||
ForEach(thread.replies) { reply in
|
||||
commentRow(reply, isReply: true)
|
||||
.padding(.leading, 20)
|
||||
}
|
||||
|
||||
if replyingToThreadId == thread.id {
|
||||
replyComposer(for: thread)
|
||||
.padding(.leading, 20)
|
||||
} else {
|
||||
Button("Reply") {
|
||||
replyingToThreadId = thread.id
|
||||
replyText = ""
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.blue)
|
||||
.padding(.leading, 20)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
private func commentRow(_ comment: OutlineComment, isReply: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
Text(comment.createdBy?.name ?? "Unknown")
|
||||
.font(.callout.weight(.semibold))
|
||||
Spacer()
|
||||
Text(comment.createdAt, format: .relative(presentation: .named))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(comment.bodyText.isEmpty ? "(empty)" : comment.bodyText)
|
||||
.font(.callout)
|
||||
.foregroundStyle(comment.bodyText.isEmpty ? .secondary : .primary)
|
||||
|
||||
if !comment.reactions.isEmpty {
|
||||
reactionChips(comment)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
reactionPickerCommentId = comment.id
|
||||
} label: {
|
||||
Image(systemName: "face.smiling")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.popover(isPresented: Binding(
|
||||
get: { reactionPickerCommentId == comment.id },
|
||||
set: { if !$0 { reactionPickerCommentId = nil } }
|
||||
)) {
|
||||
quickReactionPicker(comment)
|
||||
}
|
||||
|
||||
if !isReply {
|
||||
if comment.isResolved {
|
||||
Label("Resolved", systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
if resolvingCommentIds.contains(comment.id) {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Button(comment.isResolved ? "Unresolve" : "Resolve") {
|
||||
Task { await toggleResolved(comment) }
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
} else {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(6)
|
||||
.background(
|
||||
comment.id == focusedCommentId ? Color.accentColor.opacity(0.12) : Color.clear,
|
||||
in: RoundedRectangle(cornerRadius: 6)
|
||||
)
|
||||
.id(comment.id)
|
||||
}
|
||||
|
||||
private func reactionChips(_ comment: OutlineComment) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(comment.reactions, id: \.emoji) { reaction in
|
||||
let mine = currentUserId.map(reaction.userIds.contains) ?? false
|
||||
Button {
|
||||
Task { await toggleReaction(comment, emoji: reaction.emoji, currentlyReacted: mine) }
|
||||
} label: {
|
||||
Text("\(reaction.emoji) \(reaction.userIds.count)")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(mine ? Color.accentColor.opacity(0.2) : Color.gray.opacity(0.15), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(reactingCommentIds.contains(comment.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func quickReactionPicker(_ comment: OutlineComment) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Self.quickReactions, id: \.self) { emoji in
|
||||
let mine = currentUserId.map { userId in
|
||||
comment.reactions.first { $0.emoji == emoji }?.userIds.contains(userId) ?? false
|
||||
} ?? false
|
||||
Button {
|
||||
reactionPickerCommentId = nil
|
||||
Task { await toggleReaction(comment, emoji: emoji, currentlyReacted: mine) }
|
||||
} label: {
|
||||
Text(emoji)
|
||||
.font(.title2)
|
||||
.opacity(mine ? 1 : 0.5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
|
||||
// MARK: - Composers
|
||||
|
||||
private var newCommentComposer: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let composingAnchorText {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "text.quote")
|
||||
.foregroundStyle(.blue)
|
||||
Text(composingAnchorText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Spacer()
|
||||
Button {
|
||||
self.composingAnchorText = nil
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.help("Remove — this will post as a document-level comment instead")
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField(
|
||||
composingAnchorText == nil ? "Add a comment…" : "Comment on this text…",
|
||||
text: $newCommentText,
|
||||
axis: .vertical
|
||||
)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1...4)
|
||||
.onSubmit { Task { await postNewComment() } }
|
||||
if isPostingNewComment {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Button("Post") {
|
||||
Task { await postNewComment() }
|
||||
}
|
||||
.disabled(newCommentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func replyComposer(for thread: CommentThread) -> some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField("Reply…", text: $replyText, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1...4)
|
||||
.onSubmit { Task { await postReply(to: thread) } }
|
||||
if isPostingReply {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Button("Send") {
|
||||
Task { await postReply(to: thread) }
|
||||
}
|
||||
.disabled(replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
Button("Cancel") {
|
||||
replyingToThreadId = nil
|
||||
replyText = ""
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func load() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
comments = try await apiClient.listComments(ListCommentsRequest(documentId: document.id))
|
||||
} catch {
|
||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load comments for this document.")
|
||||
}
|
||||
}
|
||||
|
||||
private func postNewComment() async {
|
||||
let text = newCommentText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
isPostingNewComment = true
|
||||
defer { isPostingNewComment = false }
|
||||
do {
|
||||
let created = try await apiClient.createComment(
|
||||
CreateCommentRequest(documentId: document.id, text: text, anchorText: composingAnchorText)
|
||||
)
|
||||
comments.append(created)
|
||||
composingAnchorText = nil
|
||||
newCommentText = ""
|
||||
onCommentsChanged?()
|
||||
} catch {
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't post this comment.")
|
||||
}
|
||||
}
|
||||
|
||||
private func postReply(to thread: CommentThread) async {
|
||||
let text = replyText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
isPostingReply = true
|
||||
defer { isPostingReply = false }
|
||||
do {
|
||||
let created = try await apiClient.createComment(
|
||||
CreateCommentRequest(documentId: document.id, parentCommentId: thread.id, text: text)
|
||||
)
|
||||
comments.append(created)
|
||||
replyText = ""
|
||||
replyingToThreadId = nil
|
||||
onCommentsChanged?()
|
||||
} catch {
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't post this reply.")
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleResolved(_ comment: OutlineComment) async {
|
||||
resolvingCommentIds.insert(comment.id)
|
||||
defer { resolvingCommentIds.remove(comment.id) }
|
||||
do {
|
||||
let updated = comment.isResolved
|
||||
? try await apiClient.unresolveComment(id: comment.id)
|
||||
: try await apiClient.resolveComment(id: comment.id)
|
||||
replace(updated)
|
||||
} catch {
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update this comment.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Reaction endpoints return `{success: true}`, not the updated comment
|
||||
/// (see `OutlineAPIClient.addReaction`) — refetch via `commentInfo` for
|
||||
/// the real post-toggle `reactions` array instead of guessing the merge
|
||||
/// locally (another user reacting concurrently would make a guess wrong).
|
||||
private func toggleReaction(_ comment: OutlineComment, emoji: String, currentlyReacted: Bool) async {
|
||||
reactingCommentIds.insert(comment.id)
|
||||
defer { reactingCommentIds.remove(comment.id) }
|
||||
do {
|
||||
if currentlyReacted {
|
||||
try await apiClient.removeReaction(commentId: comment.id, emoji: emoji)
|
||||
} else {
|
||||
try await apiClient.addReaction(commentId: comment.id, emoji: emoji)
|
||||
}
|
||||
let refreshed = try await apiClient.commentInfo(id: comment.id)
|
||||
replace(refreshed)
|
||||
} catch {
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update that reaction.")
|
||||
}
|
||||
}
|
||||
|
||||
private func replace(_ updated: OutlineComment) {
|
||||
if let index = comments.firstIndex(where: { $0.id == updated.id }) {
|
||||
comments[index] = updated
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,6 +1,7 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import MarkdownEngine
|
||||
import MarkdownEngineCodeBlocks
|
||||
import OutlineKit
|
||||
|
||||
/// Distraction-free reading view — no toolbar/sidebar chrome, larger type.
|
||||
@@ -16,11 +17,17 @@ struct DocumentPresentSheet: View {
|
||||
@State private var text: String
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var imageProvider: OutlineImageProvider
|
||||
/// See `DocumentReaderView`'s `imageReloadTick` — same "force an
|
||||
/// `updateNSView` re-pass so the engine notices `fingerprint()` changed"
|
||||
/// mechanism, needed here too since this sheet renders its own images.
|
||||
@State private var imageReloadTick = 0
|
||||
|
||||
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
|
||||
self.apiClient = apiClient
|
||||
self.document = document
|
||||
_text = State(initialValue: document.text)
|
||||
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -44,7 +51,10 @@ struct DocumentPresentSheet: View {
|
||||
.padding(.bottom, 8)
|
||||
NativeTextViewWrapper(
|
||||
text: $text,
|
||||
configuration: .init(heightBehavior: .fitsContent),
|
||||
configuration: .init(
|
||||
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||
heightBehavior: .fitsContent
|
||||
),
|
||||
isEditable: false
|
||||
)
|
||||
.font(.system(size: 18))
|
||||
@@ -67,6 +77,12 @@ struct DocumentPresentSheet: View {
|
||||
}
|
||||
.frame(minWidth: 800, minHeight: 600)
|
||||
.background(.background)
|
||||
.animation(nil, value: imageReloadTick)
|
||||
.task {
|
||||
imageProvider.onImageLoaded = {
|
||||
Task { @MainActor in imageReloadTick += 1 }
|
||||
}
|
||||
}
|
||||
.task { await load() }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ import AppKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import MarkdownEngine
|
||||
import MarkdownEngineCodeBlocks
|
||||
import OutlineKit
|
||||
#if canImport(ImagePlayground)
|
||||
import ImagePlayground
|
||||
#endif
|
||||
|
||||
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
|
||||
/// below must run on the main thread — see the identical note on
|
||||
@@ -13,6 +17,55 @@ struct DocumentReaderView: View {
|
||||
@Environment(SessionStore.self) private var session
|
||||
@Environment(StarStore.self) private var starStore
|
||||
@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
|
||||
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
|
||||
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true
|
||||
@AppStorage("outpost.imagePlaygroundEnabled") private var isImagePlaygroundEnabled = true
|
||||
@AppStorage("outpost.pointerCursorEnabled") private var isPointerCursorEnabled = true
|
||||
|
||||
/// `ImagePlaygroundViewController.isAvailable` gates on both OS version
|
||||
/// (macOS 15.1+) and actual device/region support (Apple Intelligence
|
||||
/// eligibility) — a supported OS with an unsupported Mac still reports
|
||||
/// `false`, so this is the one check that matters, not just `#available`.
|
||||
private var isImagePlaygroundSupported: Bool {
|
||||
#if canImport(ImagePlayground)
|
||||
if #available(macOS 15.1, *) {
|
||||
return ImagePlaygroundViewController.isAvailable
|
||||
}
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
/// Outline's own "Show line numbers" preference (synced, read via
|
||||
/// `session.userPreferences`, not `@AppStorage` — this one's the
|
||||
/// server's, not a local-only Outpost setting). No `@Environment`-in-`init`
|
||||
/// problem here since this is read directly in the view, not the
|
||||
/// view model.
|
||||
private var showCodeBlockLineNumbers: Bool {
|
||||
session.userPreferences?.codeBlockLineNumbers ?? false
|
||||
}
|
||||
/// Widened left indent reserved for the number gutter when line numbers
|
||||
/// are on (default is 12pt, just enough margin, no room for digits).
|
||||
private static let lineNumberGutterWidth: CGFloat = 32
|
||||
private var editorCodeBlockStyle: CodeBlockStyle {
|
||||
showCodeBlockLineNumbers ? .init(horizontalIndent: Self.lineNumberGutterWidth) : .default
|
||||
}
|
||||
/// Outline's own "Smart text replacements" preference (synced) — smart
|
||||
/// quotes/dashes while typing. Only meaningful on the editable pane.
|
||||
private var editorTextSubstitution: TextSubstitutionPolicy {
|
||||
let enabled = session.userPreferences?.smartText ?? false
|
||||
return .init(quoteSubstitution: enabled, dashSubstitution: enabled)
|
||||
}
|
||||
/// Local-only Outpost settings (Settings → Editor) — not synced to
|
||||
/// Outline, same as Split View above.
|
||||
private var editorTextCompletion: TextCompletionPolicy {
|
||||
.init(isEnabled: isAutocompleteEnabled)
|
||||
}
|
||||
private var editorWritingTools: WritingToolsPolicy {
|
||||
.init(isEnabled: isWritingToolsEnabled)
|
||||
}
|
||||
|
||||
@State private var viewModel: DocumentReaderViewModel
|
||||
let apiClient: OutlineAPIClient
|
||||
@@ -31,16 +84,73 @@ struct DocumentReaderView: View {
|
||||
let onDocumentCreated: () -> Void
|
||||
|
||||
@State private var isShowingUnpublishConfirmation = false
|
||||
@State private var isShowingPublishSheet = false
|
||||
@State private var isShowingArchiveConfirmation = false
|
||||
@State private var isShowingDeleteConfirmation = false
|
||||
@State private var isShowingMoveSheet = false
|
||||
@State private var isShowingHistorySheet = false
|
||||
@State private var isShowingInsightsSheet = false
|
||||
@State private var isShowingCommentsSheet = false
|
||||
/// Fetched once on load, purely to drive the toolbar marker/badge and
|
||||
/// the inline anchor bars below — the sheet itself fetches its own copy
|
||||
/// independently (see `DocumentCommentsSheet`), same as every other
|
||||
/// self-contained sheet in this file.
|
||||
@State private var loadedComments: [OutlineComment] = []
|
||||
/// Comment id to scroll/focus to when the sheet opens — set when an
|
||||
/// inline anchor bar is tapped, `nil` for the plain toolbar marker.
|
||||
@State private var focusedCommentId: String?
|
||||
/// Resolved on-screen positions for each anchored comment's text, from
|
||||
/// `NativeTextViewWrapper.onCommentAnchorRectsChange`.
|
||||
@State private var commentAnchorRects: [CommentAnchorRect] = []
|
||||
/// Set from "Comment on Selection…" in the editor's right-click menu —
|
||||
/// the sheet opens straight into composing a new anchored comment.
|
||||
@State private var pendingCommentAnchorText: String?
|
||||
|
||||
private var commentCount: Int? {
|
||||
loadedComments.isEmpty ? nil : loadedComments.count
|
||||
}
|
||||
private var commentAnchorQueries: [CommentAnchorQuery] {
|
||||
loadedComments.compactMap { comment in
|
||||
guard let anchorText = comment.anchorText, !anchorText.isEmpty else { return nil }
|
||||
return CommentAnchorQuery(id: comment.id, anchorText: anchorText)
|
||||
}
|
||||
}
|
||||
@State private var isShowingPresentSheet = false
|
||||
@State private var isShowingSearchSheet = false
|
||||
@State private var isShowingShareSheet = false
|
||||
@State private var isShowingNewDocumentSheet = false
|
||||
@State private var isShowingImagePlayground = false
|
||||
@State private var actionErrorMessage: String?
|
||||
/// Pushed into the main editable pane's `NativeTextViewWrapper` to
|
||||
/// insert an Image Playground result's Markdown reference at the caret.
|
||||
@State private var pendingTextInsertion: TextInsertionRequest?
|
||||
/// Pushed by `CodeBlockLanguagePicker` to rewrite a code block's fence
|
||||
/// line when the user switches its language from the picker.
|
||||
@State private var pendingCodeBlockLanguageChange: TextRangeReplacementRequest?
|
||||
/// Live-updated by `onSelectedTextChange` on the main editable pane;
|
||||
/// `nil` when the selection is empty (caret only, nothing highlighted).
|
||||
@State private var currentSelectedText: String?
|
||||
/// Resolves `` images in this document — shared by both
|
||||
/// panes (main + split-view preview), since they render the same text.
|
||||
@State private var imageProvider: OutlineImageProvider
|
||||
/// Bumped by `imageProvider.onImageLoaded`. Not read for its value —
|
||||
/// just referenced via `.animation(nil, value:)` so SwiftUI re-evaluates
|
||||
/// this view (and so `NativeTextViewWrapper.updateNSView` re-runs and
|
||||
/// notices the provider's `fingerprint()` changed) once an async image
|
||||
/// load completes. The engine has no polling of its own for this.
|
||||
@State private var imageReloadTick = 0
|
||||
/// Snapshot of `currentSelectedText` taken the moment the Image
|
||||
/// Playground button is pressed — the sheet's seed shouldn't shift if
|
||||
/// the user's selection happens to change while it's open.
|
||||
@State private var imagePlaygroundSeedText: String?
|
||||
/// Populated live by `NativeTextViewWrapper`'s `onCodeBlockSelectionChange` —
|
||||
/// one array per instance (main pane, split-view preview pane), since
|
||||
/// each lays the same text out at a different width and gets different
|
||||
/// rects. Only non-empty when `showCodeBlockLineNumbers` is on (see its
|
||||
/// doc comment for why the gutter needs `codeBlock.horizontalIndent`
|
||||
/// widened, which is gated on the same flag).
|
||||
@State private var readerCodeBlocks: [CodeBlockSelection] = []
|
||||
@State private var previewCodeBlocks: [CodeBlockSelection] = []
|
||||
|
||||
init(
|
||||
apiClient: OutlineAPIClient,
|
||||
@@ -51,7 +161,13 @@ struct DocumentReaderView: View {
|
||||
) {
|
||||
self.apiClient = apiClient
|
||||
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))
|
||||
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
|
||||
self.onOpenChild = onOpenChild
|
||||
self.onDeleted = onDeleted
|
||||
self.onDocumentCreated = onDocumentCreated
|
||||
@@ -66,44 +182,26 @@ struct DocumentReaderView: View {
|
||||
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 {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if viewModel.isEditing {
|
||||
TextField("Title", text: $viewModel.title)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.textFieldStyle(.plain)
|
||||
}
|
||||
|
||||
if viewModel.isLoading && viewModel.text.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else if let errorMessage = viewModel.errorMessage {
|
||||
ContentUnavailableView {
|
||||
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(errorMessage)
|
||||
} actions: {
|
||||
Button("Retry") {
|
||||
Task { await viewModel.loadFullContent() }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
NativeTextViewWrapper(
|
||||
text: $viewModel.text,
|
||||
configuration: .init(heightBehavior: .fitsContent),
|
||||
documentId: viewModel.documentId,
|
||||
isEditable: viewModel.isEditing
|
||||
)
|
||||
|
||||
if !viewModel.children.isEmpty {
|
||||
childrenSection
|
||||
}
|
||||
}
|
||||
Group {
|
||||
if canShowSplitView {
|
||||
splitViewContent
|
||||
} else {
|
||||
scrollingReaderContent
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if viewModel.isLoading && !viewModel.text.isEmpty {
|
||||
@@ -127,16 +225,62 @@ struct DocumentReaderView: View {
|
||||
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await viewModel.toggleEditing() }
|
||||
} label: {
|
||||
if viewModel.isSaving {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text(viewModel.isEditing ? "Done" : "Edit")
|
||||
// Toolbar marker — only shows once there's actually
|
||||
// something to point at. Anchored comments additionally get
|
||||
// an inline vertical bar next to their text (see
|
||||
// `commentAnchorMarkers`/`onCommentAnchorRectsChange`
|
||||
// below) — this button opens the sheet unfocused, showing
|
||||
// every comment/thread.
|
||||
if let commentCount, commentCount > 0 {
|
||||
Button {
|
||||
focusedCommentId = nil
|
||||
pendingCommentAnchorText = nil
|
||||
isShowingCommentsSheet = true
|
||||
} label: {
|
||||
Image(systemName: "bubble.left.and.bubble.right")
|
||||
}
|
||||
.help("\(commentCount) Comment\(commentCount == 1 ? "" : "s")")
|
||||
.disabled(!isEffectivelyOnline)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
Text(commentCount > 10 ? "10+" : "\(commentCount)")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(2)
|
||||
.frame(minWidth: 12, minHeight: 12)
|
||||
.background(.blue, in: Circle())
|
||||
.offset(x: 0, y: -1)
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isSaving)
|
||||
|
||||
if isImagePlaygroundEnabled && isImagePlaygroundSupported {
|
||||
Button {
|
||||
imagePlaygroundSeedText = currentSelectedText
|
||||
isShowingImagePlayground = true
|
||||
} label: {
|
||||
Image(systemName: "sparkles")
|
||||
}
|
||||
.help("Create Image with Image Playground")
|
||||
.disabled(!viewModel.isEffectivelyEditable)
|
||||
}
|
||||
|
||||
if viewModel.separateEditingEnabled {
|
||||
Button {
|
||||
Task { await viewModel.toggleEditing() }
|
||||
} label: {
|
||||
if viewModel.isSaving {
|
||||
ProgressView().controlSize(.small)
|
||||
} 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…")
|
||||
}
|
||||
|
||||
Button {
|
||||
isShowingNewDocumentSheet = true
|
||||
@@ -159,13 +303,37 @@ struct DocumentReaderView: View {
|
||||
.id(menuIdentity)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
imageProvider.onImageLoaded = {
|
||||
Task { @MainActor in imageReloadTick += 1 }
|
||||
}
|
||||
}
|
||||
.animation(nil, value: imageReloadTick)
|
||||
.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 {
|
||||
await viewModel.loadPinAndSubscriptionState()
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadInsightsEnabledState()
|
||||
}
|
||||
.task {
|
||||
loadedComments = (try? await RetryPolicy.withRetry({
|
||||
try await apiClient.listComments(
|
||||
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
|
||||
)
|
||||
})) ?? []
|
||||
}
|
||||
.task {
|
||||
while !Task.isCancelled {
|
||||
await viewModel.loadViewers()
|
||||
@@ -213,6 +381,38 @@ struct DocumentReaderView: View {
|
||||
} message: {
|
||||
Text(actionErrorMessage ?? "")
|
||||
}
|
||||
.sheet(isPresented: $isShowingCommentsSheet) {
|
||||
DocumentCommentsSheet(
|
||||
apiClient: apiClient,
|
||||
document: document,
|
||||
focusedCommentId: focusedCommentId,
|
||||
pendingAnchorText: pendingCommentAnchorText,
|
||||
onCommentsChanged: {
|
||||
Task {
|
||||
loadedComments = (try? await RetryPolicy.withRetry({
|
||||
try await apiClient.listComments(
|
||||
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
|
||||
)
|
||||
})) ?? loadedComments
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $isShowingPublishSheet) {
|
||||
PublishDocumentSheet(apiClient: apiClient, document: document) {
|
||||
// Stays in the reader — unlike Move/Unpublish, publishing
|
||||
// doesn't make the document any less visible from here, so
|
||||
// there's no reason to pop back like onDeleted() does
|
||||
// elsewhere. Refresh from the server rather than guessing
|
||||
// the new collectionId locally (moveDocument may have run
|
||||
// as a second step inside the sheet).
|
||||
await viewModel.loadFullContent()
|
||||
// The doc previously had no collectionId (or a different
|
||||
// one) — the sidebar tree for its new collection needs to
|
||||
// know it exists now, same signal "New Document" sends.
|
||||
onDocumentCreated()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isShowingMoveSheet) {
|
||||
MoveDocumentSheet(apiClient: apiClient, document: document) {
|
||||
onDeleted()
|
||||
@@ -236,6 +436,59 @@ struct DocumentReaderView: View {
|
||||
onOpenChild(child)
|
||||
}
|
||||
}
|
||||
.modifier(ImagePlaygroundPresenter(
|
||||
isPresented: $isShowingImagePlayground,
|
||||
seedText: imagePlaygroundSeedText,
|
||||
seedTitle: viewModel.title.isEmpty ? "Untitled" : viewModel.title,
|
||||
onCompletion: { url in handleGeneratedImage(url) }
|
||||
))
|
||||
}
|
||||
|
||||
/// Adds "Comment on Selection…" to the right-click menu when there's an
|
||||
/// actual (non-empty) selection to anchor to — `currentSelectedText` is
|
||||
/// kept live by `onSelectedTextChange` on the same pane. Inserted at the
|
||||
/// top since it's the primary reason to right-click selected text here,
|
||||
/// matching Outline's own web editor surfacing comment as the first
|
||||
/// selection action.
|
||||
private func addCommentMenuItem(to menu: NSMenu) -> NSMenu {
|
||||
guard let selection = currentSelectedText, !selection.isEmpty else { return menu }
|
||||
let item = ClosureMenuItem(title: "Comment on Selection…") {
|
||||
pendingCommentAnchorText = selection
|
||||
focusedCommentId = nil
|
||||
isShowingCommentsSheet = true
|
||||
}
|
||||
menu.insertItem(item, at: 0)
|
||||
menu.insertItem(.separator(), at: 1)
|
||||
return menu
|
||||
}
|
||||
|
||||
/// Uploads an Image Playground result the same way the reader would any
|
||||
/// other attachment (`attachments.create` presigned target, then the
|
||||
/// direct file POST — see `OutlineAPIClient.uploadAttachmentFile`), then
|
||||
/// inserts the hosted image's Markdown reference at the caret.
|
||||
private func handleGeneratedImage(_ localURL: URL) {
|
||||
Task {
|
||||
do {
|
||||
let data = try Data(contentsOf: localURL)
|
||||
let created = try await apiClient.createAttachment(.init(
|
||||
name: localURL.lastPathComponent,
|
||||
contentType: "image/png",
|
||||
size: data.count,
|
||||
documentId: viewModel.documentId
|
||||
))
|
||||
try await apiClient.uploadAttachmentFile(created, fileData: data)
|
||||
// Outline's own editor never embeds the raw (presigned/storage)
|
||||
// upload URL in document Markdown — it writes this stable
|
||||
// redirect-by-id reference instead, which keeps resolving
|
||||
// correctly even if the underlying storage URL rotates/expires.
|
||||
pendingTextInsertion = TextInsertionRequest(
|
||||
documentId: viewModel.documentId,
|
||||
text: "\n\n)\n\n"
|
||||
)
|
||||
} catch {
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't add the generated image.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every toggle-backed piece of state shown as a checkmark inside
|
||||
@@ -272,31 +525,166 @@ struct DocumentReaderView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var childrenSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
|
||||
Text("Sub-documents")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
ForEach(viewModel.children) { child in
|
||||
Button {
|
||||
onOpenChild(child)
|
||||
} label: {
|
||||
DocumentRowView(document: child)
|
||||
/// Today's single-pane layout — page-level `ScrollView` wrapping title +
|
||||
/// content, used for the normal reading/editing view, and for every
|
||||
/// loading/error state regardless of Split View.
|
||||
private var scrollingReaderContent: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if viewModel.isEffectivelyEditable {
|
||||
TextField("Title", text: $viewModel.title)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.textFieldStyle(.plain)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
if child.id != viewModel.children.last?.id {
|
||||
Divider()
|
||||
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 {
|
||||
ZStack(alignment: .topLeading) {
|
||||
NativeTextViewWrapper(
|
||||
text: $viewModel.text,
|
||||
pendingTextInsertion: $pendingTextInsertion,
|
||||
pendingTextRangeReplacement: $pendingCodeBlockLanguageChange,
|
||||
configuration: .init(
|
||||
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||
codeBlock: editorCodeBlockStyle,
|
||||
textSubstitution: editorTextSubstitution,
|
||||
textCompletion: editorTextCompletion,
|
||||
writingTools: editorWritingTools,
|
||||
heightBehavior: .fitsContent,
|
||||
pointerCursorOverLinksWhileEditing: isPointerCursorEnabled
|
||||
),
|
||||
documentId: viewModel.documentId,
|
||||
isEditable: viewModel.isEffectivelyEditable,
|
||||
onBuildContextMenu: { menu, _ in addCommentMenuItem(to: menu) },
|
||||
onCodeBlockSelectionChange: { readerCodeBlocks = $0 },
|
||||
onSelectedTextChange: { currentSelectedText = $0 },
|
||||
commentAnchorQueries: commentAnchorQueries,
|
||||
onCommentAnchorRectsChange: { commentAnchorRects = $0 }
|
||||
)
|
||||
if showCodeBlockLineNumbers {
|
||||
ForEach(readerCodeBlocks) { selection in
|
||||
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
|
||||
}
|
||||
}
|
||||
if viewModel.isEffectivelyEditable {
|
||||
ForEach(readerCodeBlocks) { selection in
|
||||
CodeBlockLanguagePicker(selection: selection, documentId: viewModel.documentId) { request in
|
||||
pendingCodeBlockLanguageChange = request
|
||||
}
|
||||
}
|
||||
}
|
||||
ForEach(commentAnchorRects) { anchor in
|
||||
CommentAnchorMarker(rect: anchor.rect) {
|
||||
focusedCommentId = anchor.id
|
||||
pendingCommentAnchorText = nil
|
||||
isShowingCommentsSheet = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.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 the literal Markdown source in `rawSourceMode` (no syntax
|
||||
/// hiding/styling, but still the real engine — needed so selection
|
||||
/// tracking and caret-position insertion, e.g. from the Image Playground
|
||||
/// button, work here the same as everywhere else); 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 {
|
||||
NativeTextViewWrapper(
|
||||
text: $viewModel.text,
|
||||
pendingTextInsertion: $pendingTextInsertion,
|
||||
configuration: .init(rawSourceMode: true),
|
||||
fontName: "SFMono-Regular",
|
||||
documentId: viewModel.documentId,
|
||||
isEditable: viewModel.isEffectivelyEditable,
|
||||
onSelectedTextChange: { currentSelectedText = $0 }
|
||||
)
|
||||
.padding(8)
|
||||
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
ScrollView {
|
||||
ZStack(alignment: .topLeading) {
|
||||
NativeTextViewWrapper(
|
||||
text: $viewModel.text,
|
||||
configuration: .init(
|
||||
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||
codeBlock: editorCodeBlockStyle,
|
||||
heightBehavior: .fitsContent
|
||||
),
|
||||
documentId: viewModel.documentId,
|
||||
isEditable: false,
|
||||
onBuildContextMenu: { menu, _ in addCommentMenuItem(to: menu) },
|
||||
onCodeBlockSelectionChange: { previewCodeBlocks = $0 },
|
||||
onSelectedTextChange: { currentSelectedText = $0 },
|
||||
commentAnchorQueries: commentAnchorQueries,
|
||||
onCommentAnchorRectsChange: { commentAnchorRects = $0 }
|
||||
)
|
||||
if showCodeBlockLineNumbers {
|
||||
ForEach(previewCodeBlocks) { selection in
|
||||
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
|
||||
}
|
||||
}
|
||||
ForEach(commentAnchorRects) { anchor in
|
||||
CommentAnchorMarker(rect: anchor.rect) {
|
||||
focusedCommentId = anchor.id
|
||||
pendingCommentAnchorText = nil
|
||||
isShowingCommentsSheet = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var menuContent: some View {
|
||||
Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") {
|
||||
@@ -309,8 +697,10 @@ struct DocumentReaderView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
|
||||
Task { await viewModel.toggleEditing() }
|
||||
if viewModel.separateEditingEnabled {
|
||||
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
|
||||
Task { await viewModel.toggleEditing() }
|
||||
}
|
||||
}
|
||||
// Membership management now lives in DocumentShareSheet's "People
|
||||
// with access" section, alongside the share link — same sheet,
|
||||
@@ -330,10 +720,17 @@ struct DocumentReaderView: View {
|
||||
Task { await duplicate() }
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
Button("Unpublish") {
|
||||
isShowingUnpublishConfirmation = true
|
||||
if viewModel.publishedAt == nil {
|
||||
Button("Publish…") {
|
||||
isShowingPublishSheet = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
} else {
|
||||
Button("Unpublish") {
|
||||
isShowingUnpublishConfirmation = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
Button("Archive…") {
|
||||
isShowingArchiveConfirmation = true
|
||||
}
|
||||
@@ -531,4 +928,202 @@ struct DocumentReaderView: View {
|
||||
operation.run()
|
||||
}
|
||||
}
|
||||
|
||||
/// One code block's number gutter, positioned absolutely over a
|
||||
/// `NativeTextViewWrapper` via `CodeBlockSelection.rect` — same overlay
|
||||
/// pattern MarkdownEngine's own `CodeBlockButton` uses.
|
||||
///
|
||||
/// `selection.rect` spans the WHOLE fenced block (open fence line + content
|
||||
/// + close fence line), matching what the engine actually lays out — the
|
||||
/// fence lines render with invisible (`.clear`) text once the caret leaves
|
||||
/// the block, but they don't collapse to zero height, so the block is
|
||||
/// always exactly `content line count + 2` rows tall. `selection.code` is
|
||||
/// content only, so the row height and number positions below both account
|
||||
/// for that phantom top/bottom row explicitly instead of dividing by the
|
||||
/// content line count alone (which would drift the numbers upward, more so
|
||||
/// per line, the taller the block).
|
||||
///
|
||||
/// Known limitation, accepted rather than fixable app-side: a content line
|
||||
/// that soft-wraps onto a second visual row (MarkdownEngine always
|
||||
/// char-wraps code blocks, no way to opt out without forking the package)
|
||||
/// throws this off — every row below it reads one line low. Documented in
|
||||
/// TODO.local.md alongside the same package's other gaps.
|
||||
/// Thin vertical bar next to an anchored comment's text — same visual
|
||||
/// language as Word/Google Docs' margin comment indicators. Tapping opens
|
||||
/// the comments sheet focused to that thread.
|
||||
/// Top-left pill overlay on each code block — opposite corner from the
|
||||
/// engine's own copy button (top-right), same `Color.clear` + `.position()`
|
||||
/// sizing trick `CodeBlockButton` uses. Tapping opens a curated language
|
||||
/// list (not the full ~190-language highlight.js catalog — same "small
|
||||
/// curated set, not the whole thing" call as the emoji reaction picker);
|
||||
/// picking one rewrites just the fence line via
|
||||
/// `CodeBlockSelection.fenceRange`, leaving the block's content untouched.
|
||||
private struct CodeBlockLanguagePicker: View {
|
||||
let selection: CodeBlockSelection
|
||||
let documentId: String
|
||||
let onRequest: (TextRangeReplacementRequest) -> Void
|
||||
|
||||
private static let languages: [(label: String, tag: String?)] = [
|
||||
("Plain Text", nil),
|
||||
("Swift", "swift"),
|
||||
("Python", "python"),
|
||||
("JavaScript", "javascript"),
|
||||
("TypeScript", "typescript"),
|
||||
("Bash", "bash"),
|
||||
("JSON", "json"),
|
||||
("YAML", "yaml"),
|
||||
("HTML", "html"),
|
||||
("CSS", "css"),
|
||||
("SQL", "sql"),
|
||||
("Java", "java"),
|
||||
("Kotlin", "kotlin"),
|
||||
("C", "c"),
|
||||
("C++", "cpp"),
|
||||
("C#", "csharp"),
|
||||
("Go", "go"),
|
||||
("Rust", "rust"),
|
||||
("Ruby", "ruby"),
|
||||
("PHP", "php"),
|
||||
("Markdown", "markdown"),
|
||||
]
|
||||
|
||||
private var currentLabel: String {
|
||||
guard let language = selection.language, !language.isEmpty else { return "Plain Text" }
|
||||
return Self.languages.first { $0.tag == language }?.label ?? language.uppercased()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.frame(width: selection.rect.width, height: selection.rect.height)
|
||||
.overlay(alignment: .topLeading) {
|
||||
Menu {
|
||||
ForEach(Self.languages, id: \.label) { entry in
|
||||
Button(entry.label) {
|
||||
onRequest(TextRangeReplacementRequest(
|
||||
documentId: documentId,
|
||||
range: selection.fenceRange,
|
||||
replacement: "```\(entry.tag ?? "")\n"
|
||||
))
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(currentLabel.uppercased())
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.black.opacity(0.3), in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
.padding(.top, 6)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
.position(
|
||||
x: selection.rect.midX,
|
||||
y: selection.rect.midY
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CommentAnchorMarker: View {
|
||||
let rect: CGRect
|
||||
let onTap: () -> Void
|
||||
|
||||
private static let width: CGFloat = 3
|
||||
private static let gap: CGFloat = 4
|
||||
private static let hitTargetWidth: CGFloat = 16
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.frame(width: Self.hitTargetWidth, height: max(rect.height, 4))
|
||||
.contentShape(Rectangle())
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(Color.blue.opacity(0.6))
|
||||
.frame(width: Self.width)
|
||||
}
|
||||
.position(
|
||||
x: rect.minX - Self.gap - Self.width / 2,
|
||||
y: rect.minY + rect.height / 2
|
||||
)
|
||||
.onTapGesture(perform: onTap)
|
||||
.help("View comment")
|
||||
}
|
||||
}
|
||||
|
||||
private struct CodeBlockLineNumberGutter: View {
|
||||
let selection: CodeBlockSelection
|
||||
let gutterWidth: CGFloat
|
||||
|
||||
/// `selection.code` (`token.contentRange`) always ends with exactly one
|
||||
/// trailing `\n` per content line — the range runs right up to the
|
||||
/// start of the closing fence's own line, so the newline that ends the
|
||||
/// last content line is included, but there's never an unterminated
|
||||
/// final line to add one more for. Counting `\n` characters directly
|
||||
/// (not `.components(separatedBy:).count`, which is one too many
|
||||
/// whenever the string ends in the separator) is what makes a
|
||||
/// single-line block read "1", not "2".
|
||||
private var contentLineCount: Int {
|
||||
max(1, selection.code.reduce(into: 0) { count, char in if char == "\n" { count += 1 } })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let totalRows = CGFloat(contentLineCount + 2)
|
||||
let rowHeight = selection.rect.height / totalRows
|
||||
ForEach(0..<contentLineCount, id: \.self) { line in
|
||||
Text("\(line + 1)")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: gutterWidth - 6, alignment: .trailing)
|
||||
.position(
|
||||
x: selection.rect.minX + (gutterWidth - 6) / 2,
|
||||
// +1.5 rows: skip the invisible open-fence row, then
|
||||
// center within this content row.
|
||||
y: selection.rect.minY + rowHeight * (CGFloat(line) + 1.5)
|
||||
)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies `.imagePlaygroundSheet` only where it exists (macOS 15.1+, and
|
||||
/// only once the `ImagePlayground` framework is actually linked in Xcode —
|
||||
/// see `SETUP.md`). A no-op modifier everywhere else, so this file stays
|
||||
/// valid to build before that link-up happens.
|
||||
private struct ImagePlaygroundPresenter: ViewModifier {
|
||||
@Binding var isPresented: Bool
|
||||
/// Highlighted document text at the moment the button was pressed, if
|
||||
/// any — seeds Image Playground's prompt instead of opening blank.
|
||||
let seedText: String?
|
||||
/// Document title, used as the concept's title when `seedText` is used.
|
||||
let seedTitle: String
|
||||
let onCompletion: (URL) -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
#if canImport(ImagePlayground)
|
||||
if #available(macOS 15.1, *) {
|
||||
let concepts: [ImagePlaygroundConcept] = {
|
||||
guard let seedText, !seedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return []
|
||||
}
|
||||
return [ImagePlaygroundConcept.extracted(from: seedText, title: seedTitle)]
|
||||
}()
|
||||
content.imagePlaygroundSheet(
|
||||
isPresented: $isPresented,
|
||||
concepts: concepts,
|
||||
onCompletion: { url in
|
||||
isPresented = false
|
||||
onCompletion(url)
|
||||
},
|
||||
onCancellation: { isPresented = false }
|
||||
)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
#else
|
||||
content
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -9,8 +9,10 @@ final class DocumentReaderViewModel {
|
||||
var emoji: String?
|
||||
var text: String
|
||||
var collectionId: String?
|
||||
var parentDocumentId: String?
|
||||
/// `nil` = draft (not published/visible to other workspace members).
|
||||
var publishedAt: Date?
|
||||
var isFullWidth = false
|
||||
var children: [OutlineDocument] = []
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
|
||||
@@ -18,6 +20,38 @@ final class DocumentReaderViewModel {
|
||||
var isSaving = false
|
||||
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
|
||||
/// `lastViewedAt` — this is historical/aggregated view data, not live
|
||||
/// "viewing right now" presence (that needs the Hocuspocus collaboration
|
||||
@@ -38,14 +72,19 @@ final class DocumentReaderViewModel {
|
||||
let documentId: String
|
||||
private let apiClient: OutlineAPIClient
|
||||
|
||||
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
|
||||
init(apiClient: OutlineAPIClient, document: OutlineDocument, separateEditingEnabled: Bool = true) {
|
||||
self.apiClient = apiClient
|
||||
self.documentId = document.id
|
||||
self.title = document.title
|
||||
self.emoji = document.emoji
|
||||
self.text = document.text
|
||||
self.collectionId = document.collectionId
|
||||
self.parentDocumentId = document.parentDocumentId
|
||||
self.publishedAt = document.publishedAt
|
||||
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,
|
||||
@@ -61,28 +100,29 @@ final class DocumentReaderViewModel {
|
||||
emoji = full.emoji
|
||||
text = full.text
|
||||
collectionId = full.collectionId
|
||||
parentDocumentId = full.parentDocumentId
|
||||
publishedAt = full.publishedAt
|
||||
isFullWidth = full.fullWidth ?? false
|
||||
lastSyncedText = full.text
|
||||
lastSyncedTitle = full.title
|
||||
} catch {
|
||||
errorMessage = "Couldn't load this document. Check your connection and try again."
|
||||
}
|
||||
|
||||
children = (try? await apiClient.listDocuments(
|
||||
collectionId: nil,
|
||||
parentDocumentId: documentId,
|
||||
offset: 0,
|
||||
limit: 100
|
||||
)) ?? []
|
||||
}
|
||||
|
||||
func loadViewers() async {
|
||||
guard let views = try? await apiClient.listViews(ListViewsRequest(documentId: documentId)) else { return }
|
||||
// A single blip here used to just leave `viewers` empty forever with
|
||||
// no sign anything went wrong — retry-with-backoff absorbs that;
|
||||
// `try?` still covers the "still failing after retries" case, same
|
||||
// silent-but-harmless fallback as before (an empty viewers list).
|
||||
guard let views = try? await RetryPolicy.withRetry({ try await apiClient.listViews(ListViewsRequest(documentId: documentId)) }) else { return }
|
||||
viewers = views.filter { $0.lastViewedAt != nil }
|
||||
}
|
||||
|
||||
func loadPinAndSubscriptionState() async {
|
||||
// `collectionId: nil` = Home pins. This menu's Pin action is "Pin to
|
||||
// Home", not "Pin to Collection" — those are distinct on the server.
|
||||
if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)),
|
||||
if let pins = try? await RetryPolicy.withRetry({ try await apiClient.listPins(ListPinsRequest(collectionId: nil)) }),
|
||||
let match = pins.first(where: { $0.documentId == documentId }) {
|
||||
isPinned = true
|
||||
pinId = match.id
|
||||
@@ -91,7 +131,7 @@ final class DocumentReaderViewModel {
|
||||
pinId = nil
|
||||
}
|
||||
|
||||
if let subscriptions = try? await apiClient.listSubscriptions(ListSubscriptionsRequest(documentId: documentId)),
|
||||
if let subscriptions = try? await RetryPolicy.withRetry({ try await apiClient.listSubscriptions(ListSubscriptionsRequest(documentId: documentId)) }),
|
||||
let match = subscriptions.first {
|
||||
isSubscribed = true
|
||||
subscriptionId = match.id
|
||||
@@ -146,20 +186,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 {
|
||||
guard isEditing else {
|
||||
isEditing = true
|
||||
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
|
||||
saveErrorMessage = nil
|
||||
defer { isSaving = false }
|
||||
let sentTitle = title
|
||||
let sentText = text
|
||||
do {
|
||||
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: title, text: text))
|
||||
title = updated.title
|
||||
text = updated.text
|
||||
isEditing = false
|
||||
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: sentTitle, text: sentText))
|
||||
// Only reconcile with the server's response if nothing changed
|
||||
// locally while the request was in flight — otherwise this
|
||||
// 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 {
|
||||
saveErrorMessage = outlineErrorMessage(error, fallback: "Couldn't save this document.")
|
||||
}
|
||||
|
||||
@@ -22,9 +22,26 @@ struct DocumentSearchSheet: View {
|
||||
@State private var currentMatchIndex = 0
|
||||
@FocusState private var isSearchFieldFocused: Bool
|
||||
|
||||
private var matchingLineIndices: [Int] {
|
||||
guard !query.isEmpty else { return [] }
|
||||
return lines.indices.filter { lines[$0].localizedCaseInsensitiveContains(query) }
|
||||
/// Recomputed only when `query`/`lines` actually change (`recomputeMatches()`)
|
||||
/// instead of being a computed property — this used to re-scan the whole
|
||||
/// document on every access, and it's read multiple times per row
|
||||
/// (`isCurrentMatch`, the highlight check) on every SwiftUI re-render, so a
|
||||
/// large document turned into an O(n²) case-insensitive scan per frame.
|
||||
@State private var matchingLineIndices: [Int] = []
|
||||
/// O(1) membership for the per-row highlight check below — `matchingLineIndices`
|
||||
/// stays an ordered array (needed for `currentMatchIndex`/stepping), this is
|
||||
/// just a parallel lookup so a common search term with many matches doesn't
|
||||
/// make every row's highlight check an O(k) linear scan.
|
||||
@State private var matchingLineIndexSet: Set<Int> = []
|
||||
|
||||
private func recomputeMatches() {
|
||||
guard !query.isEmpty else {
|
||||
matchingLineIndices = []
|
||||
matchingLineIndexSet = []
|
||||
return
|
||||
}
|
||||
matchingLineIndices = lines.indices.filter { lines[$0].localizedCaseInsensitiveContains(query) }
|
||||
matchingLineIndexSet = Set(matchingLineIndices)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -35,7 +52,10 @@ struct DocumentSearchSheet: View {
|
||||
TextField("Search in \"\(document.title.isEmpty ? "Untitled" : document.title)\"", text: $query)
|
||||
.textFieldStyle(.plain)
|
||||
.focused($isSearchFieldFocused)
|
||||
.onChange(of: query) { currentMatchIndex = 0 }
|
||||
.onChange(of: query) {
|
||||
currentMatchIndex = 0
|
||||
recomputeMatches()
|
||||
}
|
||||
|
||||
if !matchingLineIndices.isEmpty {
|
||||
Text("\(currentMatchIndex + 1) of \(matchingLineIndices.count)")
|
||||
@@ -83,7 +103,7 @@ struct DocumentSearchSheet: View {
|
||||
.padding(.horizontal, 4)
|
||||
.background(
|
||||
isCurrentMatch(index) ? Color.yellow.opacity(0.4)
|
||||
: matchingLineIndices.contains(index) ? Color.yellow.opacity(0.15)
|
||||
: matchingLineIndexSet.contains(index) ? Color.yellow.opacity(0.15)
|
||||
: Color.clear
|
||||
)
|
||||
.id(index)
|
||||
@@ -131,6 +151,7 @@ struct DocumentSearchSheet: View {
|
||||
do {
|
||||
let text = try await apiClient.documentInfo(id: document.id).text
|
||||
lines = text.components(separatedBy: "\n")
|
||||
recomputeMatches()
|
||||
} catch {
|
||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load this document.")
|
||||
}
|
||||
|
||||
@@ -370,7 +370,7 @@ struct DocumentShareSheet: View {
|
||||
private func loadMembers() async {
|
||||
isLoadingMembers = true
|
||||
defer { isLoadingMembers = false }
|
||||
members = (try? await apiClient.documentUsers(ListDocumentUsersRequest(id: documentId))) ?? []
|
||||
members = (try? await RetryPolicy.withRetry({ try await apiClient.documentUsers(ListDocumentUsersRequest(id: documentId)) })) ?? []
|
||||
}
|
||||
|
||||
private func searchUsers(_ query: String) async {
|
||||
@@ -381,7 +381,7 @@ struct DocumentShareSheet: View {
|
||||
}
|
||||
isSearchingUsers = true
|
||||
defer { isSearchingUsers = false }
|
||||
userSearchResults = (try? await apiClient.listUsers(ListUsersRequest(query: trimmed))) ?? []
|
||||
userSearchResults = (try? await RetryPolicy.withRetry({ try await apiClient.listUsers(ListUsersRequest(query: trimmed)) })) ?? []
|
||||
}
|
||||
|
||||
private func addUser(_ user: OutlineUser) async {
|
||||
|
||||
@@ -71,7 +71,7 @@ struct NewDocumentSheet: View {
|
||||
ProgressView().frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Picker("Collection", selection: $selectedCollectionID) {
|
||||
Text("Choose a collection").tag(String?.none)
|
||||
Text("Draft (not published)").tag(String?.none)
|
||||
ForEach(collections) { collection in
|
||||
Text(collection.name).tag(Optional(collection.id))
|
||||
}
|
||||
@@ -86,6 +86,12 @@ struct NewDocumentSheet: View {
|
||||
}
|
||||
.labelsHidden()
|
||||
.disabled(selectedCollectionID == nil || isLoadingDestinationDocuments)
|
||||
|
||||
if selectedCollectionID != nil {
|
||||
Label("This will publish the document immediately, making it visible to your workspace.", systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
@@ -97,18 +103,24 @@ struct NewDocumentSheet: View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
Button("Create") {
|
||||
Button(selectedCollectionID == nil ? "Save as Draft" : "Create & Publish") {
|
||||
Task { await create() }
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(selectedCollectionID == nil || isCreating)
|
||||
.disabled(isCreating)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 380)
|
||||
.task {
|
||||
await loadCollections()
|
||||
selectedCollectionID = initialCollectionID ?? initialParentDocument?.collectionId ?? collections.first?.id
|
||||
// "By default" means a bare New Document (Home, reader
|
||||
// toolbar) starts as a draft — no `collections.first` fallback
|
||||
// like there used to be. A contextual entry point (right-click
|
||||
// a specific collection/document) still pre-fills that
|
||||
// location, since that already carries clear placement intent;
|
||||
// the publish warning above still applies to it either way.
|
||||
selectedCollectionID = initialCollectionID ?? initialParentDocument?.collectionId
|
||||
selectedParentID = initialParentDocument?.id
|
||||
isTitleFocused = true
|
||||
}
|
||||
@@ -147,7 +159,6 @@ struct NewDocumentSheet: View {
|
||||
}
|
||||
|
||||
private func create() async {
|
||||
guard let selectedCollectionID else { return }
|
||||
isCreating = true
|
||||
defer { isCreating = false }
|
||||
do {
|
||||
@@ -156,7 +167,12 @@ struct NewDocumentSheet: View {
|
||||
title: title.isEmpty ? "Untitled" : title,
|
||||
text: "",
|
||||
collectionId: selectedCollectionID,
|
||||
parentDocumentId: selectedParentID
|
||||
parentDocumentId: selectedParentID,
|
||||
// No collection/parent selected = draft; Outline can't
|
||||
// publish without one of the two regardless of this
|
||||
// flag, but setting it explicitly rather than relying
|
||||
// on that keeps intent obvious at the call site.
|
||||
publish: selectedCollectionID != nil
|
||||
)
|
||||
)
|
||||
onCreated(document)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// Destination picker for publishing a draft — same collection-then-
|
||||
/// optional-parent-document shape as `MoveDocumentSheet`, since Outline's
|
||||
/// own web app treats "where does this go" identically for both actions.
|
||||
/// Pre-selects the document's own `collectionId`/`parentDocumentId` when it
|
||||
/// already has one (a draft can already belong to a collection without
|
||||
/// being published) rather than starting blank, per explicit instruction.
|
||||
///
|
||||
/// Publishing itself is two calls, not one: `documents.update(publish:
|
||||
/// true, collectionId:)` does the actual publish and top-level collection
|
||||
/// placement in one round-trip (confirmed from a live network capture);
|
||||
/// nesting under a specific parent document isn't a documented
|
||||
/// `documents.update` field, so that part reuses `documents.move` — the
|
||||
/// same already-working mechanism `MoveDocumentSheet` uses — as a second
|
||||
/// step, only when a parent was actually chosen.
|
||||
@MainActor
|
||||
struct PublishDocumentSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let apiClient: OutlineAPIClient
|
||||
let document: OutlineDocument
|
||||
let onPublished: () async -> Void
|
||||
|
||||
@State private var collections: [OutlineCollection] = []
|
||||
@State private var selectedCollectionID: String?
|
||||
@State private var rootDocuments: [OutlineDocument] = []
|
||||
@State private var selectedParentID: String?
|
||||
@State private var isLoadingCollections = false
|
||||
@State private var isLoadingDestinationDocuments = false
|
||||
@State private var isPublishing = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Publish \"\(document.title.isEmpty ? "Untitled" : document.title)\"")
|
||||
.font(.headline)
|
||||
Text("Choose where this document should live once published.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if isLoadingCollections {
|
||||
ProgressView().frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Picker("Collection", selection: $selectedCollectionID) {
|
||||
Text("Choose a collection").tag(String?.none)
|
||||
ForEach(collections) { collection in
|
||||
Text(collection.name).tag(Optional(collection.id))
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
|
||||
Picker("Location", selection: $selectedParentID) {
|
||||
Text("Collection root").tag(String?.none)
|
||||
ForEach(rootDocuments.filter { $0.id != document.id }) { candidate in
|
||||
Text(candidate.title.isEmpty ? "Untitled" : candidate.title).tag(Optional(candidate.id))
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.disabled(selectedCollectionID == nil || isLoadingDestinationDocuments)
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
Button("Publish") {
|
||||
Task { await publish() }
|
||||
}
|
||||
.disabled(selectedCollectionID == nil || isPublishing)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 360)
|
||||
.task {
|
||||
await loadCollections()
|
||||
selectedCollectionID = document.collectionId
|
||||
selectedParentID = document.parentDocumentId
|
||||
}
|
||||
.task(id: selectedCollectionID) {
|
||||
await loadRootDocuments()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCollections() async {
|
||||
isLoadingCollections = true
|
||||
defer { isLoadingCollections = false }
|
||||
do {
|
||||
collections = try await apiClient.listCollections(offset: 0, limit: 100)
|
||||
} catch {
|
||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load collections.")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadRootDocuments() async {
|
||||
guard let selectedCollectionID else {
|
||||
rootDocuments = []
|
||||
return
|
||||
}
|
||||
isLoadingDestinationDocuments = true
|
||||
defer { isLoadingDestinationDocuments = false }
|
||||
do {
|
||||
rootDocuments = try await apiClient.listDocuments(
|
||||
collectionId: selectedCollectionID,
|
||||
parentDocumentId: nil,
|
||||
offset: 0,
|
||||
limit: 100
|
||||
)
|
||||
} catch {
|
||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load destination documents.")
|
||||
}
|
||||
}
|
||||
|
||||
private func publish() async {
|
||||
guard let selectedCollectionID else { return }
|
||||
isPublishing = true
|
||||
defer { isPublishing = false }
|
||||
do {
|
||||
_ = try await apiClient.updateDocument(
|
||||
UpdateDocumentRequest(id: document.id, collectionId: selectedCollectionID, publish: true)
|
||||
)
|
||||
// Only a documents.move call actually supports parentDocumentId —
|
||||
// skip it entirely when publishing straight to the collection root,
|
||||
// the update above already placed it there.
|
||||
if let selectedParentID {
|
||||
try await apiClient.moveDocument(
|
||||
MoveDocumentRequest(id: document.id, collectionId: selectedCollectionID, parentDocumentId: selectedParentID)
|
||||
)
|
||||
}
|
||||
await onPublished()
|
||||
dismiss()
|
||||
} catch {
|
||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't publish this document.")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -5,6 +5,7 @@ enum HomeTab: String, CaseIterable, Identifiable {
|
||||
case popular = "Popular"
|
||||
case recentlyUpdated = "Recently Updated"
|
||||
case createdByMe = "Created by Me"
|
||||
case drafts = "Drafts"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ struct HomeView: View {
|
||||
PinnedDocumentCard(document: document)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.pointerCursorOnHover()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
@@ -10,6 +10,7 @@ final class HomeViewModel {
|
||||
private(set) var popular: [OutlineDocument] = []
|
||||
private(set) var recentlyUpdated: [OutlineDocument] = []
|
||||
private(set) var createdByMe: [OutlineDocument] = []
|
||||
private(set) var drafts: [OutlineDocument] = []
|
||||
|
||||
var isLoadingPinned = false
|
||||
var isLoadingTab = false
|
||||
@@ -32,6 +33,7 @@ final class HomeViewModel {
|
||||
case .popular: popular
|
||||
case .recentlyUpdated: recentlyUpdated
|
||||
case .createdByMe: createdByMe
|
||||
case .drafts: drafts
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,16 +83,25 @@ final class HomeViewModel {
|
||||
/// `pins.list` only returns pin records, not the documents themselves —
|
||||
/// fetches each pinned document individually. Pins are a small curated
|
||||
/// set (unlike a full collection tree), so the N+1 here is acceptable
|
||||
/// where it wouldn't be in the sidebar.
|
||||
/// where it wouldn't be in the sidebar — but they're fetched concurrently
|
||||
/// (a `TaskGroup`, not a serial loop) so latency doesn't scale with pin
|
||||
/// count; `documentInfo` already goes through `CachingOutlineAPIClient`'s
|
||||
/// own cached-read/retry path either way.
|
||||
private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
|
||||
let pins = try await apiClient.listPins(ListPinsRequest(collectionId: nil))
|
||||
var documents: [OutlineDocument] = []
|
||||
for pin in pins {
|
||||
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
|
||||
documents.append(document)
|
||||
let pins = try await RetryPolicy.withRetry { try await apiClient.listPins(ListPinsRequest(collectionId: nil)) }
|
||||
let client = apiClient
|
||||
let documentsByID: [String: OutlineDocument] = await withTaskGroup(of: (String, OutlineDocument?).self) { group in
|
||||
for pin in pins {
|
||||
group.addTask { (pin.documentId, try? await client.documentInfo(id: pin.documentId)) }
|
||||
}
|
||||
var result: [String: OutlineDocument] = [:]
|
||||
for await (id, document) in group {
|
||||
if let document { result[id] = document }
|
||||
}
|
||||
return result
|
||||
}
|
||||
return documents
|
||||
// Preserve pins.list's own order rather than task-completion order.
|
||||
return pins.compactMap { documentsByID[$0.documentId] }
|
||||
}
|
||||
|
||||
private func fetch(tab: HomeTab) async throws -> [OutlineDocument] {
|
||||
@@ -115,6 +126,8 @@ final class HomeViewModel {
|
||||
return try await apiClient.documentsList(
|
||||
DocumentsListRequest(userId: userId, sort: "createdAt", direction: "DESC", limit: 25)
|
||||
)
|
||||
case .drafts:
|
||||
return try await apiClient.listDrafts(ListDraftsRequest(limit: 25))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,12 +137,13 @@ final class HomeViewModel {
|
||||
case .popular: popular = documents
|
||||
case .recentlyUpdated: recentlyUpdated = documents
|
||||
case .createdByMe: createdByMe = documents
|
||||
case .drafts: drafts = documents
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveCurrentUserID() async throws -> String {
|
||||
if let currentUserID { return currentUserID }
|
||||
let user = try await apiClient.currentUser()
|
||||
let user = try await RetryPolicy.withRetry { try await apiClient.currentUser() }
|
||||
currentUserID = user.id
|
||||
return user.id
|
||||
}
|
||||
|
||||
@@ -2,45 +2,57 @@
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// Deliberately distinct from `DocumentCardView` — the pinned section is for
|
||||
/// a quick scan of a small curated set, not browsing, so this is a dense
|
||||
/// single-line row rather than a tall card, with an explicit pin glyph so
|
||||
/// it doesn't read the same as the tab grids below it.
|
||||
/// Same tall-card shape as `DocumentCardView` (the tab grids below it) so the
|
||||
/// pinned section reads as a set of cards, not a row of tab-like chips — the
|
||||
/// accent-filled pin badge is the one thing that marks these as pinned.
|
||||
struct PinnedDocumentCard: View {
|
||||
@Environment(StarStore.self) private var starStore
|
||||
let document: OutlineDocument
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
if let emoji = document.emoji {
|
||||
Text(emoji)
|
||||
.font(.title3)
|
||||
} else {
|
||||
Image(systemName: "doc.text")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
if let emoji = document.emoji {
|
||||
Text(emoji)
|
||||
.font(.title2)
|
||||
} else {
|
||||
Image(systemName: "doc.text")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if starStore.isStarred(documentId: document.id) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
|
||||
Image(systemName: "pin.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white)
|
||||
.padding(5)
|
||||
.background(Color.accentColor, in: Circle())
|
||||
}
|
||||
|
||||
Text(document.title.isEmpty ? "Untitled" : document.title)
|
||||
.font(.callout.weight(.medium))
|
||||
.lineLimit(1)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if starStore.isStarred(documentId: document.id) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
|
||||
Image(systemName: "pin.fill")
|
||||
.font(.caption2)
|
||||
Text(document.updatedAt, format: .relative(presentation: .named))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8))
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, minHeight: 96, alignment: .topLeading)
|
||||
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -134,6 +134,7 @@ struct GlobalSearchResultsView: View {
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.pointerCursorOnHover()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+39
-16
@@ -18,8 +18,8 @@ struct OutpostApp: App {
|
||||
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
||||
|
||||
#if os(macOS)
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@State private var isShowingLogoutConfirmation = false
|
||||
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
|
||||
#endif
|
||||
|
||||
var body: some Scene {
|
||||
@@ -34,13 +34,15 @@ struct OutpostApp: App {
|
||||
.onAppear { applyMacAppearance() }
|
||||
.onChange(of: appearance) { _, _ in applyMacAppearance() }
|
||||
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
|
||||
.background(TransparentTitlebarWindowAccessor())
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.commands {
|
||||
CommandGroup(replacing: .appInfo) {
|
||||
Button("About Outpost") {
|
||||
openWindow(id: "about")
|
||||
navigation.selectedSettingsSection = .about
|
||||
navigation.isShowingSettings = true
|
||||
}
|
||||
}
|
||||
// No `Settings {}` scene anymore — Settings renders inside the
|
||||
@@ -60,22 +62,19 @@ struct OutpostApp: App {
|
||||
}
|
||||
.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
|
||||
|
||||
#if os(macOS)
|
||||
Window("About Outpost", id: "about") {
|
||||
AboutView()
|
||||
.disablesFullScreen()
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
|
||||
Window("Keyboard Shortcuts", id: "keyboard-shortcuts") {
|
||||
KeyboardShortcutsView()
|
||||
.disablesFullScreen()
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
@@ -95,3 +94,27 @@ struct OutpostApp: App {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Makes the titlebar/toolbar strip blend into the sidebar's own background
|
||||
/// instead of reading as a separate bar — same look as Mail/Notes/Finder.
|
||||
/// SwiftUI's `WindowGroup` exposes no direct hook for this, so this reaches
|
||||
/// into the underlying `NSWindow` the way `applyMacAppearance()` above
|
||||
/// reaches into `NSApp` for the same reason (no SwiftUI-level API exists).
|
||||
/// A `View` (not the window itself) is what actually needs to extend under
|
||||
/// the now-transparent titlebar — `.fullSizeContentView` just makes room;
|
||||
/// the sidebar's `.background` already does the rest with no other change.
|
||||
private struct TransparentTitlebarWindowAccessor: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView()
|
||||
DispatchQueue.main.async {
|
||||
guard let window = view.window else { return }
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.styleMask.insert(.fullSizeContentView)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OutlineKit
|
||||
|
||||
/// Turns `CachingOutlineAPIClient.repeatedFailureSummaries()` into a banner
|
||||
/// the user can actually see and act on, instead of a silently-swallowed
|
||||
/// `try?` — see the pins bug this whole mechanism exists to catch a repeat
|
||||
/// of. `RootView` polls the client periodically and feeds results in via
|
||||
/// `update(with:)`; nothing here talks to the network directly.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class APIFailureCenter {
|
||||
/// The single most-relevant category to show right now, or nil if
|
||||
/// nothing's currently past the threshold (or everything past it has
|
||||
/// been dismissed and is still in its cooldown).
|
||||
private(set) var activeBanner: RepeatedFailure?
|
||||
|
||||
/// Categories the user's already dismissed, and when — suppressed from
|
||||
/// reappearing until `dismissCooldown` passes, so a still-flaky
|
||||
/// operation doesn't pop the same banner right back up a few seconds
|
||||
/// after being told to go away.
|
||||
private var dismissedAt: [String: Date] = [:]
|
||||
private let dismissCooldown: TimeInterval = 900
|
||||
|
||||
/// Called from `RootView`'s poll loop with the latest snapshot from
|
||||
/// `CachingOutlineAPIClient`. Picks the worst-offending category
|
||||
/// (highest failure count) that isn't in cooldown; clears the banner
|
||||
/// entirely once nothing qualifies (e.g. the user went back online and
|
||||
/// everything recovered).
|
||||
func update(with summaries: [RepeatedFailure]) {
|
||||
let now = Date()
|
||||
dismissedAt = dismissedAt.filter { now.timeIntervalSince($0.value) < dismissCooldown }
|
||||
|
||||
let eligible = summaries
|
||||
.filter { dismissedAt[$0.category] == nil }
|
||||
.sorted { $0.count > $1.count }
|
||||
|
||||
activeBanner = eligible.first
|
||||
}
|
||||
|
||||
/// Dismiss without reporting — starts that category's cooldown so it
|
||||
/// won't immediately reappear on the next poll if it's still failing.
|
||||
func dismiss() {
|
||||
guard let category = activeBanner?.category else { return }
|
||||
dismissedAt[category] = Date()
|
||||
activeBanner = nil
|
||||
}
|
||||
|
||||
/// Everything folded into the report is safe to paste into a public bug
|
||||
/// tracker as-is: a category name, a generic error description, and
|
||||
/// version numbers — no document content, no server URL, no token.
|
||||
func reportURL(appVersion: String, osVersion: String) -> URL? {
|
||||
guard let banner = activeBanner else { return nil }
|
||||
var components = URLComponents(string: "https://git.psmattas.com/psmattas/Outpost/issues/new")
|
||||
let body = """
|
||||
Outpost kept failing to \(banner.category) (\(banner.count) times in the last few minutes).
|
||||
|
||||
Error: \(banner.message)
|
||||
App version: \(appVersion)
|
||||
macOS: \(osVersion)
|
||||
|
||||
<!-- Anything else you can add about what you were doing when this started would help. -->
|
||||
"""
|
||||
components?.queryItems = [URLQueryItem(name: "body", value: body)]
|
||||
return components?.url
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,128 @@
|
||||
import Observation
|
||||
|
||||
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
case appearance, account, offlineSync, advanced, about
|
||||
/// Top-level groupings shown as section headers in `SettingsSidebarList`.
|
||||
/// `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 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, navigation, 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, .navigation, .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 {
|
||||
switch self {
|
||||
case .appearance: return "Appearance"
|
||||
case .account: return "Account"
|
||||
case .editor: return "Editor"
|
||||
case .navigation: return "Navigation"
|
||||
case .offlineSync: return "Offline & Sync"
|
||||
case .advanced: return "Advanced"
|
||||
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 {
|
||||
switch self {
|
||||
case .appearance: return "paintbrush"
|
||||
case .account: return "person.crop.circle"
|
||||
case .editor: return "square.split.2x1"
|
||||
case .navigation: return "command"
|
||||
case .offlineSync: return "arrow.triangle.2.circlepath"
|
||||
case .advanced: return "wrench.and.screwdriver"
|
||||
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, .navigation, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,4 +138,6 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
final class AppNavigation {
|
||||
var isShowingSettings = false
|
||||
var selectedSettingsSection: SettingsSection? = .appearance
|
||||
/// ⌘K, see `OutpostApp`'s `CommandGroup` and `CommandPaletteView`.
|
||||
var isShowingCommandPalette = false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
|
||||
/// Shown when the same category of API call has failed repeatedly within a
|
||||
/// few minutes (see `APIFailureCenter`) — the self-diagnosing replacement
|
||||
/// for a `try?` that used to fail silently. No manual "Retry" button: the
|
||||
/// retries already happened automatically before this ever appears, so all
|
||||
/// that's left worth offering is reporting it and moving on.
|
||||
struct RepeatedFailureBanner: View {
|
||||
let message: String
|
||||
let onReport: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundStyle(.orange)
|
||||
Text(message)
|
||||
.font(.callout)
|
||||
.lineLimit(2)
|
||||
Spacer(minLength: 8)
|
||||
Button("Report", action: onReport)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
Button {
|
||||
onDismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.orange.opacity(0.12))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -3,8 +3,10 @@ import OutlineKit
|
||||
|
||||
struct RootView: View {
|
||||
@Environment(SessionStore.self) private var session
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var welcomeName: String?
|
||||
@State private var starStore = StarStore()
|
||||
@State private var failureCenter = APIFailureCenter()
|
||||
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
|
||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||
|
||||
@@ -34,10 +36,39 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
.environment(starStore)
|
||||
.environment(failureCenter)
|
||||
#if os(macOS)
|
||||
.overlay(alignment: .top) {
|
||||
if let banner = failureCenter.activeBanner {
|
||||
RepeatedFailureBanner(
|
||||
message: bannerMessage(for: banner),
|
||||
onReport: { reportActiveFailure() },
|
||||
onDismiss: { failureCenter.dismiss() }
|
||||
)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: failureCenter.activeBanner)
|
||||
#endif
|
||||
.animation(.easeInOut(duration: 0.45), value: welcomeName != nil)
|
||||
.task {
|
||||
await session.refreshTeamInfoIfNeeded()
|
||||
}
|
||||
// Repeated (non-transient) API failures already get an automatic
|
||||
// retry-with-backoff inside CachingOutlineAPIClient itself — this
|
||||
// just surfaces the ones that kept failing anyway, on a cheap poll
|
||||
// (the client's own state, no network call of its own) rather than
|
||||
// a push, since the client is a plain actor with no UI dependency.
|
||||
.task(id: session.isSignedIn) {
|
||||
guard session.isSignedIn else { return }
|
||||
while !Task.isCancelled {
|
||||
if let cachingClient = session.cachingClient {
|
||||
let summaries = await cachingClient.repeatedFailureSummaries()
|
||||
failureCenter.update(with: summaries)
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(30))
|
||||
}
|
||||
}
|
||||
.task(id: session.isSignedIn) {
|
||||
if session.isSignedIn, let apiClient = session.apiClient {
|
||||
await starStore.load(apiClient: apiClient)
|
||||
@@ -89,6 +120,35 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Category names are internal plumbing (`documents-write`, `pins`,
|
||||
/// `collections-write`, ...) — this is the one place they turn into
|
||||
/// something a user reads, so a new category added later just needs a
|
||||
/// case here, not a rewrite of the tracking/polling underneath it.
|
||||
private func bannerMessage(for failure: RepeatedFailure) -> String {
|
||||
switch failure.category {
|
||||
case "documents-write": return "Outpost is having trouble saving your document edits."
|
||||
case "collections-write": return "Outpost is having trouble saving collection changes."
|
||||
case "pins": return "Outpost is having trouble updating pins."
|
||||
case "subscriptions": return "Outpost is having trouble updating subscriptions."
|
||||
case "stars": return "Outpost is having trouble updating stars."
|
||||
case "document", "documents": return "Outpost is having trouble loading documents."
|
||||
case "collections": return "Outpost is having trouble loading collections."
|
||||
case "drafts": return "Outpost is having trouble loading drafts."
|
||||
default: return "Outpost is having trouble talking to the server (\(failure.category))."
|
||||
}
|
||||
}
|
||||
|
||||
private func reportActiveFailure() {
|
||||
guard let url = failureCenter.reportURL(
|
||||
appVersion: OutpostVersion.displayString,
|
||||
osVersion: ProcessInfo.processInfo.operatingSystemVersionString
|
||||
) else { return }
|
||||
openURL(url)
|
||||
failureCenter.dismiss()
|
||||
}
|
||||
#endif
|
||||
|
||||
private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) {
|
||||
welcomeName = result.user.name
|
||||
session.signIn(serverURL: result.serverURL, user: result.user, team: result.team)
|
||||
|
||||
@@ -6,14 +6,26 @@ import OutlineKit
|
||||
@Observable
|
||||
final class SessionStore {
|
||||
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 cacheEncryptionKeyStore: CacheEncryptionKeyStoring
|
||||
private let defaults: UserDefaults
|
||||
|
||||
var isSignedIn: Bool
|
||||
private(set) var userId: String?
|
||||
var userName: String?
|
||||
var userEmail: String?
|
||||
var userAvatarURL: URL?
|
||||
var userLanguage: String?
|
||||
var userPreferences: OutlineUserPreferences?
|
||||
var userNotificationSettings: [String: Bool]?
|
||||
var teamName: String?
|
||||
var teamAvatarURL: URL?
|
||||
private(set) var apiClient: OutlineAPIClient?
|
||||
@@ -32,27 +44,78 @@ final class SessionStore {
|
||||
defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
|
||||
}
|
||||
|
||||
init(tokenStore: TokenStoring = KeychainTokenStore(), defaults: UserDefaults = .standard) {
|
||||
init(
|
||||
tokenStore: TokenStoring = KeychainTokenStore(),
|
||||
cacheEncryptionKeyStore: CacheEncryptionKeyStoring = KeychainCacheEncryptionKeyStore(),
|
||||
defaults: UserDefaults = .standard
|
||||
) {
|
||||
self.tokenStore = tokenStore
|
||||
self.cacheEncryptionKeyStore = cacheEncryptionKeyStore
|
||||
self.defaults = defaults
|
||||
self.isSignedIn = (try? tokenStore.token()) != nil
|
||||
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
|
||||
|
||||
if isSignedIn, let serverURL {
|
||||
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore)
|
||||
let hasToken = (try? tokenStore.token()) != nil
|
||||
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,
|
||||
cacheEncryptionKeyStore: cacheEncryptionKeyStore,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
|
||||
defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey)
|
||||
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore)
|
||||
(apiClient, cachingClient) = Self.makeAPIClient(
|
||||
serverURL: serverURL,
|
||||
tokenStore: tokenStore,
|
||||
cacheEncryptionKeyStore: cacheEncryptionKeyStore,
|
||||
cache: cacheStore
|
||||
)
|
||||
apply(user: user, team: team, serverURL: serverURL)
|
||||
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(
|
||||
serverURL: URL,
|
||||
tokenStore: TokenStoring,
|
||||
cacheEncryptionKeyStore: CacheEncryptionKeyStoring,
|
||||
cache: OfflineCacheStore?
|
||||
) -> (OutlineAPIClient, CachingOutlineAPIClient?) {
|
||||
let live = LiveOutlineAPIClient(
|
||||
@@ -60,38 +123,76 @@ final class SessionStore {
|
||||
tokenStore: tokenStore
|
||||
)
|
||||
guard let cache else { return (live, nil) }
|
||||
let caching = CachingOutlineAPIClient(live: live, cache: cache)
|
||||
let caching = CachingOutlineAPIClient(live: live, cache: cache, encryptionKeyStore: cacheEncryptionKeyStore)
|
||||
return (caching, caching)
|
||||
}
|
||||
|
||||
func signOut() {
|
||||
/// Clears everything scoped to this sign-in: the API token, the offline
|
||||
/// cache's encryption key, and the cache/pending-write storage itself
|
||||
/// (in that order — wiping storage before the key would leave it
|
||||
/// readable a moment longer than necessary, and wiping the key without
|
||||
/// the storage would leave permanently-undecryptable rows sitting
|
||||
/// around instead of actually freeing anything). Whoever signs in next
|
||||
/// on this machine gets a clean slate, not a previous account's
|
||||
/// leftover cached content.
|
||||
func signOut() async {
|
||||
try? tokenStore.clear()
|
||||
await cachingClient?.clearEverythingForSignOut()
|
||||
try? cacheEncryptionKeyStore.clear()
|
||||
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
|
||||
isSignedIn = false
|
||||
userId = nil
|
||||
userName = nil
|
||||
userEmail = nil
|
||||
userAvatarURL = nil
|
||||
userLanguage = nil
|
||||
userPreferences = nil
|
||||
userNotificationSettings = nil
|
||||
teamName = nil
|
||||
teamAvatarURL = nil
|
||||
apiClient = 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
|
||||
/// in-memory state didn't.
|
||||
func refreshTeamInfoIfNeeded() async {
|
||||
guard isSignedIn, teamName == nil, let apiClient, let serverURL else { return }
|
||||
guard let auth = try? await apiClient.authInfo() else { return }
|
||||
guard let auth = try? await RetryPolicy.withRetry({ try await apiClient.authInfo() }) else { return }
|
||||
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) {
|
||||
userId = user.id
|
||||
userName = user.name
|
||||
userEmail = user.email
|
||||
// Outline can return either an absolute URL or a server-relative path
|
||||
// (e.g. `/api/files.get?key=...`) for avatarUrl — resolve against the
|
||||
// configured server so relative paths don't fail as "unsupported URL".
|
||||
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||
userLanguage = user.language
|
||||
userPreferences = user.preferences
|
||||
cachePreferences(user.preferences)
|
||||
userNotificationSettings = user.notificationSettings
|
||||
teamName = team.name
|
||||
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
@@ -41,11 +42,46 @@ struct AvatarBadge: View {
|
||||
.task(id: avatarURL) {
|
||||
loadedImage = nil
|
||||
guard let avatarURL else { return }
|
||||
guard let (data, _) = try? await URLSession.shared.data(from: avatarURL) else { return }
|
||||
loadedImage = PlatformImage(data: data)
|
||||
loadedImage = await Self.loadImage(from: avatarURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
#if os(macOS)
|
||||
Image(nsImage: image)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
/// `NSMenuItem` has no closure-based initializer — the standard AppKit
|
||||
/// pattern is a small subclass that's its own target/action, so callers can
|
||||
/// just pass a Swift closure instead of wiring up a selector by hand.
|
||||
final class ClosureMenuItem: NSMenuItem {
|
||||
private let handler: () -> Void
|
||||
|
||||
init(title: String, handler: @escaping () -> Void) {
|
||||
self.handler = handler
|
||||
super.init(title: title, action: #selector(invokeHandler), keyEquivalent: "")
|
||||
self.target = self
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
@objc private func invokeHandler() {
|
||||
handler()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
#if os(macOS)
|
||||
import MarkdownEngineCodeBlocks
|
||||
|
||||
/// One `HighlighterSwiftBridge` for the whole app. It owns a JavaScriptCore
|
||||
/// context (expensive to spin up) plus its own highlight cache, so every
|
||||
/// `NativeTextViewWrapper` should share this instance rather than each
|
||||
/// constructing its own.
|
||||
enum CodeSyntaxHighlighting {
|
||||
static let shared = HighlighterSwiftBridge()
|
||||
}
|
||||
#endif
|
||||
@@ -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,84 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import MarkdownEngine
|
||||
import OutlineKit
|
||||
|
||||
/// Resolves `` Markdown image references for the editor.
|
||||
///
|
||||
/// `EmbeddedImageProvider.image(for:)` is called synchronously from the
|
||||
/// styling pipeline and must return immediately — it can't `await` a
|
||||
/// network fetch inline. So a miss kicks off an async load in the
|
||||
/// background, caches the result, and calls `onImageLoaded` (on the main
|
||||
/// thread) once it lands; the embedder is responsible for turning that into
|
||||
/// a real SwiftUI update (see `DocumentReaderView`'s `imageReloadTick`) so
|
||||
/// `updateNSView` runs again, notices `fingerprint()` changed, and
|
||||
/// restyles — the engine has no polling of its own.
|
||||
///
|
||||
/// `url` is usually a server-relative path like
|
||||
/// `/api/attachments.redirect?id=<uuid>` — Outline's own stable reference
|
||||
/// for an uploaded attachment, which needs the same Bearer auth as every
|
||||
/// other OutlineKit request (`OutlineAPIClient.fetchAuthenticatedFile`).
|
||||
/// A plain absolute `http(s)://` URL (an external image someone pasted) is
|
||||
/// fetched directly instead, no auth attached.
|
||||
///
|
||||
/// Thread-safety mirrors `HighlighterSwiftBridge`: `NSCache` for the image
|
||||
/// store (inherently thread-safe), a lock for the small bit of state that
|
||||
/// isn't (`version`, `inFlight`) — no actor isolation, since the engine may
|
||||
/// call `image(for:)`/`fingerprint()` from whatever thread is styling.
|
||||
final class OutlineImageProvider: EmbeddedImageProvider, @unchecked Sendable {
|
||||
private let apiClient: OutlineAPIClient
|
||||
private let cache = NSCache<NSString, NSImage>()
|
||||
private let lock = NSLock()
|
||||
private var inFlight: Set<String> = []
|
||||
private var version = 0
|
||||
/// Set by the embedder; called on the main thread whenever a load
|
||||
/// completes and `fingerprint()` has changed.
|
||||
var onImageLoaded: (@Sendable () -> Void)?
|
||||
|
||||
init(apiClient: OutlineAPIClient) {
|
||||
self.apiClient = apiClient
|
||||
}
|
||||
|
||||
func image(for reference: EmbeddedImageRequest) -> NSImage? {
|
||||
let url = reference.name
|
||||
if let cached = cache.object(forKey: url as NSString) {
|
||||
return cached
|
||||
}
|
||||
beginLoad(url)
|
||||
return nil
|
||||
}
|
||||
|
||||
func fingerprint() -> AnyHashable {
|
||||
lock.withLock { version }
|
||||
}
|
||||
|
||||
private func beginLoad(_ url: String) {
|
||||
let alreadyLoading: Bool = lock.withLock {
|
||||
if inFlight.contains(url) { return true }
|
||||
inFlight.insert(url)
|
||||
return false
|
||||
}
|
||||
guard !alreadyLoading else { return }
|
||||
|
||||
Task {
|
||||
defer { lock.withLock { _ = inFlight.remove(url) } }
|
||||
do {
|
||||
let data: Data
|
||||
if url.hasPrefix("http://") || url.hasPrefix("https://"), let externalURL = URL(string: url) {
|
||||
(data, _) = try await URLSession.shared.data(from: externalURL)
|
||||
} else {
|
||||
data = try await apiClient.fetchAuthenticatedFile(path: url)
|
||||
}
|
||||
guard let image = NSImage(data: data) else { return }
|
||||
cache.setObject(image, forKey: url as NSString)
|
||||
lock.withLock { version += 1 }
|
||||
onImageLoaded?()
|
||||
} catch {
|
||||
// Best-effort: a failed load just leaves the Markdown source
|
||||
// visible (the engine's existing fallback for `image(for:)
|
||||
// == nil`), no separate error UI for an inline image fetch.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#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,33 @@
|
||||
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.1.0-ALPHA`.
|
||||
/// Empty since the App Store release (no more alpha/beta suffix) —
|
||||
/// `displayString`/`fullVersionString` just show the plain version now.
|
||||
static let releaseStage = ""
|
||||
|
||||
static var shortVersion: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
|
||||
}
|
||||
|
||||
static var buildNumber: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "3"
|
||||
}
|
||||
|
||||
/// e.g. `"0.1.0"` — for compact display (sidebar footer).
|
||||
static var displayString: String {
|
||||
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
|
||||
return "\(shortVersion)\(stageSuffix)"
|
||||
}
|
||||
|
||||
/// e.g. `"Version 0.1.0 (3)"` — for the About page.
|
||||
static var fullVersionString: String {
|
||||
"Version \(displayString) (\(buildNumber))"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import SwiftUI
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
/// Pointing-hand cursor while the mouse is over a clickable sidebar/list row,
|
||||
/// gated by the "Pointer Cursor" setting (Settings → Editor → Pointer Cursor).
|
||||
/// `didPush` tracks whether this instance actually pushed a cursor, so a
|
||||
/// mid-hover toggle of the preference can never leave `NSCursor`'s push/pop
|
||||
/// stack unbalanced — pop only fires for a push this same hover made.
|
||||
private struct PointerCursorOnHover: ViewModifier {
|
||||
@AppStorage("outpost.pointerCursorEnabled") private var isEnabled = true
|
||||
@State private var didPush = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.onHover { hovering in
|
||||
if hovering {
|
||||
guard isEnabled else { return }
|
||||
NSCursor.pointingHand.push()
|
||||
didPush = true
|
||||
} else if didPush {
|
||||
NSCursor.pop()
|
||||
didPush = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func pointerCursorOnHover() -> some View {
|
||||
modifier(PointerCursorOnHover())
|
||||
}
|
||||
}
|
||||
#else
|
||||
extension View {
|
||||
func pointerCursorOnHover() -> some View { self }
|
||||
}
|
||||
#endif
|
||||
@@ -22,7 +22,7 @@ final class StarStore {
|
||||
}
|
||||
|
||||
func load(apiClient: OutlineAPIClient) async {
|
||||
guard let stars = try? await apiClient.listStars(ListStarsRequest(offset: 0, limit: 250)) else { return }
|
||||
guard let stars = try? await RetryPolicy.withRetry({ try await apiClient.listStars(ListStarsRequest(offset: 0, limit: 250)) }) else { return }
|
||||
documentStars = Dictionary(uniqueKeysWithValues: stars.compactMap { star in
|
||||
star.documentId.map { ($0, star) }
|
||||
})
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import StoreKit
|
||||
import Observation
|
||||
|
||||
/// Backs the tip jar in Settings → About. Consumables only — a tip doesn't
|
||||
/// unlock anything, so there's no entitlement to persist or restore, and
|
||||
/// finishing the transaction immediately (rather than checking
|
||||
/// `Transaction.currentEntitlements` on launch, the way a real purchase
|
||||
/// would need to) is correct here.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TipJarStore {
|
||||
enum PurchaseState: Equatable {
|
||||
case idle
|
||||
case purchasing(String)
|
||||
case thankYou(String)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
/// Must match the consumable In-App Purchase products created in App
|
||||
/// Store Connect for this app exactly, including the bundle id prefix.
|
||||
static let productIDs = [
|
||||
"com.psmattas.OutpostApp.tip.small",
|
||||
"com.psmattas.OutpostApp.tip.medium",
|
||||
"com.psmattas.OutpostApp.tip.large",
|
||||
"com.psmattas.OutpostApp.tip.generous"
|
||||
]
|
||||
|
||||
private(set) var products: [Product] = []
|
||||
private(set) var isLoading = false
|
||||
var purchaseState: PurchaseState = .idle
|
||||
|
||||
/// Tip options don't change during a session — no reason to refetch
|
||||
/// every time the About screen appears.
|
||||
func loadProductsIfNeeded() async {
|
||||
guard products.isEmpty, !isLoading else { return }
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
let fetched = try await Product.products(for: Self.productIDs)
|
||||
// Keep the order defined above (small -> generous), not
|
||||
// whatever order the App Store happens to return them in.
|
||||
products = Self.productIDs.compactMap { id in fetched.first { $0.id == id } }
|
||||
if products.isEmpty {
|
||||
purchaseState = .failed("Tip options aren't available right now.")
|
||||
}
|
||||
} catch {
|
||||
purchaseState = .failed("Couldn't load tip options. Check your connection and try again.")
|
||||
}
|
||||
}
|
||||
|
||||
func purchase(_ product: Product) async {
|
||||
purchaseState = .purchasing(product.id)
|
||||
do {
|
||||
switch try await product.purchase() {
|
||||
case .success(let verification):
|
||||
guard case .verified(let transaction) = verification else {
|
||||
purchaseState = .failed("Couldn't verify this purchase.")
|
||||
return
|
||||
}
|
||||
await transaction.finish()
|
||||
purchaseState = .thankYou(product.id)
|
||||
case .userCancelled, .pending:
|
||||
purchaseState = .idle
|
||||
@unknown default:
|
||||
purchaseState = .idle
|
||||
}
|
||||
} catch {
|
||||
purchaseState = .failed("Something went wrong completing the purchase.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ extension View {
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Log Out", role: .destructive) {
|
||||
session.signOut()
|
||||
Task { await session.signOut() }
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// Grabs the hosting `NSWindow` once it's attached to a screen, for the
|
||||
/// handful of things SwiftUI's `Window` scene doesn't expose a modifier for.
|
||||
private struct WindowConfigurator: NSViewRepresentable {
|
||||
let configure: (NSWindow) -> Void
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView()
|
||||
DispatchQueue.main.async {
|
||||
if let window = view.window {
|
||||
configure(window)
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// `Window` scenes default to a full standard titlebar, fullscreen
|
||||
/// (green) button included — not appropriate for fixed-size reference
|
||||
/// panels like About or Keyboard Shortcuts, which have no reason to
|
||||
/// support fullscreen at all.
|
||||
func disablesFullScreen() -> some View {
|
||||
background(
|
||||
WindowConfigurator { window in
|
||||
window.collectionBehavior.remove(.fullScreenPrimary)
|
||||
window.collectionBehavior.insert(.fullScreenNone)
|
||||
window.standardWindowButton(.zoomButton)?.isHidden = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -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://apps.apple.com/us/app/outpost-for-outline/id6802736230">
|
||||
<img src="docs/assets/mac-app-store-badge.svg" height="40" alt="Download on the Mac App Store">
|
||||
</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.
|
||||
|
||||
> **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
|
||||
|
||||
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
|
||||
|
||||
- Xcode 16+
|
||||
- iOS 17+ / iPadOS 17+ / macOS 14+
|
||||
- Xcode 27+ (currently developed against an Xcode 27 beta — this is a hard minimum, not a suggestion)
|
||||
- macOS 14+ to run the app. iOS/iPadOS support is planned but not in the current build (see the note above)
|
||||
- A self-hosted (or hosted) Outline instance with API access
|
||||
|
||||
> They will be updated. These are old requirements.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Clone the repo and open the `.xcodeproj` in Xcode.
|
||||
@@ -40,7 +42,23 @@ Parts of this codebase are AI-assisted (built with the help of AI coding tools).
|
||||
|
||||
## 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.
|
||||
Outpost itself is licensed under the [Business Source License 1.1](./LICENSE) — the same license family Outline's own server uses, for the same reason. In short: free to read, self-host, and modify for personal or non-commercial use; not free to repackage as a competing hosted product or to distribute under a name/branding that claims official or affiliated status. It converts automatically to Apache License 2.0 on the change date stated in [`LICENSE`](./LICENSE). "Outpost" and its logo are trademarks of the project — see the license's trademark notice.
|
||||
|
||||
This project is a client only — it does not include, vendor, or redistribute any of Outline's own (also BSL 1.1 licensed) server source.
|
||||
|
||||
## Credits
|
||||
|
||||
The native markdown editor is built on [swift-markdown-engine](https://github.com/nodes-app/swift-markdown-engine) by Luca Chen, licensed under Apache License 2.0. It's vendored directly in [`Vendor/swift-markdown-engine`](./Vendor/swift-markdown-engine) (see its [`LICENSE`](./Vendor/swift-markdown-engine/LICENSE)); local fixes made in that copy haven't been upstreamed yet.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
+3
-3
@@ -18,9 +18,9 @@ within 14 days depending on severity.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Outpost is in early alpha (`0.0.x`) — there's no stable release line
|
||||
yet. Only the most recent tagged release receives fixes; please make
|
||||
sure you're on the latest alpha before reporting.
|
||||
Outpost is early (`0.1.x`) — there's no stable release line yet. Only
|
||||
the most recent tagged release receives fixes; please make sure
|
||||
you're on the latest release before reporting.
|
||||
|
||||
| Version | Supported |
|
||||
| :--- | :---: |
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Build & Test (macOS)
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Select Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: latest-stable
|
||||
|
||||
- name: Show Swift version
|
||||
run: swift --version
|
||||
|
||||
- name: Build
|
||||
run: swift build -v
|
||||
|
||||
- name: Test
|
||||
run: swift test --parallel
|
||||
@@ -0,0 +1,17 @@
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Swift Package Manager
|
||||
/.build
|
||||
/.swiftpm/xcode
|
||||
Package.resolved
|
||||
|
||||
# Xcode
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
*.xcodeproj/project.xcworkspace/xcuserdata/
|
||||
*.xcodeproj/xcuserdata/
|
||||
|
||||
# Generated documentation
|
||||
/docs
|
||||
*.doccarchive
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
version: 1
|
||||
builder:
|
||||
configs:
|
||||
- documentation_targets: [MarkdownEngine]
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
# Architecture
|
||||
|
||||
## Source layout
|
||||
|
||||
```bash
|
||||
Sources/
|
||||
├── MarkdownEngine/ # core target — zero deps
|
||||
│ ├── Configuration/ # MarkdownEditorConfiguration + MarkdownEditorTheme
|
||||
│ ├── Extensions/ # the extension seam: MarkdownExtension + bundled opt-ins
|
||||
│ ├── Services/ # 4 protocols, no-op defaults, WikiLinkService
|
||||
│ ├── Parser/ # two-phase AST: BlockParser → InlineParser → DocumentAST (+ token projection)
|
||||
│ ├── Styling/ # MarkdownASTStyler (AST walk) + MarkdownStyler facade for NSImage passes
|
||||
│ ├── Renderer/ # LayoutBridge, MarkdownTextLayoutFragment, EmbeddedImageCache
|
||||
│ ├── Input/ # MarkdownInputHandler + MarkdownListHandler
|
||||
│ ├── TextView/
|
||||
│ │ ├── NativeTextViewWrapper.swift # SwiftUI entry point (NSViewRepresentable)
|
||||
│ │ ├── NativeTextViewContainer.swift # the scroll view's documentView: header band + text column stacking
|
||||
│ │ ├── ScrollingHeaderController.swift # scroll-away header: hosting, collapse/expand, teardown
|
||||
│ │ ├── ClampedScrollView.swift # scroll range clamped to real content height
|
||||
│ │ ├── NativeTextView/ # AppKit subclass + UX extensions (paste, drag-select, …)
|
||||
│ │ └── Coordinator/ # NSTextViewDelegate split by concern (restyling, find, …)
|
||||
│ └── MarkdownEngine.docc/ # DocC catalog
|
||||
├── MarkdownEngineCodeBlocks/ # opt-in SPM product — pulls in HighlighterSwift
|
||||
│ └── HighlighterSwiftBridge.swift # SyntaxHighlighter conformance
|
||||
└── MarkdownEngineLatex/ # opt-in SPM product — pulls in SwiftMath
|
||||
└── SwiftMathBridge.swift # LatexRenderer conformance
|
||||
```
|
||||
|
||||
The rest of this file is a per-directory tour, in the order text flows
|
||||
through the engine.
|
||||
|
||||
## [`Parser/`](Sources/MarkdownEngine/Parser): text → AST → tokens
|
||||
|
||||
A two-phase AST pipeline following CommonMark's model — block structure first,
|
||||
inline content second. There is no regex tokenizer anymore; the structural
|
||||
regexes are gone, replaced by hand-written scanners and a real syntax tree.
|
||||
|
||||
1. **`BlockParser`** splits the document into a flat, gap-free (tiling)
|
||||
sequence of `Block`s: `heading`, `paragraph`, `blockquote`, `list`,
|
||||
`fencedCode`, `blockLatex`, `table`, `thematicBreak`, `blank`. Hand-written
|
||||
line scanners. It memoizes the last parse (UTF-16 buffer cache) so the
|
||||
per-keystroke callers share one line-scan.
|
||||
2. **`InlineParser`** turns a single inline-bearing block's text into an inline
|
||||
AST (`[InlineNode]`) with correct CommonMark precedence: code spans →
|
||||
escapes → link family (`![[…]]`, `[[…]]`, ``, `[…](…)`, `~~…~~`,
|
||||
`$…$`) → emphasis (`*`/`_` delimiter runs) → `buildTree`. Each pass claims
|
||||
spans only in regions not already claimed, so there are never partial
|
||||
overlaps and the tree is a clean containment tree. That invariant is also
|
||||
what keeps the pass linear in span count: claimed ranges are consulted
|
||||
through a cursor rather than rescanned, and `buildTree` derives containment
|
||||
from a sort instead of comparing spans pairwise.
|
||||
3. **`MarkdownAST` / `DocumentAST.parse`** combines the two into the semantic
|
||||
document AST — `[BlockNode]`, each inline-bearing block carrying its parsed
|
||||
`[InlineNode]` children in absolute document coordinates. `BlockNode`,
|
||||
`InlineNode`, and `ListItem` are defined here.
|
||||
|
||||
**Tokens are now a projection of the AST, not the source of truth.**
|
||||
`MarkdownTokenizer` is just a namespace; its entry point `parseTokensViaAST`
|
||||
(implemented in **`BlockScopedTokenizer`** — the live tokenization pipeline)
|
||||
walks each `BlockParser` block and emits the legacy flat `[MarkdownToken]`
|
||||
shape: block-level tokens (heading, blockquote, fenced code, table, block
|
||||
LaTeX) come from **`BlockLevelTokenizer`** (hand scanners), inline tokens from
|
||||
the AST via **`InlineASTAdapter`** (`[InlineNode]` → `[MarkdownToken]`). Token
|
||||
shapes are reproduced 1:1 from the old regex tokenizer (parity-checked), so the
|
||||
consumers that still read tokens — the NSImage render passes, code-block
|
||||
handling, `MarkdownInputHandler`, `ContextMenu`, and `MarkdownDetection`
|
||||
(caret-aware active-token indices) — keep working unchanged.
|
||||
|
||||
**Invariant:** Ranges everywhere are absolute UTF-16 `NSRange`s into the source
|
||||
(the editor is TextKit-2 / `NSTextView`-based, so UTF-16 offsets are the native
|
||||
currency).
|
||||
|
||||
**Invariant:** Parsing is incremental. With `scopedRanges`, `DocumentAST.parse`
|
||||
parses inlines only for blocks intersecting the edit, and `BlockScopedTokenizer`
|
||||
memoizes per-block tokens (substring → tokens, FIFO-capped) — so a keystroke
|
||||
re-parses one block, not the whole document (≈ O(edit)).
|
||||
|
||||
## [`Extensions/`](Sources/MarkdownEngine/Extensions): opt-in constructs beyond pure markdown
|
||||
|
||||
`MarkdownExtension` contributes an inline span form (`InlineSyntax`, e.g.
|
||||
`==highlight==`), a fenced block form (`BlockSyntax`, e.g. `::: … :::`), or
|
||||
both — plus content attributes and an HTML wrapper for the clean-copy path.
|
||||
Registered via `MarkdownEditorConfiguration.extensions`; unregistered syntax
|
||||
stays literal text. Extensions never emit ranges — the parser derives all
|
||||
geometry — and every parse cache keys on the registry fingerprint, so the
|
||||
registered set can change at runtime.
|
||||
|
||||
**Invariant:** built-in constructs always classify first; an extension can
|
||||
never take text away from core markdown.
|
||||
|
||||
## [`Services/`](Sources/MarkdownEngine/Services): how does the engine talk to your app?
|
||||
|
||||
`MarkdownEditorServices.swift` declares the four service protocols. Each is
|
||||
called synchronously when its construct is styled or rendered: `WikiLinkResolver`
|
||||
while styling wiki-links, `EmbeddedImageProvider` from the image-embed render
|
||||
pass, `SyntaxHighlighter` from code styling, `LatexRenderer` from the LaTeX
|
||||
render passes.
|
||||
|
||||
`WikiLinkService.swift` handles the dual-form storage / display transform —
|
||||
storage is `[[Name|<id>]]`, display is `[[Name]]`. The coordinator runs it both
|
||||
ways every time `rebuildTextStorageAndStyle()` fires.
|
||||
|
||||
**Invariant:** Service callbacks are synchronous. If an embedder's
|
||||
implementation is slow, it caches (both bundled bridges do); the engine never
|
||||
async-renders.
|
||||
|
||||
**Invariant:** Wiki-link storage and display are different strings. Display IDs
|
||||
never leak into the binding.
|
||||
|
||||
## [`Styling/`](Sources/MarkdownEngine/Styling): how does the AST become attributes?
|
||||
|
||||
`MarkdownASTStyler.styleAttributes()` is the live styler. It walks the document
|
||||
AST and emits `[StyledRange]`, **composing** attributes on descent: a heading
|
||||
sets a large bold font, descending into bold adds the bold trait (keeping the
|
||||
size), into italic adds italic — so nested / combined inline styles stack
|
||||
instead of overwriting each other. (Composition is what the old flat pass
|
||||
pipeline got wrong, e.g. the shrinking bold in `# **n*o*des**`.)
|
||||
|
||||
`MarkdownStyler.styleAttributes()` (`MarkdownStyler.swift:43`) is now a thin
|
||||
facade: it builds the `StylingContext`, runs the AST styler for all text
|
||||
styling, then appends the passes that still render **NSImages** and therefore
|
||||
still consume tokens — block / inline LaTeX (`+Latex`), image embeds and image
|
||||
links (`+Images`), and rendered tables (`+Tables`). `MarkdownStyler+TaskCheckboxes`
|
||||
and `+BulletMarkers` no longer style (the AST styler does); they keep only the
|
||||
caret / selection range helpers (`taskSyntaxRange`, `bulletSyntaxRange`,
|
||||
`hrLineRange`) the text-view delegate uses.
|
||||
|
||||
If the coordinator passes `scopedRanges`, only the intersecting blocks are
|
||||
re-styled — the optimization that keeps per-keystroke restyling cheap.
|
||||
|
||||
**Invariant:** Markers shrink, they don't disappear. Inactive markers render at
|
||||
`hiddenMarkerFontSize`; they're never removed from text storage. Every
|
||||
selection / copy / find / undo bug downstream traces back to violating this.
|
||||
|
||||
## [`Renderer/`](Sources/MarkdownEngine/Renderer): TextKit 2 layout
|
||||
|
||||
Thin wrappers around `NSTextLayoutManager` (`LayoutBridge.swift`), a custom
|
||||
`MarkdownTextLayoutFragment` for precise positioning, and `EmbeddedImageCache`
|
||||
keyed by an embedder-supplied fingerprint so images and LaTeX results
|
||||
invalidate when the embedder says so.
|
||||
|
||||
## [`Input/`](Sources/MarkdownEngine/Input): typing-time helpers
|
||||
|
||||
`MarkdownInputHandler.swift` handles auto-wrap for `$…$` / `$$…$$` / `![[…]]`.
|
||||
`MarkdownListHandler.swift` handles list continuation, indent / outdent, and
|
||||
task-checkbox toggling on Enter / Tab / Backspace. Both run synchronously inside
|
||||
the text-view delegate.
|
||||
|
||||
## [`TextView/`](Sources/MarkdownEngine/TextView): NSTextView + SwiftUI bridge
|
||||
|
||||
The entry point is `NativeTextViewWrapper.swift` — an `NSViewRepresentable` that
|
||||
owns the coordinator and the configured text view.
|
||||
|
||||
The scroll view's `documentView` is **always** `NativeTextViewContainer`, never
|
||||
the text view itself. The container stacks up to three kinds of siblings in a
|
||||
flipped coordinate space: the optional scroll-away header band at the top (a
|
||||
clipped `NSHostingView` managed by `ScrollingHeaderController`, reserved height
|
||||
mirrored into `container.headerHeight`), the `NativeTextView` at
|
||||
`y = headerHeight` (centered at a fixed width when
|
||||
`configuration.readingWidth` is set), and — in reading-column mode — the
|
||||
full-width wide-table breakout overlays. Anything that converts between
|
||||
text-view-local rects and scroll/document space must lift by the text view's
|
||||
origin inside the container (`convert(_:to:)` or `frame.origin`); see
|
||||
`viewRect(forCharacterRange:)` and the find-in-document paths for the pattern.
|
||||
|
||||
Two sub-folders matter:
|
||||
|
||||
- `NativeTextView/` — extensions on the AppKit subclass (paste, drag-select
|
||||
boost, spell policy, caret workarounds, frame/overscroll management)
|
||||
- `Coordinator/` — `NSTextViewDelegate` glue, split by concern (restyling,
|
||||
writing-tools, find, code-blocks, inline selection, autocorrect)
|
||||
|
||||
Application of `[StyledRange]` to text storage happens in
|
||||
`Coordinator/NativeTextViewCoordinator+Restyling.swift` →
|
||||
`rebuildTextStorageAndStyle()`, which tokenizes via `parseTokensViaAST` and
|
||||
calls `MarkdownStyler.styleAttributes()`.
|
||||
|
||||
## [`Configuration/`](Sources/MarkdownEngine/Configuration): the tunables
|
||||
|
||||
`MarkdownEditorConfiguration` is a struct of structs — one nested group per
|
||||
concern (headings, codeBlock, blockLatex, overscroll, markers, lists, …) —
|
||||
passed by reference into the styler via the `StylingContext`.
|
||||
`MarkdownEditorTheme` is its colour sub-field.
|
||||
+377
@@ -0,0 +1,377 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to swift-markdown-engine are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.12.0] - 2026-08-10
|
||||
|
||||
### Added
|
||||
- `onPersistScrollOffset` / `restoreScrollOffset` on `NativeTextViewWrapper` —
|
||||
scroll memory an embedder can keep somewhere that outlives the editor. The
|
||||
engine's own per-document offsets live on the coordinator, so an embedder that
|
||||
routes to a different screen and back lost them: nothing recorded the offset on
|
||||
the way out (there was no `dismantleNSView` at all), and the restore was gated
|
||||
on a document switch, which a remount is not — `makeCoordinator` seeds
|
||||
`documentId`, so the first update pass never looks like one. Teardown now hands
|
||||
the offset over, and the restore is latched instead of gated, retrying for a
|
||||
bounded few passes because the first pass after a remount still carries the
|
||||
embedder's empty buffer. Both closures are asked at call time, so the
|
||||
embedder's own retention rules can see changes made on the way out. Passing
|
||||
neither leaves behavior unchanged.
|
||||
|
||||
- `NSAttributedString.Key.markdownBlockBackground` — a background painted
|
||||
across the whole line box by `MarkdownTextLayoutFragment` instead of the
|
||||
glyph box AppKit's `.backgroundColor` covers. Embedder extensions can use it
|
||||
wherever a fill should read as a block.
|
||||
|
||||
### Changed
|
||||
- **Inline parse cost is linear in the spans per region, not quadratic.** Every
|
||||
pass after the first consulted the claimed ranges by scanning the whole array
|
||||
— once per character in `scanEscapes` and `collectDelimiterRuns`, once per
|
||||
candidate in `scanLinkFamily` — and `buildTree` decided containment by testing
|
||||
each span against every other. The passes walk the string left to right and
|
||||
claimed ranges never partially overlap, so a cursor over the sorted ranges answers both
|
||||
questions in amortised constant time, and sorting spans by start ascending /
|
||||
length descending turns containment into a single ordered walk. A paragraph of
|
||||
240 code spans parses in 0.5ms rather than 33ms; 6x the spans now costs 6x the
|
||||
parse instead of ~30x. Affects every claimed-span construct — code, escapes,
|
||||
links, images, wiki links, inline LaTeX, emphasis, and extension spans. No
|
||||
parse result changes.
|
||||
- `==highlight==` fills the line box. AppKit paints `.backgroundColor` over
|
||||
ascent + descent only, so the marker fell short of the line height by the
|
||||
leading plus `paragraph.lineHeightExtraSpacing`, and a highlight that wrapped
|
||||
came out as a stack of bands. `HighlightExtension` returns
|
||||
`.markdownBlockBackground` now, so the block is continuous at any font size.
|
||||
Table cells rasterize their own text and keep the glyph-box fill.
|
||||
|
||||
### Fixed
|
||||
- Bare URLs and emails survive rich copy as real links. The editor styler
|
||||
linkifies them with `NSDataDetector`, but the HTML renderer emitted them as
|
||||
plain text, so the pasteboard's HTML/RTF/web-archive flavors carried no anchor
|
||||
at all, and whether a copied URL arrived clickable was left to the receiving
|
||||
app — Apple Mail runs its own detection and linkifies anyway, a consumer that
|
||||
takes the rich flavor verbatim pastes dead text. `MarkdownHTMLRenderer` now
|
||||
wraps detector matches in `<a href>` (emails as `mailto:`) using the same
|
||||
system detector as the styler; the RTF and web-archive flavors are derived
|
||||
from that HTML, so all three inherit the link. Explicit `[title](url)` links
|
||||
were already correct; a URL-shaped run inside a link's own title stays plain
|
||||
so anchors never nest, and code spans remain excluded, matching the styler.
|
||||
Table cells are unaffected: they render no inline markup on the copy path.
|
||||
- Markdown link labels may hold inline code and escaped punctuation —
|
||||
``[`App`](/tmp/App.swift:56)`` stayed literal. Code spans and escapes are
|
||||
claimed before links so they stay opaque, and the link pass rejected every
|
||||
candidate overlapping a claimed span, including one lying entirely inside the
|
||||
label. Spans contained in the label are permitted now, links act as
|
||||
containers when the tree is built, and partial overlaps or spans crossing the
|
||||
label boundary are still rejected.
|
||||
- Initially narrow tables reflow when the editor width shrinks instead of
|
||||
retaining stale image geometry until an unrelated full restyle.
|
||||
|
||||
## [0.11.0] - 2026-07-31
|
||||
|
||||
### Added
|
||||
- **Ordered lists render their position.** An item's number is computed from its
|
||||
place in the run and painted over the source digits, so typing, deleting,
|
||||
merging and pasting renumber live. The `.md` file is never rewritten — the
|
||||
source stays valid CommonMark whatever it says. The whole source marker is
|
||||
hidden as one unit and the slot is kerned to the display width, so the dot
|
||||
travels with the digits at any digit count; the raw digits are revealed while
|
||||
they are edited.
|
||||
- `MarkdownEditorConfiguration.cursorFollowsSpanInk` (opt-in, off by default):
|
||||
the caret and the I-beam take the ink of the extension span they sit in. It
|
||||
matters for an extension that INVERTS its content — dark ink on a light block
|
||||
— where both cursors are otherwise drawn in the block's own color and
|
||||
disappear inside it. `InvertedIBeamCursor` recolors the live `NSCursor.iBeam`
|
||||
image, which keeps the system shape and the user's pointer size.
|
||||
|
||||
### Fixed
|
||||
- Find-in-document no longer erases other backgrounds. Clearing its highlights
|
||||
removed `.backgroundColor` across the whole document, which blanked extension
|
||||
spans, code fences and table cells until some unrelated restyle repainted
|
||||
them. Find now marks its own backgrounds and restyles only the paragraphs it
|
||||
touched.
|
||||
|
||||
### Performance
|
||||
- **Large notes open ~14× faster.** Measured on a 346k-char / 5,241-block note
|
||||
(Release): first open 19.5 s → 1.35 s, warm open 1.1 s → 590 ms, switching
|
||||
away 440 ms → 115 ms. Styling is built on a detached string and transferred
|
||||
with one `setAttributedString` — per-key `addAttribute` on live TextKit-2
|
||||
storage left weak tombstones in Foundation's attribute-intern table, which
|
||||
turned quadratic. The restyle apply uses a paragraph overlap index above 32
|
||||
paragraphs, the redundant second full-document parse and the re-entrant
|
||||
full-document layout during rebuild are gone, and the SwiftMath render cache
|
||||
persists to disk (717 ms → 35 ms on relaunch, byte-identical geometry).
|
||||
- **Editing long ordered lists.** One Return in an 800-item loose list:
|
||||
944 ms → 67 ms (was quadratic in list length). Typing in a 1,600-item ordered
|
||||
list: 89 ms → 41 ms per key. Resolving the caret ink across 1,600 spans:
|
||||
0.134 ms → 0.002 ms per keystroke.
|
||||
|
||||
### Known limitations
|
||||
- A list item's continuation line is a paragraph and ends the run, so a
|
||||
multi-line item switches numbering off below it.
|
||||
- A loose list keeps stale numbers after a pure digit edit (a digit edit is not
|
||||
classified as structural).
|
||||
- Ordered task items (`1. [ ] x`) consume a number but render none.
|
||||
|
||||
## [0.10.1] - 2026-07-22
|
||||
|
||||
### Added
|
||||
- Custom SF Symbols for task checkboxes: `MarkdownEditorConfiguration` accepts
|
||||
custom unchecked and checked symbols for `- [ ]` / `- [x]` task-list items
|
||||
(opt-in; the defaults are unchanged).
|
||||
|
||||
### Fixed
|
||||
- List markers no longer disappear while a selection covers them, and selecting
|
||||
a list item now reveals its raw marker syntax like other inline constructs.
|
||||
- An unclosed ``` fence no longer swallows the rest of the document: typing an
|
||||
opening fence above existing content left every block below it (tables,
|
||||
block LaTeX, thematic breaks, links) unrendered until the closing fence was
|
||||
typed. A fence now forms a code block only once its closing fence exists.
|
||||
|
||||
## [0.10.0] - 2026-07-15
|
||||
|
||||
### Added
|
||||
- **Extension seam**: opt-in constructs beyond pure markdown. A
|
||||
`MarkdownExtension` contributes an inline form (`==highlight==`), a fenced
|
||||
block form (`::: … :::`), or both — plus content attributes and an HTML
|
||||
wrapper for the clean-copy path; register instances via
|
||||
`MarkdownEditorConfiguration.extensions`. Extensions never emit ranges — the
|
||||
parser derives all geometry, so a misbehaving extension can at worst restyle
|
||||
its own construct. Marker/fence hiding, caret reveal, incremental restyle,
|
||||
table cells, and rich copy are handled generically. Registered extensions can
|
||||
change at runtime; all parse caches key on the registry.
|
||||
- `HighlightExtension` (`==text==`) and `StrikethroughExtension` (`~~text~~`),
|
||||
the former built-ins repackaged as extensions, and `ContainerExtension`
|
||||
(`::: … :::`), the first fenced block extension.
|
||||
|
||||
### Changed
|
||||
- **Breaking**: `==highlight==` and `~~strikethrough~~` are no longer part of
|
||||
the core grammar. Unregistered, the syntax stays literal text. To keep the
|
||||
previous behavior:
|
||||
`configuration.extensions = [HighlightExtension(), StrikethroughExtension()]`.
|
||||
The formatting actions (context menu, `applyHighlightRequest` /
|
||||
`applyStrikethroughRequest`) still insert/remove the markers either way;
|
||||
construct detection (toggle-off, selection state) requires the extension.
|
||||
|
||||
### Fixed
|
||||
- A pre-existing incremental-parse gap surfaced by the seam review:
|
||||
backspace-joining two paragraphs could leave transiently wrong styling
|
||||
(extra spacing or a stray emphasis/code span across the join) until the next
|
||||
edit re-parsed the region.
|
||||
|
||||
## [0.9.0] - 2026-07-13
|
||||
|
||||
### Added
|
||||
- `MarkdownEditorConfiguration.rawSourceMode`: present the document as raw
|
||||
Markdown source — no syntax hiding, no markdown styling, and no wiki-link
|
||||
display transform (`[[Name|UUID]]` shows verbatim). The editor keeps base
|
||||
font/paragraph styling and stays fully editable; smart Markdown input
|
||||
handling (list continuation, `$$`/`![[` auto-wrap, ⇧⇥ outdent) is disabled
|
||||
while raw. Runtime switching is supported and rebuilds the document
|
||||
immediately; the current document's undo stack is dropped on a switch
|
||||
because undo actions recorded against the other mode's display text would
|
||||
replay at stale ranges. Default `false` — existing embedders are unaffected.
|
||||
- Find & replace: two optional bus notifications, `replaceCurrent` (replace the
|
||||
focused match and advance) and `replaceAll` (replace every match in one undo
|
||||
step, back-to-front so ranges stay valid). Both edit the engine's displayed
|
||||
text with proper `shouldChangeText`/`didChangeText` undo registration and
|
||||
report the remaining count via `findResults`. Purely additive — embedders that
|
||||
don't set the bus names are unaffected.
|
||||
- Clean clipboard: ⌘C copies the selection as rich text (RTF + `com.apple.webarchive`)
|
||||
built from the AST rather than the raw storage form, and paste converts HTML to
|
||||
Markdown. Wiki-link `[[Name|UUID]]` side-channels no longer leak into copied text.
|
||||
|
||||
### Fixed
|
||||
- Find/jump scroll now works without a reading column. The TextKit 2
|
||||
fragment-enumeration scroll path (with `.ensuresLayout`) runs universally
|
||||
instead of only when `readingWidth` was set; the unreliable
|
||||
`NSTextView.scrollRangeToVisible` (which routes through the absent TextKit 1
|
||||
layout manager for off-screen content) is now only the last-resort fallback.
|
||||
- Inline syntax markers (`**`, `*`, `~~`, `==`) now use `mutedText` foreground
|
||||
color while the caret is inside the corresponding span, matching the existing
|
||||
behavior of inline code backticks and link/wiki-link brackets. This makes
|
||||
highlight `==` markers visually distinct from body text in edit state.
|
||||
- Web links `[text](url)` now share the wiki-link "edit zone": clicking the outer
|
||||
~30% of the link's first/last visible character places the caret just outside the
|
||||
markers (before `[` / after `)`) and reveals the source for editing instead of
|
||||
navigating, matching `[[…]]` behavior. Previously the edit zone only resolved
|
||||
`.wikiLink` tokens, so a web link dropped the caret between its brackets and did
|
||||
not reveal. Middle-of-link clicks still navigate; read-only links stay navigable.
|
||||
- Auto-linking (`NSDataDetector`) no longer linkifies a URL that sits inside a
|
||||
markdown or wiki link's own range. A link's `(url)` previously got its own
|
||||
competing `.link` attribute on top of the link — making the raw URL independently
|
||||
navigable and offsetting the click edit zone. Bare URLs outside links still
|
||||
autolink; URLs inside code were already excluded.
|
||||
- Wiki links: UUID-robust labels/embeds and keyboard navigation in the inline
|
||||
autocomplete list.
|
||||
|
||||
### Contributors
|
||||
- Find/jump scroll fix and find & replace by @ChristineTham
|
||||
- Inline syntax-marker color fix by @sospartan
|
||||
- rawSourceMode, clean clipboard, web-link edit zone, and wiki-link robustness by @luca-chen198
|
||||
|
||||
## [0.8.0] - 2026-06-28
|
||||
|
||||
### Added
|
||||
- `MarkdownEditorBus.findQuery` / `findResults`: query-based in-document find. The host posts a
|
||||
search string (+ current index) and the engine matches against its OWN displayed text,
|
||||
highlighting in display coordinates and posting the match count back. This is correct where the
|
||||
displayed text differs from the source — e.g. node links rendered shorter than `[[Name|UUID]]`,
|
||||
LaTeX, or images — which the legacy `findScrollToRange` (host-computed source-coordinate ranges)
|
||||
highlighted at the wrong offset. Opt-in; `findScrollToRange` is unchanged for existing embedders.
|
||||
- `NativeTextView.isCursorExcluded: ((CGPoint) -> Bool)?` — embedder-supplied
|
||||
predicate that suppresses the edit-mode I-beam cursor when the mouse is inside
|
||||
a defined exclusion zone (e.g. a formatting toolbar). When the closure returns
|
||||
`true`, `mouseMoved:` skips calling `super.mouseMoved` to avoid NSTextView's
|
||||
built-in I-beam cursor, setting the arrow cursor instead. Exposed through
|
||||
`NativeTextViewWrapper.isCursorExcluded`.
|
||||
- `NativeTextViewWrapper.onBuildContextMenu: ((NSMenu, NSRange) -> NSMenu)?` —
|
||||
embedder hook to build the editor's right-click menu. The engine hands over the
|
||||
default `NSMenu` + the current selection; the embedder returns the menu to show
|
||||
(driving the `didMarkdown*` actions through the bus). Keeps the engine UI-free.
|
||||
- `==highlight==` inline markup: double-equals markers around text apply a
|
||||
background color (configurable via `MarkdownEditorTheme.highlightColor`,
|
||||
default `.systemOrange.withAlphaComponent(0.4)`). Content is recursively parsed so nested emphasis,
|
||||
code, etc. work inside highlights.
|
||||
- `MarkdownEditorBus.applyHighlightRequest` /
|
||||
`selectionHighlightDidChange`: bus notification names for driving a
|
||||
highlight toolbar button from host UI.
|
||||
- `MarkdownEditorBus` extended with nine new notification types for
|
||||
formatting toolbar integration: `applyStrikethroughRequest`,
|
||||
`applyInlineCodeRequest`, `applyBlockquoteRequest`,
|
||||
`applyUnorderedListRequest`, `applyOrderedListRequest`,
|
||||
`applyLinkRequest`, `applyCodeBlockRequest`,
|
||||
`applyHorizontalRuleRequest`, `applyImageRequest`. Embedders wire
|
||||
these into `NotificationCenter` to trigger formatting from
|
||||
external UI (toolbars, menus) without reaching into the editor's
|
||||
view hierarchy.
|
||||
- New formatting actions on the coordinator (callable directly or
|
||||
via the bus above): `didMarkdownStrikethrough`, `didMarkdownInlineCode`,
|
||||
`didMarkdownBlockquote`, `didMarkdownLink`, `didMarkdownCodeBlock`,
|
||||
`didMarkdownHorizontalRule`, `didMarkdownImage`.
|
||||
- Word-boundary detection in inline formatting: when the cursor is
|
||||
placed inside an English word with no active text selection, bold,
|
||||
italic, strikethrough, and inline-code actions now auto-select the
|
||||
containing word before wrapping. If no word character is adjacent
|
||||
to the cursor, empty markers are inserted as before. The cursor's
|
||||
relative offset within the word is preserved after wrapping
|
||||
(e.g. `wo|rd` → `**wo|rd**`).
|
||||
- Headless test suite for formatting actions (`FormattingActionTests`
|
||||
— 21 tests covering bold, strikethrough, inline code, blockquote,
|
||||
link, code block, horizontal rule, and image insertion).
|
||||
|
||||
### Changed
|
||||
- The engine no longer ships a built-in right-click "Format" context menu — menus
|
||||
are now embedder-supplied via `onBuildContextMenu` (above). The system rich-text
|
||||
"Font" submenu (Bold/Italic/Show Colors…) is stripped from the default menu, since
|
||||
those font traits don't apply to Markdown.
|
||||
|
||||
### Fixed
|
||||
- Blockquote removal no longer doubles trailing newlines when the
|
||||
original line already carries one.
|
||||
|
||||
## [0.7.1] - 2026-06-20
|
||||
|
||||
### Added
|
||||
- `MarkdownEditorConfiguration.heightBehavior` (`.scrolls` default / `.fitsContent`):
|
||||
in `.fitsContent` the editor grows to its content height and reports it to
|
||||
SwiftUI, so an enclosing `ScrollView` scrolls the page instead of a nested
|
||||
internal scroller. Opt-in, off by default — no change for existing embedders. (#75)
|
||||
- `BlockquoteStyle` configuration struct with `extraLineHeight` to control line
|
||||
spacing inside blockquotes, following the `ListStyle.extraLineHeight` /
|
||||
`ParagraphStyle.lineHeightExtraSpacing` pattern. Defaults to `0` (no extra
|
||||
spacing), preserving existing rendering. (#76)
|
||||
|
||||
### Fixed
|
||||
- Mouse-wheel / trackball scrolling no longer clamps back at the bottom past a
|
||||
stale-small content-height measurement. (#71)
|
||||
- Inspector clip mask and caret reveal at the document end. (#73)
|
||||
- Scroll position is remembered per document across switches, and Writing Tools
|
||||
results stay styled and visible after accept. (#70)
|
||||
- Empty-file placeholder no longer clips to one line after a view rebuild. (#69)
|
||||
|
||||
### Added
|
||||
- Scroll-away header: `NativeTextViewWrapper` gains `header: AnyView?`,
|
||||
`headerCollapsedHeight: CGFloat`, and `headerExpanded: Bool`. The engine
|
||||
hosts the supplied SwiftUI view above the document body, scrolling with
|
||||
it; collapsing animates the reserved band down to `headerCollapsedHeight`
|
||||
(the top row stays visible, lower rows clip away). The hosted content
|
||||
refreshes on every SwiftUI update and stays fully interactive. Composes
|
||||
with `readingWidth`. See the README's *Scrolling Header* section.
|
||||
|
||||
### Changed
|
||||
- The scroll view's `documentView` is now always an engine-internal
|
||||
container view (hosting the text view, the optional scroll-away header,
|
||||
and the reading column's breakout overlays) rather than sometimes the
|
||||
`NSTextView` itself. Embedders that reached into
|
||||
`scrollView.documentView` expecting an `NSTextView` must adapt — the
|
||||
document view's class was never API.
|
||||
- **Breaking**: The editor's enclosing scroll view no longer applies a
|
||||
hard-coded `top: 55.4` content inset. The default is now `0` on every
|
||||
edge, matching the most common embedding case where the editor fills
|
||||
its container exactly. Embedders that previously relied on the engine
|
||||
reserving header space (e.g. for a translucent toolbar) must opt in
|
||||
explicitly:
|
||||
|
||||
```swift
|
||||
var config = MarkdownEditorConfiguration.default
|
||||
config.safeAreaInsets = SafeAreaInsets(top: 55.4)
|
||||
```
|
||||
|
||||
### Added
|
||||
- `SafeAreaInsets` struct exposing `top` / `leading` / `trailing` / `bottom`
|
||||
inset knobs for the editor's enclosing scroll view, configurable via
|
||||
`MarkdownEditorConfiguration.safeAreaInsets`.
|
||||
- `MarkdownASTStyler` now stamps `.spellingState: 0` on fenced code blocks
|
||||
and inline `` `code` `` spans, completing the engine's existing
|
||||
spell-check suppression convention (links, wiki-links, LaTeX, and tables
|
||||
already carry the same attribute). The system spell-checker no longer
|
||||
underlines tokens inside code regions even when continuous spell
|
||||
checking is enabled.
|
||||
|
||||
### Fixed
|
||||
- Undo is now kept per `documentId`, so Cmd+Z keeps working after switching
|
||||
files. The single reused `NSTextView` previously wiped its undo manager on
|
||||
every document switch; the editor now vends a per-document `UndoManager`
|
||||
(via the new `undoManager(for:)` delegate method) whose undo/redo stack
|
||||
survives switching away and back. (#77)
|
||||
- A document's surviving undo stack is dropped when its text is reloaded
|
||||
*changed* while it was switched away (e.g. renaming a node rewrites the
|
||||
`[[label]]` in every file that links it), so Cmd+Z can no longer replay
|
||||
stale ranges against the rewritten content. (#78)
|
||||
- `NativeTextViewWrapper` keeps links clickable and text selectable
|
||||
when `isEditable: false`; `isSelectable` is no longer coupled to
|
||||
`isEditable`. (#31)
|
||||
- `NativeTextViewWrapper` now applies its initial styling pass even when
|
||||
the bound text starts at its final value (e.g. supplied as a SwiftUI
|
||||
`@State` initializer). Previously the editor would render the raw
|
||||
Markdown source until the user clicked into the document, because the
|
||||
coordinator's `lastSyncedText` already matched the bound text at first
|
||||
`updateNSView`. The early-return now also requires `didInitialFormatting`
|
||||
to be true, which only flips after the first styling pass completes.
|
||||
|
||||
### Added
|
||||
- Initial public API surface:
|
||||
- `NativeTextViewWrapper` — SwiftUI bridge for the AppKit-backed editor
|
||||
- `MarkdownEditorConfiguration` — every spacing / sizing / behavior knob
|
||||
- `MarkdownEditorTheme` — color palette, defaults to system colors
|
||||
- `MarkdownEditorServices` — container for the four service protocols
|
||||
- Service protocols: `WikiLinkResolver`, `EmbeddedImageProvider`,
|
||||
`SyntaxHighlighter`, `LatexRenderer`
|
||||
- No-op default implementations: `NoOpWikiLinkResolver`,
|
||||
`NoOpEmbeddedImageProvider`, `PlainTextSyntaxHighlighter`,
|
||||
`NoOpLatexRenderer`
|
||||
- `WikiLinkService` — bidirectional storage / display roundtrip helper
|
||||
- `PasteboardImageReader` — pasteboard image inspection helpers
|
||||
- Selection / replacement value types: `WikiLinkSelection`,
|
||||
`InlineSelectionState`, `InlineReplacementRequest`, `CodeBlockSelection`
|
||||
- `CodeBlockButton` — drop-in copy button overlay
|
||||
- DocC documentation catalog with landing page and topic groups
|
||||
- Triple-slash documentation comments on the full public API surface
|
||||
|
||||
[Unreleased]: https://github.com/nodes-app/swift-markdown-engine/compare/0.7.1...HEAD
|
||||
[0.7.1]: https://github.com/nodes-app/swift-markdown-engine/compare/0.7.0...0.7.1
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
# Contributing to MarkdownEngine
|
||||
|
||||
Thanks for your interest. **MarkdownEngine is maintained by one person —
|
||||
expect 1–2 weeks for review.** A pull request is the normal way in, for
|
||||
fixes, documentation and new extensions alike. If a change is large or
|
||||
architectural, open it as a draft PR with the design sketched in the
|
||||
description — that gets you an answer faster than describing it in prose.
|
||||
|
||||
> **New here?** Start with [ARCHITECTURE.md](ARCHITECTURE.md) — a
|
||||
> codemap that walks each directory in the order text flows through
|
||||
> the engine.
|
||||
|
||||
## Development setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nodes-app/swift-markdown-engine.git
|
||||
cd swift-markdown-engine
|
||||
swift build
|
||||
swift test
|
||||
```
|
||||
|
||||
Open `Package.swift` in Xcode for a graphical environment. The runnable
|
||||
demo is in `Demo/MarkdownEngineDemo.xcodeproj` — open and **Run** to see
|
||||
your changes against a real app target.
|
||||
|
||||
### Local DocC preview
|
||||
|
||||
Temporarily add the [swift-docc-plugin](https://github.com/swiftlang/swift-docc-plugin)
|
||||
to `Package.swift`, then `swift package --disable-sandbox preview-documentation
|
||||
--target MarkdownEngine`. It's intentionally not a permanent dependency — the
|
||||
core product stays free of optional tooling.
|
||||
|
||||
## Reporting bugs
|
||||
|
||||
Include:
|
||||
|
||||
- A minimal reproducer (the smallest Markdown input + code that triggers
|
||||
it)
|
||||
- macOS, Xcode, and Swift versions
|
||||
- Expected vs. actual behavior
|
||||
|
||||
Screen recordings welcome.
|
||||
|
||||
## Pull requests
|
||||
|
||||
- One logical change per PR, branched from `main`
|
||||
- Tests for new tokenizer / styler / service / extension behavior in
|
||||
`Tests/MarkdownEngineTests/`
|
||||
- DocC comments for any public-API change; update `Demo/` if relevant
|
||||
- One-line entry in `CHANGELOG.md` under `[Unreleased]`
|
||||
- `swift build` and `swift test` must be green; CI runs the same checks
|
||||
|
||||
## Design constraints
|
||||
|
||||
Non-negotiable for the core `MarkdownEngine` target:
|
||||
|
||||
- **Don't add external dependencies to the core `MarkdownEngine`
|
||||
target.** App-specific behaviors plug in through the four service
|
||||
protocols (`WikiLinkResolver`, `EmbeddedImageProvider`,
|
||||
`SyntaxHighlighter`, `LatexRenderer`) instead. The two existing
|
||||
bridge products (`MarkdownEngineCodeBlocks` → HighlighterSwift,
|
||||
`MarkdownEngineLatex` → SwiftMath) are the deliberate exception so
|
||||
consumers can opt in. A new bridge or a new core dependency is a bigger
|
||||
call — make the case in the PR description.
|
||||
- **New constructs are extensions, not core grammar.** A construct like
|
||||
`==highlight==` (inline) or a `::: … :::` fenced block belongs in
|
||||
`Sources/MarkdownEngine/Extensions/` as a `MarkdownExtension` — see
|
||||
`HighlightExtension` / `ContainerExtension` as templates — never a new case
|
||||
threaded through the parser, styler, and renderer. This keeps the core pure
|
||||
markdown and each construct isolated. Image/overlay-rendered constructs
|
||||
(tables, math) are the exception — they still need core work.
|
||||
- **Public surface stays small.** Favor `internal`; new public symbols
|
||||
need a DocC comment.
|
||||
|
||||
## Commit messages
|
||||
|
||||
Imperative subject, blank line, then a paragraph explaining *why*:
|
||||
|
||||
```
|
||||
Tokenize escaped backticks inside fenced code blocks
|
||||
|
||||
The previous tokenizer treated `\`` inside ``` … ``` as a token
|
||||
delimiter, which broke any code block containing escaped backtick
|
||||
examples. The new behavior matches CommonMark.
|
||||
```
|
||||
|
||||
The "what" is in the diff.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions are licensed under
|
||||
the [Apache 2.0 License](LICENSE).
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 60;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1A2B3C0000000000000004BB /* MarkdownEngineDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C0000000000000004AA /* MarkdownEngineDemoApp.swift */; };
|
||||
1A2B3C0000000000000005BB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C0000000000000005AA /* ContentView.swift */; };
|
||||
1A2B3C0000000000000006BB /* MDE.icon in Resources */ = {isa = PBXBuildFile; fileRef = 1A2B3C0000000000000006AA /* MDE.icon */; };
|
||||
1A2B3C0000000000000007CC /* MarkdownEngine in Frameworks */ = {isa = PBXBuildFile; productRef = 1A2B3C0000000000000007BB /* MarkdownEngine */; };
|
||||
1A2B3C0000000000000008BB /* MarkdownEngineCodeBlocks in Frameworks */ = {isa = PBXBuildFile; productRef = 1A2B3C0000000000000008AA /* MarkdownEngineCodeBlocks */; };
|
||||
1A2B3C0000000000000009BB /* MarkdownEngineLatex in Frameworks */ = {isa = PBXBuildFile; productRef = 1A2B3C0000000000000009AA /* MarkdownEngineLatex */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
1A2B3C0000000000000003DD /* MarkdownEngineDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MarkdownEngineDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
1A2B3C0000000000000004AA /* MarkdownEngineDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownEngineDemoApp.swift; sourceTree = "<group>"; };
|
||||
1A2B3C0000000000000005AA /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
1A2B3C0000000000000006AA /* MDE.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = MDE.icon; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
1A2B3C0000000000000003BB /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1A2B3C0000000000000007CC /* MarkdownEngine in Frameworks */,
|
||||
1A2B3C0000000000000008BB /* MarkdownEngineCodeBlocks in Frameworks */,
|
||||
1A2B3C0000000000000009BB /* MarkdownEngineLatex in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
1A2B3C0000000000000001BB = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1A2B3C0000000000000001DD /* MarkdownEngineDemo */,
|
||||
1A2B3C0000000000000001CC /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1A2B3C0000000000000001CC /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1A2B3C0000000000000003DD /* MarkdownEngineDemo.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1A2B3C0000000000000001DD /* MarkdownEngineDemo */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1A2B3C0000000000000004AA /* MarkdownEngineDemoApp.swift */,
|
||||
1A2B3C0000000000000005AA /* ContentView.swift */,
|
||||
1A2B3C0000000000000006AA /* MDE.icon */,
|
||||
);
|
||||
path = MarkdownEngineDemo;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
1A2B3C0000000000000001EE /* MarkdownEngineDemo */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 1A2B3C0000000000000001FF /* Build configuration list for PBXNativeTarget "MarkdownEngineDemo" */;
|
||||
buildPhases = (
|
||||
1A2B3C0000000000000003AA /* Sources */,
|
||||
1A2B3C0000000000000003BB /* Frameworks */,
|
||||
1A2B3C0000000000000003CC /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = MarkdownEngineDemo;
|
||||
packageProductDependencies = (
|
||||
1A2B3C0000000000000007BB /* MarkdownEngine */,
|
||||
1A2B3C0000000000000008AA /* MarkdownEngineCodeBlocks */,
|
||||
1A2B3C0000000000000009AA /* MarkdownEngineLatex */,
|
||||
);
|
||||
productName = MarkdownEngineDemo;
|
||||
productReference = 1A2B3C0000000000000003DD /* MarkdownEngineDemo.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
1A2B3C0000000000000001AA /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1500;
|
||||
LastUpgradeCheck = 1500;
|
||||
TargetAttributes = {
|
||||
1A2B3C0000000000000001EE = {
|
||||
CreatedOnToolsVersion = 15.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 1A2B3C0000000000000002AA /* Build configuration list for PBXProject "MarkdownEngineDemo" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 1A2B3C0000000000000001BB;
|
||||
packageReferences = (
|
||||
1A2B3C0000000000000007AA /* XCLocalSwiftPackageReference ".." */,
|
||||
);
|
||||
productRefGroup = 1A2B3C0000000000000001CC /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
1A2B3C0000000000000001EE /* MarkdownEngineDemo */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
1A2B3C0000000000000003CC /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1A2B3C0000000000000006BB /* MDE.icon in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
1A2B3C0000000000000003AA /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1A2B3C0000000000000004BB /* MarkdownEngineDemoApp.swift in Sources */,
|
||||
1A2B3C0000000000000005BB /* ContentView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
1A2B3C0000000000000002BB /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = MDE;
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "MarkdownEngine Demo";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.nodes-app.MarkdownEngineDemo";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
1A2B3C0000000000000002CC /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = MDE;
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "MarkdownEngine Demo";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.nodes-app.MarkdownEngineDemo";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
1A2B3C0000000000000002DD /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
1A2B3C0000000000000002EE /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
1A2B3C0000000000000001FF /* Build configuration list for PBXNativeTarget "MarkdownEngineDemo" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
1A2B3C0000000000000002BB /* Debug */,
|
||||
1A2B3C0000000000000002CC /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
1A2B3C0000000000000002AA /* Build configuration list for PBXProject "MarkdownEngineDemo" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
1A2B3C0000000000000002DD /* Debug */,
|
||||
1A2B3C0000000000000002EE /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
1A2B3C0000000000000007AA /* XCLocalSwiftPackageReference ".." */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = ..;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
1A2B3C0000000000000007BB /* MarkdownEngine */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = MarkdownEngine;
|
||||
};
|
||||
1A2B3C0000000000000008AA /* MarkdownEngineCodeBlocks */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = MarkdownEngineCodeBlocks;
|
||||
};
|
||||
1A2B3C0000000000000009AA /* MarkdownEngineLatex */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = MarkdownEngineLatex;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 1A2B3C0000000000000001AA /* Project object */;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user