Compare commits
100
Commits
cd6277f5aa
..
0.1.0
@@ -6,7 +6,7 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
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
|
- type: input
|
||||||
id: summary
|
id: summary
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
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
|
- type: input
|
||||||
id: summary
|
id: summary
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
+7
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Thank you for contributing. Please read this guide before opening issues or PRs.
|
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
|
## Questions
|
||||||
|
|
||||||
Open an issue — this is a single-repo project, there's no separate issue tracker to route to.
|
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
|
Licensor: Puranjay Savar Mattas
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
Licensed Work: Outpost
|
||||||
in the Software without restriction, including without limitation the rights
|
The Licensed Work is (c) 2026 Puranjay Savar Mattas
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
Additional Use Grant: You may use, copy, modify, and self-host the Licensed
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
Work, and build and distribute your own modified or
|
||||||
furnished to do so, subject to the following conditions:
|
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
|
You may not, without a separate commercial agreement
|
||||||
copies or substantial portions of the Software.
|
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
|
Change Date: 2036-08-20
|
||||||
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 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 Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline
|
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline
|
||||||
/// support at the existing protocol boundary, so no view model needs to know
|
/// 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 encoder: JSONEncoder
|
||||||
private let decoder: JSONDecoder
|
private let decoder: JSONDecoder
|
||||||
private let keyEncoder: JSONEncoder
|
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.live = live
|
||||||
self.cache = cache
|
self.cache = cache
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
|
self.encryptionKeyStore = encryptionKeyStore
|
||||||
|
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
encoder.dateEncodingStrategy = .iso8601
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
@@ -62,7 +85,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
// MARK: - Cached reads
|
// MARK: - Cached reads
|
||||||
|
|
||||||
public func documentInfo(id: String) async throws -> OutlineDocument {
|
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(
|
public func listDocuments(
|
||||||
@@ -72,7 +95,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
limit: Int
|
limit: Int
|
||||||
) async throws -> [OutlineDocument] {
|
) async throws -> [OutlineDocument] {
|
||||||
let key = "documents:\(collectionId ?? "-"):\(parentDocumentId ?? "-"):\(offset):\(limit)"
|
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(
|
try await self.live.listDocuments(
|
||||||
collectionId: collectionId,
|
collectionId: collectionId,
|
||||||
parentDocumentId: parentDocumentId,
|
parentDocumentId: parentDocumentId,
|
||||||
@@ -83,23 +106,29 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] {
|
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] {
|
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)
|
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] {
|
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)
|
try await self.live.listCollections(offset: offset, limit: limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func collectionInfo(id: String) async throws -> OutlineCollection {
|
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
|
// MARK: - Queueable writes
|
||||||
@@ -115,10 +144,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
|
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do {
|
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)
|
await cacheDocument(result)
|
||||||
return result
|
return result
|
||||||
} catch {
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "documents-write", error)
|
||||||
return await queueDocumentCreate(request)
|
return await queueDocumentCreate(request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,10 +159,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do {
|
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)
|
await cacheDocument(result)
|
||||||
return result
|
return result
|
||||||
} catch {
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "documents-write", error)
|
||||||
return try await queueDocumentUpdate(request, dueTo: error)
|
return try await queueDocumentUpdate(request, dueTo: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,10 +174,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
|
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do {
|
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)
|
await cacheCollection(result)
|
||||||
return result
|
return result
|
||||||
} catch {
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "collections-write", error)
|
||||||
return try await queueCollectionUpdate(request, dueTo: error)
|
return try await queueCollectionUpdate(request, dueTo: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,7 +188,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
|
|
||||||
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
|
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
|
||||||
if !isManualOfflineModeEnabled {
|
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)
|
return await queuePinCreate(request)
|
||||||
}
|
}
|
||||||
@@ -162,9 +204,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
if await cancelIfNeverSynced(id: id) { return }
|
if await cancelIfNeverSynced(id: id) { return }
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do {
|
do {
|
||||||
try await live.deletePin(id: id)
|
try await RetryPolicy.withRetry { try await self.live.deletePin(id: id) }
|
||||||
|
recordSuccess(category: "pins")
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "pins", error)
|
||||||
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
|
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -174,7 +218,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
|
|
||||||
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
|
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
|
||||||
if !isManualOfflineModeEnabled {
|
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)
|
return await queueSubscriptionCreate(request)
|
||||||
}
|
}
|
||||||
@@ -183,9 +234,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
if await cancelIfNeverSynced(id: id) { return }
|
if await cancelIfNeverSynced(id: id) { return }
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do {
|
do {
|
||||||
try await live.deleteSubscription(id: id)
|
try await RetryPolicy.withRetry { try await self.live.deleteSubscription(id: id) }
|
||||||
|
recordSuccess(category: "subscriptions")
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "subscriptions", error)
|
||||||
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
|
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -195,14 +248,28 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
|
|
||||||
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
|
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
|
||||||
if !isManualOfflineModeEnabled {
|
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)
|
return await queueStarDocumentCreate(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
|
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
|
||||||
if !isManualOfflineModeEnabled {
|
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)
|
return await queueStarCollectionCreate(request)
|
||||||
}
|
}
|
||||||
@@ -211,9 +278,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
if await cancelIfNeverSynced(id: id) { return }
|
if await cancelIfNeverSynced(id: id) { return }
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do {
|
do {
|
||||||
try await live.deleteStar(id: id)
|
try await RetryPolicy.withRetry { try await self.live.deleteStar(id: id) }
|
||||||
|
recordSuccess(category: "stars")
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "stars", error)
|
||||||
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
|
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -303,6 +372,34 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await live.listViews(request)
|
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 {
|
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
|
||||||
try await live.addDocumentUser(request)
|
try await live.addDocumentUser(request)
|
||||||
}
|
}
|
||||||
@@ -343,6 +440,10 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await live.uploadAttachmentFile(result, fileData: fileData)
|
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 {
|
public func deleteAttachment(id: String) async throws {
|
||||||
try await live.deleteAttachment(id: id)
|
try await live.deleteAttachment(id: id)
|
||||||
}
|
}
|
||||||
@@ -355,6 +456,42 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await live.updateUserName(request)
|
try await live.updateUserName(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
|
||||||
|
try await live.updateUserLanguage(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
|
||||||
|
try await live.updateUserPreferences(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteAccount() async throws {
|
||||||
|
try await live.deleteAccount()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||||
|
try await live.subscribeToNotifications(eventType: eventType)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||||
|
try await live.unsubscribeFromNotifications(eventType: eventType)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
|
||||||
|
try await live.listApiKeys(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
|
||||||
|
try await live.createApiKey(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteApiKey(id: String) async throws {
|
||||||
|
try await live.deleteApiKey(id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func installationInfo() async throws -> OutlineInstallationInfo {
|
||||||
|
try await live.installationInfo()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Sync management (Settings surface)
|
// MARK: - Sync management (Settings surface)
|
||||||
|
|
||||||
public func pendingOperations() async -> [PendingOperationSummary] {
|
public func pendingOperations() async -> [PendingOperationSummary] {
|
||||||
@@ -371,6 +508,15 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
await cache.clearAll()
|
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
|
/// Replays every queued operation against `live`, in the order they were
|
||||||
/// queued. Each is independent — one failing doesn't block the rest.
|
/// queued. Each is independent — one failing doesn't block the rest.
|
||||||
public func flushPendingOperations() async -> SyncFlushSummary {
|
public func flushPendingOperations() async -> SyncFlushSummary {
|
||||||
@@ -397,6 +543,15 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
/// existing cached-read methods already do the caching as a side effect,
|
/// existing cached-read methods already do the caching as a side effect,
|
||||||
/// this just has to drive the walk and separately cache each document by
|
/// this just has to drive the walk and separately cache each document by
|
||||||
/// id (`listDocuments`'s cache key is the list, not the individual doc).
|
/// id (`listDocuments`'s cache key is the list, not the individual doc).
|
||||||
|
///
|
||||||
|
/// Recurses into every document's children, not just collections' own
|
||||||
|
/// root-level documents — a document with sub-documents used to leave
|
||||||
|
/// them uncached entirely (only reachable if something else happened to
|
||||||
|
/// open them individually first). Also caches each collection under its
|
||||||
|
/// own `"collection:<id>"` key (previously only cached as part of the
|
||||||
|
/// paginated list blob), so both are individually enumerable afterward
|
||||||
|
/// via `OfflineCacheStore.loadAll(keyPrefix:)` — see
|
||||||
|
/// `cachedDocumentsIndex()`/`cachedCollectionsIndex()`.
|
||||||
public func performFullSync() async -> FullSyncSummary {
|
public func performFullSync() async -> FullSyncSummary {
|
||||||
var documentsCount = 0
|
var documentsCount = 0
|
||||||
var errors: [String] = []
|
var errors: [String] = []
|
||||||
@@ -421,56 +576,136 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for collection in collections {
|
for collection in collections {
|
||||||
var offset = 0
|
await cacheCollection(collection)
|
||||||
let limit = 100
|
let result = await cacheDocumentTree(collectionId: collection.id, parentDocumentId: nil, collectionName: collection.name)
|
||||||
while true {
|
documentsCount += result.count
|
||||||
let documents: [OutlineDocument]
|
errors.append(contentsOf: result.errors)
|
||||||
do {
|
|
||||||
documents = try await listDocuments(collectionId: collection.id, parentDocumentId: nil, offset: offset, limit: limit)
|
|
||||||
} catch {
|
|
||||||
errors.append("\(collection.name): \(errorDescription(error))")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
for document in documents {
|
|
||||||
await cacheDocument(document)
|
|
||||||
}
|
|
||||||
documentsCount += documents.count
|
|
||||||
guard documents.count == limit else { break }
|
|
||||||
offset += limit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return FullSyncSummary(collectionsCount: collections.count, documentsCount: documentsCount, errors: errors, finishedAt: Date())
|
return FullSyncSummary(collectionsCount: collections.count, documentsCount: documentsCount, errors: errors, finishedAt: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Caches every document under `parentDocumentId` (`nil` = a
|
||||||
|
/// collection's root level) and recurses into each one's own children,
|
||||||
|
/// depth-first, until a branch runs out of sub-documents. Returns a
|
||||||
|
/// plain `(count, errors)` pair rather than mutating shared state across
|
||||||
|
/// `await` boundaries, since this calls itself recursively.
|
||||||
|
private func cacheDocumentTree(
|
||||||
|
collectionId: String,
|
||||||
|
parentDocumentId: String?,
|
||||||
|
collectionName: String
|
||||||
|
) async -> (count: Int, errors: [String]) {
|
||||||
|
var count = 0
|
||||||
|
var errors: [String] = []
|
||||||
|
var offset = 0
|
||||||
|
let limit = 100
|
||||||
|
while true {
|
||||||
|
let documents: [OutlineDocument]
|
||||||
|
do {
|
||||||
|
documents = try await listDocuments(collectionId: collectionId, parentDocumentId: parentDocumentId, offset: offset, limit: limit)
|
||||||
|
} catch {
|
||||||
|
errors.append("\(collectionName): \(errorDescription(error))")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for document in documents {
|
||||||
|
await cacheDocument(document)
|
||||||
|
count += 1
|
||||||
|
let childResult = await cacheDocumentTree(collectionId: collectionId, parentDocumentId: document.id, collectionName: collectionName)
|
||||||
|
count += childResult.count
|
||||||
|
errors.append(contentsOf: childResult.errors)
|
||||||
|
}
|
||||||
|
guard documents.count == limit else { break }
|
||||||
|
offset += limit
|
||||||
|
}
|
||||||
|
return (count, errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every individually cached document from the last Full Local Sync —
|
||||||
|
/// empty if a sync has never run (or found nothing). Purely a local
|
||||||
|
/// SwiftData read, no network involved.
|
||||||
|
public func cachedDocumentsIndex() async -> [OutlineDocument] {
|
||||||
|
let payloads = await cache.loadAll(keyPrefix: "document:")
|
||||||
|
return payloads.compactMap { 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
|
// 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
|
// Manual offline mode means "skip the network entirely," not just
|
||||||
// "prefer it" — without this check, a read would still hit `live`
|
// "prefer it" — without this check, a read would still hit `live`
|
||||||
// (and succeed, showing content beyond whatever's cached) any time
|
// (and succeed, showing content beyond whatever's cached) any time
|
||||||
// the device actually had a connection, defeating the point of
|
// the device actually had a connection, defeating the point of
|
||||||
// deliberately testing/working as if offline.
|
// deliberately testing/working as if offline.
|
||||||
if isManualOfflineModeEnabled {
|
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
|
return cached
|
||||||
}
|
}
|
||||||
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
let result = try await fetch()
|
let result = try await RetryPolicy.withRetry { try await fetch() }
|
||||||
if let data = try? encoder.encode(result) {
|
recordSuccess(category: category)
|
||||||
|
if let data = encryptedEncode(result) {
|
||||||
await cache.save(data, forKey: key)
|
await cache.save(data, forKey: key)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
} catch {
|
} 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
|
return cached
|
||||||
}
|
}
|
||||||
throw error
|
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 {
|
private func requestKey(_ prefix: String, _ request: some Encodable) -> String {
|
||||||
guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else {
|
guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else {
|
||||||
return prefix
|
return prefix
|
||||||
@@ -479,19 +714,19 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func cacheDocument(_ document: OutlineDocument) async {
|
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)")
|
await cache.save(data, forKey: "document:\(document.id)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cacheCollection(_ collection: OutlineCollection) async {
|
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)")
|
await cache.save(data, forKey: "collection:\(collection.id)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async {
|
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)
|
await cache.enqueueOperation(id: id, kind: kind.rawValue, payload: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,7 +765,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
|
|
||||||
private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument {
|
private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument {
|
||||||
guard let baseData = await cache.load(forKey: "document:\(request.id)"),
|
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))
|
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||||
}
|
}
|
||||||
let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text)
|
let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text)
|
||||||
@@ -559,7 +794,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
// of it and the update would just fail every retry.
|
// of it and the update would just fail every retry.
|
||||||
if merged.id.hasPrefix("pending-"),
|
if merged.id.hasPrefix("pending-"),
|
||||||
let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }),
|
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(
|
let resolvedCreate = CreateDocumentRequest(
|
||||||
title: merged.title,
|
title: merged.title,
|
||||||
text: merged.text,
|
text: merged.text,
|
||||||
@@ -581,7 +816,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
|
|
||||||
private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection {
|
private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection {
|
||||||
guard let baseData = await cache.load(forKey: "collection:\(request.id)"),
|
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))
|
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||||
}
|
}
|
||||||
let merged = OutlineCollection(
|
let merged = OutlineCollection(
|
||||||
@@ -631,44 +866,84 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
}
|
}
|
||||||
switch kind {
|
switch kind {
|
||||||
case .createDocument:
|
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)
|
let result = try await live.createDocument(request)
|
||||||
await cacheDocument(result)
|
await cacheDocument(result)
|
||||||
// The placeholder id (== operation.id) is now a dead orphan —
|
// The placeholder id (== operation.id) is now a dead orphan —
|
||||||
// nothing server-side will ever answer to it again.
|
// nothing server-side will ever answer to it again.
|
||||||
await cache.removeCacheEntry(forKey: "document:\(operation.id)")
|
await cache.removeCacheEntry(forKey: "document:\(operation.id)")
|
||||||
case .updateDocument:
|
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)
|
let result = try await live.updateDocument(request)
|
||||||
await cacheDocument(result)
|
await cacheDocument(result)
|
||||||
case .updateCollection:
|
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)
|
let result = try await live.updateCollection(request)
|
||||||
await cacheCollection(result)
|
await cacheCollection(result)
|
||||||
case .createPin:
|
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)
|
_ = try await live.createPin(request)
|
||||||
case .deletePin:
|
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)
|
try await live.deletePin(id: request.id)
|
||||||
case .createSubscription:
|
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)
|
_ = try await live.createSubscription(request)
|
||||||
case .deleteSubscription:
|
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)
|
try await live.deleteSubscription(id: request.id)
|
||||||
case .starDocument:
|
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)
|
_ = try await live.starDocument(request)
|
||||||
case .deleteStar:
|
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)
|
try await live.deleteStar(id: request.id)
|
||||||
case .starCollection:
|
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)
|
_ = 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 {
|
private func errorDescription(_ error: Error) -> String {
|
||||||
if let apiError = error as? OutlineAPIError {
|
if let apiError = error as? OutlineAPIError {
|
||||||
switch apiError {
|
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
|
return try? modelContext.fetch(descriptor).first?.payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Everything cached under a key prefix — e.g. every individually
|
||||||
|
/// cached document (`"document:<id>"`) or collection
|
||||||
|
/// (`"collection:<id>"`) after a Full Local Sync, for building a local
|
||||||
|
/// search index without a per-item exact-key lookup.
|
||||||
|
public func loadAll(keyPrefix: String) -> [Data] {
|
||||||
|
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key.starts(with: keyPrefix) })
|
||||||
|
return ((try? modelContext.fetch(descriptor)) ?? []).map(\.payload)
|
||||||
|
}
|
||||||
|
|
||||||
/// Used to drop a temporary `pending-*` document's cache entry once a
|
/// Used to drop a temporary `pending-*` document's cache entry once a
|
||||||
/// queued create syncs and the server hands back the real id — the
|
/// queued create syncs and the server hands back the real id — the
|
||||||
/// placeholder key would otherwise sit around as a dead orphan forever.
|
/// placeholder key would otherwise sit around as a dead orphan forever.
|
||||||
@@ -58,6 +67,22 @@ public actor OfflineCacheStore {
|
|||||||
try? modelContext.save()
|
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
|
// MARK: - Offline write queue
|
||||||
|
|
||||||
/// Upserts by `id` — a second call with the same id (an edit coalescing
|
/// 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]
|
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument]
|
||||||
/// Documents the current user has recently viewed. Backed by `documents.viewed`.
|
/// Documents the current user has recently viewed. Backed by `documents.viewed`.
|
||||||
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument]
|
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`.
|
/// Full-text search with snippets/ranking. Backed by `documents.search`.
|
||||||
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult]
|
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult]
|
||||||
/// Title-only search — faster, no snippets. Backed by `documents.search_titles`.
|
/// 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`.
|
/// Historical view records, not live presence. Backed by `views.list`.
|
||||||
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView]
|
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
|
/// See `OutlineMembership`/`OutlineDocumentMember` — `add` is confirmed
|
||||||
/// from Outline's official docs, the rest are best-effort.
|
/// from Outline's official docs, the rest are best-effort.
|
||||||
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership
|
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership
|
||||||
@@ -75,6 +92,14 @@ public protocol OutlineAPIClient: Sendable {
|
|||||||
/// target. See `OutlineAttachment`/`CreateAttachmentResult`.
|
/// target. See `OutlineAttachment`/`CreateAttachmentResult`.
|
||||||
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult
|
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult
|
||||||
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws
|
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
|
/// Best-effort — matches the shape every other simple `id`-only delete
|
||||||
/// in this API uses (`pins.delete`, `stars.delete`, …), not confirmed
|
/// in this API uses (`pins.delete`, `stars.delete`, …), not confirmed
|
||||||
/// against a live server specifically for attachments yet.
|
/// against a live server specifically for attachments yet.
|
||||||
@@ -83,4 +108,26 @@ public protocol OutlineAPIClient: Sendable {
|
|||||||
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser
|
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser
|
||||||
/// `users.update`, name only. See `UpdateUserNameRequest`.
|
/// `users.update`, name only. See `UpdateUserNameRequest`.
|
||||||
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser
|
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))
|
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] {
|
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
|
||||||
try await post("documents.search", body: request)
|
try await post("documents.search", body: request)
|
||||||
}
|
}
|
||||||
@@ -175,6 +179,34 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await post("views.list", body: request)
|
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 {
|
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
|
||||||
try await post("documents.add_user", body: request)
|
try await post("documents.add_user", body: request)
|
||||||
}
|
}
|
||||||
@@ -278,6 +310,45 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
public func deleteAttachment(id: String) async throws {
|
||||||
try await postForSuccess("attachments.delete", body: StarIDParams(id: id))
|
try await postForSuccess("attachments.delete", body: StarIDParams(id: id))
|
||||||
}
|
}
|
||||||
@@ -290,6 +361,42 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await post("users.update", body: request)
|
try await post("users.update", body: request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
|
||||||
|
try await post("users.update", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
|
||||||
|
try await post("users.update", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteAccount() async throws {
|
||||||
|
try await postForSuccess("users.delete", body: EmptyParams())
|
||||||
|
}
|
||||||
|
|
||||||
|
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||||
|
try await post("users.notificationsSubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||||
|
try await post("users.notificationsUnsubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
|
||||||
|
try await post("apiKeys.list", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
|
||||||
|
try await post("apiKeys.create", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteApiKey(id: String) async throws {
|
||||||
|
try await postForSuccess("apiKeys.delete", body: StarIDParams(id: id))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func installationInfo() async throws -> OutlineInstallationInfo {
|
||||||
|
try await post("installation.info", body: EmptyParams())
|
||||||
|
}
|
||||||
|
|
||||||
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
|
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
|
||||||
guard let token = try? tokenStore.token() else {
|
guard let token = try? tokenStore.token() else {
|
||||||
throw OutlineAPIError.tokenUnavailable
|
throw OutlineAPIError.tokenUnavailable
|
||||||
@@ -449,6 +556,11 @@ private struct StarIDParams: Encodable {
|
|||||||
let id: String
|
let id: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct CommentReactionParams: Encodable {
|
||||||
|
let id: String
|
||||||
|
let emoji: String
|
||||||
|
}
|
||||||
|
|
||||||
private struct MoveDocumentResponse: Decodable {
|
private struct MoveDocumentResponse: Decodable {
|
||||||
let documents: [OutlineDocument]?
|
let documents: [OutlineDocument]?
|
||||||
let collections: [OutlineCollection]?
|
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,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 email: String?
|
||||||
public let avatarUrl: String?
|
public let avatarUrl: String?
|
||||||
public let role: String?
|
public let role: String?
|
||||||
|
public let language: String?
|
||||||
|
public let preferences: OutlineUserPreferences?
|
||||||
|
/// Keyed by `NotificationEventType`'s raw values, plus event types this
|
||||||
|
/// app has no UI for — kept as a flexible dictionary rather than a
|
||||||
|
/// fixed struct for that reason. `true` means subscribed.
|
||||||
|
public let notificationSettings: [String: Bool]?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
email: String? = nil,
|
email: String? = nil,
|
||||||
avatarUrl: String? = nil,
|
avatarUrl: String? = nil,
|
||||||
role: String? = nil
|
role: String? = nil,
|
||||||
|
language: String? = nil,
|
||||||
|
preferences: OutlineUserPreferences? = nil,
|
||||||
|
notificationSettings: [String: Bool]? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
self.email = email
|
self.email = email
|
||||||
self.avatarUrl = avatarUrl
|
self.avatarUrl = avatarUrl
|
||||||
self.role = role
|
self.role = role
|
||||||
|
self.language = language
|
||||||
|
self.preferences = preferences
|
||||||
|
self.notificationSettings = notificationSettings
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `User.preferences` — a free-form JSON blob on Outline's own `User` row,
|
||||||
|
/// not a fixed-shape API resource. Wire key names below are confirmed
|
||||||
|
/// against a live server's own web app traffic (captured toggling every
|
||||||
|
/// Preferences setting one at a time), not guessed — see the `CodingKeys`
|
||||||
|
/// mapping for the two that don't match this struct's own property names.
|
||||||
|
///
|
||||||
|
/// The server also validates `preferences` against a known key allowlist
|
||||||
|
/// and rejects the whole `users.update` call (not just the bad field) if
|
||||||
|
/// any key it doesn't recognize is present — confirmed live via a
|
||||||
|
/// `"notificationBadge: Invalid Input"` error when an earlier, wrong value
|
||||||
|
/// was sent. That's also why `fullWidthDocuments` is kept here even though
|
||||||
|
/// this app has no UI for it yet: this app always sends the *whole*
|
||||||
|
/// preferences object back on every save (see
|
||||||
|
/// `UpdateUserPreferencesRequest`), so silently dropping an unknown key
|
||||||
|
/// during decode would permanently clear it the next time any other
|
||||||
|
/// preference here gets saved.
|
||||||
|
public struct OutlineUserPreferences: Codable, Hashable, Sendable {
|
||||||
|
public var rememberLastPath: Bool?
|
||||||
|
/// App-facing polarity: `true` means "separate editing mode is on" —
|
||||||
|
/// the opposite of the wire's own `seamlessEdit` (seamless editing and
|
||||||
|
/// separate editing modes are each other's negation), inverted in
|
||||||
|
/// `init(from:)`/`encode(to:)` so nothing outside this file has to
|
||||||
|
/// remember that.
|
||||||
|
public var separateEditing: Bool?
|
||||||
|
public var useCursorPointer: Bool?
|
||||||
|
public var codeBlockLineNumbers: Bool?
|
||||||
|
public var showCommentMarker: Bool?
|
||||||
|
public var smartText: Bool?
|
||||||
|
/// One of `NotificationBadgeStyle`'s raw values.
|
||||||
|
public var notificationBadge: String?
|
||||||
|
/// No UI in this app yet — preserved purely so saving any other
|
||||||
|
/// preference here doesn't clobber it. See the type doc comment.
|
||||||
|
public var fullWidthDocuments: Bool?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
rememberLastPath: Bool? = nil,
|
||||||
|
separateEditing: Bool? = nil,
|
||||||
|
useCursorPointer: Bool? = nil,
|
||||||
|
codeBlockLineNumbers: Bool? = nil,
|
||||||
|
showCommentMarker: Bool? = nil,
|
||||||
|
smartText: Bool? = nil,
|
||||||
|
notificationBadge: String? = nil,
|
||||||
|
fullWidthDocuments: Bool? = nil
|
||||||
|
) {
|
||||||
|
self.rememberLastPath = rememberLastPath
|
||||||
|
self.separateEditing = separateEditing
|
||||||
|
self.useCursorPointer = useCursorPointer
|
||||||
|
self.codeBlockLineNumbers = codeBlockLineNumbers
|
||||||
|
self.showCommentMarker = showCommentMarker
|
||||||
|
self.smartText = smartText
|
||||||
|
self.notificationBadge = notificationBadge
|
||||||
|
self.fullWidthDocuments = fullWidthDocuments
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case rememberLastPath
|
||||||
|
case seamlessEdit
|
||||||
|
case useCursorPointer
|
||||||
|
case codeBlockLineNumbers
|
||||||
|
case commentsInGutter
|
||||||
|
case enableSmartText
|
||||||
|
case notificationBadge
|
||||||
|
case fullWidthDocuments
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
rememberLastPath = try container.decodeIfPresent(Bool.self, forKey: .rememberLastPath)
|
||||||
|
separateEditing = try container.decodeIfPresent(Bool.self, forKey: .seamlessEdit).map { !$0 }
|
||||||
|
useCursorPointer = try container.decodeIfPresent(Bool.self, forKey: .useCursorPointer)
|
||||||
|
codeBlockLineNumbers = try container.decodeIfPresent(Bool.self, forKey: .codeBlockLineNumbers)
|
||||||
|
showCommentMarker = try container.decodeIfPresent(Bool.self, forKey: .commentsInGutter)
|
||||||
|
smartText = try container.decodeIfPresent(Bool.self, forKey: .enableSmartText)
|
||||||
|
notificationBadge = try container.decodeIfPresent(String.self, forKey: .notificationBadge)
|
||||||
|
fullWidthDocuments = try container.decodeIfPresent(Bool.self, forKey: .fullWidthDocuments)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encodeIfPresent(rememberLastPath, forKey: .rememberLastPath)
|
||||||
|
try container.encodeIfPresent(separateEditing.map { !$0 }, forKey: .seamlessEdit)
|
||||||
|
try container.encodeIfPresent(useCursorPointer, forKey: .useCursorPointer)
|
||||||
|
try container.encodeIfPresent(codeBlockLineNumbers, forKey: .codeBlockLineNumbers)
|
||||||
|
try container.encodeIfPresent(showCommentMarker, forKey: .commentsInGutter)
|
||||||
|
try container.encodeIfPresent(smartText, forKey: .enableSmartText)
|
||||||
|
try container.encodeIfPresent(notificationBadge, forKey: .notificationBadge)
|
||||||
|
try container.encodeIfPresent(fullWidthDocuments, forKey: .fullWidthDocuments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App-icon unread indicator style. Wire values confirmed live (captured
|
||||||
|
/// setting all three from Outline's own web app).
|
||||||
|
public enum NotificationBadgeStyle: String, CaseIterable, Identifiable, Sendable {
|
||||||
|
case none = "disabled"
|
||||||
|
case unreadIndicator = "indicator"
|
||||||
|
case unreadCount = "count"
|
||||||
|
|
||||||
|
public var id: String { rawValue }
|
||||||
|
|
||||||
|
public var label: String {
|
||||||
|
switch self {
|
||||||
|
case .none: return "None"
|
||||||
|
case .unreadIndicator: return "Unread Indicator"
|
||||||
|
case .unreadCount: return "Unread Count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,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,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 struct CreateDocumentRequest: Codable, Sendable {
|
||||||
public let title: String
|
public let title: String
|
||||||
public let text: 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 parentDocumentId: String?
|
||||||
public let publish: Bool
|
public let publish: Bool
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
title: String,
|
title: String,
|
||||||
text: String,
|
text: String,
|
||||||
collectionId: String,
|
collectionId: String? = nil,
|
||||||
parentDocumentId: String? = nil,
|
parentDocumentId: String? = nil,
|
||||||
publish: Bool = true
|
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 append: Bool?
|
||||||
public let fullWidth: Bool?
|
public let fullWidth: Bool?
|
||||||
public let insightsEnabled: 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(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
@@ -14,7 +21,9 @@ public struct UpdateDocumentRequest: Codable, Sendable {
|
|||||||
text: String? = nil,
|
text: String? = nil,
|
||||||
append: Bool? = nil,
|
append: Bool? = nil,
|
||||||
fullWidth: Bool? = nil,
|
fullWidth: Bool? = nil,
|
||||||
insightsEnabled: Bool? = nil
|
insightsEnabled: Bool? = nil,
|
||||||
|
collectionId: String? = nil,
|
||||||
|
publish: Bool? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
@@ -22,5 +31,7 @@ public struct UpdateDocumentRequest: Codable, Sendable {
|
|||||||
self.append = append
|
self.append = append
|
||||||
self.fullWidth = fullWidth
|
self.fullWidth = fullWidth
|
||||||
self.insightsEnabled = insightsEnabled
|
self.insightsEnabled = insightsEnabled
|
||||||
|
self.collectionId = collectionId
|
||||||
|
self.publish = publish
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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 XCTest
|
||||||
|
import CryptoKit
|
||||||
@testable import OutlineKit
|
@testable import OutlineKit
|
||||||
|
|
||||||
private struct NotStubbed: Error {}
|
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 documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||||
func listViewedDocuments(offset: Int, limit: Int) 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 searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() }
|
||||||
func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||||
func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
|
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 listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { throw NotStubbed() }
|
||||||
func deleteSubscription(id: String) async throws { throw NotStubbed() }
|
func deleteSubscription(id: String) async throws { throw NotStubbed() }
|
||||||
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { 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 addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { throw NotStubbed() }
|
||||||
func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() }
|
func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() }
|
||||||
func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { throw NotStubbed() }
|
func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { throw NotStubbed() }
|
||||||
@@ -91,13 +100,39 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
|
|||||||
func currentUser() async throws -> OutlineUser { throw NotStubbed() }
|
func currentUser() async throws -> OutlineUser { throw NotStubbed() }
|
||||||
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() }
|
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() }
|
||||||
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws { 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 deleteAttachment(id: String) async throws { throw NotStubbed() }
|
||||||
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
|
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() }
|
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
|
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
|
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
|
func deleteAccount() async throws { throw NotStubbed() }
|
||||||
|
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
|
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
|
func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] { throw NotStubbed() }
|
||||||
|
func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey { throw NotStubbed() }
|
||||||
|
func deleteApiKey(id: String) async throws { throw NotStubbed() }
|
||||||
|
func installationInfo() async throws -> OutlineInstallationInfo { throw NotStubbed() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct StubTransportError: Error {}
|
private struct StubTransportError: Error {}
|
||||||
|
|
||||||
|
/// 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 {
|
final class CachingOutlineAPIClientTests: XCTestCase {
|
||||||
private func makeCache() throws -> OfflineCacheStore {
|
private func makeCache() throws -> OfflineCacheStore {
|
||||||
OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true))
|
OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true))
|
||||||
@@ -127,7 +162,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
let document = makeDocument()
|
let document = makeDocument()
|
||||||
stub.documentInfoHandler = { _ in document }
|
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")
|
let result = try await sut.documentInfo(id: "doc-1")
|
||||||
|
|
||||||
@@ -143,7 +178,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
if callCount == 1 { return document }
|
if callCount == 1 { return document }
|
||||||
throw StubTransportError()
|
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.
|
// First call succeeds and populates the cache.
|
||||||
_ = try await sut.documentInfo(id: "doc-1")
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
@@ -157,7 +192,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws {
|
func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try await sut.documentInfo(id: "doc-1")
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
@@ -176,7 +211,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
if callCount == 1 { return collections }
|
if callCount == 1 { return collections }
|
||||||
throw StubTransportError()
|
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)
|
_ = try await sut.listCollections(offset: 0, limit: 25)
|
||||||
let result = try await sut.listCollections(offset: 0, limit: 25)
|
let result = try await sut.listCollections(offset: 0, limit: 25)
|
||||||
@@ -189,7 +224,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
let docOne = makeDocument(id: "doc-1", title: "One")
|
let docOne = makeDocument(id: "doc-1", title: "One")
|
||||||
let docTwo = makeDocument(id: "doc-2", title: "Two")
|
let docTwo = makeDocument(id: "doc-2", title: "Two")
|
||||||
stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo }
|
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-1")
|
||||||
_ = try await sut.documentInfo(id: "doc-2")
|
_ = try await sut.documentInfo(id: "doc-2")
|
||||||
@@ -209,7 +244,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
let original = makeDocument(id: "doc-1", title: "Original")
|
let original = makeDocument(id: "doc-1", title: "Original")
|
||||||
stub.documentInfoHandler = { _ in original }
|
stub.documentInfoHandler = { _ in original }
|
||||||
stub.updateDocumentHandler = { _ 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())
|
||||||
|
|
||||||
// Populate the cache with the base document first (as a real open would).
|
// Populate the cache with the base document first (as a real open would).
|
||||||
_ = try await sut.documentInfo(id: "doc-1")
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
@@ -230,7 +265,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws {
|
func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.updateDocumentHandler = { _ 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())
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x"))
|
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x"))
|
||||||
@@ -245,7 +280,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
let original = makeDocument(id: "doc-1", title: "Original")
|
let original = makeDocument(id: "doc-1", title: "Original")
|
||||||
stub.documentInfoHandler = { _ in original }
|
stub.documentInfoHandler = { _ in original }
|
||||||
stub.updateDocumentHandler = { _ 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())
|
||||||
|
|
||||||
_ = try await sut.documentInfo(id: "doc-1")
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "First Edit"))
|
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "First Edit"))
|
||||||
@@ -258,7 +293,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws {
|
func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.createPinHandler = { _ in throw StubTransportError() }
|
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"))
|
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
|
||||||
XCTAssertTrue(pin.id.hasPrefix("pending-"))
|
XCTAssertTrue(pin.id.hasPrefix("pending-"))
|
||||||
@@ -276,7 +311,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
let original = makeDocument(id: "doc-1", title: "Original")
|
let original = makeDocument(id: "doc-1", title: "Original")
|
||||||
stub.documentInfoHandler = { _ in original }
|
stub.documentInfoHandler = { _ in original }
|
||||||
stub.updateDocumentHandler = { _ 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())
|
||||||
|
|
||||||
_ = try await sut.documentInfo(id: "doc-1")
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
|
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
|
||||||
@@ -300,7 +335,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
let original = makeDocument(id: "doc-1", title: "Original")
|
let original = makeDocument(id: "doc-1", title: "Original")
|
||||||
stub.documentInfoHandler = { _ in original }
|
stub.documentInfoHandler = { _ in original }
|
||||||
stub.updateDocumentHandler = { _ 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())
|
||||||
|
|
||||||
_ = try await sut.documentInfo(id: "doc-1")
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
|
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
|
||||||
@@ -327,7 +362,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
liveCallCount += 1
|
liveCallCount += 1
|
||||||
return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil)
|
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"))
|
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
|
||||||
|
|
||||||
@@ -352,7 +387,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
return [cachedCollection]
|
return [cachedCollection]
|
||||||
}
|
}
|
||||||
let cache = try makeCache()
|
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.
|
// Online first — populates the cache normally.
|
||||||
let firstResult = try await sut.listCollections(offset: 0, limit: 25)
|
let firstResult = try await sut.listCollections(offset: 0, limit: 25)
|
||||||
@@ -371,7 +406,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
func testCacheStorageSummaryReflectsCachedItems() async throws {
|
func testCacheStorageSummaryReflectsCachedItems() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.documentInfoHandler = { _ in self.makeDocument() }
|
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")
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
let summary = await sut.cacheStorageSummary()
|
let summary = await sut.cacheStorageSummary()
|
||||||
@@ -400,7 +435,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
return (0..<count).map { self.makeCollection(id: "col-\(offset + $0)") }
|
return (0..<count).map { self.makeCollection(id: "col-\(offset + $0)") }
|
||||||
}
|
}
|
||||||
stub.listDocumentsHandler = { _, _, _, _ in [] }
|
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()
|
let summary = await sut.performFullSync()
|
||||||
|
|
||||||
@@ -412,11 +447,16 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
func testPerformFullSyncCachesEachDocumentIndividually() async throws {
|
func testPerformFullSyncCachesEachDocumentIndividually() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
|
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
|
||||||
stub.listDocumentsHandler = { _, _, offset, _ in
|
// Must return empty for any non-nil parentDocumentId (no children) —
|
||||||
offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
|
// performFullSync now recurses into every document's own children,
|
||||||
|
// so a stub that ignores parentDocumentId and always returns the
|
||||||
|
// same root documents regardless would recurse into itself forever.
|
||||||
|
stub.listDocumentsHandler = { _, parentDocumentId, offset, _ in
|
||||||
|
guard parentDocumentId == nil else { return [] }
|
||||||
|
return offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
|
||||||
}
|
}
|
||||||
let cache = try makeCache()
|
let cache = try makeCache()
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
|
let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
let summary = await sut.performFullSync()
|
let summary = await sut.performFullSync()
|
||||||
XCTAssertEqual(summary.documentsCount, 2)
|
XCTAssertEqual(summary.documentsCount, 2)
|
||||||
@@ -428,12 +468,40 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(cachedDoc.id, "doc-2")
|
XCTAssertEqual(cachedDoc.id, "doc-2")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPerformFullSyncRecursesIntoNestedDocuments() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
|
||||||
|
// doc-1 (root) -> doc-2 (child of doc-1) -> doc-3 (grandchild) —
|
||||||
|
// regression test for the real gap this fixed: only root-level
|
||||||
|
// documents were ever cached before, so a document's own
|
||||||
|
// sub-documents were never reachable offline at all unless
|
||||||
|
// something else happened to open them individually first.
|
||||||
|
stub.listDocumentsHandler = { _, parentDocumentId, offset, _ in
|
||||||
|
guard offset == 0 else { return [] }
|
||||||
|
switch parentDocumentId {
|
||||||
|
case nil: return [self.makeDocument(id: "doc-1")]
|
||||||
|
case "doc-1": return [self.makeDocument(id: "doc-2")]
|
||||||
|
case "doc-2": return [self.makeDocument(id: "doc-3")]
|
||||||
|
default: return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cache = try makeCache()
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: cache, 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
|
// MARK: - Offline document creation
|
||||||
|
|
||||||
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
|
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
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(
|
let created = try await sut.createDocument(
|
||||||
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
||||||
@@ -457,7 +525,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||||
stub.updateDocumentHandler = { _ 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(
|
let created = try await sut.createDocument(
|
||||||
CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1")
|
CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1")
|
||||||
@@ -477,7 +545,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws {
|
func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
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(
|
let created = try await sut.createDocument(
|
||||||
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
||||||
@@ -502,4 +570,169 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
// expected — nothing left under the old id
|
// 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")
|
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 {
|
func testCreatePinDecodesPin() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
httpClient.responseData = """
|
httpClient.responseData = """
|
||||||
@@ -1074,6 +1205,318 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
|
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 {
|
func testDeleteAttachmentSendsRequest() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
httpClient.responseData = """
|
httpClient.responseData = """
|
||||||
|
|||||||
@@ -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;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 110;
|
objectVersion = 77;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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 */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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; };
|
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; };
|
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; };
|
FAF99C31302CE96200C9949F /* OutpostUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OutpostUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
@@ -59,9 +61,10 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */,
|
FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */,
|
||||||
FAF99CB1302D120500C9949F /* MarkdownEngineCodeBlocks in Frameworks */,
|
FA7596B430366A1D0000167E /* MarkdownEngineCodeBlocks in Frameworks */,
|
||||||
FAF99CB3302D120500C9949F /* MarkdownEngineLatex in Frameworks */,
|
FA7596B630366A1D0000167E /* MarkdownEngineLatex in Frameworks */,
|
||||||
FAF99CAF302D120500C9949F /* MarkdownEngine in Frameworks */,
|
FADBE087303734FE001E69F0 /* ImagePlayground.framework in Frameworks */,
|
||||||
|
FA7596B230366A1D0000167E /* MarkdownEngine in Frameworks */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
FAF99C24302CE96200C9949F /* Frameworks */ = {
|
FAF99C24302CE96200C9949F /* Frameworks */ = {
|
||||||
@@ -77,12 +80,21 @@
|
|||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
FADBE085303734FE001E69F0 /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
FADBE086303734FE001E69F0 /* ImagePlayground.framework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
FAF99C0F302CE96100C9949F = {
|
FAF99C0F302CE96100C9949F = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
FAF99C1A302CE96100C9949F /* Outpost */,
|
FAF99C1A302CE96100C9949F /* Outpost */,
|
||||||
FAF99C2A302CE96200C9949F /* OutpostTests */,
|
FAF99C2A302CE96200C9949F /* OutpostTests */,
|
||||||
FAF99C34302CE96200C9949F /* OutpostUITests */,
|
FAF99C34302CE96200C9949F /* OutpostUITests */,
|
||||||
|
FADBE085303734FE001E69F0 /* Frameworks */,
|
||||||
FAF99C19302CE96100C9949F /* Products */,
|
FAF99C19302CE96100C9949F /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -116,9 +128,9 @@
|
|||||||
name = Outpost;
|
name = Outpost;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
FAF99C43302CF1BD00C9949F /* OutlineKit */,
|
FAF99C43302CF1BD00C9949F /* OutlineKit */,
|
||||||
FAF99CAE302D120500C9949F /* MarkdownEngine */,
|
FA7596B130366A1D0000167E /* MarkdownEngine */,
|
||||||
FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */,
|
FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */,
|
||||||
FAF99CB2302D120500C9949F /* MarkdownEngineLatex */,
|
FA7596B530366A1D0000167E /* MarkdownEngineLatex */,
|
||||||
);
|
);
|
||||||
productName = Outpost;
|
productName = Outpost;
|
||||||
productReference = FAF99C18302CE96100C9949F /* Outpost.app */;
|
productReference = FAF99C18302CE96100C9949F /* Outpost.app */;
|
||||||
@@ -200,7 +212,7 @@
|
|||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */,
|
FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */,
|
||||||
FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */,
|
FA7596B030366A1D0000167E /* XCLocalSwiftPackageReference "Vendor/swift-markdown-engine" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = FAF99C19302CE96100C9949F /* Products */;
|
productRefGroup = FAF99C19302CE96100C9949F /* Products */;
|
||||||
@@ -299,7 +311,7 @@
|
|||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -361,7 +373,7 @@
|
|||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -385,15 +397,20 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Outpost;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||||
@@ -407,20 +424,22 @@
|
|||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 0.0.3;
|
MARKETING_VERSION = 0.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
XROS_DEPLOYMENT_TARGET = 27.0;
|
XROS_DEPLOYMENT_TARGET = 27.0;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
@@ -430,15 +449,20 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Outpost;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||||
@@ -452,20 +476,22 @@
|
|||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 0.0.3;
|
MARKETING_VERSION = 0.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
XROS_DEPLOYMENT_TARGET = 27.0;
|
XROS_DEPLOYMENT_TARGET = 27.0;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
@@ -476,10 +502,10 @@
|
|||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -502,10 +528,10 @@
|
|||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -527,10 +553,10 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -552,10 +578,10 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -610,43 +636,33 @@
|
|||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
FA7596B030366A1D0000167E /* XCLocalSwiftPackageReference "Vendor/swift-markdown-engine" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = "Vendor/swift-markdown-engine";
|
||||||
|
};
|
||||||
FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */ = {
|
FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */ = {
|
||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = OutlineKit;
|
relativePath = OutlineKit;
|
||||||
};
|
};
|
||||||
/* End XCLocalSwiftPackageReference section */
|
/* 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 */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
FA7596B130366A1D0000167E /* MarkdownEngine */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = MarkdownEngine;
|
||||||
|
};
|
||||||
|
FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = MarkdownEngineCodeBlocks;
|
||||||
|
};
|
||||||
|
FA7596B530366A1D0000167E /* MarkdownEngineLatex */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = MarkdownEngineLatex;
|
||||||
|
};
|
||||||
FAF99C43302CF1BD00C9949F /* OutlineKit */ = {
|
FAF99C43302CF1BD00C9949F /* OutlineKit */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = OutlineKit;
|
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 */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = FAF99C10302CE96100C9949F /* Project object */;
|
rootObject = FAF99C10302CE96100C9949F /* Project object */;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "f233fa96f0c6bdcdbf87f726af38f25704f6d46a156a27dfac47541baa63bf97",
|
"originHash" : "4127e8224149bef00a33500e8db49748a735e1c07626f2255faea52554afef5e",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "highlighterswift",
|
"identity" : "highlighterswift",
|
||||||
@@ -10,15 +10,6 @@
|
|||||||
"version" : "3.1.0"
|
"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",
|
"identity" : "swiftmath",
|
||||||
"kind" : "remoteSourceControl",
|
"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 AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Bare content (icon, name, version, links) with no window chrome — reused
|
/// Bare content (icon, name, version, links) with no window chrome — the
|
||||||
/// by both the standalone "About Outpost" window (`AboutView`, the standard
|
/// macOS "About Outpost" app-menu command now opens Settings' own About
|
||||||
/// macOS app-menu affordance) and the Settings page's own About section, so
|
/// section directly (no separate popup window), so this is its only caller.
|
||||||
/// the two can't drift out of sync.
|
|
||||||
struct AboutInfoView: View {
|
struct AboutInfoView: View {
|
||||||
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
|
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
|
||||||
private let releasesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/releases")!
|
|
||||||
|
|
||||||
var appName: String {
|
var appName: String {
|
||||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost"
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bumped alongside `MARKETING_VERSION` in the Xcode project — kept out
|
var versionString: String { OutpostVersion.fullVersionString }
|
||||||
/// of the bundle version itself since `CFBundleShortVersionString` is
|
|
||||||
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
|
|
||||||
private let releaseStage = "ALPHA"
|
|
||||||
|
|
||||||
var versionString: String {
|
|
||||||
let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
|
|
||||||
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
|
||||||
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
|
|
||||||
return "Version \(shortVersion)\(stageSuffix) (\(buildNumber))"
|
|
||||||
}
|
|
||||||
|
|
||||||
private var copyrightYear: String {
|
private var copyrightYear: String {
|
||||||
String(Calendar.current.component(.year, from: Date()))
|
String(Calendar.current.component(.year, from: Date()))
|
||||||
@@ -55,35 +43,20 @@ struct AboutInfoView: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
VStack(spacing: 10) {
|
Link(destination: repositoryURL) {
|
||||||
Link(destination: repositoryURL) {
|
Label("View Source on Git", systemImage: "link")
|
||||||
Label("View Source on Git", systemImage: "link")
|
|
||||||
}
|
|
||||||
.font(.callout)
|
|
||||||
|
|
||||||
Button("Check for Updates…") {
|
|
||||||
checkForUpdates()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.font(.callout)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.frame(maxWidth: 240)
|
||||||
|
|
||||||
|
TipJarView()
|
||||||
|
|
||||||
Text("© \(copyrightYear) Puranjay Savar Mattas")
|
Text("© \(copyrightYear) Puranjay Savar Mattas")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No Sparkle-style in-app updater yet — this just opens the releases page
|
|
||||||
// on the self-hosted Gitea instance so the user can check/download manually.
|
|
||||||
private func checkForUpdates() {
|
|
||||||
NSWorkspace.shared.open(releasesURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AboutView: View {
|
|
||||||
var body: some View {
|
|
||||||
AboutInfoView()
|
|
||||||
.padding(32)
|
|
||||||
.frame(width: 320)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,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)
|
#if os(macOS)
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import StoreKit
|
||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
|
||||||
/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label
|
/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label
|
||||||
@@ -9,21 +10,13 @@ import OutlineKit
|
|||||||
struct AccountFooter: View {
|
struct AccountFooter: View {
|
||||||
@Environment(SessionStore.self) private var session
|
@Environment(SessionStore.self) private var session
|
||||||
@Environment(AppNavigation.self) private var navigation
|
@Environment(AppNavigation.self) private var navigation
|
||||||
@Environment(\.openURL) private var openURL
|
@Environment(\.requestReview) private var requestReview
|
||||||
@Environment(\.openWindow) private var openWindow
|
|
||||||
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
||||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||||
@State private var isMenuPresented = false
|
@State private var isMenuPresented = false
|
||||||
@State private var isShowingLogoutConfirmation = false
|
@State private var isShowingLogoutConfirmation = false
|
||||||
@State private var isShowingProfile = 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 {
|
var body: some View {
|
||||||
Button {
|
Button {
|
||||||
isMenuPresented = true
|
isMenuPresented = true
|
||||||
@@ -63,20 +56,10 @@ struct AccountFooter: View {
|
|||||||
|
|
||||||
private var menuContent: some View {
|
private var menuContent: some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
menuItem("Keyboard Shortcuts…") { openWindow(id: "keyboard-shortcuts") }
|
// Apple's own review/feedback prompt — there's no separate
|
||||||
|
// native channel for "bug" vs. "feedback", so one button covers
|
||||||
Divider()
|
// both.
|
||||||
|
menuItem("Leave Us Feedback") { requestReview() }
|
||||||
menuItem("Documentation") { openURL(repositoryURL) }
|
|
||||||
if let apiDocumentationURL {
|
|
||||||
menuItem("API Documentation") { openURL(apiDocumentationURL) }
|
|
||||||
}
|
|
||||||
menuItem("Changelog") { openURL(repositoryURL) }
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
menuItem("Send Us Feedback") { openURL(issuesURL) }
|
|
||||||
menuItem("Report a Bug") { openURL(issuesURL) }
|
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
@@ -106,6 +89,14 @@ struct AccountFooter: View {
|
|||||||
// as "Invalid attempt to open a new transaction during CA
|
// as "Invalid attempt to open a new transaction during CA
|
||||||
// commit") — letting the popover's dismissal finish first avoids it.
|
// commit") — letting the popover's dismissal finish first avoids it.
|
||||||
menuItem("Settings…") { Task { @MainActor in navigation.isShowingSettings = true } }
|
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()
|
Divider()
|
||||||
|
|
||||||
|
|||||||
@@ -74,8 +74,10 @@ struct AvatarCropperView: View {
|
|||||||
Button("Cancel", role: .cancel, action: onCancel)
|
Button("Cancel", role: .cancel, action: onCancel)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Use Photo") {
|
Button("Use Photo") {
|
||||||
if let data = renderFinalImage() {
|
Task {
|
||||||
onConfirm(data)
|
if let data = await renderFinalImage() {
|
||||||
|
onConfirm(data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
@@ -100,15 +102,26 @@ struct AvatarCropperView: View {
|
|||||||
.clipped()
|
.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
|
@MainActor
|
||||||
private func renderFinalImage() -> Data? {
|
private func renderFinalImage() async -> Data? {
|
||||||
let content = avatarContent
|
let content = avatarContent
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
.frame(width: diameter, height: diameter)
|
.frame(width: diameter, height: diameter)
|
||||||
let renderer = ImageRenderer(content: content)
|
let renderer = ImageRenderer(content: content)
|
||||||
renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays
|
renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays
|
||||||
guard let nsImage = renderer.nsImage else { return nil }
|
guard let tiffData = renderer.nsImage?.tiffRepresentation else { return nil }
|
||||||
return nsImage.jpegData(compressionQuality: 0.9)
|
return await Task.detached(priority: .userInitiated) {
|
||||||
|
NSBitmapImageRep(data: tiffData)?.representation(using: .jpeg, properties: [.compressionFactor: 0.9])
|
||||||
|
}.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#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,5 +1,6 @@
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import OutlineKit
|
||||||
|
|
||||||
/// Swapped into the real sidebar's content slot (search field, collections
|
/// Swapped into the real sidebar's content slot (search field, collections
|
||||||
/// tree, account footer) while Settings is open — same sidebar, different
|
/// tree, account footer) while Settings is open — same sidebar, different
|
||||||
@@ -8,13 +9,26 @@ import SwiftUI
|
|||||||
/// back.
|
/// back.
|
||||||
///
|
///
|
||||||
/// Grouped by `SettingsCategory` — `general` (ours) sits under an "Outpost"
|
/// Grouped by `SettingsCategory` — `general` (ours) sits under an "Outpost"
|
||||||
/// header at the top, then Outline's own Account/Workspace/Integrations &
|
/// header at the top, then Outline's own Account/Workspace groups, matching
|
||||||
/// Installation groups, matching the settings page structure of the
|
/// the settings page structure of the Outline web app. Outline's own server
|
||||||
/// Outline web app.
|
/// version has no dedicated section (there used to be an Integrations &
|
||||||
|
/// Installation category for just that) — it's cheap enough to show
|
||||||
|
/// unconditionally in the footer here instead, alongside Outpost's own
|
||||||
|
/// version.
|
||||||
struct SettingsSidebarList: View {
|
struct SettingsSidebarList: View {
|
||||||
@Binding var selection: SettingsSection?
|
@Binding var selection: SettingsSection?
|
||||||
let onDone: () -> Void
|
let onDone: () -> Void
|
||||||
|
|
||||||
|
@Environment(SessionStore.self) private var session
|
||||||
|
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||||
|
@State private var outlineVersion: String?
|
||||||
|
|
||||||
|
/// Mirrors `SettingsView`'s own check — a real dropped connection or
|
||||||
|
/// the manual Offline Mode toggle both mean there's no server to ask.
|
||||||
|
private var isEffectivelyOnline: Bool {
|
||||||
|
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -28,7 +42,15 @@ struct SettingsSidebarList: View {
|
|||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
List(selection: $selection) {
|
List(selection: $selection) {
|
||||||
ForEach(SettingsCategory.allCases) { category in
|
// 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 }
|
let sections = SettingsSection.allCases.filter { $0.category == category }
|
||||||
Section {
|
Section {
|
||||||
ForEach(sections) { section in
|
ForEach(sections) { section in
|
||||||
@@ -37,7 +59,21 @@ struct SettingsSidebarList: View {
|
|||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
if let title = category.title {
|
if let title = category.title {
|
||||||
Text(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())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,12 +82,50 @@ struct SettingsSidebarList: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
versionFooter
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
Button("Done", action: onDone)
|
Button("Done", action: onDone)
|
||||||
.keyboardShortcut(.cancelAction)
|
.keyboardShortcut(.cancelAction)
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(12)
|
.padding(12)
|
||||||
}
|
}
|
||||||
|
// Keyed to connectivity, not a one-shot `.task {}` — reconnecting
|
||||||
|
// (or turning the manual Offline Mode toggle back off) re-fires
|
||||||
|
// this automatically instead of leaving the footer stuck on
|
||||||
|
// whatever it last knew, or blank, until Settings is reopened.
|
||||||
|
.task(id: isEffectivelyOnline) { await refreshOutlineVersion() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
#endif
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,21 +3,17 @@ import SwiftUI
|
|||||||
struct AuthHeaderView: View {
|
struct AuthHeaderView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
ZStack {
|
// A plain Image Set, not the AppIcon *app icon* asset — App Icon
|
||||||
Circle()
|
// sets aren't reliably resolvable through Image(_:)/UIImage
|
||||||
.fill(
|
// (named:) at runtime (confirmed live: showed nothing). This is
|
||||||
LinearGradient(
|
// the same source artwork (outpost-ios-1024.png) duplicated
|
||||||
colors: [Color.accentColor, Color.accentColor.opacity(0.6)],
|
// into a normal image set so SwiftUI can actually load it.
|
||||||
startPoint: .topLeading,
|
Image("AppLogo")
|
||||||
endPoint: .bottomTrailing
|
.resizable()
|
||||||
)
|
.scaledToFit()
|
||||||
)
|
.frame(width: 72, height: 72)
|
||||||
.frame(width: 64, height: 64)
|
.clipShape(RoundedRectangle(cornerRadius: 72 * 0.2237, style: .continuous))
|
||||||
Image(systemName: "text.book.closed.fill")
|
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||||
.font(.system(size: 26, weight: .semibold))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
}
|
|
||||||
.shadow(color: Color.accentColor.opacity(0.35), radius: 12, y: 6)
|
|
||||||
|
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
Text("Welcome to Outpost")
|
Text("Welcome to Outpost")
|
||||||
|
|||||||
@@ -30,8 +30,15 @@ struct CollectionDocumentsOutline: View {
|
|||||||
/// every document in the tree.
|
/// every document in the tree.
|
||||||
@State private var pinsByDocumentID: [String: OutlinePin] = [:]
|
@State private var pinsByDocumentID: [String: OutlinePin] = [:]
|
||||||
|
|
||||||
private var tree: [DocumentNode] {
|
/// Recomputed only when `viewModel.documents`/`sortOption` actually change
|
||||||
buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
|
/// (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(
|
init(
|
||||||
@@ -86,11 +93,14 @@ struct CollectionDocumentsOutline: View {
|
|||||||
.task(id: "\(refreshToken)-\(externalRefreshToken)") {
|
.task(id: "\(refreshToken)-\(externalRefreshToken)") {
|
||||||
await viewModel.load()
|
await viewModel.load()
|
||||||
await loadPins()
|
await loadPins()
|
||||||
|
rebuildTree()
|
||||||
}
|
}
|
||||||
|
.onChange(of: viewModel.documents) { rebuildTree() }
|
||||||
|
.onChange(of: sortOption) { rebuildTree() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadPins() async {
|
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) })
|
pinsByDocumentID = Dictionary(uniqueKeysWithValues: pins.map { ($0.documentId, $0) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,6 +167,7 @@ private struct DocumentNodeRow: View {
|
|||||||
.frame(width: 12)
|
.frame(width: 12)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
@@ -186,6 +197,7 @@ private struct DocumentNodeRow: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
}
|
}
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ struct CollectionListContent: View {
|
|||||||
CollectionRowView(collection: collection)
|
CollectionRowView(collection: collection)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import MarkdownEngine
|
import MarkdownEngine
|
||||||
|
import MarkdownEngineCodeBlocks
|
||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
|
||||||
/// Read-only for now — document/overview editing isn't wired up yet. Uses
|
/// 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.
|
/// `isSelectable` and link-opening both still need to work.
|
||||||
struct CollectionOverviewContent: View {
|
struct CollectionOverviewContent: View {
|
||||||
@State private var markdown: String
|
@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 ?? "")
|
_markdown = State(initialValue: collection.description ?? "")
|
||||||
|
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
NativeTextViewWrapper(
|
NativeTextViewWrapper(
|
||||||
text: $markdown,
|
text: $markdown,
|
||||||
configuration: .init(heightBehavior: .fitsContent),
|
configuration: .init(
|
||||||
|
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||||
|
heightBehavior: .fitsContent
|
||||||
|
),
|
||||||
isEditable: false
|
isEditable: false
|
||||||
)
|
)
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.animation(nil, value: imageReloadTick)
|
||||||
|
.task {
|
||||||
|
imageProvider.onImageLoaded = {
|
||||||
|
Task { @MainActor in imageReloadTick += 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
|
||||||
struct CollectionOverviewView: View {
|
struct CollectionOverviewView: View {
|
||||||
|
let apiClient: OutlineAPIClient
|
||||||
let collection: OutlineCollection
|
let collection: OutlineCollection
|
||||||
@State private var viewModel: DocumentsViewModel
|
@State private var viewModel: DocumentsViewModel
|
||||||
@State private var selectedTab: CollectionTab = .overview
|
@State private var selectedTab: CollectionTab = .overview
|
||||||
@@ -25,6 +26,7 @@ struct CollectionOverviewView: View {
|
|||||||
searchQuery: Binding<String>,
|
searchQuery: Binding<String>,
|
||||||
onOpenDocument: @escaping (OutlineDocument) -> Void
|
onOpenDocument: @escaping (OutlineDocument) -> Void
|
||||||
) {
|
) {
|
||||||
|
self.apiClient = apiClient
|
||||||
self.collection = collection
|
self.collection = collection
|
||||||
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
||||||
_searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id))
|
_searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id))
|
||||||
@@ -32,8 +34,15 @@ struct CollectionOverviewView: View {
|
|||||||
self.onOpenDocument = onOpenDocument
|
self.onOpenDocument = onOpenDocument
|
||||||
}
|
}
|
||||||
|
|
||||||
private var sortedDocuments: [OutlineDocument] {
|
/// Recomputed only when `viewModel.documents`/`selectedTab` actually
|
||||||
selectedTab.sorted(viewModel.documents)
|
/// 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 {
|
var body: some View {
|
||||||
@@ -59,7 +68,7 @@ struct CollectionOverviewView: View {
|
|||||||
if !trimmedSearchQuery.isEmpty {
|
if !trimmedSearchQuery.isEmpty {
|
||||||
searchResultsList
|
searchResultsList
|
||||||
} else if selectedTab == .overview {
|
} else if selectedTab == .overview {
|
||||||
CollectionOverviewContent(collection: collection)
|
CollectionOverviewContent(apiClient: apiClient, collection: collection)
|
||||||
} else {
|
} else {
|
||||||
documentList
|
documentList
|
||||||
}
|
}
|
||||||
@@ -92,6 +101,8 @@ struct CollectionOverviewView: View {
|
|||||||
await viewModel.checkForRemoteChanges()
|
await viewModel.checkForRemoteChanges()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: viewModel.documents) { resortDocuments() }
|
||||||
|
.onChange(of: selectedTab) { resortDocuments() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spans the full window width, centered, directly under the toolbar —
|
// Spans the full window width, centered, directly under the toolbar —
|
||||||
@@ -114,6 +125,7 @@ struct CollectionOverviewView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
@@ -150,6 +162,7 @@ struct CollectionOverviewView: View {
|
|||||||
DocumentRowView(document: document)
|
DocumentRowView(document: document)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,6 +195,7 @@ struct CollectionOverviewView: View {
|
|||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ struct CollectionTreeRow: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
.animation(.easeInOut(duration: 0.15), value: isExpanded)
|
.animation(.easeInOut(duration: 0.15), value: isExpanded)
|
||||||
.contextMenu { contextMenuContent }
|
.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(SessionStore.self) private var session
|
||||||
@Environment(AppNavigation.self) private var navigation
|
@Environment(AppNavigation.self) private var navigation
|
||||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||||
|
@AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false
|
||||||
/// The landing state — no collection selected yet is what Home actually
|
/// The landing state — no collection selected yet is what Home actually
|
||||||
/// means, so this starts `true` rather than auto-selecting the first
|
/// means, so this starts `true` rather than auto-selecting the first
|
||||||
/// collection the way this used to work.
|
/// collection the way this used to work.
|
||||||
@@ -23,6 +24,11 @@ struct ContentView_macOS: View {
|
|||||||
/// Home's "New Document" buttons) — every expanded sidebar row reloads
|
/// Home's "New Document" buttons) — every expanded sidebar row reloads
|
||||||
/// itself in response. See `CollectionDocumentsOutline.externalRefreshToken`.
|
/// itself in response. See `CollectionDocumentsOutline.externalRefreshToken`.
|
||||||
@State private var documentsChangedToken = 0
|
@State private var documentsChangedToken = 0
|
||||||
|
/// Guards the restore-on-launch attempt to exactly once per app launch
|
||||||
|
/// — without this, `mainContent`'s `.task` would re-run (and
|
||||||
|
/// re-navigate out from under the user) every time it reappears, e.g.
|
||||||
|
/// after a trip through Settings.
|
||||||
|
@State private var hasAttemptedLocationRestore = false
|
||||||
|
|
||||||
private var trimmedGlobalQuery: String {
|
private var trimmedGlobalQuery: String {
|
||||||
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -147,6 +153,38 @@ struct ContentView_macOS: View {
|
|||||||
if newValue != nil {
|
if newValue != nil {
|
||||||
isShowingHome = false
|
isShowingHome = false
|
||||||
}
|
}
|
||||||
|
persistLastLocationIfEnabled()
|
||||||
|
}
|
||||||
|
.onChange(of: documentPath) { _, _ in
|
||||||
|
persistLastLocationIfEnabled()
|
||||||
|
}
|
||||||
|
.onChange(of: isShowingHome) { _, _ in
|
||||||
|
persistLastLocationIfEnabled()
|
||||||
|
}
|
||||||
|
// Once per launch, before the user has a chance to navigate
|
||||||
|
// manually — restores whatever `restoreLastLocationIfEnabled`
|
||||||
|
// finds, or leaves today's Home default alone if there's nothing
|
||||||
|
// to restore (preference off, nothing stored yet, or resolution
|
||||||
|
// fails e.g. a deleted document/collection or being offline).
|
||||||
|
.task {
|
||||||
|
guard !hasAttemptedLocationRestore else { return }
|
||||||
|
hasAttemptedLocationRestore = true
|
||||||
|
await restoreLastLocationIfEnabled()
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if navigation.isShowingCommandPalette, let apiClient = session.apiClient {
|
||||||
|
CommandPaletteView(
|
||||||
|
apiClient: apiClient,
|
||||||
|
cachingClient: session.cachingClient,
|
||||||
|
fullWorkspaceSearch: isCommandPaletteFullWorkspaceSearch,
|
||||||
|
onSelectDocument: openDocument,
|
||||||
|
onSelectCollection: { collection in
|
||||||
|
selectedCollection = collection
|
||||||
|
replaceDocumentPath(with: [])
|
||||||
|
},
|
||||||
|
onDismiss: { navigation.isShowingCommandPalette = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +198,63 @@ struct ContentView_macOS: View {
|
|||||||
replaceDocumentPath(with: [])
|
replaceDocumentPath(with: [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Remember previous location (Preferences → Remember previous location)
|
||||||
|
|
||||||
|
private static let lastLocationDefaultsKey = "outline.lastLocation"
|
||||||
|
|
||||||
|
/// What gets persisted — `isHome` disambiguates "was on Home" from "no
|
||||||
|
/// collection selected yet" (the latter only otherwise happens on the
|
||||||
|
/// brief `ContentUnavailableView` placeholder state), since both would
|
||||||
|
/// otherwise look identical (`collectionId == nil`).
|
||||||
|
private struct LastLocation: Codable {
|
||||||
|
var isHome: Bool
|
||||||
|
var collectionId: String?
|
||||||
|
var documentIds: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called from every navigation-changing `.onChange` — cheap to persist
|
||||||
|
/// on every change rather than debouncing, this is just a small JSON
|
||||||
|
/// blob in `UserDefaults`, not a network call.
|
||||||
|
private func persistLastLocationIfEnabled() {
|
||||||
|
guard session.userPreferences?.rememberLastPath == true else { return }
|
||||||
|
let location = LastLocation(isHome: isShowingHome, collectionId: selectedCollection?.id, documentIds: documentPath.map(\.id))
|
||||||
|
guard let data = try? JSONEncoder().encode(location) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: Self.lastLocationDefaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves IDs back into real `OutlineCollection`/`OutlineDocument`
|
||||||
|
/// objects via the API — stored IDs alone aren't enough to populate
|
||||||
|
/// `selectedCollection`/`documentPath` directly. Resolves the document
|
||||||
|
/// chain in order and stops at the first failure (deleted document,
|
||||||
|
/// offline, etc.) rather than aborting the whole restore — whatever
|
||||||
|
/// prefix of the chain resolved successfully is still a better landing
|
||||||
|
/// spot than falling all the way back to Home.
|
||||||
|
private func restoreLastLocationIfEnabled() async {
|
||||||
|
guard session.userPreferences?.rememberLastPath == true,
|
||||||
|
let apiClient = session.apiClient,
|
||||||
|
let data = UserDefaults.standard.data(forKey: Self.lastLocationDefaultsKey),
|
||||||
|
let location = try? JSONDecoder().decode(LastLocation.self, from: data)
|
||||||
|
else { return }
|
||||||
|
// A pure "was on Home, nothing pushed" location needs no action —
|
||||||
|
// Home is already the default state before this ever runs.
|
||||||
|
guard location.collectionId != nil || !location.documentIds.isEmpty else { return }
|
||||||
|
|
||||||
|
if let collectionId = location.collectionId {
|
||||||
|
guard let collection = try? await apiClient.collectionInfo(id: collectionId) else { return }
|
||||||
|
selectedCollection = collection
|
||||||
|
isShowingHome = false
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedChain: [OutlineDocument] = []
|
||||||
|
for documentId in location.documentIds {
|
||||||
|
guard let document = try? await apiClient.documentInfo(id: documentId) else { break }
|
||||||
|
resolvedChain.append(document)
|
||||||
|
}
|
||||||
|
if !resolvedChain.isEmpty {
|
||||||
|
replaceDocumentPath(with: resolvedChain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var contextualSearchField: some View {
|
private var contextualSearchField: some View {
|
||||||
if isContextualSearchExpanded || !contextualSearchQuery.isEmpty {
|
if isContextualSearchExpanded || !contextualSearchQuery.isEmpty {
|
||||||
@@ -350,6 +445,13 @@ struct ContentView_macOS: View {
|
|||||||
if !documentPath.isEmpty {
|
if !documentPath.isEmpty {
|
||||||
documentPath.removeLast()
|
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 }
|
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)
|
#if os(macOS)
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import MarkdownEngine
|
import MarkdownEngine
|
||||||
|
import MarkdownEngineCodeBlocks
|
||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
|
||||||
/// Distraction-free reading view — no toolbar/sidebar chrome, larger type.
|
/// 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 text: String
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var errorMessage: String?
|
@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) {
|
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
|
||||||
self.apiClient = apiClient
|
self.apiClient = apiClient
|
||||||
self.document = document
|
self.document = document
|
||||||
_text = State(initialValue: document.text)
|
_text = State(initialValue: document.text)
|
||||||
|
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -44,7 +51,10 @@ struct DocumentPresentSheet: View {
|
|||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
NativeTextViewWrapper(
|
NativeTextViewWrapper(
|
||||||
text: $text,
|
text: $text,
|
||||||
configuration: .init(heightBehavior: .fitsContent),
|
configuration: .init(
|
||||||
|
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||||
|
heightBehavior: .fitsContent
|
||||||
|
),
|
||||||
isEditable: false
|
isEditable: false
|
||||||
)
|
)
|
||||||
.font(.system(size: 18))
|
.font(.system(size: 18))
|
||||||
@@ -67,6 +77,12 @@ struct DocumentPresentSheet: View {
|
|||||||
}
|
}
|
||||||
.frame(minWidth: 800, minHeight: 600)
|
.frame(minWidth: 800, minHeight: 600)
|
||||||
.background(.background)
|
.background(.background)
|
||||||
|
.animation(nil, value: imageReloadTick)
|
||||||
|
.task {
|
||||||
|
imageProvider.onImageLoaded = {
|
||||||
|
Task { @MainActor in imageReloadTick += 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
.task { await load() }
|
.task { await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import AppKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
import MarkdownEngine
|
import MarkdownEngine
|
||||||
|
import MarkdownEngineCodeBlocks
|
||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
#if canImport(ImagePlayground)
|
||||||
|
import ImagePlayground
|
||||||
|
#endif
|
||||||
|
|
||||||
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
|
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
|
||||||
/// below must run on the main thread — see the identical note on
|
/// 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(SessionStore.self) private var session
|
||||||
@Environment(StarStore.self) private var starStore
|
@Environment(StarStore.self) private var starStore
|
||||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||||
|
/// Local-only Outpost setting (Settings → Editor), not synced to
|
||||||
|
/// Outline — see `SettingsView.editorDetail`.
|
||||||
|
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
|
||||||
|
@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
|
@State private var viewModel: DocumentReaderViewModel
|
||||||
let apiClient: OutlineAPIClient
|
let apiClient: OutlineAPIClient
|
||||||
@@ -31,16 +84,73 @@ struct DocumentReaderView: View {
|
|||||||
let onDocumentCreated: () -> Void
|
let onDocumentCreated: () -> Void
|
||||||
|
|
||||||
@State private var isShowingUnpublishConfirmation = false
|
@State private var isShowingUnpublishConfirmation = false
|
||||||
|
@State private var isShowingPublishSheet = false
|
||||||
@State private var isShowingArchiveConfirmation = false
|
@State private var isShowingArchiveConfirmation = false
|
||||||
@State private var isShowingDeleteConfirmation = false
|
@State private var isShowingDeleteConfirmation = false
|
||||||
@State private var isShowingMoveSheet = false
|
@State private var isShowingMoveSheet = false
|
||||||
@State private var isShowingHistorySheet = false
|
@State private var isShowingHistorySheet = false
|
||||||
@State private var isShowingInsightsSheet = 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 isShowingPresentSheet = false
|
||||||
@State private var isShowingSearchSheet = false
|
@State private var isShowingSearchSheet = false
|
||||||
@State private var isShowingShareSheet = false
|
@State private var isShowingShareSheet = false
|
||||||
@State private var isShowingNewDocumentSheet = false
|
@State private var isShowingNewDocumentSheet = false
|
||||||
|
@State private var isShowingImagePlayground = false
|
||||||
@State private var actionErrorMessage: String?
|
@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(
|
init(
|
||||||
apiClient: OutlineAPIClient,
|
apiClient: OutlineAPIClient,
|
||||||
@@ -51,7 +161,13 @@ struct DocumentReaderView: View {
|
|||||||
) {
|
) {
|
||||||
self.apiClient = apiClient
|
self.apiClient = apiClient
|
||||||
self.document = document
|
self.document = document
|
||||||
|
// `separateEditingEnabled` can't be read from `@Environment` here —
|
||||||
|
// environment values aren't populated yet inside a view's `init`,
|
||||||
|
// only from `body` onward. Defaults to `true` (today's only
|
||||||
|
// behavior) and gets set for real in `.task` below once `session`
|
||||||
|
// is actually available.
|
||||||
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
|
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
|
||||||
|
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
|
||||||
self.onOpenChild = onOpenChild
|
self.onOpenChild = onOpenChild
|
||||||
self.onDeleted = onDeleted
|
self.onDeleted = onDeleted
|
||||||
self.onDocumentCreated = onDocumentCreated
|
self.onDocumentCreated = onDocumentCreated
|
||||||
@@ -66,44 +182,26 @@ struct DocumentReaderView: View {
|
|||||||
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Split View needs the full window height (each pane scrolls itself),
|
||||||
|
/// which an unbounded page-level `ScrollView` can't give it — a
|
||||||
|
/// `minHeight` inside one just resolves to exactly that minimum, not
|
||||||
|
/// "fill available space", since there's no bounded space to fill.
|
||||||
|
/// Only switches over once there's real content to show; loading/error
|
||||||
|
/// states still go through the normal scrolling layout.
|
||||||
|
private var canShowSplitView: Bool {
|
||||||
|
isSplitViewEnabled
|
||||||
|
&& viewModel.isEffectivelyEditable
|
||||||
|
&& viewModel.errorMessage == nil
|
||||||
|
&& !(viewModel.isLoading && viewModel.text.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
Group {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
if canShowSplitView {
|
||||||
if viewModel.isEditing {
|
splitViewContent
|
||||||
TextField("Title", text: $viewModel.title)
|
} else {
|
||||||
.font(.largeTitle.weight(.bold))
|
scrollingReaderContent
|
||||||
.textFieldStyle(.plain)
|
|
||||||
}
|
|
||||||
|
|
||||||
if viewModel.isLoading && viewModel.text.isEmpty {
|
|
||||||
ProgressView()
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
} else if let errorMessage = viewModel.errorMessage {
|
|
||||||
ContentUnavailableView {
|
|
||||||
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
|
|
||||||
} description: {
|
|
||||||
Text(errorMessage)
|
|
||||||
} actions: {
|
|
||||||
Button("Retry") {
|
|
||||||
Task { await viewModel.loadFullContent() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
NativeTextViewWrapper(
|
|
||||||
text: $viewModel.text,
|
|
||||||
configuration: .init(heightBehavior: .fitsContent),
|
|
||||||
documentId: viewModel.documentId,
|
|
||||||
isEditable: viewModel.isEditing
|
|
||||||
)
|
|
||||||
|
|
||||||
if !viewModel.children.isEmpty {
|
|
||||||
childrenSection
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
}
|
||||||
.overlay(alignment: .topTrailing) {
|
.overlay(alignment: .topTrailing) {
|
||||||
if viewModel.isLoading && !viewModel.text.isEmpty {
|
if viewModel.isLoading && !viewModel.text.isEmpty {
|
||||||
@@ -127,16 +225,62 @@ struct DocumentReaderView: View {
|
|||||||
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
// Toolbar marker — only shows once there's actually
|
||||||
Task { await viewModel.toggleEditing() }
|
// something to point at. Anchored comments additionally get
|
||||||
} label: {
|
// an inline vertical bar next to their text (see
|
||||||
if viewModel.isSaving {
|
// `commentAnchorMarkers`/`onCommentAnchorRectsChange`
|
||||||
ProgressView().controlSize(.small)
|
// below) — this button opens the sheet unfocused, showing
|
||||||
} else {
|
// every comment/thread.
|
||||||
Text(viewModel.isEditing ? "Done" : "Edit")
|
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 {
|
Button {
|
||||||
isShowingNewDocumentSheet = true
|
isShowingNewDocumentSheet = true
|
||||||
@@ -159,13 +303,37 @@ struct DocumentReaderView: View {
|
|||||||
.id(menuIdentity)
|
.id(menuIdentity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
imageProvider.onImageLoaded = {
|
||||||
|
Task { @MainActor in imageReloadTick += 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(nil, value: imageReloadTick)
|
||||||
.task { await viewModel.loadFullContent() }
|
.task { await viewModel.loadFullContent() }
|
||||||
|
// See the doc comment on `DocumentReaderViewModel.separateEditingEnabled`
|
||||||
|
// for why this can't just be read at `init` time.
|
||||||
|
.task { viewModel.separateEditingEnabled = session.userPreferences?.separateEditing ?? true }
|
||||||
|
.onChange(of: viewModel.text) {
|
||||||
|
guard !viewModel.separateEditingEnabled else { return }
|
||||||
|
viewModel.scheduleAutosave()
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.title) {
|
||||||
|
guard !viewModel.separateEditingEnabled else { return }
|
||||||
|
viewModel.scheduleAutosave()
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
await viewModel.loadPinAndSubscriptionState()
|
await viewModel.loadPinAndSubscriptionState()
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await viewModel.loadInsightsEnabledState()
|
await viewModel.loadInsightsEnabledState()
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
loadedComments = (try? await RetryPolicy.withRetry({
|
||||||
|
try await apiClient.listComments(
|
||||||
|
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
|
||||||
|
)
|
||||||
|
})) ?? []
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
await viewModel.loadViewers()
|
await viewModel.loadViewers()
|
||||||
@@ -213,6 +381,38 @@ struct DocumentReaderView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text(actionErrorMessage ?? "")
|
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) {
|
.sheet(isPresented: $isShowingMoveSheet) {
|
||||||
MoveDocumentSheet(apiClient: apiClient, document: document) {
|
MoveDocumentSheet(apiClient: apiClient, document: document) {
|
||||||
onDeleted()
|
onDeleted()
|
||||||
@@ -236,6 +436,59 @@ struct DocumentReaderView: View {
|
|||||||
onOpenChild(child)
|
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
|
/// Every toggle-backed piece of state shown as a checkmark inside
|
||||||
@@ -272,31 +525,166 @@ struct DocumentReaderView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var childrenSection: some View {
|
/// Today's single-pane layout — page-level `ScrollView` wrapping title +
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
/// content, used for the normal reading/editing view, and for every
|
||||||
Divider()
|
/// loading/error state regardless of Split View.
|
||||||
.padding(.vertical, 4)
|
private var scrollingReaderContent: some View {
|
||||||
|
ScrollView {
|
||||||
Text("Sub-documents")
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
.font(.caption.weight(.semibold))
|
if viewModel.isEffectivelyEditable {
|
||||||
.foregroundStyle(.secondary)
|
TextField("Title", text: $viewModel.title)
|
||||||
|
.font(.largeTitle.weight(.bold))
|
||||||
ForEach(viewModel.children) { child in
|
.textFieldStyle(.plain)
|
||||||
Button {
|
|
||||||
onOpenChild(child)
|
|
||||||
} label: {
|
|
||||||
DocumentRowView(document: child)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
|
|
||||||
if child.id != viewModel.children.last?.id {
|
if viewModel.isLoading && viewModel.text.isEmpty {
|
||||||
Divider()
|
ProgressView()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
} else if let errorMessage = viewModel.errorMessage {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
|
||||||
|
} description: {
|
||||||
|
Text(errorMessage)
|
||||||
|
} actions: {
|
||||||
|
Button("Retry") {
|
||||||
|
Task { await viewModel.loadFullContent() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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
|
@ViewBuilder
|
||||||
private var menuContent: some View {
|
private var menuContent: some View {
|
||||||
Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") {
|
Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") {
|
||||||
@@ -309,8 +697,10 @@ struct DocumentReaderView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
|
if viewModel.separateEditingEnabled {
|
||||||
Task { await viewModel.toggleEditing() }
|
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
|
||||||
|
Task { await viewModel.toggleEditing() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Membership management now lives in DocumentShareSheet's "People
|
// Membership management now lives in DocumentShareSheet's "People
|
||||||
// with access" section, alongside the share link — same sheet,
|
// with access" section, alongside the share link — same sheet,
|
||||||
@@ -330,10 +720,17 @@ struct DocumentReaderView: View {
|
|||||||
Task { await duplicate() }
|
Task { await duplicate() }
|
||||||
}
|
}
|
||||||
.disabled(!isEffectivelyOnline)
|
.disabled(!isEffectivelyOnline)
|
||||||
Button("Unpublish") {
|
if viewModel.publishedAt == nil {
|
||||||
isShowingUnpublishConfirmation = true
|
Button("Publish…") {
|
||||||
|
isShowingPublishSheet = true
|
||||||
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
|
} else {
|
||||||
|
Button("Unpublish") {
|
||||||
|
isShowingUnpublishConfirmation = true
|
||||||
|
}
|
||||||
|
.disabled(!isEffectivelyOnline)
|
||||||
}
|
}
|
||||||
.disabled(!isEffectivelyOnline)
|
|
||||||
Button("Archive…") {
|
Button("Archive…") {
|
||||||
isShowingArchiveConfirmation = true
|
isShowingArchiveConfirmation = true
|
||||||
}
|
}
|
||||||
@@ -531,4 +928,202 @@ struct DocumentReaderView: View {
|
|||||||
operation.run()
|
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
|
#endif
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ final class DocumentReaderViewModel {
|
|||||||
var emoji: String?
|
var emoji: String?
|
||||||
var text: String
|
var text: String
|
||||||
var collectionId: String?
|
var collectionId: String?
|
||||||
|
var parentDocumentId: String?
|
||||||
|
/// `nil` = draft (not published/visible to other workspace members).
|
||||||
|
var publishedAt: Date?
|
||||||
var isFullWidth = false
|
var isFullWidth = false
|
||||||
var children: [OutlineDocument] = []
|
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
|
|
||||||
@@ -18,6 +20,38 @@ final class DocumentReaderViewModel {
|
|||||||
var isSaving = false
|
var isSaving = false
|
||||||
var saveErrorMessage: String?
|
var saveErrorMessage: String?
|
||||||
|
|
||||||
|
/// Snapshot of the preference, set once via `.task` right after the
|
||||||
|
/// view appears (can't be read from `@Environment` inside the view's
|
||||||
|
/// own `init`) rather than a live binding to `SessionStore` — matches
|
||||||
|
/// how `isFullWidth` etc. are already seeded from the document at init
|
||||||
|
/// rather than observed reactively. A change made in Settings while a
|
||||||
|
/// document is already open takes effect the next document opened, not
|
||||||
|
/// mid-session; an acceptable tradeoff for how rarely this gets
|
||||||
|
/// toggled versus the complexity of threading a live preference
|
||||||
|
/// reference through every reader instance.
|
||||||
|
var separateEditingEnabled: Bool
|
||||||
|
|
||||||
|
/// The single source of truth the view reads for both "show the title
|
||||||
|
/// field" and "is the text view editable" — when separate editing is
|
||||||
|
/// off there's no Edit/Done mode at all, the document is just always
|
||||||
|
/// editable (assuming permission; there's no per-document permission
|
||||||
|
/// field to pre-check against, so an unauthorized edit simply fails to
|
||||||
|
/// save rather than being blocked client-side up front).
|
||||||
|
var isEffectivelyEditable: Bool {
|
||||||
|
separateEditingEnabled ? isEditing : true
|
||||||
|
}
|
||||||
|
|
||||||
|
private var autosaveTask: Task<Void, Never>?
|
||||||
|
/// Tracks the last known-synced-with-the-server values so
|
||||||
|
/// `scheduleAutosave()` can no-op when called just because `text`/
|
||||||
|
/// `title` were reassigned *from* a server response (initial load, or
|
||||||
|
/// a completed save) rather than actually edited — without this, every
|
||||||
|
/// document open in the always-editable mode would fire one pointless
|
||||||
|
/// autosave round-trip immediately, re-sending exactly what was just
|
||||||
|
/// received.
|
||||||
|
private var lastSyncedText: String
|
||||||
|
private var lastSyncedTitle: String
|
||||||
|
|
||||||
/// Recent viewers, `views.list` filtered to entries that actually have a
|
/// Recent viewers, `views.list` filtered to entries that actually have a
|
||||||
/// `lastViewedAt` — this is historical/aggregated view data, not live
|
/// `lastViewedAt` — this is historical/aggregated view data, not live
|
||||||
/// "viewing right now" presence (that needs the Hocuspocus collaboration
|
/// "viewing right now" presence (that needs the Hocuspocus collaboration
|
||||||
@@ -38,14 +72,19 @@ final class DocumentReaderViewModel {
|
|||||||
let documentId: String
|
let documentId: String
|
||||||
private let apiClient: OutlineAPIClient
|
private let apiClient: OutlineAPIClient
|
||||||
|
|
||||||
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
|
init(apiClient: OutlineAPIClient, document: OutlineDocument, separateEditingEnabled: Bool = true) {
|
||||||
self.apiClient = apiClient
|
self.apiClient = apiClient
|
||||||
self.documentId = document.id
|
self.documentId = document.id
|
||||||
self.title = document.title
|
self.title = document.title
|
||||||
self.emoji = document.emoji
|
self.emoji = document.emoji
|
||||||
self.text = document.text
|
self.text = document.text
|
||||||
self.collectionId = document.collectionId
|
self.collectionId = document.collectionId
|
||||||
|
self.parentDocumentId = document.parentDocumentId
|
||||||
|
self.publishedAt = document.publishedAt
|
||||||
self.isFullWidth = document.fullWidth ?? false
|
self.isFullWidth = document.fullWidth ?? false
|
||||||
|
self.separateEditingEnabled = separateEditingEnabled
|
||||||
|
self.lastSyncedText = document.text
|
||||||
|
self.lastSyncedTitle = document.title
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The list endpoint's copy of a document isn't guaranteed to be the full,
|
/// The list endpoint's copy of a document isn't guaranteed to be the full,
|
||||||
@@ -61,28 +100,29 @@ final class DocumentReaderViewModel {
|
|||||||
emoji = full.emoji
|
emoji = full.emoji
|
||||||
text = full.text
|
text = full.text
|
||||||
collectionId = full.collectionId
|
collectionId = full.collectionId
|
||||||
|
parentDocumentId = full.parentDocumentId
|
||||||
|
publishedAt = full.publishedAt
|
||||||
isFullWidth = full.fullWidth ?? false
|
isFullWidth = full.fullWidth ?? false
|
||||||
|
lastSyncedText = full.text
|
||||||
|
lastSyncedTitle = full.title
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Couldn't load this document. Check your connection and try again."
|
errorMessage = "Couldn't load this document. Check your connection and try again."
|
||||||
}
|
}
|
||||||
|
|
||||||
children = (try? await apiClient.listDocuments(
|
|
||||||
collectionId: nil,
|
|
||||||
parentDocumentId: documentId,
|
|
||||||
offset: 0,
|
|
||||||
limit: 100
|
|
||||||
)) ?? []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadViewers() async {
|
func loadViewers() async {
|
||||||
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 }
|
viewers = views.filter { $0.lastViewedAt != nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadPinAndSubscriptionState() async {
|
func loadPinAndSubscriptionState() async {
|
||||||
// `collectionId: nil` = Home pins. This menu's Pin action is "Pin to
|
// `collectionId: nil` = Home pins. This menu's Pin action is "Pin to
|
||||||
// Home", not "Pin to Collection" — those are distinct on the server.
|
// 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 }) {
|
let match = pins.first(where: { $0.documentId == documentId }) {
|
||||||
isPinned = true
|
isPinned = true
|
||||||
pinId = match.id
|
pinId = match.id
|
||||||
@@ -91,7 +131,7 @@ final class DocumentReaderViewModel {
|
|||||||
pinId = nil
|
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 {
|
let match = subscriptions.first {
|
||||||
isSubscribed = true
|
isSubscribed = true
|
||||||
subscriptionId = match.id
|
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 {
|
func toggleEditing() async {
|
||||||
guard isEditing else {
|
guard isEditing else {
|
||||||
isEditing = true
|
isEditing = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
await save()
|
||||||
|
if saveErrorMessage == nil {
|
||||||
|
isEditing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debounced save for the always-editable (separate editing off) path —
|
||||||
|
/// cancels any pending save and starts a fresh countdown on every call,
|
||||||
|
/// so a save only actually fires once typing pauses, not on every
|
||||||
|
/// keystroke. Goes through the same `updateDocument` call the explicit
|
||||||
|
/// Done-button save uses, which is already offline-queue-aware
|
||||||
|
/// (`CachingOutlineAPIClient`), so autosave while offline just queues
|
||||||
|
/// like any other edit instead of needing separate handling here.
|
||||||
|
func scheduleAutosave() {
|
||||||
|
guard text != lastSyncedText || title != lastSyncedTitle else { return }
|
||||||
|
autosaveTask?.cancel()
|
||||||
|
autosaveTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: .seconds(1.5))
|
||||||
|
guard let self, !Task.isCancelled else { return }
|
||||||
|
await self.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() async {
|
||||||
isSaving = true
|
isSaving = true
|
||||||
saveErrorMessage = nil
|
saveErrorMessage = nil
|
||||||
defer { isSaving = false }
|
defer { isSaving = false }
|
||||||
|
let sentTitle = title
|
||||||
|
let sentText = text
|
||||||
do {
|
do {
|
||||||
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: title, text: text))
|
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: sentTitle, text: sentText))
|
||||||
title = updated.title
|
// Only reconcile with the server's response if nothing changed
|
||||||
text = updated.text
|
// locally while the request was in flight — otherwise this
|
||||||
isEditing = false
|
// would clobber keystrokes typed during a debounced autosave's
|
||||||
|
// round trip. Whatever's newer goes out on the next autosave
|
||||||
|
// cycle regardless, since `scheduleAutosave()` keeps getting
|
||||||
|
// re-triggered by continued typing.
|
||||||
|
if title == sentTitle { title = updated.title }
|
||||||
|
if text == sentText { text = updated.text }
|
||||||
|
lastSyncedTitle = sentTitle
|
||||||
|
lastSyncedText = sentText
|
||||||
} catch {
|
} catch {
|
||||||
saveErrorMessage = outlineErrorMessage(error, fallback: "Couldn't save this document.")
|
saveErrorMessage = outlineErrorMessage(error, fallback: "Couldn't save this document.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,26 @@ struct DocumentSearchSheet: View {
|
|||||||
@State private var currentMatchIndex = 0
|
@State private var currentMatchIndex = 0
|
||||||
@FocusState private var isSearchFieldFocused: Bool
|
@FocusState private var isSearchFieldFocused: Bool
|
||||||
|
|
||||||
private var matchingLineIndices: [Int] {
|
/// Recomputed only when `query`/`lines` actually change (`recomputeMatches()`)
|
||||||
guard !query.isEmpty else { return [] }
|
/// instead of being a computed property — this used to re-scan the whole
|
||||||
return lines.indices.filter { lines[$0].localizedCaseInsensitiveContains(query) }
|
/// 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 {
|
var body: some View {
|
||||||
@@ -35,7 +52,10 @@ struct DocumentSearchSheet: View {
|
|||||||
TextField("Search in \"\(document.title.isEmpty ? "Untitled" : document.title)\"", text: $query)
|
TextField("Search in \"\(document.title.isEmpty ? "Untitled" : document.title)\"", text: $query)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.focused($isSearchFieldFocused)
|
.focused($isSearchFieldFocused)
|
||||||
.onChange(of: query) { currentMatchIndex = 0 }
|
.onChange(of: query) {
|
||||||
|
currentMatchIndex = 0
|
||||||
|
recomputeMatches()
|
||||||
|
}
|
||||||
|
|
||||||
if !matchingLineIndices.isEmpty {
|
if !matchingLineIndices.isEmpty {
|
||||||
Text("\(currentMatchIndex + 1) of \(matchingLineIndices.count)")
|
Text("\(currentMatchIndex + 1) of \(matchingLineIndices.count)")
|
||||||
@@ -83,7 +103,7 @@ struct DocumentSearchSheet: View {
|
|||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
.background(
|
.background(
|
||||||
isCurrentMatch(index) ? Color.yellow.opacity(0.4)
|
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
|
: Color.clear
|
||||||
)
|
)
|
||||||
.id(index)
|
.id(index)
|
||||||
@@ -131,6 +151,7 @@ struct DocumentSearchSheet: View {
|
|||||||
do {
|
do {
|
||||||
let text = try await apiClient.documentInfo(id: document.id).text
|
let text = try await apiClient.documentInfo(id: document.id).text
|
||||||
lines = text.components(separatedBy: "\n")
|
lines = text.components(separatedBy: "\n")
|
||||||
|
recomputeMatches()
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load this document.")
|
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load this document.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ struct DocumentShareSheet: View {
|
|||||||
private func loadMembers() async {
|
private func loadMembers() async {
|
||||||
isLoadingMembers = true
|
isLoadingMembers = true
|
||||||
defer { isLoadingMembers = false }
|
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 {
|
private func searchUsers(_ query: String) async {
|
||||||
@@ -381,7 +381,7 @@ struct DocumentShareSheet: View {
|
|||||||
}
|
}
|
||||||
isSearchingUsers = true
|
isSearchingUsers = true
|
||||||
defer { isSearchingUsers = false }
|
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 {
|
private func addUser(_ user: OutlineUser) async {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ struct NewDocumentSheet: View {
|
|||||||
ProgressView().frame(maxWidth: .infinity)
|
ProgressView().frame(maxWidth: .infinity)
|
||||||
} else {
|
} else {
|
||||||
Picker("Collection", selection: $selectedCollectionID) {
|
Picker("Collection", selection: $selectedCollectionID) {
|
||||||
Text("Choose a collection").tag(String?.none)
|
Text("Draft (not published)").tag(String?.none)
|
||||||
ForEach(collections) { collection in
|
ForEach(collections) { collection in
|
||||||
Text(collection.name).tag(Optional(collection.id))
|
Text(collection.name).tag(Optional(collection.id))
|
||||||
}
|
}
|
||||||
@@ -86,6 +86,12 @@ struct NewDocumentSheet: View {
|
|||||||
}
|
}
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.disabled(selectedCollectionID == nil || isLoadingDestinationDocuments)
|
.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 {
|
if let errorMessage {
|
||||||
@@ -97,18 +103,24 @@ struct NewDocumentSheet: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Cancel", role: .cancel) { dismiss() }
|
Button("Cancel", role: .cancel) { dismiss() }
|
||||||
Button("Create") {
|
Button(selectedCollectionID == nil ? "Save as Draft" : "Create & Publish") {
|
||||||
Task { await create() }
|
Task { await create() }
|
||||||
}
|
}
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
.disabled(selectedCollectionID == nil || isCreating)
|
.disabled(isCreating)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
.frame(width: 380)
|
.frame(width: 380)
|
||||||
.task {
|
.task {
|
||||||
await loadCollections()
|
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
|
selectedParentID = initialParentDocument?.id
|
||||||
isTitleFocused = true
|
isTitleFocused = true
|
||||||
}
|
}
|
||||||
@@ -147,7 +159,6 @@ struct NewDocumentSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func create() async {
|
private func create() async {
|
||||||
guard let selectedCollectionID else { return }
|
|
||||||
isCreating = true
|
isCreating = true
|
||||||
defer { isCreating = false }
|
defer { isCreating = false }
|
||||||
do {
|
do {
|
||||||
@@ -156,7 +167,12 @@ struct NewDocumentSheet: View {
|
|||||||
title: title.isEmpty ? "Untitled" : title,
|
title: title.isEmpty ? "Untitled" : title,
|
||||||
text: "",
|
text: "",
|
||||||
collectionId: selectedCollectionID,
|
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)
|
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 popular = "Popular"
|
||||||
case recentlyUpdated = "Recently Updated"
|
case recentlyUpdated = "Recently Updated"
|
||||||
case createdByMe = "Created by Me"
|
case createdByMe = "Created by Me"
|
||||||
|
case drafts = "Drafts"
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ struct HomeView: View {
|
|||||||
PinnedDocumentCard(document: document)
|
PinnedDocumentCard(document: document)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ final class HomeViewModel {
|
|||||||
private(set) var popular: [OutlineDocument] = []
|
private(set) var popular: [OutlineDocument] = []
|
||||||
private(set) var recentlyUpdated: [OutlineDocument] = []
|
private(set) var recentlyUpdated: [OutlineDocument] = []
|
||||||
private(set) var createdByMe: [OutlineDocument] = []
|
private(set) var createdByMe: [OutlineDocument] = []
|
||||||
|
private(set) var drafts: [OutlineDocument] = []
|
||||||
|
|
||||||
var isLoadingPinned = false
|
var isLoadingPinned = false
|
||||||
var isLoadingTab = false
|
var isLoadingTab = false
|
||||||
@@ -32,6 +33,7 @@ final class HomeViewModel {
|
|||||||
case .popular: popular
|
case .popular: popular
|
||||||
case .recentlyUpdated: recentlyUpdated
|
case .recentlyUpdated: recentlyUpdated
|
||||||
case .createdByMe: createdByMe
|
case .createdByMe: createdByMe
|
||||||
|
case .drafts: drafts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,16 +83,25 @@ final class HomeViewModel {
|
|||||||
/// `pins.list` only returns pin records, not the documents themselves —
|
/// `pins.list` only returns pin records, not the documents themselves —
|
||||||
/// fetches each pinned document individually. Pins are a small curated
|
/// fetches each pinned document individually. Pins are a small curated
|
||||||
/// set (unlike a full collection tree), so the N+1 here is acceptable
|
/// 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] {
|
private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
|
||||||
let pins = try await apiClient.listPins(ListPinsRequest(collectionId: nil))
|
let pins = try await RetryPolicy.withRetry { try await apiClient.listPins(ListPinsRequest(collectionId: nil)) }
|
||||||
var documents: [OutlineDocument] = []
|
let client = apiClient
|
||||||
for pin in pins {
|
let documentsByID: [String: OutlineDocument] = await withTaskGroup(of: (String, OutlineDocument?).self) { group in
|
||||||
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
|
for pin in pins {
|
||||||
documents.append(document)
|
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] {
|
private func fetch(tab: HomeTab) async throws -> [OutlineDocument] {
|
||||||
@@ -115,6 +126,8 @@ final class HomeViewModel {
|
|||||||
return try await apiClient.documentsList(
|
return try await apiClient.documentsList(
|
||||||
DocumentsListRequest(userId: userId, sort: "createdAt", direction: "DESC", limit: 25)
|
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 .popular: popular = documents
|
||||||
case .recentlyUpdated: recentlyUpdated = documents
|
case .recentlyUpdated: recentlyUpdated = documents
|
||||||
case .createdByMe: createdByMe = documents
|
case .createdByMe: createdByMe = documents
|
||||||
|
case .drafts: drafts = documents
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveCurrentUserID() async throws -> String {
|
private func resolveCurrentUserID() async throws -> String {
|
||||||
if let currentUserID { return currentUserID }
|
if let currentUserID { return currentUserID }
|
||||||
let user = try await apiClient.currentUser()
|
let user = try await RetryPolicy.withRetry { try await apiClient.currentUser() }
|
||||||
currentUserID = user.id
|
currentUserID = user.id
|
||||||
return user.id
|
return user.id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,45 +2,57 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
|
||||||
/// Deliberately distinct from `DocumentCardView` — the pinned section is for
|
/// Same tall-card shape as `DocumentCardView` (the tab grids below it) so the
|
||||||
/// a quick scan of a small curated set, not browsing, so this is a dense
|
/// pinned section reads as a set of cards, not a row of tab-like chips — the
|
||||||
/// single-line row rather than a tall card, with an explicit pin glyph so
|
/// accent-filled pin badge is the one thing that marks these as pinned.
|
||||||
/// it doesn't read the same as the tab grids below it.
|
|
||||||
struct PinnedDocumentCard: View {
|
struct PinnedDocumentCard: View {
|
||||||
@Environment(StarStore.self) private var starStore
|
@Environment(StarStore.self) private var starStore
|
||||||
let document: OutlineDocument
|
let document: OutlineDocument
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 10) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
if let emoji = document.emoji {
|
HStack(spacing: 6) {
|
||||||
Text(emoji)
|
if let emoji = document.emoji {
|
||||||
.font(.title3)
|
Text(emoji)
|
||||||
} else {
|
.font(.title2)
|
||||||
Image(systemName: "doc.text")
|
} else {
|
||||||
.font(.body)
|
Image(systemName: "doc.text")
|
||||||
.foregroundStyle(.secondary)
|
.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)
|
Text(document.title.isEmpty ? "Untitled" : document.title)
|
||||||
.font(.callout.weight(.medium))
|
.font(.headline)
|
||||||
.lineLimit(1)
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Text(document.updatedAt, format: .relative(presentation: .named))
|
||||||
|
.font(.caption)
|
||||||
if starStore.isStarred(documentId: document.id) {
|
|
||||||
Image(systemName: "star.fill")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.yellow)
|
|
||||||
}
|
|
||||||
|
|
||||||
Image(systemName: "pin.fill")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(14)
|
||||||
.padding(.vertical, 9)
|
.frame(maxWidth: .infinity, minHeight: 96, alignment: .topLeading)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8))
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ struct GlobalSearchResultsView: View {
|
|||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-16
@@ -18,8 +18,8 @@ struct OutpostApp: App {
|
|||||||
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@Environment(\.openWindow) private var openWindow
|
|
||||||
@State private var isShowingLogoutConfirmation = false
|
@State private var isShowingLogoutConfirmation = false
|
||||||
|
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
@@ -34,13 +34,15 @@ struct OutpostApp: App {
|
|||||||
.onAppear { applyMacAppearance() }
|
.onAppear { applyMacAppearance() }
|
||||||
.onChange(of: appearance) { _, _ in applyMacAppearance() }
|
.onChange(of: appearance) { _, _ in applyMacAppearance() }
|
||||||
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
|
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
|
||||||
|
.background(TransparentTitlebarWindowAccessor())
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.commands {
|
.commands {
|
||||||
CommandGroup(replacing: .appInfo) {
|
CommandGroup(replacing: .appInfo) {
|
||||||
Button("About Outpost") {
|
Button("About Outpost") {
|
||||||
openWindow(id: "about")
|
navigation.selectedSettingsSection = .about
|
||||||
|
navigation.isShowingSettings = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// No `Settings {}` scene anymore — Settings renders inside the
|
// No `Settings {}` scene anymore — Settings renders inside the
|
||||||
@@ -60,22 +62,19 @@ struct OutpostApp: App {
|
|||||||
}
|
}
|
||||||
.disabled(!session.isSignedIn)
|
.disabled(!session.isSignedIn)
|
||||||
}
|
}
|
||||||
|
// Settings → Editor → Command Palette gates this — disabled
|
||||||
|
// (not just a no-op) when the user's turned it off, matching
|
||||||
|
// how Settings…/Log Out already disable rather than silently
|
||||||
|
// do nothing.
|
||||||
|
CommandGroup(after: .newItem) {
|
||||||
|
Button("Command Palette…") {
|
||||||
|
navigation.isShowingCommandPalette = true
|
||||||
|
}
|
||||||
|
.keyboardShortcut("k")
|
||||||
|
.disabled(!session.isSignedIn || !isCommandPaletteEnabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
Window("About Outpost", id: "about") {
|
|
||||||
AboutView()
|
|
||||||
.disablesFullScreen()
|
|
||||||
}
|
|
||||||
.windowResizability(.contentSize)
|
|
||||||
|
|
||||||
Window("Keyboard Shortcuts", id: "keyboard-shortcuts") {
|
|
||||||
KeyboardShortcutsView()
|
|
||||||
.disablesFullScreen()
|
|
||||||
}
|
|
||||||
.windowResizability(.contentSize)
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -95,3 +94,27 @@ struct OutpostApp: App {
|
|||||||
}
|
}
|
||||||
#endif
|
#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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
|
|||||||
case general
|
case general
|
||||||
case account
|
case account
|
||||||
case workspace
|
case workspace
|
||||||
case integrationsInstallation
|
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
@@ -17,23 +16,24 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
|
|||||||
case .general: return "Outpost"
|
case .general: return "Outpost"
|
||||||
case .account: return "Account"
|
case .account: return "Account"
|
||||||
case .workspace: return "Workspace"
|
case .workspace: return "Workspace"
|
||||||
case .integrationsInstallation: return "Integrations & Installation"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One entry in the Settings sidebar. Mirrors Outline's own settings
|
/// One entry in the Settings sidebar. Mirrors Outline's own settings
|
||||||
/// categories (Account/Workspace/Integrations & Installation) so this app's
|
/// categories (Account/Workspace) so this app's settings read as a native
|
||||||
/// settings read as a native counterpart to the web app's, plus a `general`
|
/// counterpart to the web app's, plus a `general` group for things that are
|
||||||
/// group for things that are ours and don't map onto Outline's structure
|
/// ours and don't map onto Outline's structure (offline/sync, advanced,
|
||||||
/// (offline/sync, advanced, about, appearance).
|
/// 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/Integrations cases are navigation-only for
|
/// Most of the Account/Workspace cases are navigation-only for now —
|
||||||
/// now — `SettingsView` renders a "Coming Soon" placeholder for anything not
|
/// `SettingsView` renders a "Coming Soon" placeholder for anything not
|
||||||
/// explicitly built yet. Content lands section by section.
|
/// explicitly built yet. Content lands section by section.
|
||||||
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||||
// General (ours)
|
// General (ours)
|
||||||
case appearance, offlineSync, advanced, about
|
case appearance, editor, navigation, offlineSync, advanced, about
|
||||||
|
|
||||||
// Account
|
// Account
|
||||||
case profile, preferences, notifications, passkeys, apiAccess
|
case profile, preferences, notifications, passkeys, apiAccess
|
||||||
@@ -41,27 +41,24 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
// Workspace
|
// Workspace
|
||||||
case details, authentication, security, ai, members, groups, templates, emojis, applications, shared, links, webhooks, importData, exportData
|
case details, authentication, security, ai, members, groups, templates, emojis, applications, shared, links, webhooks, importData, exportData
|
||||||
|
|
||||||
// Integrations & Installation
|
|
||||||
case installation
|
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
var category: SettingsCategory {
|
var category: SettingsCategory {
|
||||||
switch self {
|
switch self {
|
||||||
case .appearance, .offlineSync, .advanced, .about:
|
case .appearance, .editor, .navigation, .offlineSync, .advanced, .about:
|
||||||
return .general
|
return .general
|
||||||
case .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
case .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
||||||
return .account
|
return .account
|
||||||
case .details, .authentication, .security, .ai, .members, .groups, .templates, .emojis, .applications, .shared, .links, .webhooks, .importData, .exportData:
|
case .details, .authentication, .security, .ai, .members, .groups, .templates, .emojis, .applications, .shared, .links, .webhooks, .importData, .exportData:
|
||||||
return .workspace
|
return .workspace
|
||||||
case .installation:
|
|
||||||
return .integrationsInstallation
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .appearance: return "Appearance"
|
case .appearance: return "Appearance"
|
||||||
|
case .editor: return "Editor"
|
||||||
|
case .navigation: return "Navigation"
|
||||||
case .offlineSync: return "Offline & Sync"
|
case .offlineSync: return "Offline & Sync"
|
||||||
case .advanced: return "Advanced"
|
case .advanced: return "Advanced"
|
||||||
case .about: return "About"
|
case .about: return "About"
|
||||||
@@ -84,13 +81,14 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
case .webhooks: return "Webhooks"
|
case .webhooks: return "Webhooks"
|
||||||
case .importData: return "Import"
|
case .importData: return "Import"
|
||||||
case .exportData: return "Export"
|
case .exportData: return "Export"
|
||||||
case .installation: return "Installation"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var icon: String {
|
var icon: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .appearance: return "paintbrush"
|
case .appearance: return "paintbrush"
|
||||||
|
case .editor: return "square.split.2x1"
|
||||||
|
case .navigation: return "command"
|
||||||
case .offlineSync: return "arrow.triangle.2.circlepath"
|
case .offlineSync: return "arrow.triangle.2.circlepath"
|
||||||
case .advanced: return "wrench.and.screwdriver"
|
case .advanced: return "wrench.and.screwdriver"
|
||||||
case .about: return "info.circle"
|
case .about: return "info.circle"
|
||||||
@@ -113,16 +111,15 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
case .webhooks: return "bolt.horizontal"
|
case .webhooks: return "bolt.horizontal"
|
||||||
case .importData: return "square.and.arrow.down"
|
case .importData: return "square.and.arrow.down"
|
||||||
case .exportData: return "square.and.arrow.up"
|
case .exportData: return "square.and.arrow.up"
|
||||||
case .installation: return "shippingbox"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything actually built so far — everything else in Account/
|
/// Everything actually built so far — everything else in Account/
|
||||||
/// Workspace/Integrations & Installation renders a "Coming Soon"
|
/// Workspace renders a "Coming Soon" placeholder until its content is
|
||||||
/// placeholder until its content is specified and built.
|
/// specified and built.
|
||||||
var isImplemented: Bool {
|
var isImplemented: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .appearance, .offlineSync, .advanced, .about, .profile:
|
case .appearance, .editor, .navigation, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -141,4 +138,6 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
final class AppNavigation {
|
final class AppNavigation {
|
||||||
var isShowingSettings = false
|
var isShowingSettings = false
|
||||||
var selectedSettingsSection: SettingsSection? = .appearance
|
var selectedSettingsSection: SettingsSection? = .appearance
|
||||||
|
/// ⌘K, see `OutpostApp`'s `CommandGroup` and `CommandPaletteView`.
|
||||||
|
var isShowingCommandPalette = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
struct RootView: View {
|
||||||
@Environment(SessionStore.self) private var session
|
@Environment(SessionStore.self) private var session
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
@State private var welcomeName: String?
|
@State private var welcomeName: String?
|
||||||
@State private var starStore = StarStore()
|
@State private var starStore = StarStore()
|
||||||
|
@State private var failureCenter = APIFailureCenter()
|
||||||
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
|
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
|
||||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||||
|
|
||||||
@@ -34,10 +36,39 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.environment(starStore)
|
.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)
|
.animation(.easeInOut(duration: 0.45), value: welcomeName != nil)
|
||||||
.task {
|
.task {
|
||||||
await session.refreshTeamInfoIfNeeded()
|
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) {
|
.task(id: session.isSignedIn) {
|
||||||
if session.isSignedIn, let apiClient = session.apiClient {
|
if session.isSignedIn, let apiClient = session.apiClient {
|
||||||
await starStore.load(apiClient: 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) {
|
private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) {
|
||||||
welcomeName = result.user.name
|
welcomeName = result.user.name
|
||||||
session.signIn(serverURL: result.serverURL, user: result.user, team: result.team)
|
session.signIn(serverURL: result.serverURL, user: result.user, team: result.team)
|
||||||
|
|||||||
@@ -6,8 +6,16 @@ import OutlineKit
|
|||||||
@Observable
|
@Observable
|
||||||
final class SessionStore {
|
final class SessionStore {
|
||||||
private static let serverURLDefaultsKey = "outline.serverURL"
|
private static let serverURLDefaultsKey = "outline.serverURL"
|
||||||
|
/// Preferences now drive real editor behavior (separate editing, etc.),
|
||||||
|
/// not just a settings screen — they need to survive a cold launch with
|
||||||
|
/// no network, not just live in memory from the last successful fetch.
|
||||||
|
/// Still read-only while offline (Settings already gates every toggle
|
||||||
|
/// on `isEffectivelyOnline`) — this only makes the *last known* values
|
||||||
|
/// available, never lets them be changed without a server round-trip.
|
||||||
|
private static let userPreferencesDefaultsKey = "outline.userPreferences"
|
||||||
|
|
||||||
private let tokenStore: TokenStoring
|
private let tokenStore: TokenStoring
|
||||||
|
private let cacheEncryptionKeyStore: CacheEncryptionKeyStoring
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
var isSignedIn: Bool
|
var isSignedIn: Bool
|
||||||
@@ -15,6 +23,9 @@ final class SessionStore {
|
|||||||
var userName: String?
|
var userName: String?
|
||||||
var userEmail: String?
|
var userEmail: String?
|
||||||
var userAvatarURL: URL?
|
var userAvatarURL: URL?
|
||||||
|
var userLanguage: String?
|
||||||
|
var userPreferences: OutlineUserPreferences?
|
||||||
|
var userNotificationSettings: [String: Bool]?
|
||||||
var teamName: String?
|
var teamName: String?
|
||||||
var teamAvatarURL: URL?
|
var teamAvatarURL: URL?
|
||||||
private(set) var apiClient: OutlineAPIClient?
|
private(set) var apiClient: OutlineAPIClient?
|
||||||
@@ -33,27 +44,78 @@ final class SessionStore {
|
|||||||
defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
|
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.tokenStore = tokenStore
|
||||||
|
self.cacheEncryptionKeyStore = cacheEncryptionKeyStore
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
self.isSignedIn = (try? tokenStore.token()) != nil
|
|
||||||
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
|
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
|
||||||
|
|
||||||
if isSignedIn, let serverURL {
|
let hasToken = (try? tokenStore.token()) != nil
|
||||||
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore)
|
let storedServerURL = defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
|
||||||
|
|
||||||
|
if hasToken, let storedServerURL {
|
||||||
|
isSignedIn = true
|
||||||
|
(apiClient, cachingClient) = Self.makeAPIClient(
|
||||||
|
serverURL: storedServerURL,
|
||||||
|
tokenStore: tokenStore,
|
||||||
|
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) {
|
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
|
||||||
defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey)
|
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)
|
apply(user: user, team: team, serverURL: serverURL)
|
||||||
isSignedIn = true
|
isSignedIn = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `static` (not an instance method) so `init` can call it before every
|
||||||
|
/// stored property has a value — same reason `makeAPIClient` is static.
|
||||||
|
private static func loadCachedPreferences(defaults: UserDefaults) -> OutlineUserPreferences? {
|
||||||
|
guard let data = defaults.data(forKey: userPreferencesDefaultsKey) else { return nil }
|
||||||
|
return try? JSONDecoder().decode(OutlineUserPreferences.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `nil` clears the cache instead of writing a `null` — happens whenever
|
||||||
|
/// a fresh fetch legitimately comes back with no preferences set, so a
|
||||||
|
/// stale cached value from a previous account/state can't linger.
|
||||||
|
private func cachePreferences(_ preferences: OutlineUserPreferences?) {
|
||||||
|
guard let preferences, let data = try? JSONEncoder().encode(preferences) else {
|
||||||
|
defaults.removeObject(forKey: Self.userPreferencesDefaultsKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defaults.set(data, forKey: Self.userPreferencesDefaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
private static func makeAPIClient(
|
private static func makeAPIClient(
|
||||||
serverURL: URL,
|
serverURL: URL,
|
||||||
tokenStore: TokenStoring,
|
tokenStore: TokenStoring,
|
||||||
|
cacheEncryptionKeyStore: CacheEncryptionKeyStoring,
|
||||||
cache: OfflineCacheStore?
|
cache: OfflineCacheStore?
|
||||||
) -> (OutlineAPIClient, CachingOutlineAPIClient?) {
|
) -> (OutlineAPIClient, CachingOutlineAPIClient?) {
|
||||||
let live = LiveOutlineAPIClient(
|
let live = LiveOutlineAPIClient(
|
||||||
@@ -61,29 +123,47 @@ final class SessionStore {
|
|||||||
tokenStore: tokenStore
|
tokenStore: tokenStore
|
||||||
)
|
)
|
||||||
guard let cache else { return (live, nil) }
|
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)
|
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()
|
try? tokenStore.clear()
|
||||||
|
await cachingClient?.clearEverythingForSignOut()
|
||||||
|
try? cacheEncryptionKeyStore.clear()
|
||||||
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
|
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
|
||||||
isSignedIn = false
|
isSignedIn = false
|
||||||
userId = nil
|
userId = nil
|
||||||
userName = nil
|
userName = nil
|
||||||
userEmail = nil
|
userEmail = nil
|
||||||
userAvatarURL = nil
|
userAvatarURL = nil
|
||||||
|
userLanguage = nil
|
||||||
|
userPreferences = nil
|
||||||
|
userNotificationSettings = nil
|
||||||
teamName = nil
|
teamName = nil
|
||||||
teamAvatarURL = nil
|
teamAvatarURL = nil
|
||||||
apiClient = nil
|
apiClient = nil
|
||||||
cachingClient = nil
|
cachingClient = nil
|
||||||
|
defaults.removeObject(forKey: Self.userPreferencesDefaultsKey)
|
||||||
|
// Same key `ContentView_macOS` persists "Remember previous
|
||||||
|
// location" under — cleared here too so switching accounts/servers
|
||||||
|
// can't restore a stale location that belongs to a different sign-in.
|
||||||
|
defaults.removeObject(forKey: "outline.lastLocation")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-fetches user/workspace name/logo on relaunch, when the token survived but this
|
/// Re-fetches user/workspace name/logo on relaunch, when the token survived but this
|
||||||
/// in-memory state didn't.
|
/// in-memory state didn't.
|
||||||
func refreshTeamInfoIfNeeded() async {
|
func refreshTeamInfoIfNeeded() async {
|
||||||
guard isSignedIn, teamName == nil, let apiClient, let serverURL else { return }
|
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)
|
apply(user: auth.user, team: auth.team, serverURL: serverURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +175,10 @@ final class SessionStore {
|
|||||||
userId = user.id
|
userId = user.id
|
||||||
userName = user.name
|
userName = user.name
|
||||||
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||||
|
userLanguage = user.language
|
||||||
|
userPreferences = user.preferences
|
||||||
|
cachePreferences(user.preferences)
|
||||||
|
userNotificationSettings = user.notificationSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) {
|
private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) {
|
||||||
@@ -105,6 +189,10 @@ final class SessionStore {
|
|||||||
// (e.g. `/api/files.get?key=...`) for avatarUrl — resolve against the
|
// (e.g. `/api/files.get?key=...`) for avatarUrl — resolve against the
|
||||||
// configured server so relative paths don't fail as "unsupported URL".
|
// configured server so relative paths don't fail as "unsupported URL".
|
||||||
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||||
|
userLanguage = user.language
|
||||||
|
userPreferences = user.preferences
|
||||||
|
cachePreferences(user.preferences)
|
||||||
|
userNotificationSettings = user.notificationSettings
|
||||||
teamName = team.name
|
teamName = team.name
|
||||||
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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 {
|
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
|
documentStars = Dictionary(uniqueKeysWithValues: stars.compactMap { star in
|
||||||
star.documentId.map { ($0, star) }
|
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
|
titleVisibility: .visible
|
||||||
) {
|
) {
|
||||||
Button("Log Out", role: .destructive) {
|
Button("Log Out", role: .destructive) {
|
||||||
session.signOut()
|
Task { await session.signOut() }
|
||||||
}
|
}
|
||||||
Button("Cancel", role: .cancel) {}
|
Button("Cancel", role: .cancel) {}
|
||||||
} message: {
|
} 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.
|
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
|
## Why
|
||||||
|
|
||||||
Outline's web app is great, but there's no native Apple client with full editing parity. This project connects to a self-hosted Outline instance over its REST API and realtime collaboration socket to provide a proper native experience across the Apple ecosystem.
|
Outline's web app is great, but there's no native Apple client with full editing parity. This project connects to a self-hosted Outline instance over its REST API and realtime collaboration socket to provide a proper native experience across the Apple ecosystem.
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Early development. See `CLAUDE.md` for the current architecture and phased build plan.
|
|
||||||
|
|
||||||
- [ ] Phase 1 — Auth, browse, search, REST-only editing
|
|
||||||
- [ ] Phase 2 — Realtime collaborative editing (Yjs/Hocuspocus)
|
|
||||||
- [ ] Phase 3 — Offline cache, tables, embeds, comments, macOS polish
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Xcode 16+
|
- Xcode 27+ (currently developed against an Xcode 27 beta — this is a hard minimum, not a suggestion)
|
||||||
- iOS 17+ / iPadOS 17+ / macOS 14+
|
- macOS 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
|
- A self-hosted (or hosted) Outline instance with API access
|
||||||
|
|
||||||
> They will be updated. These are old requirements.
|
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. Clone the repo and open the `.xcodeproj` in Xcode.
|
1. Clone the repo and open the `.xcodeproj` in Xcode.
|
||||||
@@ -40,7 +42,23 @@ Parts of this codebase are AI-assisted (built with the help of AI coding tools).
|
|||||||
|
|
||||||
## License
|
## 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
|
## Not affiliated with Outline
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -18,9 +18,9 @@ within 14 days depending on severity.
|
|||||||
|
|
||||||
## Supported Versions
|
## Supported Versions
|
||||||
|
|
||||||
Outpost is in early alpha (`0.0.x`) — there's no stable release line
|
Outpost is early (`0.1.x`) — there's no stable release line yet. Only
|
||||||
yet. Only the most recent tagged release receives fixes; please make
|
the most recent tagged release receives fixes; please make sure
|
||||||
sure you're on the latest alpha before reporting.
|
you're on the latest release before reporting.
|
||||||
|
|
||||||
| Version | Supported |
|
| 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 */;
|
||||||
|
}
|
||||||
Generated
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1500"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "1A2B3C0000000000000001EE"
|
||||||
|
BuildableName = "MarkdownEngineDemo.app"
|
||||||
|
BlueprintName = "MarkdownEngineDemo"
|
||||||
|
ReferencedContainer = "container:MarkdownEngineDemo.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<Testables>
|
||||||
|
</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">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "1A2B3C0000000000000001EE"
|
||||||
|
BuildableName = "MarkdownEngineDemo.app"
|
||||||
|
BlueprintName = "MarkdownEngineDemo"
|
||||||
|
ReferencedContainer = "container:MarkdownEngineDemo.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "1A2B3C0000000000000001EE"
|
||||||
|
BuildableName = "MarkdownEngineDemo.app"
|
||||||
|
BlueprintName = "MarkdownEngineDemo"
|
||||||
|
ReferencedContainer = "container:MarkdownEngineDemo.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
//
|
||||||
|
// ContentView.swift
|
||||||
|
// MarkdownEngine
|
||||||
|
//
|
||||||
|
// Created by Nicolas von Mallinckrodt on 29.04.26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import MarkdownEngine
|
||||||
|
|
||||||
|
// Optional bridge products. Each is independent — drop either of these
|
||||||
|
// `#if` blocks (or remove the matching Swift Package product dependency
|
||||||
|
// from the Xcode project) and the demo still compiles. Code blocks fall
|
||||||
|
// back to plain monospace; LaTeX falls back to its raw `$…$` source.
|
||||||
|
#if canImport(MarkdownEngineCodeBlocks)
|
||||||
|
import MarkdownEngineCodeBlocks
|
||||||
|
#endif
|
||||||
|
#if canImport(MarkdownEngineLatex)
|
||||||
|
import MarkdownEngineLatex
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@State private var text: String = sampleMarkdown
|
||||||
|
|
||||||
|
// Engine modes, flipped live from the toolbar.
|
||||||
|
@State private var isReadOnly = false
|
||||||
|
@State private var showRawSource = false
|
||||||
|
@State private var useReadingColumn = false
|
||||||
|
|
||||||
|
// Base font size; all relative sizing (headings, code, math) tracks it.
|
||||||
|
@State private var fontSize: CGFloat = 16
|
||||||
|
|
||||||
|
// Scroll-away header demo.
|
||||||
|
@State private var showHeader = false
|
||||||
|
@State private var headerExpanded = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NativeTextViewWrapper(
|
||||||
|
text: $text,
|
||||||
|
configuration: configuration,
|
||||||
|
fontSize: fontSize,
|
||||||
|
isEditable: !isReadOnly,
|
||||||
|
placeholder: NSAttributedString(
|
||||||
|
string: "Empty document — start typing, markdown styles live…",
|
||||||
|
attributes: [
|
||||||
|
.font: NSFont.systemFont(ofSize: fontSize),
|
||||||
|
.foregroundColor: NSColor.secondaryLabelColor,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
header: showHeader ? AnyView(demoHeader) : nil,
|
||||||
|
headerCollapsedHeight: 40,
|
||||||
|
headerExpanded: headerExpanded
|
||||||
|
)
|
||||||
|
// `readingWidth` is applied when the underlying NSView is built, so
|
||||||
|
// flipping the reading column recreates the editor via `.id`. The
|
||||||
|
// `text` binding survives; scroll position resets — fine for a demo.
|
||||||
|
.id(useReadingColumn)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItemGroup {
|
||||||
|
Toggle(isOn: $isReadOnly) {
|
||||||
|
Label("Read-only", systemImage: isReadOnly ? "lock" : "lock.open")
|
||||||
|
}
|
||||||
|
.help("Read-only: the styled document stays scrollable and selectable, editing is off")
|
||||||
|
|
||||||
|
Toggle(isOn: $showRawSource) {
|
||||||
|
Label("Raw source", systemImage: "chevron.left.forwardslash.chevron.right")
|
||||||
|
}
|
||||||
|
.help("Raw markdown source: no styling, no syntax hiding")
|
||||||
|
|
||||||
|
Toggle(isOn: $useReadingColumn) {
|
||||||
|
Label("Reading column", systemImage: "arrow.right.and.line.vertical.and.arrow.left")
|
||||||
|
}
|
||||||
|
.help("Centered fixed-width reading column — wide tables still break out to full width")
|
||||||
|
|
||||||
|
ControlGroup {
|
||||||
|
Button {
|
||||||
|
fontSize = max(10, fontSize - 2)
|
||||||
|
} label: {
|
||||||
|
Label("Smaller text", systemImage: "textformat.size.smaller")
|
||||||
|
}
|
||||||
|
.disabled(fontSize <= 10)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
fontSize = min(28, fontSize + 2)
|
||||||
|
} label: {
|
||||||
|
Label("Larger text", systemImage: "textformat.size.larger")
|
||||||
|
}
|
||||||
|
.disabled(fontSize >= 28)
|
||||||
|
}
|
||||||
|
.help("Base font size — headings, code, and math scale relative to it")
|
||||||
|
|
||||||
|
Menu {
|
||||||
|
Toggle("Show header", isOn: $showHeader)
|
||||||
|
Toggle("Expanded", isOn: $headerExpanded)
|
||||||
|
.disabled(!showHeader)
|
||||||
|
} label: {
|
||||||
|
Label("Header", systemImage: "rectangle.topthird.inset.filled")
|
||||||
|
}
|
||||||
|
.help("Scroll-away header: an embedder-supplied SwiftUI view hosted above the document")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sample scroll-away header: a fixed top row (kept visible when collapsed)
|
||||||
|
/// plus detail rows that reveal/hide with the `headerExpanded` toggle.
|
||||||
|
private var demoHeader: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text("Scroll-away header").font(.headline)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(height: 40) // == headerCollapsedHeight: the always-visible row
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("These rows clip away when the header collapses.")
|
||||||
|
Text("The header scrolls with the document body and stays fully interactive.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.font(.callout)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine talks to your app through service protocols. Two of them —
|
||||||
|
/// `SyntaxHighlighter` and `LatexRenderer` — render the code-block and
|
||||||
|
/// LaTeX visuals. The base `MarkdownEngine` ships no-op defaults
|
||||||
|
/// (plain monospace, raw `$…$`); the optional `MarkdownEngineCodeBlocks`
|
||||||
|
/// and `MarkdownEngineLatex` products ship ready-made bridges backed by
|
||||||
|
/// HighlighterSwift and SwiftMath respectively.
|
||||||
|
///
|
||||||
|
/// This demo opportunistically plugs in whichever bridges are linked,
|
||||||
|
/// so you can see exactly what each one adds.
|
||||||
|
private var configuration: MarkdownEditorConfiguration {
|
||||||
|
var config = MarkdownEditorConfiguration.default
|
||||||
|
|
||||||
|
#if canImport(MarkdownEngineCodeBlocks)
|
||||||
|
// Syntax highlighting for fenced code blocks. Auto-switches between
|
||||||
|
// `atom-one-light` and `atom-one-dark` with system appearance.
|
||||||
|
config.services.syntaxHighlighter = HighlighterSwiftBridge()
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if canImport(MarkdownEngineLatex)
|
||||||
|
// LaTeX rendering for `$inline$` and `$$block$$` math. Uses the
|
||||||
|
// Latin Modern math font and tints formulas to match the theme.
|
||||||
|
config.services.latex = SwiftMathBridge()
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Opt-in constructs beyond pure markdown. The core engine no longer
|
||||||
|
// knows `==highlight==` or `~~strikethrough~~` — they are extensions
|
||||||
|
// you register. Unregistered syntax stays literal text.
|
||||||
|
config.extensions = [HighlightExtension(), StrikethroughExtension()]
|
||||||
|
|
||||||
|
// Toolbar-driven modes.
|
||||||
|
config.rawSourceMode = showRawSource
|
||||||
|
config.readingWidth = useReadingColumn ? 620 : nil
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the demo markdown shown when the editor first loads.
|
||||||
|
///
|
||||||
|
/// The text is composed from a fixed header/footer plus feature sections.
|
||||||
|
/// Three of them — inline formatting, block math, and code — swap between
|
||||||
|
/// a full showcase and a short "feature unavailable" note depending on
|
||||||
|
/// which optional bridge products are linked.
|
||||||
|
///
|
||||||
|
/// When a bridge is missing, the fallback links to the README section
|
||||||
|
/// that explains how to enable that feature in your own app.
|
||||||
|
private var sampleMarkdown: String {
|
||||||
|
[
|
||||||
|
markdownHeader,
|
||||||
|
inlineFormattingSection,
|
||||||
|
blocksSection,
|
||||||
|
taskListSection,
|
||||||
|
extensionSection,
|
||||||
|
tableSection,
|
||||||
|
latexSection,
|
||||||
|
codeSection,
|
||||||
|
markdownFooter,
|
||||||
|
].joined(separator: "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blockquote + list demo: quotes keep inline styling; lists auto-continue
|
||||||
|
/// on Return, renumber, and change nesting with Tab / Shift-Tab.
|
||||||
|
private let blocksSection = """
|
||||||
|
## Blockquotes & lists
|
||||||
|
|
||||||
|
> Blockquotes keep full **inline** styling — and quote markers hide like every other marker.
|
||||||
|
|
||||||
|
Lists auto-continue on Return; Tab and Shift-Tab move the nesting level:
|
||||||
|
|
||||||
|
- Unordered lists
|
||||||
|
- nest two spaces per level
|
||||||
|
- up to three levels deep
|
||||||
|
|
||||||
|
1. Ordered lists renumber as you edit
|
||||||
|
2. and auto-continue too
|
||||||
|
"""
|
||||||
|
|
||||||
|
/// Task-list demo: click a checkbox to toggle it. The glyphs are SF Symbols;
|
||||||
|
/// embedders can swap them via `TaskCheckboxStyle` (`config.taskCheckbox`).
|
||||||
|
private let taskListSection = """
|
||||||
|
## Task lists
|
||||||
|
|
||||||
|
- [x] Draw checkboxes as SF Symbols
|
||||||
|
- [ ] Click a box to toggle it
|
||||||
|
- [ ] Ship it
|
||||||
|
"""
|
||||||
|
|
||||||
|
/// Extension seam demo: `==highlight==` and `~~strikethrough~~` are NOT part
|
||||||
|
/// of the core grammar anymore — they're supplied by the opt-in
|
||||||
|
/// `HighlightExtension` and `StrikethroughExtension` registered above.
|
||||||
|
private let extensionSection = """
|
||||||
|
## Extensions
|
||||||
|
|
||||||
|
The engine core parses pure markdown; extra constructs are opt-in extensions. \
|
||||||
|
This ==highlighted text== comes from `HighlightExtension`, and this \
|
||||||
|
~~struck-through text~~ from `StrikethroughExtension`. Unregistered, the exact \
|
||||||
|
same characters would stay literal markdown. Nesting works too: \
|
||||||
|
==with *italic* inside== and ~~also *nested*~~.
|
||||||
|
"""
|
||||||
|
|
||||||
|
/// Table layout demo: the first table's cells WRAP to the available width
|
||||||
|
/// (CSS auto-layout style); the second has so many columns that even the
|
||||||
|
/// longest-word minimums don't fit — it stays wide and scrolls horizontally.
|
||||||
|
private let tableSection = """
|
||||||
|
## Tables
|
||||||
|
|
||||||
|
Cells wrap to the available width:
|
||||||
|
|
||||||
|
| Novel | Opening line |
|
||||||
|
|---|---|
|
||||||
|
| Der Zauberberg (1924) | "Ein einfacher junger Mensch reiste im Hochsommer von Hamburg, seiner Vaterstadt, nach Davos-Platz im Graubündischen." |
|
||||||
|
| The Master and Margarita (1966–67) | "At the sunset hour of one warm spring day two men were to be seen at Patriarch's Ponds." (trans. Michael Glenny) |
|
||||||
|
| The Picture of Dorian Gray (1890) | "The studio was filled with the rich odour of roses, and when the light summer wind stirred amidst the trees of the garden, there came through the open door the heavy scent of the lilac, or the more delicate perfume of the pink-flowering thorn." |
|
||||||
|
|
||||||
|
Too many columns → horizontal scroll instead of crushed cells:
|
||||||
|
|
||||||
|
| Movement | Landmark novel | Narrative signature | Characteristic preoccupations | Philosophical undercurrents | Contemporaneous reception | Posthumous reputation | Author | Structural device | Central symbol | Typical setting | Enduring influence |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Modernism | Der Zauberberg | Essayistic time-dilation | Sanatorium cosmopolitanism | Schopenhauer-inflected pessimism | Immediate bestseller | Cornerstone of literary modernism | Thomas Mann | Bildungsroman inversion | The mountain as timeless enclosure | Alpine sanatorium | Shaped the European novel of ideas |
|
||||||
|
| Menippean satire | The Master and Margarita | Novel-within-a-novel | Cowardice and censorship | Faustian epigraph | Suppressed, samizdat-circulated | Perennial Russian favorite | Mikhail Bulgakov | Interleaved dual narratives | The devil as satirical mirror | Soviet Moscow and biblical Jerusalem | Model for satire under censorship |
|
||||||
|
| Aestheticism | The Picture of Dorian Gray | Epigrammatic wit | Portrait-as-conscience | Paterian hedonism | Scandalized reviewers | Perpetually adapted | Oscar Wilde | Portrait as moral ledger | The aging portrait | Fin de siècle London | Touchstone for art for art’s sake |
|
||||||
|
"""
|
||||||
|
|
||||||
|
private let markdownHeader = """
|
||||||
|
# MarkdownEngine
|
||||||
|
|
||||||
|
A native macOS Markdown editor built on **TextKit 2**, bridged to SwiftUI — brought to you by [nodes-web.com](https://nodes-web.com).
|
||||||
|
|
||||||
|
Edit this text live. Formatting updates as you type — and the toolbar flips engine modes at runtime: read-only, raw markdown source, and a centered reading column.
|
||||||
|
|
||||||
|
---
|
||||||
|
"""
|
||||||
|
|
||||||
|
/// Inline formatting demo. Drops the inline-LaTeX example sentence when
|
||||||
|
/// the LaTeX bridge isn't linked, so the reader doesn't see raw `$…$`.
|
||||||
|
private var inlineFormattingSection: String {
|
||||||
|
#if canImport(MarkdownEngineLatex)
|
||||||
|
return #"""
|
||||||
|
## Inline formatting
|
||||||
|
|
||||||
|
Mix **bold**, *italic*, and ***both at once***. Reach for `inline code` when a short snippet helps. Inline math fits naturally in prose — the Pythagorean identity says $a^2 + b^2 = c^2$, and Euler's identity famously claims $e^{i\pi} + 1 = 0$.
|
||||||
|
"""#
|
||||||
|
#else
|
||||||
|
return """
|
||||||
|
## Inline formatting
|
||||||
|
|
||||||
|
Mix **bold**, *italic*, and ***both at once***. Reach for `inline code` when a short snippet helps.
|
||||||
|
"""
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block LaTeX demo when the `MarkdownEngineLatex` bridge is linked;
|
||||||
|
/// otherwise a short note pointing to the README section that explains
|
||||||
|
/// how to enable LaTeX rendering.
|
||||||
|
private var latexSection: String {
|
||||||
|
#if canImport(MarkdownEngineLatex)
|
||||||
|
return #"""
|
||||||
|
## Block math
|
||||||
|
|
||||||
|
$$
|
||||||
|
\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}
|
||||||
|
$$
|
||||||
|
|
||||||
|
$$
|
||||||
|
\frac{\partial}{\partial t}\Psi(\mathbf{r}, t) = -\frac{i}{\hbar}\hat{H}\,\Psi(\mathbf{r}, t)
|
||||||
|
$$
|
||||||
|
"""#
|
||||||
|
#else
|
||||||
|
return """
|
||||||
|
## LaTeX
|
||||||
|
|
||||||
|
LaTeX (`$inline$` and `$$block$$`) is parsed but not rendered without the optional `MarkdownEngineLatex` product. See [LaTeX Rendering](https://github.com/nodes-app/swift-markdown-engine#latex-rendering) in the README to wire it up.
|
||||||
|
"""
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fenced code-block demo when the `MarkdownEngineCodeBlocks` bridge is
|
||||||
|
/// linked; otherwise a plain monospace example and a link to the
|
||||||
|
/// README's Code Blocks section.
|
||||||
|
private var codeSection: String {
|
||||||
|
#if canImport(MarkdownEngineCodeBlocks)
|
||||||
|
return #"""
|
||||||
|
## Code
|
||||||
|
|
||||||
|
Swift, with syntax highlighting:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import SwiftUI
|
||||||
|
import MarkdownEngine
|
||||||
|
|
||||||
|
struct Editor: View {
|
||||||
|
@State private var text = "# Hello"
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NativeTextViewWrapper(text: $text)
|
||||||
|
.frame(minWidth: 640, minHeight: 480)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
And a little JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"engine": "MarkdownEngine",
|
||||||
|
"features": ["latex", "code", "wiki-links"],
|
||||||
|
"version": 1.0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
"""#
|
||||||
|
#else
|
||||||
|
return #"""
|
||||||
|
## Code
|
||||||
|
|
||||||
|
Fenced code blocks render as plain monospace without the optional `MarkdownEngineCodeBlocks` product. See [Code Blocks](https://github.com/nodes-app/swift-markdown-engine#code-blocks) in the README for syntax-highlighted output:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let greeting = "Hello, world!"
|
||||||
|
```
|
||||||
|
"""#
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private let markdownFooter = """
|
||||||
|
---
|
||||||
|
|
||||||
|
"""
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="173" height="185" viewBox="0 0 173 185" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_1869_2042)">
|
||||||
|
<path d="M84.1452 185H85.3003C97.6509 185 102.36 181.114 102.36 168.928V109.41L90.8093 115.68L142.878 145.616C146.788 147.646 149.897 148.971 152.741 148.971C157.628 148.971 161.004 145.616 165.091 139.523L165.714 138.552C168.38 134.577 169.712 130.957 169.712 127.955C169.712 123.009 166.247 119.301 160.116 115.945L107.07 85.4796V98.1072L160.027 69.0549C166.247 65.7875 169.445 62.167 169.445 57.2219C169.445 54.2195 168.113 50.7756 165.891 46.8019L165.27 45.6539C161.271 39.2076 157.628 35.9403 152.83 35.9403C149.987 35.9403 146.698 37.2649 142.79 39.2959L90.8981 69.2315L102.36 75.5011V16.0716C102.36 3.88544 97.6509 0 85.3003 0H84.1452C71.7944 0 67.0852 3.88544 67.0852 16.0716V75.5011L78.814 69.2315L26.7452 39.2959C22.8356 37.0883 19.1926 35.6754 16.1715 35.6754C11.2845 35.6754 7.28607 39.031 4.17616 45.8305L3.64304 46.8019C1.68824 50.8639 0.444272 54.3961 0.444272 57.3986C0.444272 62.0787 3.37647 65.6992 9.59631 69.1431L62.6425 98.1072V85.4796L9.50741 115.945C3.37647 119.301 0 122.922 0 127.778C0 130.78 1.42167 134.401 3.73189 138.463L4.26502 139.258C7.9969 145.704 11.5511 149.06 16.6158 149.06C19.4591 149.06 22.8356 147.646 26.6564 145.616L78.814 115.68L67.0852 109.41V168.928C67.0852 181.114 71.7944 185 84.1452 185Z" fill="#E0A347"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_1869_2042">
|
||||||
|
<rect width="173" height="185" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="173" height="185" viewBox="0 0 173 185" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_1869_2051)">
|
||||||
|
<path d="M84.1452 185H85.3003C97.6509 185 102.36 181.114 102.36 168.928V109.41L90.8093 115.68L142.878 145.616C146.788 147.646 149.897 148.971 152.741 148.971C157.628 148.971 161.004 145.616 165.091 139.523L165.714 138.552C168.38 134.577 169.712 130.957 169.712 127.955C169.712 123.009 166.247 119.301 160.116 115.945L107.07 85.4796V98.1072L160.027 69.0549C166.247 65.7875 169.445 62.167 169.445 57.2219C169.445 54.2195 168.113 50.7756 165.891 46.8019L165.27 45.6539C161.271 39.2076 157.628 35.9403 152.83 35.9403C149.987 35.9403 146.698 37.2649 142.79 39.2959L90.8981 69.2315L102.36 75.5011V16.0716C102.36 3.88544 97.6509 0 85.3003 0H84.1452C71.7944 0 67.0852 3.88544 67.0852 16.0716V75.5011L78.814 69.2315L26.7452 39.2959C22.8356 37.0883 19.1926 35.6754 16.1715 35.6754C11.2845 35.6754 7.28607 39.031 4.17616 45.8305L3.64304 46.8019C1.68824 50.8639 0.444272 54.3961 0.444272 57.3986C0.444272 62.0787 3.37647 65.6992 9.59631 69.1431L62.6425 98.1072V85.4796L9.50741 115.945C3.37647 119.301 0 122.922 0 127.778C0 130.78 1.42167 134.401 3.73189 138.463L4.26502 139.258C7.9969 145.704 11.5511 149.06 16.6158 149.06C19.4591 149.06 22.8356 147.646 26.6564 145.616L78.814 115.68L67.0852 109.41V168.928C67.0852 181.114 71.7944 185 84.1452 185Z" fill="#D1403F"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_1869_2051">
|
||||||
|
<rect width="173" height="185" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="173" height="185" viewBox="0 0 173 185" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_1869_2047)">
|
||||||
|
<path d="M84.1452 185H85.3003C97.6509 185 102.36 181.114 102.36 168.928V109.41L90.8093 115.68L142.878 145.616C146.788 147.646 149.897 148.971 152.741 148.971C157.628 148.971 161.004 145.616 165.091 139.523L165.714 138.552C168.38 134.577 169.712 130.957 169.712 127.955C169.712 123.009 166.247 119.301 160.116 115.945L107.07 85.4796V98.1072L160.027 69.0549C166.247 65.7875 169.445 62.167 169.445 57.2219C169.445 54.2195 168.113 50.7756 165.891 46.8019L165.27 45.6539C161.271 39.2076 157.628 35.9403 152.83 35.9403C149.987 35.9403 146.698 37.2649 142.79 39.2959L90.8981 69.2315L102.36 75.5011V16.0716C102.36 3.88544 97.6509 0 85.3003 0H84.1452C71.7944 0 67.0852 3.88544 67.0852 16.0716V75.5011L78.814 69.2315L26.7452 39.2959C22.8356 37.0883 19.1926 35.6754 16.1715 35.6754C11.2845 35.6754 7.28607 39.031 4.17616 45.8305L3.64304 46.8019C1.68824 50.8639 0.444272 54.3961 0.444272 57.3986C0.444272 62.0787 3.37647 65.6992 9.59631 69.1431L62.6425 98.1072V85.4796L9.50741 115.945C3.37647 119.301 0 122.922 0 127.778C0 130.78 1.42167 134.401 3.73189 138.463L4.26502 139.258C7.9969 145.704 11.5511 149.06 16.6158 149.06C19.4591 149.06 22.8356 147.646 26.6564 145.616L78.814 115.68L67.0852 109.41V168.928C67.0852 181.114 71.7944 185 84.1452 185Z" fill="#056CC1"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_1869_2047">
|
||||||
|
<rect width="173" height="185" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"fill" : "automatic",
|
||||||
|
"groups" : [
|
||||||
|
{
|
||||||
|
"blend-mode" : "normal",
|
||||||
|
"blur-material" : 0.5,
|
||||||
|
"hidden" : false,
|
||||||
|
"layers" : [
|
||||||
|
{
|
||||||
|
"fill" : "automatic",
|
||||||
|
"image-name" : "staroflife.fill 2-1.svg",
|
||||||
|
"name" : "staroflife.fill 2-1",
|
||||||
|
"position" : {
|
||||||
|
"scale" : 3,
|
||||||
|
"translation-in-points" : [
|
||||||
|
0,
|
||||||
|
90
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"image-name" : "staroflife.fill 2.svg",
|
||||||
|
"name" : "staroflife.fill 2",
|
||||||
|
"position" : {
|
||||||
|
"scale" : 3,
|
||||||
|
"translation-in-points" : [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"image-name" : "staroflife.fill 1.svg",
|
||||||
|
"name" : "staroflife.fill 1",
|
||||||
|
"position" : {
|
||||||
|
"scale" : 3,
|
||||||
|
"translation-in-points" : [
|
||||||
|
0,
|
||||||
|
-90
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lighting" : "individual",
|
||||||
|
"shadow" : {
|
||||||
|
"kind" : "layer-color",
|
||||||
|
"opacity" : 0.5
|
||||||
|
},
|
||||||
|
"specular" : false,
|
||||||
|
"translucency" : {
|
||||||
|
"enabled" : true,
|
||||||
|
"value" : 0.45
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms" : {
|
||||||
|
"circles" : [
|
||||||
|
"watchOS"
|
||||||
|
],
|
||||||
|
"squares" : "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// MarkdownEngineDemoApp.swift
|
||||||
|
// MarkdownEngine
|
||||||
|
//
|
||||||
|
// Created by Nicolas von Mallinckrodt on 29.04.26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct MarkdownEngineDemoApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup("MarkdownEngine Demo") {
|
||||||
|
ContentView()
|
||||||
|
.frame(minWidth: 640, minHeight: 480)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user