Compare commits
114
Commits
@@ -69,3 +69,9 @@ Issues and PRs use two label prefixes:
|
||||
## Questions
|
||||
|
||||
Open an issue — this is a single-repo project, there's no separate issue tracker to route to.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Outpost is licensed under the [Business Source License 1.1](./LICENSE), not a traditional OSI open-source license — see the [README's License section](./README.md#license) for what that means in practice. By submitting a PR, you agree your contribution is licensed under the same terms as the rest of the project.
|
||||
|
||||
@@ -1,22 +1,123 @@
|
||||
MIT License
|
||||
Business Source License 1.1
|
||||
|
||||
Copyright (c) 2026 Puranjay Savar Mattas
|
||||
Parameters
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Licensor: Puranjay Savar Mattas
|
||||
Licensed Work: Outpost
|
||||
The Licensed Work is (c) 2026 Puranjay Savar Mattas
|
||||
Additional Use Grant: You may use, copy, modify, and self-host the Licensed
|
||||
Work, and build and distribute your own modified or
|
||||
unmodified copies of it, for personal, educational, or
|
||||
internal non-commercial purposes.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
You may not, without a separate commercial agreement
|
||||
with the Licensor:
|
||||
(a) offer the Licensed Work, or any modified or
|
||||
unmodified version of it, as a hosted or
|
||||
distributed product or service to third parties,
|
||||
whether for a fee or free of charge; or
|
||||
(b) distribute the Licensed Work, or any modified or
|
||||
unmodified version of it, under a name, logo, or
|
||||
branding that states or implies it is an official,
|
||||
endorsed, or affiliated product of Outline, Inc.
|
||||
or of any other third party, or that removes or
|
||||
obscures its origin as an independent,
|
||||
unaffiliated project.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Change Date: 2036-08-20
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
For information about alternative licensing arrangements for the Licensed
|
||||
Work, contact the Licensor.
|
||||
|
||||
Notice
|
||||
|
||||
The Business Source License (this document, or the "License") is not an
|
||||
Open Source license. However, the Licensed Work will eventually be made
|
||||
available under an Open Source License, as stated in this License.
|
||||
|
||||
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
|
||||
"Business Source License" is a trademark of MariaDB Corporation Ab.
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
|
||||
Business Source License 1.1
|
||||
|
||||
Terms
|
||||
|
||||
The Licensor hereby grants you the right to copy, modify, create derivative
|
||||
works, redistribute, and make non-production use of the Licensed Work. The
|
||||
Licensor may make an Additional Use Grant, above, permitting limited
|
||||
production use.
|
||||
|
||||
Effective on the Change Date, or the fourth anniversary of the first
|
||||
publicly available distribution of a specific version of the Licensed Work
|
||||
under this License, whichever comes first, the Licensor hereby grants you
|
||||
rights under the terms of the Change License, and the rights granted in the
|
||||
paragraph above terminate.
|
||||
|
||||
If your use of the Licensed Work does not comply with the requirements
|
||||
currently in effect as described in this License, you must purchase a
|
||||
commercial license from the Licensor, its affiliated entities, or authorized
|
||||
resellers, or you must refrain from using the Licensed Work.
|
||||
|
||||
All copies of the original and modified Licensed Work, and derivative works
|
||||
of the Licensed Work, are subject to this License. This License applies
|
||||
separately for each version of the Licensed Work and the Change Date may
|
||||
vary for each version of the Licensed Work released by Licensor.
|
||||
|
||||
You must conspicuously display this License on each original or modified
|
||||
copy of the Licensed Work. If you receive the Licensed Work in original or
|
||||
modified form from a third party, the terms and conditions set forth in
|
||||
this License apply to your use of that work.
|
||||
|
||||
Any use of the Licensed Work in violation of this License will automatically
|
||||
terminate your rights under this License for the current and all other
|
||||
versions of the Licensed Work.
|
||||
|
||||
This License does not grant you any right in any trademark or logo of
|
||||
Licensor or its affiliates (provided that you may use a trademark or logo
|
||||
of Licensor as expressly required by this License).
|
||||
|
||||
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
||||
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
||||
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
|
||||
TITLE.
|
||||
|
||||
MariaDB hereby grants you permission to use this License's text to license
|
||||
your works, and to refer to it using the trademark "Business Source
|
||||
License", as long as you comply with the Covenants of Licensor below.
|
||||
|
||||
Covenants of Licensor
|
||||
|
||||
In consideration of the right to use this License's text and the "Business
|
||||
Source License" name and trademark, Licensor covenants to MariaDB, and to
|
||||
all other recipients of the licensed work to be provided by Licensor:
|
||||
|
||||
1. To specify as the Change License the GPL Version 2.0 or any later
|
||||
version, or a license that is compatible with GPL Version 2.0 or a later
|
||||
version, where "compatible" means that software provided under the
|
||||
Change License can be included in a program with software provided
|
||||
under GPL Version 2.0 or a later version. Licensor may specify
|
||||
additional Change Licenses without limitation.
|
||||
|
||||
2. To either: (a) specify an additional grant of rights to use that does
|
||||
not impose any additional restriction on the right granted in this
|
||||
License, as the Additional Use Grant; or (b) insert the text "None" to
|
||||
specify a Change License.
|
||||
|
||||
3. To specify a Change Date.
|
||||
|
||||
4. Not to modify this License in any other way.
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
|
||||
Trademark Notice
|
||||
|
||||
"Outpost" and any associated logo are trademarks of the Licensor. This
|
||||
License does not grant permission to use them to identify or market any
|
||||
product, service, or distribution of the Licensed Work — modified or
|
||||
unmodified — that is not published by the Licensor, including forks. See
|
||||
the Additional Use Grant above.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Generic key/value row backing `OfflineCacheStore` — one table for every
|
||||
/// cached response shape instead of a `@Model` per Outline type, so adding a
|
||||
/// new cached endpoint never needs a schema migration.
|
||||
@Model
|
||||
public final class CachedPayload {
|
||||
@Attribute(.unique) public var key: String
|
||||
public var payload: Data
|
||||
public var cachedAt: Date
|
||||
|
||||
public init(key: String, payload: Data, cachedAt: Date) {
|
||||
self.key = key
|
||||
self.payload = payload
|
||||
self.cachedAt = cachedAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
import Foundation
|
||||
|
||||
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline
|
||||
/// support at the existing protocol boundary, so no view model needs to know
|
||||
/// the network exists:
|
||||
///
|
||||
/// - **Reads** (`documentInfo`, `listDocuments`, `documentsList`,
|
||||
/// `listViewedDocuments`, `listCollections`, `collectionInfo`): read-through
|
||||
/// cache. Always tries live first — never pre-checks reachability, since
|
||||
/// "did this specific request just fail" is a more honest signal than a
|
||||
/// reachability monitor, and the two can disagree (captive portals,
|
||||
/// flaky Wi-Fi). Falls back to the cache only on failure; a live success
|
||||
/// always overwrites the cache, so staleness is bounded by "last time this
|
||||
/// endpoint actually worked."
|
||||
/// - **A small set of writes** (`updateDocument`, `updateCollection`,
|
||||
/// pin/star/subscribe create+delete): applied optimistically against the
|
||||
/// cache and queued in `OfflineCacheStore`'s `PendingOperation` table on
|
||||
/// failure, then replayed in order by `flushPendingOperations()` once back
|
||||
/// online. Deliberately doesn't include anything that creates new tree
|
||||
/// structure (`createDocument`, `moveDocument`, `archiveDocument`,
|
||||
/// `deleteDocument`, `deleteCollection`, `duplicateDocument`) — those need
|
||||
/// real server-assigned ids to stay consistent with the rest of the tree,
|
||||
/// and reconciling a locally-invented id with the one the server hands
|
||||
/// back on sync is a much bigger problem than this pass takes on. Sharing,
|
||||
/// permissions, search, and export also stay live-only — read the request,
|
||||
/// they need someone else's server session, not just a network.
|
||||
/// - **Manual offline mode** (`offlineModeDefaultsKey`): when set, skips
|
||||
/// attempting `live` entirely — same fallback/queue paths as a real
|
||||
/// failure, just chosen on purpose instead of discovered.
|
||||
public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
public static let offlineModeDefaultsKey = "outpost.offlineModeEnabled"
|
||||
|
||||
private let live: OutlineAPIClient
|
||||
private let cache: OfflineCacheStore
|
||||
private let defaults: UserDefaults
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
private let keyEncoder: JSONEncoder
|
||||
|
||||
public init(live: OutlineAPIClient, cache: OfflineCacheStore, defaults: UserDefaults = .standard) {
|
||||
self.live = live
|
||||
self.cache = cache
|
||||
self.defaults = defaults
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
self.encoder = encoder
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
self.decoder = decoder
|
||||
|
||||
let keyEncoder = JSONEncoder()
|
||||
keyEncoder.outputFormatting = .sortedKeys
|
||||
self.keyEncoder = keyEncoder
|
||||
}
|
||||
|
||||
private var isManualOfflineModeEnabled: Bool {
|
||||
defaults.bool(forKey: Self.offlineModeDefaultsKey)
|
||||
}
|
||||
|
||||
// MARK: - Cached reads
|
||||
|
||||
public func documentInfo(id: String) async throws -> OutlineDocument {
|
||||
try await cachedFetch(key: "document:\(id)") { try await self.live.documentInfo(id: id) }
|
||||
}
|
||||
|
||||
public func listDocuments(
|
||||
collectionId: String?,
|
||||
parentDocumentId: String?,
|
||||
offset: Int,
|
||||
limit: Int
|
||||
) async throws -> [OutlineDocument] {
|
||||
let key = "documents:\(collectionId ?? "-"):\(parentDocumentId ?? "-"):\(offset):\(limit)"
|
||||
return try await cachedFetch(key: key) {
|
||||
try await self.live.listDocuments(
|
||||
collectionId: collectionId,
|
||||
parentDocumentId: parentDocumentId,
|
||||
offset: offset,
|
||||
limit: limit
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] {
|
||||
try await cachedFetch(key: requestKey("documentsList", request)) { try await self.live.documentsList(request) }
|
||||
}
|
||||
|
||||
public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] {
|
||||
try await cachedFetch(key: "documentsViewed:\(offset):\(limit)") {
|
||||
try await self.live.listViewedDocuments(offset: offset, limit: limit)
|
||||
}
|
||||
}
|
||||
|
||||
public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] {
|
||||
try await cachedFetch(key: "documentsDrafts:\(request.offset):\(request.limit)") {
|
||||
try await self.live.listDrafts(request)
|
||||
}
|
||||
}
|
||||
|
||||
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
|
||||
try await cachedFetch(key: "collections:\(offset):\(limit)") {
|
||||
try await self.live.listCollections(offset: offset, limit: limit)
|
||||
}
|
||||
}
|
||||
|
||||
public func collectionInfo(id: String) async throws -> OutlineCollection {
|
||||
try await cachedFetch(key: "collection:\(id)") { try await self.live.collectionInfo(id: id) }
|
||||
}
|
||||
|
||||
// MARK: - Queueable writes
|
||||
|
||||
/// The one exception to "no new tree structure while offline" —
|
||||
/// synthesizes a document under a `pending-<uuid>` id (same scheme as
|
||||
/// pin/star/subscribe), caches it so it's immediately readable/editable,
|
||||
/// and queues the real create. On sync, the server-assigned id replaces
|
||||
/// the placeholder in the cache; anything still referencing the old id
|
||||
/// directly (a currently-open reader, most likely) won't follow that
|
||||
/// rename automatically — a known, narrow limitation, not something this
|
||||
/// pass tries to solve generally.
|
||||
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
let result = try await live.createDocument(request)
|
||||
await cacheDocument(result)
|
||||
return result
|
||||
} catch {
|
||||
return await queueDocumentCreate(request)
|
||||
}
|
||||
}
|
||||
return await queueDocumentCreate(request)
|
||||
}
|
||||
|
||||
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
let result = try await live.updateDocument(request)
|
||||
await cacheDocument(result)
|
||||
return result
|
||||
} catch {
|
||||
return try await queueDocumentUpdate(request, dueTo: error)
|
||||
}
|
||||
}
|
||||
return try await queueDocumentUpdate(request, dueTo: nil)
|
||||
}
|
||||
|
||||
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
let result = try await live.updateCollection(request)
|
||||
await cacheCollection(result)
|
||||
return result
|
||||
} catch {
|
||||
return try await queueCollectionUpdate(request, dueTo: error)
|
||||
}
|
||||
}
|
||||
return try await queueCollectionUpdate(request, dueTo: nil)
|
||||
}
|
||||
|
||||
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do { return try await live.createPin(request) } catch { return await queuePinCreate(request) }
|
||||
}
|
||||
return await queuePinCreate(request)
|
||||
}
|
||||
|
||||
public func deletePin(id: String) async throws {
|
||||
if await cancelIfNeverSynced(id: id) { return }
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
try await live.deletePin(id: id)
|
||||
return
|
||||
} catch {
|
||||
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
|
||||
return
|
||||
}
|
||||
}
|
||||
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
|
||||
}
|
||||
|
||||
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do { return try await live.createSubscription(request) } catch { return await queueSubscriptionCreate(request) }
|
||||
}
|
||||
return await queueSubscriptionCreate(request)
|
||||
}
|
||||
|
||||
public func deleteSubscription(id: String) async throws {
|
||||
if await cancelIfNeverSynced(id: id) { return }
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
try await live.deleteSubscription(id: id)
|
||||
return
|
||||
} catch {
|
||||
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
|
||||
return
|
||||
}
|
||||
}
|
||||
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
|
||||
}
|
||||
|
||||
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do { return try await live.starDocument(request) } catch { return await queueStarDocumentCreate(request) }
|
||||
}
|
||||
return await queueStarDocumentCreate(request)
|
||||
}
|
||||
|
||||
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do { return try await live.starCollection(request) } catch { return await queueStarCollectionCreate(request) }
|
||||
}
|
||||
return await queueStarCollectionCreate(request)
|
||||
}
|
||||
|
||||
public func deleteStar(id: String) async throws {
|
||||
if await cancelIfNeverSynced(id: id) { return }
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
try await live.deleteStar(id: id)
|
||||
return
|
||||
} catch {
|
||||
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
|
||||
return
|
||||
}
|
||||
}
|
||||
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
|
||||
}
|
||||
|
||||
// MARK: - Pass-through
|
||||
|
||||
public func authInfo() async throws -> OutlineAuthInfo {
|
||||
try await live.authInfo()
|
||||
}
|
||||
|
||||
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
|
||||
try await live.searchDocuments(request)
|
||||
}
|
||||
|
||||
public func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] {
|
||||
try await live.searchDocumentTitles(request)
|
||||
}
|
||||
|
||||
public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate {
|
||||
try await live.templatizeDocument(request)
|
||||
}
|
||||
|
||||
public func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] {
|
||||
try await live.duplicateDocument(request)
|
||||
}
|
||||
|
||||
public func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument {
|
||||
try await live.unpublishDocument(request)
|
||||
}
|
||||
|
||||
public func archiveDocument(id: String) async throws -> OutlineDocument {
|
||||
try await live.archiveDocument(id: id)
|
||||
}
|
||||
|
||||
public func moveDocument(_ request: MoveDocumentRequest) async throws {
|
||||
try await live.moveDocument(request)
|
||||
}
|
||||
|
||||
public func deleteDocument(_ request: DeleteDocumentRequest) async throws {
|
||||
try await live.deleteDocument(request)
|
||||
}
|
||||
|
||||
public func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] {
|
||||
try await live.documentInsights(request)
|
||||
}
|
||||
|
||||
public func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] {
|
||||
try await live.listRevisions(request)
|
||||
}
|
||||
|
||||
public func exportDocument(id: String) async throws -> String {
|
||||
try await live.exportDocument(id: id)
|
||||
}
|
||||
|
||||
public func createShare(_ request: CreateShareRequest) async throws -> OutlineShare {
|
||||
try await live.createShare(request)
|
||||
}
|
||||
|
||||
public func shareInfo(documentId: String) async throws -> OutlineShare? {
|
||||
try await live.shareInfo(documentId: documentId)
|
||||
}
|
||||
|
||||
public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare {
|
||||
try await live.updateShare(request)
|
||||
}
|
||||
|
||||
public func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] {
|
||||
try await live.listShares(request)
|
||||
}
|
||||
|
||||
public func revokeShare(id: String) async throws {
|
||||
try await live.revokeShare(id: id)
|
||||
}
|
||||
|
||||
public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
|
||||
try await live.listPins(request)
|
||||
}
|
||||
|
||||
public func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] {
|
||||
try await live.listSubscriptions(request)
|
||||
}
|
||||
|
||||
public func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] {
|
||||
try await live.listViews(request)
|
||||
}
|
||||
|
||||
public func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] {
|
||||
try await live.listComments(request)
|
||||
}
|
||||
|
||||
public func commentInfo(id: String) async throws -> OutlineComment {
|
||||
try await live.commentInfo(id: id)
|
||||
}
|
||||
|
||||
public func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment {
|
||||
try await live.createComment(request)
|
||||
}
|
||||
|
||||
public func resolveComment(id: String) async throws -> OutlineComment {
|
||||
try await live.resolveComment(id: id)
|
||||
}
|
||||
|
||||
public func unresolveComment(id: String) async throws -> OutlineComment {
|
||||
try await live.unresolveComment(id: id)
|
||||
}
|
||||
|
||||
public func addReaction(commentId: String, emoji: String) async throws {
|
||||
try await live.addReaction(commentId: commentId, emoji: emoji)
|
||||
}
|
||||
|
||||
public func removeReaction(commentId: String, emoji: String) async throws {
|
||||
try await live.removeReaction(commentId: commentId, emoji: emoji)
|
||||
}
|
||||
|
||||
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
|
||||
try await live.addDocumentUser(request)
|
||||
}
|
||||
|
||||
public func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws {
|
||||
try await live.removeDocumentUser(request)
|
||||
}
|
||||
|
||||
public func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] {
|
||||
try await live.documentUsers(request)
|
||||
}
|
||||
|
||||
public func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] {
|
||||
try await live.listUsers(request)
|
||||
}
|
||||
|
||||
public func deleteCollection(id: String) async throws {
|
||||
try await live.deleteCollection(id: id)
|
||||
}
|
||||
|
||||
public func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation {
|
||||
try await live.exportCollection(request)
|
||||
}
|
||||
|
||||
public func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] {
|
||||
try await live.listStars(request)
|
||||
}
|
||||
|
||||
public func currentUser() async throws -> OutlineUser {
|
||||
try await live.currentUser()
|
||||
}
|
||||
|
||||
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
|
||||
try await live.createAttachment(request)
|
||||
}
|
||||
|
||||
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
|
||||
try await live.uploadAttachmentFile(result, fileData: fileData)
|
||||
}
|
||||
|
||||
public func fetchAuthenticatedFile(path: String) async throws -> Data {
|
||||
try await live.fetchAuthenticatedFile(path: path)
|
||||
}
|
||||
|
||||
public func deleteAttachment(id: String) async throws {
|
||||
try await live.deleteAttachment(id: id)
|
||||
}
|
||||
|
||||
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
|
||||
try await live.updateUserAvatar(request)
|
||||
}
|
||||
|
||||
public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser {
|
||||
try await live.updateUserName(request)
|
||||
}
|
||||
|
||||
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
|
||||
try await live.updateUserLanguage(request)
|
||||
}
|
||||
|
||||
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
|
||||
try await live.updateUserPreferences(request)
|
||||
}
|
||||
|
||||
public func deleteAccount() async throws {
|
||||
try await live.deleteAccount()
|
||||
}
|
||||
|
||||
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||
try await live.subscribeToNotifications(eventType: eventType)
|
||||
}
|
||||
|
||||
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||
try await live.unsubscribeFromNotifications(eventType: eventType)
|
||||
}
|
||||
|
||||
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
|
||||
try await live.listApiKeys(request)
|
||||
}
|
||||
|
||||
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
|
||||
try await live.createApiKey(request)
|
||||
}
|
||||
|
||||
public func deleteApiKey(id: String) async throws {
|
||||
try await live.deleteApiKey(id: id)
|
||||
}
|
||||
|
||||
public func installationInfo() async throws -> OutlineInstallationInfo {
|
||||
try await live.installationInfo()
|
||||
}
|
||||
|
||||
// MARK: - Sync management (Settings surface)
|
||||
|
||||
public func pendingOperations() async -> [PendingOperationSummary] {
|
||||
await cache.pendingOperations().map {
|
||||
PendingOperationSummary(id: $0.id, kind: $0.kind, createdAt: $0.createdAt, attemptCount: $0.attemptCount, lastError: $0.lastError)
|
||||
}
|
||||
}
|
||||
|
||||
public func cacheStorageSummary() async -> CacheStorageSummary {
|
||||
CacheStorageSummary(itemCount: await cache.itemCount(), totalBytes: await cache.totalBytes())
|
||||
}
|
||||
|
||||
public func clearCache() async {
|
||||
await cache.clearAll()
|
||||
}
|
||||
|
||||
/// Replays every queued operation against `live`, in the order they were
|
||||
/// queued. Each is independent — one failing doesn't block the rest.
|
||||
public func flushPendingOperations() async -> SyncFlushSummary {
|
||||
let operations = await cache.pendingOperations()
|
||||
var succeeded = 0
|
||||
var failed = 0
|
||||
for operation in operations {
|
||||
do {
|
||||
try await replay(operation)
|
||||
await cache.removeOperation(id: operation.id)
|
||||
succeeded += 1
|
||||
} catch {
|
||||
await cache.recordFailure(id: operation.id, error: errorDescription(error))
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
return SyncFlushSummary(succeeded: succeeded, failed: failed)
|
||||
}
|
||||
|
||||
/// "Full Local Sync": eagerly walks every collection and caches every
|
||||
/// document's full content (not just whatever's been opened), so
|
||||
/// browsing offline works for the whole workspace, not only what was
|
||||
/// already viewed. Goes through `self`, not `live`, directly — the
|
||||
/// existing cached-read methods already do the caching as a side effect,
|
||||
/// this just has to drive the walk and separately cache each document by
|
||||
/// id (`listDocuments`'s cache key is the list, not the individual doc).
|
||||
///
|
||||
/// Recurses into every document's children, not just collections' own
|
||||
/// root-level documents — a document with sub-documents used to leave
|
||||
/// them uncached entirely (only reachable if something else happened to
|
||||
/// open them individually first). Also caches each collection under its
|
||||
/// own `"collection:<id>"` key (previously only cached as part of the
|
||||
/// paginated list blob), so both are individually enumerable afterward
|
||||
/// via `OfflineCacheStore.loadAll(keyPrefix:)` — see
|
||||
/// `cachedDocumentsIndex()`/`cachedCollectionsIndex()`.
|
||||
public func performFullSync() async -> FullSyncSummary {
|
||||
var documentsCount = 0
|
||||
var errors: [String] = []
|
||||
var collections: [OutlineCollection] = []
|
||||
var collectionsOffset = 0
|
||||
let collectionsLimit = 100
|
||||
// Outline rejects any `limit` over 100 outright — page in increments
|
||||
// of that instead of guessing a total up front (the protocol doesn't
|
||||
// expose `pagination`'s total count, only the page itself); a page
|
||||
// shorter than the limit is what signals "that was the last one".
|
||||
while true {
|
||||
let page: [OutlineCollection]
|
||||
do {
|
||||
page = try await listCollections(offset: collectionsOffset, limit: collectionsLimit)
|
||||
} catch {
|
||||
errors.append(errorDescription(error))
|
||||
break
|
||||
}
|
||||
collections.append(contentsOf: page)
|
||||
guard page.count == collectionsLimit else { break }
|
||||
collectionsOffset += collectionsLimit
|
||||
}
|
||||
|
||||
for collection in collections {
|
||||
await cacheCollection(collection)
|
||||
let result = await cacheDocumentTree(collectionId: collection.id, parentDocumentId: nil, collectionName: collection.name)
|
||||
documentsCount += result.count
|
||||
errors.append(contentsOf: result.errors)
|
||||
}
|
||||
|
||||
return FullSyncSummary(collectionsCount: collections.count, documentsCount: documentsCount, errors: errors, finishedAt: Date())
|
||||
}
|
||||
|
||||
/// Caches every document under `parentDocumentId` (`nil` = a
|
||||
/// collection's root level) and recurses into each one's own children,
|
||||
/// depth-first, until a branch runs out of sub-documents. Returns a
|
||||
/// plain `(count, errors)` pair rather than mutating shared state across
|
||||
/// `await` boundaries, since this calls itself recursively.
|
||||
private func cacheDocumentTree(
|
||||
collectionId: String,
|
||||
parentDocumentId: String?,
|
||||
collectionName: String
|
||||
) async -> (count: Int, errors: [String]) {
|
||||
var count = 0
|
||||
var errors: [String] = []
|
||||
var offset = 0
|
||||
let limit = 100
|
||||
while true {
|
||||
let documents: [OutlineDocument]
|
||||
do {
|
||||
documents = try await listDocuments(collectionId: collectionId, parentDocumentId: parentDocumentId, offset: offset, limit: limit)
|
||||
} catch {
|
||||
errors.append("\(collectionName): \(errorDescription(error))")
|
||||
break
|
||||
}
|
||||
for document in documents {
|
||||
await cacheDocument(document)
|
||||
count += 1
|
||||
let childResult = await cacheDocumentTree(collectionId: collectionId, parentDocumentId: document.id, collectionName: collectionName)
|
||||
count += childResult.count
|
||||
errors.append(contentsOf: childResult.errors)
|
||||
}
|
||||
guard documents.count == limit else { break }
|
||||
offset += limit
|
||||
}
|
||||
return (count, errors)
|
||||
}
|
||||
|
||||
/// Every individually cached document from the last Full Local Sync —
|
||||
/// empty if a sync has never run (or found nothing). Purely a local
|
||||
/// SwiftData read, no network involved.
|
||||
public func cachedDocumentsIndex() async -> [OutlineDocument] {
|
||||
let payloads = await cache.loadAll(keyPrefix: "document:")
|
||||
return payloads.compactMap { try? decoder.decode(OutlineDocument.self, from: $0) }
|
||||
}
|
||||
|
||||
/// Every individually cached collection from the last Full Local Sync.
|
||||
public func cachedCollectionsIndex() async -> [OutlineCollection] {
|
||||
let payloads = await cache.loadAll(keyPrefix: "collection:")
|
||||
return payloads.compactMap { try? decoder.decode(OutlineCollection.self, from: $0) }
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T {
|
||||
// Manual offline mode means "skip the network entirely," not just
|
||||
// "prefer it" — without this check, a read would still hit `live`
|
||||
// (and succeed, showing content beyond whatever's cached) any time
|
||||
// the device actually had a connection, defeating the point of
|
||||
// deliberately testing/working as if offline.
|
||||
if isManualOfflineModeEnabled {
|
||||
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) {
|
||||
return cached
|
||||
}
|
||||
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||
}
|
||||
do {
|
||||
let result = try await fetch()
|
||||
if let data = try? encoder.encode(result) {
|
||||
await cache.save(data, forKey: key)
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) {
|
||||
return cached
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func requestKey(_ prefix: String, _ request: some Encodable) -> String {
|
||||
guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else {
|
||||
return prefix
|
||||
}
|
||||
return "\(prefix):\(json)"
|
||||
}
|
||||
|
||||
private func cacheDocument(_ document: OutlineDocument) async {
|
||||
if let data = try? encoder.encode(document) {
|
||||
await cache.save(data, forKey: "document:\(document.id)")
|
||||
}
|
||||
}
|
||||
|
||||
private func cacheCollection(_ collection: OutlineCollection) async {
|
||||
if let data = try? encoder.encode(collection) {
|
||||
await cache.save(data, forKey: "collection:\(collection.id)")
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async {
|
||||
guard let data = try? encoder.encode(payload) else { return }
|
||||
await cache.enqueueOperation(id: id, kind: kind.rawValue, payload: data)
|
||||
}
|
||||
|
||||
/// A delete targeting a `pending-*` id can only mean "cancel the create
|
||||
/// still sitting in the queue" — the server has never heard of that id,
|
||||
/// so queuing the delete would just fail once synced. Returns whether it
|
||||
/// found (and removed) a matching create, meaning the caller is done.
|
||||
private func cancelIfNeverSynced(id: String) async -> Bool {
|
||||
guard id.hasPrefix("pending-") else { return false }
|
||||
return await cache.removeOperation(id: id)
|
||||
}
|
||||
|
||||
private func queueDocumentCreate(_ request: CreateDocumentRequest) async -> OutlineDocument {
|
||||
let pendingId = "pending-\(UUID().uuidString)"
|
||||
let now = Date()
|
||||
let synthesized = OutlineDocument(
|
||||
id: pendingId,
|
||||
title: request.title,
|
||||
text: request.text,
|
||||
emoji: nil,
|
||||
collectionId: request.collectionId,
|
||||
parentDocumentId: request.parentDocumentId,
|
||||
url: "/doc/\(pendingId)",
|
||||
revision: nil,
|
||||
fullWidth: nil,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
publishedAt: request.publish ? now : nil,
|
||||
archivedAt: nil,
|
||||
deletedAt: nil
|
||||
)
|
||||
await cacheDocument(synthesized)
|
||||
await enqueue(.createDocument, payload: request, id: pendingId)
|
||||
return synthesized
|
||||
}
|
||||
|
||||
private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument {
|
||||
guard let baseData = await cache.load(forKey: "document:\(request.id)"),
|
||||
let base = try? decoder.decode(OutlineDocument.self, from: baseData) else {
|
||||
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||
}
|
||||
let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text)
|
||||
let merged = OutlineDocument(
|
||||
id: base.id,
|
||||
title: request.title ?? base.title,
|
||||
text: mergedText,
|
||||
emoji: base.emoji,
|
||||
collectionId: base.collectionId,
|
||||
parentDocumentId: base.parentDocumentId,
|
||||
url: base.url,
|
||||
revision: base.revision,
|
||||
fullWidth: request.fullWidth ?? base.fullWidth,
|
||||
createdAt: base.createdAt,
|
||||
updatedAt: Date(),
|
||||
publishedAt: base.publishedAt,
|
||||
archivedAt: base.archivedAt,
|
||||
deletedAt: base.deletedAt
|
||||
)
|
||||
await cacheDocument(merged)
|
||||
|
||||
// Still-unsynced create for this exact document — fold the edit into
|
||||
// the pending create's payload instead of queuing a separate update.
|
||||
// A separate update would target `merged.id`, which is still the
|
||||
// `pending-*` placeholder at replay time; the server has never heard
|
||||
// of it and the update would just fail every retry.
|
||||
if merged.id.hasPrefix("pending-"),
|
||||
let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }),
|
||||
let createRequest = try? decoder.decode(CreateDocumentRequest.self, from: createOp.payload) {
|
||||
let resolvedCreate = CreateDocumentRequest(
|
||||
title: merged.title,
|
||||
text: merged.text,
|
||||
collectionId: createRequest.collectionId,
|
||||
parentDocumentId: createRequest.parentDocumentId,
|
||||
publish: createRequest.publish
|
||||
)
|
||||
await enqueue(.createDocument, payload: resolvedCreate, id: merged.id)
|
||||
return merged
|
||||
}
|
||||
|
||||
// Fully resolved (not the original partial request) so replaying just
|
||||
// this one queued operation reproduces `merged` exactly — that's what
|
||||
// makes coalescing a second edit onto the same queued id safe.
|
||||
let resolved = UpdateDocumentRequest(id: merged.id, title: merged.title, text: merged.text, fullWidth: merged.fullWidth)
|
||||
await enqueue(.updateDocument, payload: resolved, id: "update-document-\(merged.id)")
|
||||
return merged
|
||||
}
|
||||
|
||||
private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection {
|
||||
guard let baseData = await cache.load(forKey: "collection:\(request.id)"),
|
||||
let base = try? decoder.decode(OutlineCollection.self, from: baseData) else {
|
||||
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||
}
|
||||
let merged = OutlineCollection(
|
||||
id: base.id,
|
||||
name: request.name ?? base.name,
|
||||
description: request.description ?? base.description,
|
||||
color: base.color,
|
||||
icon: base.icon,
|
||||
createdAt: base.createdAt,
|
||||
updatedAt: Date()
|
||||
)
|
||||
await cacheCollection(merged)
|
||||
let resolved = UpdateCollectionRequest(id: merged.id, name: merged.name, description: merged.description)
|
||||
await enqueue(.updateCollection, payload: resolved, id: "update-collection-\(merged.id)")
|
||||
return merged
|
||||
}
|
||||
|
||||
private func queuePinCreate(_ request: CreatePinRequest) async -> OutlinePin {
|
||||
let pendingId = "pending-\(UUID().uuidString)"
|
||||
await enqueue(.createPin, payload: request, id: pendingId)
|
||||
return OutlinePin(id: pendingId, documentId: request.documentId, collectionId: request.collectionId, index: nil)
|
||||
}
|
||||
|
||||
private func queueSubscriptionCreate(_ request: CreateSubscriptionRequest) async -> OutlineSubscription {
|
||||
let pendingId = "pending-\(UUID().uuidString)"
|
||||
await enqueue(.createSubscription, payload: request, id: pendingId)
|
||||
return OutlineSubscription(id: pendingId, documentId: request.documentId, collectionId: nil, event: request.event)
|
||||
}
|
||||
|
||||
private func queueStarDocumentCreate(_ request: StarDocumentRequest) async -> OutlineStar {
|
||||
let pendingId = "pending-\(UUID().uuidString)"
|
||||
await enqueue(.starDocument, payload: request, id: pendingId)
|
||||
return OutlineStar(id: pendingId, index: nil, documentId: request.documentId, collectionId: nil)
|
||||
}
|
||||
|
||||
private func queueStarCollectionCreate(_ request: StarCollectionRequest) async -> OutlineStar {
|
||||
let pendingId = "pending-\(UUID().uuidString)"
|
||||
await enqueue(.starCollection, payload: request, id: pendingId)
|
||||
return OutlineStar(id: pendingId, index: nil, documentId: nil, collectionId: request.collectionId)
|
||||
}
|
||||
|
||||
private func replay(_ operation: PendingOperation) async throws {
|
||||
guard let kind = PendingOperationKind(rawValue: operation.kind) else {
|
||||
throw OutlineAPIError.decoding(DecodingError.dataCorrupted(
|
||||
DecodingError.Context(codingPath: [], debugDescription: "Unknown pending operation kind: \(operation.kind)")
|
||||
))
|
||||
}
|
||||
switch kind {
|
||||
case .createDocument:
|
||||
let request = try decoder.decode(CreateDocumentRequest.self, from: operation.payload)
|
||||
let result = try await live.createDocument(request)
|
||||
await cacheDocument(result)
|
||||
// The placeholder id (== operation.id) is now a dead orphan —
|
||||
// nothing server-side will ever answer to it again.
|
||||
await cache.removeCacheEntry(forKey: "document:\(operation.id)")
|
||||
case .updateDocument:
|
||||
let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload)
|
||||
let result = try await live.updateDocument(request)
|
||||
await cacheDocument(result)
|
||||
case .updateCollection:
|
||||
let request = try decoder.decode(UpdateCollectionRequest.self, from: operation.payload)
|
||||
let result = try await live.updateCollection(request)
|
||||
await cacheCollection(result)
|
||||
case .createPin:
|
||||
let request = try decoder.decode(CreatePinRequest.self, from: operation.payload)
|
||||
_ = try await live.createPin(request)
|
||||
case .deletePin:
|
||||
let request = try decoder.decode(IDPayload.self, from: operation.payload)
|
||||
try await live.deletePin(id: request.id)
|
||||
case .createSubscription:
|
||||
let request = try decoder.decode(CreateSubscriptionRequest.self, from: operation.payload)
|
||||
_ = try await live.createSubscription(request)
|
||||
case .deleteSubscription:
|
||||
let request = try decoder.decode(IDPayload.self, from: operation.payload)
|
||||
try await live.deleteSubscription(id: request.id)
|
||||
case .starDocument:
|
||||
let request = try decoder.decode(StarDocumentRequest.self, from: operation.payload)
|
||||
_ = try await live.starDocument(request)
|
||||
case .deleteStar:
|
||||
let request = try decoder.decode(IDPayload.self, from: operation.payload)
|
||||
try await live.deleteStar(id: request.id)
|
||||
case .starCollection:
|
||||
let request = try decoder.decode(StarCollectionRequest.self, from: operation.payload)
|
||||
_ = try await live.starCollection(request)
|
||||
}
|
||||
}
|
||||
|
||||
private func errorDescription(_ error: Error) -> String {
|
||||
if let apiError = error as? OutlineAPIError {
|
||||
switch apiError {
|
||||
case .unauthorized: return "Sign-in expired."
|
||||
case .notFound: return "Not found on the server."
|
||||
case .server(let status, let message): return message ?? "Server error (\(status))."
|
||||
case .decoding: return "Unexpected response shape."
|
||||
case .transport: return "Couldn't reach the server."
|
||||
case .tokenUnavailable: return "Couldn't access the saved sign-in."
|
||||
}
|
||||
}
|
||||
return String(describing: error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Read-through cache backing `CachingOutlineAPIClient` — stores whatever it
|
||||
/// last fetched successfully, keyed by request shape, so browsing keeps
|
||||
/// working (stale) once the network stops. Also owns the offline write
|
||||
/// queue (`PendingOperation`) — same store, same actor, since both need
|
||||
/// synchronized access to the same on-disk SwiftData container.
|
||||
@ModelActor
|
||||
public actor OfflineCacheStore {
|
||||
public static func makeContainer(inMemory: Bool = false) throws -> ModelContainer {
|
||||
let configuration = ModelConfiguration(isStoredInMemoryOnly: inMemory)
|
||||
return try ModelContainer(for: CachedPayload.self, PendingOperation.self, configurations: configuration)
|
||||
}
|
||||
|
||||
// MARK: - Read-through cache
|
||||
|
||||
public func save(_ data: Data, forKey key: String) {
|
||||
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
|
||||
if let existing = try? modelContext.fetch(descriptor).first {
|
||||
existing.payload = data
|
||||
existing.cachedAt = Date()
|
||||
} else {
|
||||
modelContext.insert(CachedPayload(key: key, payload: data, cachedAt: Date()))
|
||||
}
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
public func load(forKey key: String) -> Data? {
|
||||
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
|
||||
return try? modelContext.fetch(descriptor).first?.payload
|
||||
}
|
||||
|
||||
/// Everything cached under a key prefix — e.g. every individually
|
||||
/// cached document (`"document:<id>"`) or collection
|
||||
/// (`"collection:<id>"`) after a Full Local Sync, for building a local
|
||||
/// search index without a per-item exact-key lookup.
|
||||
public func loadAll(keyPrefix: String) -> [Data] {
|
||||
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key.starts(with: keyPrefix) })
|
||||
return ((try? modelContext.fetch(descriptor)) ?? []).map(\.payload)
|
||||
}
|
||||
|
||||
/// Used to drop a temporary `pending-*` document's cache entry once a
|
||||
/// queued create syncs and the server hands back the real id — the
|
||||
/// placeholder key would otherwise sit around as a dead orphan forever.
|
||||
public func removeCacheEntry(forKey key: String) {
|
||||
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
|
||||
guard let existing = try? modelContext.fetch(descriptor).first else { return }
|
||||
modelContext.delete(existing)
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
public func itemCount() -> Int {
|
||||
(try? modelContext.fetchCount(FetchDescriptor<CachedPayload>())) ?? 0
|
||||
}
|
||||
|
||||
public func totalBytes() -> Int {
|
||||
let descriptor = FetchDescriptor<CachedPayload>()
|
||||
let rows = (try? modelContext.fetch(descriptor)) ?? []
|
||||
return rows.reduce(0) { $0 + $1.payload.count }
|
||||
}
|
||||
|
||||
public func clearAll() {
|
||||
let descriptor = FetchDescriptor<CachedPayload>()
|
||||
guard let rows = try? modelContext.fetch(descriptor) else { return }
|
||||
rows.forEach { modelContext.delete($0) }
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
// MARK: - Offline write queue
|
||||
|
||||
/// Upserts by `id` — a second call with the same id (an edit coalescing
|
||||
/// onto a still-unsynced edit, or a create being cancelled by its own
|
||||
/// synthesized id) replaces the row in place rather than piling up.
|
||||
public func enqueueOperation(id: String, kind: String, payload: Data) {
|
||||
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
|
||||
if let existing = try? modelContext.fetch(descriptor).first {
|
||||
existing.kind = kind
|
||||
existing.payload = payload
|
||||
existing.lastError = nil
|
||||
existing.attemptCount = 0
|
||||
} else {
|
||||
modelContext.insert(PendingOperation(id: id, kind: kind, payload: payload, createdAt: Date()))
|
||||
}
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
public func pendingOperations() -> [PendingOperation] {
|
||||
let descriptor = FetchDescriptor<PendingOperation>(sortBy: [SortDescriptor(\.createdAt)])
|
||||
return (try? modelContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
/// Returns whether a matching operation was actually found and removed —
|
||||
/// callers use this to detect "this targeted something that never made
|
||||
/// it to the server in the first place" and skip queuing a delete.
|
||||
@discardableResult
|
||||
public func removeOperation(id: String) -> Bool {
|
||||
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
|
||||
guard let existing = try? modelContext.fetch(descriptor).first else { return false }
|
||||
modelContext.delete(existing)
|
||||
try? modelContext.save()
|
||||
return true
|
||||
}
|
||||
|
||||
public func recordFailure(id: String, error: String) {
|
||||
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
|
||||
guard let existing = try? modelContext.fetch(descriptor).first else { return }
|
||||
existing.lastAttemptAt = Date()
|
||||
existing.lastError = error
|
||||
existing.attemptCount += 1
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// A queued mutation made while offline, waiting to replay against the live
|
||||
/// server. `id` is deliberately overloaded: for actions that create a new
|
||||
/// server-side record (pin, star, subscription), it's also the synthesized
|
||||
/// placeholder id handed back to the caller immediately — so a matching
|
||||
/// delete queued before that create ever syncs can cancel both out by id
|
||||
/// instead of hitting a server that's never heard of the placeholder. For
|
||||
/// actions that edit an existing record (document/collection updates), it's
|
||||
/// deterministic per target id, so a second edit before the first syncs
|
||||
/// coalesces into one queued operation instead of piling up.
|
||||
@Model
|
||||
public final class PendingOperation {
|
||||
@Attribute(.unique) public var id: String
|
||||
public var kind: String
|
||||
public var payload: Data
|
||||
public var createdAt: Date
|
||||
public var lastAttemptAt: Date?
|
||||
public var lastError: String?
|
||||
public var attemptCount: Int
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
kind: String,
|
||||
payload: Data,
|
||||
createdAt: Date,
|
||||
lastAttemptAt: Date? = nil,
|
||||
lastError: String? = nil,
|
||||
attemptCount: Int = 0
|
||||
) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.payload = payload
|
||||
self.createdAt = createdAt
|
||||
self.lastAttemptAt = lastAttemptAt
|
||||
self.lastError = lastError
|
||||
self.attemptCount = attemptCount
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
/// UI-facing snapshot of a queued offline mutation — deliberately not the
|
||||
/// `@Model` type itself, so Settings can display it without holding a
|
||||
/// reference into the SwiftData store.
|
||||
public struct PendingOperationSummary: Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let kind: String
|
||||
public let createdAt: Date
|
||||
public let attemptCount: Int
|
||||
public let lastError: String?
|
||||
}
|
||||
|
||||
public struct CacheStorageSummary: Sendable {
|
||||
public let itemCount: Int
|
||||
public let totalBytes: Int
|
||||
}
|
||||
|
||||
public struct SyncFlushSummary: Sendable {
|
||||
public let succeeded: Int
|
||||
public let failed: Int
|
||||
}
|
||||
|
||||
public struct FullSyncSummary: Sendable {
|
||||
public let collectionsCount: Int
|
||||
public let documentsCount: Int
|
||||
public let errors: [String]
|
||||
public let finishedAt: Date
|
||||
}
|
||||
|
||||
/// Every mutation `CachingOutlineAPIClient` knows how to queue offline and
|
||||
/// replay later. Deliberately a small, explicit set — see its doc comment
|
||||
/// for what's excluded and why.
|
||||
enum PendingOperationKind: String, Codable, Sendable {
|
||||
case createDocument
|
||||
case updateDocument
|
||||
case updateCollection
|
||||
case createPin
|
||||
case deletePin
|
||||
case createSubscription
|
||||
case deleteSubscription
|
||||
case starDocument
|
||||
case deleteStar
|
||||
case starCollection
|
||||
}
|
||||
|
||||
/// Shared payload shape for the delete-by-id queueable operations.
|
||||
struct IDPayload: Codable, Sendable {
|
||||
let id: String
|
||||
}
|
||||
@@ -13,6 +13,9 @@ public protocol OutlineAPIClient: Sendable {
|
||||
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument]
|
||||
/// Documents the current user has recently viewed. Backed by `documents.viewed`.
|
||||
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument]
|
||||
/// Draft (unpublished) documents belonging to the current user. Backed
|
||||
/// by `documents.drafts`.
|
||||
func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument]
|
||||
/// Full-text search with snippets/ranking. Backed by `documents.search`.
|
||||
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult]
|
||||
/// Title-only search — faster, no snippets. Backed by `documents.search_titles`.
|
||||
@@ -48,6 +51,20 @@ public protocol OutlineAPIClient: Sendable {
|
||||
/// Historical view records, not live presence. Backed by `views.list`.
|
||||
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView]
|
||||
|
||||
/// See `OutlineComment`. `resolve`/`unresolve`/`add_reaction`/
|
||||
/// `remove_reaction` are confirmed real against Outline's own server
|
||||
/// source but aren't in the vendored spec.
|
||||
func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment]
|
||||
func commentInfo(id: String) async throws -> OutlineComment
|
||||
func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment
|
||||
func resolveComment(id: String) async throws -> OutlineComment
|
||||
func unresolveComment(id: String) async throws -> OutlineComment
|
||||
/// Reaction endpoints return `{success: true}`, not the updated
|
||||
/// comment — callers refetch via `commentInfo` for the fresh
|
||||
/// `reactions` array.
|
||||
func addReaction(commentId: String, emoji: String) async throws
|
||||
func removeReaction(commentId: String, emoji: String) async throws
|
||||
|
||||
/// See `OutlineMembership`/`OutlineDocumentMember` — `add` is confirmed
|
||||
/// from Outline's official docs, the rest are best-effort.
|
||||
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership
|
||||
@@ -69,4 +86,48 @@ public protocol OutlineAPIClient: Sendable {
|
||||
func deleteStar(id: String) async throws
|
||||
|
||||
func currentUser() async throws -> OutlineUser
|
||||
|
||||
/// Two-step presigned upload: this requests where/how to upload,
|
||||
/// `uploadAttachmentFile` performs the actual multipart POST to that
|
||||
/// target. See `OutlineAttachment`/`CreateAttachmentResult`.
|
||||
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult
|
||||
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws
|
||||
/// Fetches raw bytes from an authenticated, server-relative GET path —
|
||||
/// e.g. `/api/attachments.redirect?id=<uuid>`, the reference Outline's
|
||||
/// own editor embeds for uploaded images in document Markdown. Unlike
|
||||
/// `post`'s RPC endpoints, this is a GET that 302-redirects to the
|
||||
/// actual (often presigned, cross-host) storage URL; `path` is resolved
|
||||
/// against the client's base URL, same as `uploadAttachmentFile`'s
|
||||
/// `uploadUrl` handling.
|
||||
func fetchAuthenticatedFile(path: String) async throws -> Data
|
||||
/// Best-effort — matches the shape every other simple `id`-only delete
|
||||
/// in this API uses (`pins.delete`, `stars.delete`, …), not confirmed
|
||||
/// against a live server specifically for attachments yet.
|
||||
func deleteAttachment(id: String) async throws
|
||||
/// `users.update`, avatar only. See `UpdateUserAvatarRequest`.
|
||||
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser
|
||||
/// `users.update`, name only. See `UpdateUserNameRequest`.
|
||||
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser
|
||||
/// `users.update`, language only. See `UpdateUserLanguageRequest`.
|
||||
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser
|
||||
/// `users.update`, preferences only. See `UpdateUserPreferencesRequest`.
|
||||
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser
|
||||
/// Backed by `users.delete` — self-service account deletion, no
|
||||
/// confirmation code param confirmed live, matches every other simple
|
||||
/// no-body delete in this API.
|
||||
func deleteAccount() async throws
|
||||
/// `nil` targets every notification event. See `NotificationEventType`.
|
||||
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
|
||||
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
|
||||
|
||||
/// Settings → API & Access.
|
||||
func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey]
|
||||
/// The returned `OutlineAPIKey.value` is the only time the full
|
||||
/// plaintext key is ever available — the caller is responsible for
|
||||
/// displaying it once and then discarding it.
|
||||
func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey
|
||||
func deleteApiKey(id: String) async throws
|
||||
|
||||
/// Settings → Installation. Self-hosted server version info.
|
||||
func installationInfo() async throws -> OutlineInstallationInfo
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
||||
try await post("documents.viewed", body: PaginationParams(offset: offset, limit: limit))
|
||||
}
|
||||
|
||||
public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] {
|
||||
try await post("documents.drafts", body: request)
|
||||
}
|
||||
|
||||
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
|
||||
try await post("documents.search", body: request)
|
||||
}
|
||||
@@ -175,6 +179,34 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
||||
try await post("views.list", body: request)
|
||||
}
|
||||
|
||||
public func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] {
|
||||
try await post("comments.list", body: request)
|
||||
}
|
||||
|
||||
public func commentInfo(id: String) async throws -> OutlineComment {
|
||||
try await post("comments.info", body: StarIDParams(id: id))
|
||||
}
|
||||
|
||||
public func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment {
|
||||
try await post("comments.create", body: request)
|
||||
}
|
||||
|
||||
public func resolveComment(id: String) async throws -> OutlineComment {
|
||||
try await post("comments.resolve", body: StarIDParams(id: id))
|
||||
}
|
||||
|
||||
public func unresolveComment(id: String) async throws -> OutlineComment {
|
||||
try await post("comments.unresolve", body: StarIDParams(id: id))
|
||||
}
|
||||
|
||||
public func addReaction(commentId: String, emoji: String) async throws {
|
||||
try await postForSuccess("comments.add_reaction", body: CommentReactionParams(id: commentId, emoji: emoji))
|
||||
}
|
||||
|
||||
public func removeReaction(commentId: String, emoji: String) async throws {
|
||||
try await postForSuccess("comments.remove_reaction", body: CommentReactionParams(id: commentId, emoji: emoji))
|
||||
}
|
||||
|
||||
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
|
||||
try await post("documents.add_user", body: request)
|
||||
}
|
||||
@@ -229,6 +261,142 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
||||
try await post("users.info", body: EmptyParams())
|
||||
}
|
||||
|
||||
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
|
||||
try await post("attachments.create", body: request)
|
||||
}
|
||||
|
||||
/// No Bearer/CSRF header attached here, deliberately — the presigned
|
||||
/// `form.sig` field (short-lived, scoped to this exact upload key) is
|
||||
/// what authorizes this specific request, the same way an S3 presigned
|
||||
/// POST works. Best-effort against a live server: if it turns out the
|
||||
/// self-hosted local-storage backend also wants a bearer token here,
|
||||
/// that's a one-line addition once confirmed, not a design change.
|
||||
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
|
||||
let (body, contentType) = MultipartFormDataBuilder.build(
|
||||
fields: result.form,
|
||||
fileFieldName: "file",
|
||||
fileName: result.attachment.name ?? "avatar",
|
||||
fileData: fileData,
|
||||
fileContentType: result.form["Content-Type"] ?? "application/octet-stream"
|
||||
)
|
||||
|
||||
// `uploadUrl` is a host-relative path (e.g. "/api/files.create") on
|
||||
// a self-hosted local-storage backend, not an absolute S3 URL —
|
||||
// resolving against `baseURL` handles both: `URL(string:relativeTo:)`
|
||||
// replaces the whole path for a leading-slash relative string per
|
||||
// RFC 3986, same resolution already used for user/team avatar URLs.
|
||||
guard let uploadURL = URL(string: result.uploadUrl, relativeTo: baseURL)?.absoluteURL else {
|
||||
throw OutlineAPIError.transport(URLError(.badURL))
|
||||
}
|
||||
|
||||
var request = URLRequest(url: uploadURL)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = body
|
||||
|
||||
let data: Data
|
||||
let response: HTTPURLResponse
|
||||
do {
|
||||
(data, response) = try await httpClient.send(request)
|
||||
} catch let error as OutlineAPIError {
|
||||
throw error
|
||||
} catch {
|
||||
throw OutlineAPIError.transport(error)
|
||||
}
|
||||
|
||||
guard (200...299).contains(response.statusCode) else {
|
||||
let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data)
|
||||
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
|
||||
}
|
||||
}
|
||||
|
||||
public func fetchAuthenticatedFile(path: String) async throws -> Data {
|
||||
guard let token = try? tokenStore.token() else {
|
||||
throw OutlineAPIError.tokenUnavailable
|
||||
}
|
||||
// Same host-relative-or-absolute resolution as uploadAttachmentFile's uploadUrl.
|
||||
guard let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else {
|
||||
throw OutlineAPIError.transport(URLError(.badURL))
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
// URLSession's default redirect handling drops Authorization on a
|
||||
// cross-host redirect (same as a browser dropping cookies on one) —
|
||||
// exactly what's wanted here: authorize the request to Outline's own
|
||||
// `attachments.redirect`, not the presigned storage URL it 302s to.
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
let data: Data
|
||||
let response: HTTPURLResponse
|
||||
do {
|
||||
(data, response) = try await httpClient.send(request)
|
||||
} catch let error as OutlineAPIError {
|
||||
throw error
|
||||
} catch {
|
||||
throw OutlineAPIError.transport(error)
|
||||
}
|
||||
|
||||
guard (200...299).contains(response.statusCode) else {
|
||||
switch response.statusCode {
|
||||
case 401:
|
||||
throw OutlineAPIError.unauthorized
|
||||
case 404:
|
||||
throw OutlineAPIError.notFound
|
||||
default:
|
||||
throw OutlineAPIError.server(status: response.statusCode, message: nil)
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
public func deleteAttachment(id: String) async throws {
|
||||
try await postForSuccess("attachments.delete", body: StarIDParams(id: id))
|
||||
}
|
||||
|
||||
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
|
||||
try await post("users.update", body: request)
|
||||
}
|
||||
|
||||
public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser {
|
||||
try await post("users.update", body: request)
|
||||
}
|
||||
|
||||
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
|
||||
try await post("users.update", body: request)
|
||||
}
|
||||
|
||||
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
|
||||
try await post("users.update", body: request)
|
||||
}
|
||||
|
||||
public func deleteAccount() async throws {
|
||||
try await postForSuccess("users.delete", body: EmptyParams())
|
||||
}
|
||||
|
||||
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||
try await post("users.notificationsSubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
|
||||
}
|
||||
|
||||
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||
try await post("users.notificationsUnsubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
|
||||
}
|
||||
|
||||
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
|
||||
try await post("apiKeys.list", body: request)
|
||||
}
|
||||
|
||||
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
|
||||
try await post("apiKeys.create", body: request)
|
||||
}
|
||||
|
||||
public func deleteApiKey(id: String) async throws {
|
||||
try await postForSuccess("apiKeys.delete", body: StarIDParams(id: id))
|
||||
}
|
||||
|
||||
public func installationInfo() async throws -> OutlineInstallationInfo {
|
||||
try await post("installation.info", body: EmptyParams())
|
||||
}
|
||||
|
||||
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
|
||||
guard let token = try? tokenStore.token() else {
|
||||
throw OutlineAPIError.tokenUnavailable
|
||||
@@ -388,6 +556,11 @@ private struct StarIDParams: Encodable {
|
||||
let id: String
|
||||
}
|
||||
|
||||
private struct CommentReactionParams: Encodable {
|
||||
let id: String
|
||||
let emoji: String
|
||||
}
|
||||
|
||||
private struct MoveDocumentResponse: Decodable {
|
||||
let documents: [OutlineDocument]?
|
||||
let collections: [OutlineCollection]?
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
|
||||
/// Minimal recursive JSON tree for fields the API returns as genuinely
|
||||
/// arbitrary/untyped JSON (`type: object` with no fixed schema in the
|
||||
/// OpenAPI spec) — currently just `Comment.data`, a ProseMirror document.
|
||||
/// Decode-only: nothing in this codebase writes comment bodies back yet.
|
||||
public enum JSONValue: Decodable, Sendable {
|
||||
case string(String)
|
||||
case number(Double)
|
||||
case bool(Bool)
|
||||
case object([String: JSONValue])
|
||||
case array([JSONValue])
|
||||
case null
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
self = .null
|
||||
} else if let value = try? container.decode(Bool.self) {
|
||||
self = .bool(value)
|
||||
} else if let value = try? container.decode(Double.self) {
|
||||
self = .number(value)
|
||||
} else if let value = try? container.decode(String.self) {
|
||||
self = .string(value)
|
||||
} else if let value = try? container.decode([String: JSONValue].self) {
|
||||
self = .object(value)
|
||||
} else if let value = try? container.decode([JSONValue].self) {
|
||||
self = .array(value)
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONValue {
|
||||
/// Best-effort plain-text extraction from a ProseMirror-shaped document —
|
||||
/// walks `content` arrays, concatenates `text` node strings, and adds a
|
||||
/// trailing newline after known block-level node types so paragraphs
|
||||
/// don't run together. Good enough for a read-only comment list; not a
|
||||
/// real ProseMirror renderer (no marks, no lists/tables structure).
|
||||
public func plainText() -> String {
|
||||
switch self {
|
||||
case .object(let fields):
|
||||
if case .string(let text)? = fields["text"] {
|
||||
return text
|
||||
}
|
||||
let childText: String
|
||||
if case .array(let items)? = fields["content"] {
|
||||
childText = items.map { $0.plainText() }.joined()
|
||||
} else {
|
||||
childText = ""
|
||||
}
|
||||
if case .string(let type)? = fields["type"], Self.blockTypes.contains(type) {
|
||||
return childText + "\n"
|
||||
}
|
||||
return childText
|
||||
case .array(let items):
|
||||
return items.map { $0.plainText() }.joined()
|
||||
case .string(let value):
|
||||
return value
|
||||
case .number, .bool, .null:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
private static let blockTypes: Set<String> = [
|
||||
"paragraph", "heading", "listItem", "blockquote", "codeBlock", "list_item", "code_block"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
/// The subset of Outline's notification event types this app exposes a
|
||||
/// toggle for. Wire values confirmed live (captured `users.update`
|
||||
/// responses showing the full `notificationSettings` dictionary after
|
||||
/// toggling each one in Outline's own web app). The server tracks more
|
||||
/// event types than this app has UI for (`revisions.create`,
|
||||
/// `emails.onboarding`, `emails.features` were also seen live) — those are
|
||||
/// left alone since `users.notificationsSubscribe`/`Unsubscribe` are
|
||||
/// per-event, not a whole-object replace like `preferences`, so there's no
|
||||
/// clobbering risk in only covering a subset.
|
||||
public enum NotificationEventType: String, CaseIterable, Sendable {
|
||||
case documentPublish = "documents.publish"
|
||||
case documentUpdate = "documents.update"
|
||||
case commentCreate = "comments.create"
|
||||
case commentMentioned = "comments.mentioned"
|
||||
case documentMentioned = "documents.mentioned"
|
||||
case commentGroupMentioned = "comments.group_mentioned"
|
||||
case documentGroupMentioned = "documents.group_mentioned"
|
||||
case commentResolve = "comments.resolve"
|
||||
case reactionCreate = "reactions.create"
|
||||
case collectionCreate = "collections.create"
|
||||
case emailsInviteAccepted = "emails.invite_accepted"
|
||||
case documentAddUser = "documents.add_user"
|
||||
case collectionAddUser = "collections.add_user"
|
||||
case emailsExportCompleted = "emails.export_completed"
|
||||
case accessRequestCreate = "access_requests.create"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
/// A personal API key (Settings → API & Access). Only the last 4 characters
|
||||
/// of the actual token are ever returned by the server — there's no way to
|
||||
/// see a full key again after creation, matching every other API-key UI
|
||||
/// convention.
|
||||
public struct OutlineAPIKey: Codable, Identifiable, Hashable, Sendable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let last4: String?
|
||||
public let scope: [String]?
|
||||
public let createdAt: Date
|
||||
public let expiresAt: Date?
|
||||
public let lastActiveAt: Date?
|
||||
/// The full plaintext key — present *only* in `apiKeys.create`'s
|
||||
/// response, confirmed live: `apiKeys.list` never includes it, matching
|
||||
/// "shown once at creation" being enforced server-side, not just a
|
||||
/// client-side UI convention this app has to uphold on its own.
|
||||
public let value: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
last4: String? = nil,
|
||||
scope: [String]? = nil,
|
||||
createdAt: Date,
|
||||
expiresAt: Date? = nil,
|
||||
lastActiveAt: Date? = nil,
|
||||
value: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.last4 = last4
|
||||
self.scope = scope
|
||||
self.createdAt = createdAt
|
||||
self.expiresAt = expiresAt
|
||||
self.lastActiveAt = lastActiveAt
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/// One uploaded file — created via a two-step presigned upload
|
||||
/// (`attachments.create` for the upload target, then a direct POST to
|
||||
/// `uploadUrl`/`form`). Confirmed live against a self-hosted instance's
|
||||
/// local-storage backend; `size` comes back as a string there, not a
|
||||
/// number — same "don't trust the vendored spec's implied types" lesson as
|
||||
/// everywhere else this codebase has hit it.
|
||||
public struct OutlineAttachment: Decodable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let documentId: String?
|
||||
public let contentType: String?
|
||||
public let name: String?
|
||||
public let url: String?
|
||||
public let size: String?
|
||||
}
|
||||
|
||||
/// `attachments.create`'s response — everything needed to perform the
|
||||
/// actual upload. `form` fields (Content-Type, key, acl, sig, `_csrf`, …)
|
||||
/// must all be included as their own multipart parts, in the same request
|
||||
/// as the file itself, POSTed to `uploadUrl`.
|
||||
public struct CreateAttachmentResult: Decodable, Sendable {
|
||||
public let attachment: OutlineAttachment
|
||||
public let uploadUrl: String
|
||||
public let form: [String: String]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
/// A comment (or reply, via `parentCommentId`) on a document. Backed by
|
||||
/// `comments.*` — `create`/`info`/`update`/`delete`/`list` are in the
|
||||
/// vendored OpenAPI spec; `resolve`/`unresolve`/`add_reaction`/
|
||||
/// `remove_reaction` are not (confirmed against Outline's own server
|
||||
/// source instead — same "spec mirror is incomplete" lesson as
|
||||
/// `OutlinePin`). `data` is the comment body as a ProseMirror document,
|
||||
/// not plain text — see `JSONValue.plainText()`.
|
||||
public struct OutlineComment: Decodable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let data: JSONValue
|
||||
public let documentId: String
|
||||
public let parentCommentId: String?
|
||||
public let createdAt: Date
|
||||
public let createdBy: OutlineUser?
|
||||
public let updatedAt: Date?
|
||||
public let resolvedAt: Date?
|
||||
public let resolvedBy: OutlineUser?
|
||||
public let reactions: [ReactionSummary]
|
||||
/// The document text this comment is anchored to — only populated when
|
||||
/// the request set `includeAnchorText: true`; `nil` for a document-level
|
||||
/// (non-anchored) comment either way. No prefix/suffix comes back on
|
||||
/// read (only accepted as create-time disambiguation input), so
|
||||
/// re-finding this text in the rendered document is inherently
|
||||
/// best-effort — first occurrence wins, same as Outline's own create
|
||||
/// behavior when nothing else disambiguates.
|
||||
public let anchorText: String?
|
||||
|
||||
public var isResolved: Bool { resolvedAt != nil }
|
||||
public var bodyText: String {
|
||||
data.plainText().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
/// One emoji's worth of reactions on a comment — grouped by emoji server-side
|
||||
/// (`ReactionSummary` in Outline's own source), not one object per reaction.
|
||||
public struct ReactionSummary: Codable, Sendable, Equatable {
|
||||
public let emoji: String
|
||||
public let userIds: [String]
|
||||
|
||||
public init(emoji: String, userIds: [String]) {
|
||||
self.emoji = emoji
|
||||
self.userIds = userIds
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
/// `installation.info` — the self-hosted server's own version, confirmed
|
||||
/// live. `policies` (a separate top-level array alongside `data` in the raw
|
||||
/// response) isn't modeled here — not used by this app's Installation
|
||||
/// settings page.
|
||||
public struct OutlineInstallationInfo: Decodable, Sendable {
|
||||
public let version: String
|
||||
public let latestVersion: String
|
||||
public let versionsBehind: Int
|
||||
}
|
||||
@@ -6,18 +6,30 @@ public struct OutlineUser: Codable, Identifiable, Hashable, Sendable {
|
||||
public let email: String?
|
||||
public let avatarUrl: String?
|
||||
public let role: String?
|
||||
public let language: String?
|
||||
public let preferences: OutlineUserPreferences?
|
||||
/// Keyed by `NotificationEventType`'s raw values, plus event types this
|
||||
/// app has no UI for — kept as a flexible dictionary rather than a
|
||||
/// fixed struct for that reason. `true` means subscribed.
|
||||
public let notificationSettings: [String: Bool]?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
email: String? = nil,
|
||||
avatarUrl: String? = nil,
|
||||
role: String? = nil
|
||||
role: String? = nil,
|
||||
language: String? = nil,
|
||||
preferences: OutlineUserPreferences? = nil,
|
||||
notificationSettings: [String: Bool]? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.email = email
|
||||
self.avatarUrl = avatarUrl
|
||||
self.role = role
|
||||
self.language = language
|
||||
self.preferences = preferences
|
||||
self.notificationSettings = notificationSettings
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
|
||||
/// `User.preferences` — a free-form JSON blob on Outline's own `User` row,
|
||||
/// not a fixed-shape API resource. Wire key names below are confirmed
|
||||
/// against a live server's own web app traffic (captured toggling every
|
||||
/// Preferences setting one at a time), not guessed — see the `CodingKeys`
|
||||
/// mapping for the two that don't match this struct's own property names.
|
||||
///
|
||||
/// The server also validates `preferences` against a known key allowlist
|
||||
/// and rejects the whole `users.update` call (not just the bad field) if
|
||||
/// any key it doesn't recognize is present — confirmed live via a
|
||||
/// `"notificationBadge: Invalid Input"` error when an earlier, wrong value
|
||||
/// was sent. That's also why `fullWidthDocuments` is kept here even though
|
||||
/// this app has no UI for it yet: this app always sends the *whole*
|
||||
/// preferences object back on every save (see
|
||||
/// `UpdateUserPreferencesRequest`), so silently dropping an unknown key
|
||||
/// during decode would permanently clear it the next time any other
|
||||
/// preference here gets saved.
|
||||
public struct OutlineUserPreferences: Codable, Hashable, Sendable {
|
||||
public var rememberLastPath: Bool?
|
||||
/// App-facing polarity: `true` means "separate editing mode is on" —
|
||||
/// the opposite of the wire's own `seamlessEdit` (seamless editing and
|
||||
/// separate editing modes are each other's negation), inverted in
|
||||
/// `init(from:)`/`encode(to:)` so nothing outside this file has to
|
||||
/// remember that.
|
||||
public var separateEditing: Bool?
|
||||
public var useCursorPointer: Bool?
|
||||
public var codeBlockLineNumbers: Bool?
|
||||
public var showCommentMarker: Bool?
|
||||
public var smartText: Bool?
|
||||
/// One of `NotificationBadgeStyle`'s raw values.
|
||||
public var notificationBadge: String?
|
||||
/// No UI in this app yet — preserved purely so saving any other
|
||||
/// preference here doesn't clobber it. See the type doc comment.
|
||||
public var fullWidthDocuments: Bool?
|
||||
|
||||
public init(
|
||||
rememberLastPath: Bool? = nil,
|
||||
separateEditing: Bool? = nil,
|
||||
useCursorPointer: Bool? = nil,
|
||||
codeBlockLineNumbers: Bool? = nil,
|
||||
showCommentMarker: Bool? = nil,
|
||||
smartText: Bool? = nil,
|
||||
notificationBadge: String? = nil,
|
||||
fullWidthDocuments: Bool? = nil
|
||||
) {
|
||||
self.rememberLastPath = rememberLastPath
|
||||
self.separateEditing = separateEditing
|
||||
self.useCursorPointer = useCursorPointer
|
||||
self.codeBlockLineNumbers = codeBlockLineNumbers
|
||||
self.showCommentMarker = showCommentMarker
|
||||
self.smartText = smartText
|
||||
self.notificationBadge = notificationBadge
|
||||
self.fullWidthDocuments = fullWidthDocuments
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case rememberLastPath
|
||||
case seamlessEdit
|
||||
case useCursorPointer
|
||||
case codeBlockLineNumbers
|
||||
case commentsInGutter
|
||||
case enableSmartText
|
||||
case notificationBadge
|
||||
case fullWidthDocuments
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
rememberLastPath = try container.decodeIfPresent(Bool.self, forKey: .rememberLastPath)
|
||||
separateEditing = try container.decodeIfPresent(Bool.self, forKey: .seamlessEdit).map { !$0 }
|
||||
useCursorPointer = try container.decodeIfPresent(Bool.self, forKey: .useCursorPointer)
|
||||
codeBlockLineNumbers = try container.decodeIfPresent(Bool.self, forKey: .codeBlockLineNumbers)
|
||||
showCommentMarker = try container.decodeIfPresent(Bool.self, forKey: .commentsInGutter)
|
||||
smartText = try container.decodeIfPresent(Bool.self, forKey: .enableSmartText)
|
||||
notificationBadge = try container.decodeIfPresent(String.self, forKey: .notificationBadge)
|
||||
fullWidthDocuments = try container.decodeIfPresent(Bool.self, forKey: .fullWidthDocuments)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encodeIfPresent(rememberLastPath, forKey: .rememberLastPath)
|
||||
try container.encodeIfPresent(separateEditing.map { !$0 }, forKey: .seamlessEdit)
|
||||
try container.encodeIfPresent(useCursorPointer, forKey: .useCursorPointer)
|
||||
try container.encodeIfPresent(codeBlockLineNumbers, forKey: .codeBlockLineNumbers)
|
||||
try container.encodeIfPresent(showCommentMarker, forKey: .commentsInGutter)
|
||||
try container.encodeIfPresent(smartText, forKey: .enableSmartText)
|
||||
try container.encodeIfPresent(notificationBadge, forKey: .notificationBadge)
|
||||
try container.encodeIfPresent(fullWidthDocuments, forKey: .fullWidthDocuments)
|
||||
}
|
||||
}
|
||||
|
||||
/// App-icon unread indicator style. Wire values confirmed live (captured
|
||||
/// setting all three from Outline's own web app).
|
||||
public enum NotificationBadgeStyle: String, CaseIterable, Identifiable, Sendable {
|
||||
case none = "disabled"
|
||||
case unreadIndicator = "indicator"
|
||||
case unreadCount = "count"
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .none: return "None"
|
||||
case .unreadIndicator: return "Unread Indicator"
|
||||
case .unreadCount: return "Unread Count"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
/// Builds the multipart/form-data body for Outline's presigned-upload
|
||||
/// targets (`attachments.create`'s `uploadUrl`/`form`) — an S3-style
|
||||
/// presigned POST: every `form` field has to ride along as its own part in
|
||||
/// the same request as the file, not as query params or headers.
|
||||
enum MultipartFormDataBuilder {
|
||||
static func build(
|
||||
fields: [String: String],
|
||||
fileFieldName: String,
|
||||
fileName: String,
|
||||
fileData: Data,
|
||||
fileContentType: String,
|
||||
boundary: String = "Boundary-\(UUID().uuidString)"
|
||||
) -> (body: Data, contentType: String) {
|
||||
var body = Data()
|
||||
|
||||
for (key, value) in fields {
|
||||
body.append("--\(boundary)\r\n".utf8Data)
|
||||
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".utf8Data)
|
||||
body.append(value.utf8Data)
|
||||
body.append("\r\n".utf8Data)
|
||||
}
|
||||
|
||||
body.append("--\(boundary)\r\n".utf8Data)
|
||||
body.append(
|
||||
"Content-Disposition: form-data; name=\"\(fileFieldName)\"; filename=\"\(fileName)\"\r\n".utf8Data
|
||||
)
|
||||
body.append("Content-Type: \(fileContentType)\r\n\r\n".utf8Data)
|
||||
body.append(fileData)
|
||||
body.append("\r\n".utf8Data)
|
||||
body.append("--\(boundary)--\r\n".utf8Data)
|
||||
|
||||
return (body, "multipart/form-data; boundary=\(boundary)")
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var utf8Data: Data { Data(utf8) }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
/// `apiKeys.create`. `expiresAt: nil` (the key omitted entirely, not sent as
|
||||
/// literal `null`) confirmed live to mean no expiration — Swift's
|
||||
/// synthesized `Encodable` already omits `nil` optionals via
|
||||
/// `encodeIfPresent`, so no custom `encode(to:)` is needed here the way
|
||||
/// `UpdateUserAvatarRequest` needed one for the opposite case.
|
||||
public struct CreateApiKeyRequest: Encodable, Sendable {
|
||||
public let name: String
|
||||
public let expiresAt: Date?
|
||||
/// `nil`/omitted grants full access — confirmed live (every key created
|
||||
/// without a scope came back with unrestricted access). A specific
|
||||
/// scope is a list of allowed API paths, e.g. `["/api/documents.info"]`.
|
||||
public let scope: [String]?
|
||||
|
||||
public init(name: String, expiresAt: Date? = nil, scope: [String]? = nil) {
|
||||
self.name = name
|
||||
self.expiresAt = expiresAt
|
||||
self.scope = scope
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
/// Requests an upload target for a new file — not the upload itself, see
|
||||
/// `OutlineAPIClient.uploadAttachmentFile`. `documentId: nil` is what a
|
||||
/// user-avatar upload sends (confirmed live); a real value scopes the
|
||||
/// attachment to a document instead (e.g. an inline image embed).
|
||||
public struct CreateAttachmentRequest: Encodable, Sendable {
|
||||
public let name: String
|
||||
public let contentType: String
|
||||
public let size: Int
|
||||
public let documentId: String?
|
||||
|
||||
public init(name: String, contentType: String, size: Int, documentId: String? = nil) {
|
||||
self.name = name
|
||||
self.contentType = contentType
|
||||
self.size = size
|
||||
self.documentId = documentId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/// `parentCommentId: nil` creates a document-level (top-level) comment;
|
||||
/// non-nil creates a reply (Outline supports one level of nesting — a
|
||||
/// reply's own `parentCommentId` should always be a top-level comment's
|
||||
/// id, never another reply's). `anchorText` creates an inline (anchored)
|
||||
/// comment instead of a document-level one — the first occurrence of that
|
||||
/// exact substring in the document's plain text is used, same as Outline's
|
||||
/// own web editor; `anchorPrefix`/`anchorSuffix` aren't sent (this app has
|
||||
/// no UI for choosing between multiple identical occurrences). `text` is
|
||||
/// the documented markdown convenience field for `data` (a ProseMirror
|
||||
/// document) — simplest path for plain-text comment bodies, no need to
|
||||
/// construct ProseMirror JSON by hand.
|
||||
public struct CreateCommentRequest: Encodable, Sendable {
|
||||
public let documentId: String
|
||||
public let parentCommentId: String?
|
||||
public let text: String
|
||||
public let anchorText: String?
|
||||
|
||||
public init(documentId: String, parentCommentId: String? = nil, text: String, anchorText: String? = nil) {
|
||||
self.documentId = documentId
|
||||
self.parentCommentId = parentCommentId
|
||||
self.text = text
|
||||
self.anchorText = anchorText
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
public struct CreateDocumentRequest: Encodable, Sendable {
|
||||
public struct CreateDocumentRequest: Codable, Sendable {
|
||||
public let title: String
|
||||
public let text: String
|
||||
public let collectionId: String
|
||||
/// `nil` (with `parentDocumentId` also `nil`) creates a draft — Outline
|
||||
/// requires one of the two to publish at all, regardless of `publish`.
|
||||
public let collectionId: String?
|
||||
public let parentDocumentId: String?
|
||||
public let publish: Bool
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
text: String,
|
||||
collectionId: String,
|
||||
collectionId: String? = nil,
|
||||
parentDocumentId: String? = nil,
|
||||
publish: Bool = true
|
||||
) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
|
||||
/// See `OutlinePin`. `collectionId: nil` = "Pin to Home", non-nil = "Pin to
|
||||
/// Collection" — these are distinct actions on the real server.
|
||||
public struct CreatePinRequest: Encodable, Sendable {
|
||||
public struct CreatePinRequest: Codable, Sendable {
|
||||
public let documentId: String
|
||||
public let collectionId: String?
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
/// See `OutlineSubscription` — best-effort shape, not in the vendored spec.
|
||||
public struct CreateSubscriptionRequest: Encodable, Sendable {
|
||||
public struct CreateSubscriptionRequest: Codable, Sendable {
|
||||
public let documentId: String
|
||||
public let event: String
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
public struct StarCollectionRequest: Encodable, Sendable {
|
||||
public struct StarCollectionRequest: Codable, Sendable {
|
||||
public let collectionId: String
|
||||
|
||||
public init(collectionId: String) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
public struct StarDocumentRequest: Encodable, Sendable {
|
||||
public struct StarDocumentRequest: Codable, Sendable {
|
||||
public let documentId: String
|
||||
|
||||
public init(documentId: String) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
public struct UpdateCollectionRequest: Encodable, Sendable {
|
||||
public struct UpdateCollectionRequest: Codable, Sendable {
|
||||
public let id: String
|
||||
public let name: String?
|
||||
public let description: String?
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
public struct UpdateDocumentRequest: Encodable, Sendable {
|
||||
public struct UpdateDocumentRequest: Codable, Sendable {
|
||||
public let id: String
|
||||
public let title: String?
|
||||
public let text: String?
|
||||
public let append: Bool?
|
||||
public let fullWidth: Bool?
|
||||
public let insightsEnabled: Bool?
|
||||
/// Moves the document to this collection. Combined with `publish: true`,
|
||||
/// this is how a draft (no `collectionId`, or one it just hasn't left
|
||||
/// yet) gets published into a specific collection in one call.
|
||||
public let collectionId: String?
|
||||
/// Publishes a draft, making it visible to other workspace members.
|
||||
/// Documented as a no-op if the document is already published.
|
||||
public let publish: Bool?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
@@ -14,7 +21,9 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
|
||||
text: String? = nil,
|
||||
append: Bool? = nil,
|
||||
fullWidth: Bool? = nil,
|
||||
insightsEnabled: Bool? = nil
|
||||
insightsEnabled: Bool? = nil,
|
||||
collectionId: String? = nil,
|
||||
publish: Bool? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
@@ -22,5 +31,7 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
|
||||
self.append = append
|
||||
self.fullWidth = fullWidth
|
||||
self.insightsEnabled = insightsEnabled
|
||||
self.collectionId = collectionId
|
||||
self.publish = publish
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
/// `users.update`, scoped to just the avatar. Custom `encode(to:)` because
|
||||
/// Swift's synthesized `Encodable` uses `encodeIfPresent` for `Optional`
|
||||
/// properties, which *omits* the key entirely when the value is `nil` —
|
||||
/// removing the avatar needs a literal `"avatarUrl": null` in the request
|
||||
/// body, not the key missing. `id` is required: confirmed live (the
|
||||
/// captured request body's length only matches `{"id": "...", "avatarUrl":
|
||||
/// ...}`, not a shorter shape without it).
|
||||
public struct UpdateUserAvatarRequest: Encodable, Sendable {
|
||||
public let id: String
|
||||
public let avatarUrl: String?
|
||||
|
||||
public init(id: String, avatarUrl: String?) {
|
||||
self.id = id
|
||||
self.avatarUrl = avatarUrl
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, avatarUrl
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(avatarUrl, forKey: .avatarUrl)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
/// `users.update`, language only.
|
||||
public struct UpdateUserLanguageRequest: Encodable, Sendable {
|
||||
public let id: String
|
||||
public let language: String
|
||||
|
||||
public init(id: String, language: String) {
|
||||
self.id = id
|
||||
self.language = language
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
/// `users.update`, name only.
|
||||
public struct UpdateUserNameRequest: Encodable, Sendable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
|
||||
public init(id: String, name: String) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
/// `users.update`, preferences only. Sends the *whole* preferences object
|
||||
/// back (not a single changed key) — avoids needing to know whether the
|
||||
/// server deep-merges a partial `preferences` body or replaces it outright.
|
||||
public struct UpdateUserPreferencesRequest: Encodable, Sendable {
|
||||
public let id: String
|
||||
public let preferences: OutlineUserPreferences
|
||||
|
||||
public init(id: String, preferences: OutlineUserPreferences) {
|
||||
self.id = id
|
||||
self.preferences = preferences
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
import XCTest
|
||||
@testable import OutlineKit
|
||||
|
||||
private struct NotStubbed: Error {}
|
||||
|
||||
/// Conforms to the full protocol (so it can stand in for `live`), but only
|
||||
/// the two methods under test in this file have real behavior — everything
|
||||
/// else throws loudly if a test accidentally exercises it.
|
||||
private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable {
|
||||
var documentInfoHandler: (@Sendable (String) async throws -> OutlineDocument)?
|
||||
var listCollectionsHandler: (@Sendable (Int, Int) async throws -> [OutlineCollection])?
|
||||
var updateDocumentHandler: (@Sendable (UpdateDocumentRequest) async throws -> OutlineDocument)?
|
||||
var createPinHandler: (@Sendable (CreatePinRequest) async throws -> OutlinePin)?
|
||||
var deletePinHandler: (@Sendable (String) async throws -> Void)?
|
||||
var listDocumentsHandler: (@Sendable (String?, String?, Int, Int) async throws -> [OutlineDocument])?
|
||||
var createDocumentHandler: (@Sendable (CreateDocumentRequest) async throws -> OutlineDocument)?
|
||||
|
||||
func authInfo() async throws -> OutlineAuthInfo { throw NotStubbed() }
|
||||
|
||||
func documentInfo(id: String) async throws -> OutlineDocument {
|
||||
guard let handler = documentInfoHandler else { throw NotStubbed() }
|
||||
return try await handler(id)
|
||||
}
|
||||
|
||||
func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument] {
|
||||
guard let handler = listDocumentsHandler else { throw NotStubbed() }
|
||||
return try await handler(collectionId, parentDocumentId, offset, limit)
|
||||
}
|
||||
|
||||
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||
func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() }
|
||||
func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||
func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
|
||||
guard let handler = createDocumentHandler else { throw NotStubbed() }
|
||||
return try await handler(request)
|
||||
}
|
||||
|
||||
func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
||||
guard let handler = updateDocumentHandler else { throw NotStubbed() }
|
||||
return try await handler(request)
|
||||
}
|
||||
|
||||
func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar { throw NotStubbed() }
|
||||
func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate { throw NotStubbed() }
|
||||
func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||
func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument { throw NotStubbed() }
|
||||
func archiveDocument(id: String) async throws -> OutlineDocument { throw NotStubbed() }
|
||||
func moveDocument(_ request: MoveDocumentRequest) async throws { throw NotStubbed() }
|
||||
func deleteDocument(_ request: DeleteDocumentRequest) async throws { throw NotStubbed() }
|
||||
func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] { throw NotStubbed() }
|
||||
func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] { throw NotStubbed() }
|
||||
func exportDocument(id: String) async throws -> String { throw NotStubbed() }
|
||||
func createShare(_ request: CreateShareRequest) async throws -> OutlineShare { throw NotStubbed() }
|
||||
func shareInfo(documentId: String) async throws -> OutlineShare? { throw NotStubbed() }
|
||||
func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare { throw NotStubbed() }
|
||||
func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] { throw NotStubbed() }
|
||||
func revokeShare(id: String) async throws { throw NotStubbed() }
|
||||
func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
|
||||
guard let handler = createPinHandler else { throw NotStubbed() }
|
||||
return try await handler(request)
|
||||
}
|
||||
|
||||
func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { throw NotStubbed() }
|
||||
|
||||
func deletePin(id: String) async throws {
|
||||
guard let handler = deletePinHandler else { throw NotStubbed() }
|
||||
try await handler(id)
|
||||
}
|
||||
func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription { throw NotStubbed() }
|
||||
func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { throw NotStubbed() }
|
||||
func deleteSubscription(id: String) async throws { throw NotStubbed() }
|
||||
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { throw NotStubbed() }
|
||||
func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] { throw NotStubbed() }
|
||||
func commentInfo(id: String) async throws -> OutlineComment { throw NotStubbed() }
|
||||
func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment { throw NotStubbed() }
|
||||
func resolveComment(id: String) async throws -> OutlineComment { throw NotStubbed() }
|
||||
func unresolveComment(id: String) async throws -> OutlineComment { throw NotStubbed() }
|
||||
func addReaction(commentId: String, emoji: String) async throws { throw NotStubbed() }
|
||||
func removeReaction(commentId: String, emoji: String) async throws { throw NotStubbed() }
|
||||
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { throw NotStubbed() }
|
||||
func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() }
|
||||
func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { throw NotStubbed() }
|
||||
func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] { throw NotStubbed() }
|
||||
|
||||
func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
|
||||
guard let handler = listCollectionsHandler else { throw NotStubbed() }
|
||||
return try await handler(offset, limit)
|
||||
}
|
||||
|
||||
func collectionInfo(id: String) async throws -> OutlineCollection { throw NotStubbed() }
|
||||
func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection { throw NotStubbed() }
|
||||
func deleteCollection(id: String) async throws { throw NotStubbed() }
|
||||
func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation { throw NotStubbed() }
|
||||
func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar { throw NotStubbed() }
|
||||
func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { throw NotStubbed() }
|
||||
func deleteStar(id: String) async throws { throw NotStubbed() }
|
||||
func currentUser() async throws -> OutlineUser { throw NotStubbed() }
|
||||
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() }
|
||||
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws { throw NotStubbed() }
|
||||
func fetchAuthenticatedFile(path: String) async throws -> Data { throw NotStubbed() }
|
||||
func deleteAttachment(id: String) async throws { throw NotStubbed() }
|
||||
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func deleteAccount() async throws { throw NotStubbed() }
|
||||
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] { throw NotStubbed() }
|
||||
func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey { throw NotStubbed() }
|
||||
func deleteApiKey(id: String) async throws { throw NotStubbed() }
|
||||
func installationInfo() async throws -> OutlineInstallationInfo { throw NotStubbed() }
|
||||
}
|
||||
|
||||
private struct StubTransportError: Error {}
|
||||
|
||||
final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
private func makeCache() throws -> OfflineCacheStore {
|
||||
OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true))
|
||||
}
|
||||
|
||||
private func makeDocument(id: String = "doc-1", title: String = "Hello") -> OutlineDocument {
|
||||
OutlineDocument(
|
||||
id: id,
|
||||
title: title,
|
||||
text: "body",
|
||||
url: "/doc/\(id)",
|
||||
createdAt: Date(timeIntervalSince1970: 0),
|
||||
updatedAt: Date(timeIntervalSince1970: 0)
|
||||
)
|
||||
}
|
||||
|
||||
private func makeCollection(id: String = "col-1", name: String = "Engineering") -> OutlineCollection {
|
||||
OutlineCollection(
|
||||
id: id,
|
||||
name: name,
|
||||
createdAt: Date(timeIntervalSince1970: 0),
|
||||
updatedAt: Date(timeIntervalSince1970: 0)
|
||||
)
|
||||
}
|
||||
|
||||
func testDocumentInfoWritesThroughToCacheOnSuccess() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let document = makeDocument()
|
||||
stub.documentInfoHandler = { _ in document }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
let result = try await sut.documentInfo(id: "doc-1")
|
||||
|
||||
XCTAssertEqual(result, document)
|
||||
}
|
||||
|
||||
func testDocumentInfoFallsBackToCacheWhenLiveFails() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let document = makeDocument()
|
||||
var callCount = 0
|
||||
stub.documentInfoHandler = { _ in
|
||||
callCount += 1
|
||||
if callCount == 1 { return document }
|
||||
throw StubTransportError()
|
||||
}
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
// First call succeeds and populates the cache.
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
// Second call fails live — should transparently return the cached value.
|
||||
let result = try await sut.documentInfo(id: "doc-1")
|
||||
|
||||
XCTAssertEqual(result, document)
|
||||
XCTAssertEqual(callCount, 2)
|
||||
}
|
||||
|
||||
func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
do {
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
XCTFail("Expected an error")
|
||||
} catch is StubTransportError {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
func testListCollectionsFallsBackToCacheWhenLiveFails() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let collections = [makeCollection()]
|
||||
var callCount = 0
|
||||
stub.listCollectionsHandler = { _, _ in
|
||||
callCount += 1
|
||||
if callCount == 1 { return collections }
|
||||
throw StubTransportError()
|
||||
}
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
_ = try await sut.listCollections(offset: 0, limit: 25)
|
||||
let result = try await sut.listCollections(offset: 0, limit: 25)
|
||||
|
||||
XCTAssertEqual(result, collections)
|
||||
}
|
||||
|
||||
func testDifferentDocumentIdsAreCachedSeparately() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let docOne = makeDocument(id: "doc-1", title: "One")
|
||||
let docTwo = makeDocument(id: "doc-2", title: "Two")
|
||||
stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
_ = try await sut.documentInfo(id: "doc-2")
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
|
||||
let cachedOne = try await sut.documentInfo(id: "doc-1")
|
||||
let cachedTwo = try await sut.documentInfo(id: "doc-2")
|
||||
|
||||
XCTAssertEqual(cachedOne, docOne)
|
||||
XCTAssertEqual(cachedTwo, docTwo)
|
||||
}
|
||||
|
||||
// MARK: - Offline writes
|
||||
|
||||
func testUpdateDocumentQueuesAndAppliesOptimisticallyWhenLiveFails() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let original = makeDocument(id: "doc-1", title: "Original")
|
||||
stub.documentInfoHandler = { _ in original }
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
// Populate the cache with the base document first (as a real open would).
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
|
||||
let updated = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
|
||||
XCTAssertEqual(updated.title, "Edited Offline")
|
||||
|
||||
// Re-opening the document (still offline) should reflect the optimistic edit.
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
let reopened = try await sut.documentInfo(id: "doc-1")
|
||||
XCTAssertEqual(reopened.title, "Edited Offline")
|
||||
|
||||
let pending = await sut.pendingOperations()
|
||||
XCTAssertEqual(pending.count, 1)
|
||||
XCTAssertEqual(pending.first?.kind, "updateDocument")
|
||||
}
|
||||
|
||||
func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
do {
|
||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x"))
|
||||
XCTFail("Expected an error")
|
||||
} catch is StubTransportError {
|
||||
// expected — nothing to optimistically merge onto
|
||||
}
|
||||
}
|
||||
|
||||
func testSecondOfflineEditCoalescesIntoOneQueuedOperation() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let original = makeDocument(id: "doc-1", title: "Original")
|
||||
stub.documentInfoHandler = { _ in original }
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
_ = 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: "Second Edit"))
|
||||
|
||||
let pending = await sut.pendingOperations()
|
||||
XCTAssertEqual(pending.count, 1)
|
||||
}
|
||||
|
||||
func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.createPinHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
|
||||
XCTAssertTrue(pin.id.hasPrefix("pending-"))
|
||||
let pendingCount1 = await sut.pendingOperations().count
|
||||
XCTAssertEqual(pendingCount1, 1)
|
||||
|
||||
try await sut.deletePin(id: pin.id)
|
||||
|
||||
let pendingCount0 = await sut.pendingOperations().count
|
||||
XCTAssertEqual(pendingCount0, 0)
|
||||
}
|
||||
|
||||
func testFlushPendingOperationsReplaysAndClearsOnSuccess() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let original = makeDocument(id: "doc-1", title: "Original")
|
||||
stub.documentInfoHandler = { _ in original }
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
|
||||
let pendingCount1 = await sut.pendingOperations().count
|
||||
XCTAssertEqual(pendingCount1, 1)
|
||||
|
||||
// Network's back — replay should now succeed.
|
||||
stub.updateDocumentHandler = { request in
|
||||
self.makeDocument(id: request.id, title: request.title ?? "")
|
||||
}
|
||||
let summary = await sut.flushPendingOperations()
|
||||
|
||||
XCTAssertEqual(summary.succeeded, 1)
|
||||
XCTAssertEqual(summary.failed, 0)
|
||||
let pendingCount0 = await sut.pendingOperations().count
|
||||
XCTAssertEqual(pendingCount0, 0)
|
||||
}
|
||||
|
||||
func testFlushPendingOperationsRecordsErrorAndKeepsFailedOperation() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let original = makeDocument(id: "doc-1", title: "Original")
|
||||
stub.documentInfoHandler = { _ in original }
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
|
||||
|
||||
// Still offline — replay should fail and keep the operation queued.
|
||||
let summary = await sut.flushPendingOperations()
|
||||
|
||||
XCTAssertEqual(summary.succeeded, 0)
|
||||
XCTAssertEqual(summary.failed, 1)
|
||||
let pending = await sut.pendingOperations()
|
||||
XCTAssertEqual(pending.count, 1)
|
||||
XCTAssertEqual(pending.first?.attemptCount, 1)
|
||||
XCTAssertNotNil(pending.first?.lastError)
|
||||
}
|
||||
|
||||
func testManualOfflineModeSkipsLiveEntirely() async throws {
|
||||
let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOffline")!
|
||||
defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOffline")
|
||||
defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey)
|
||||
|
||||
let stub = StubOutlineAPIClient()
|
||||
var liveCallCount = 0
|
||||
stub.createPinHandler = { _ in
|
||||
liveCallCount += 1
|
||||
return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil)
|
||||
}
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults)
|
||||
|
||||
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
|
||||
|
||||
XCTAssertEqual(liveCallCount, 0, "Manual offline mode should never attempt the live call")
|
||||
XCTAssertTrue(pin.id.hasPrefix("pending-"))
|
||||
}
|
||||
|
||||
/// Regression test: manual offline mode used to only gate writes —
|
||||
/// reads (listCollections, documentInfo, etc.) still hit `live` first
|
||||
/// any time the device actually had a connection, silently defeating
|
||||
/// "skip the network entirely" for the one thing that mattered most:
|
||||
/// the sidebar showing more than what was actually cached.
|
||||
func testManualOfflineModeSkipsLiveForReadsToo() async throws {
|
||||
let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOfflineReads")!
|
||||
defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOfflineReads")
|
||||
|
||||
let stub = StubOutlineAPIClient()
|
||||
let cachedCollection = makeCollection(id: "col-1")
|
||||
var liveCallCount = 0
|
||||
stub.listCollectionsHandler = { _, _ in
|
||||
liveCallCount += 1
|
||||
return [cachedCollection]
|
||||
}
|
||||
let cache = try makeCache()
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults)
|
||||
|
||||
// Online first — populates the cache normally.
|
||||
let firstResult = try await sut.listCollections(offset: 0, limit: 25)
|
||||
XCTAssertEqual(firstResult, [cachedCollection])
|
||||
XCTAssertEqual(liveCallCount, 1)
|
||||
|
||||
// Flip manual offline mode on, still "connected" (stub would happily
|
||||
// answer) — the live call must not be attempted at all.
|
||||
defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey)
|
||||
let secondResult = try await sut.listCollections(offset: 0, limit: 25)
|
||||
|
||||
XCTAssertEqual(secondResult, [cachedCollection])
|
||||
XCTAssertEqual(liveCallCount, 1, "listCollections should have served entirely from cache")
|
||||
}
|
||||
|
||||
func testCacheStorageSummaryReflectsCachedItems() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.documentInfoHandler = { _ in self.makeDocument() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
let summary = await sut.cacheStorageSummary()
|
||||
|
||||
XCTAssertEqual(summary.itemCount, 1)
|
||||
XCTAssertGreaterThan(summary.totalBytes, 0)
|
||||
|
||||
await sut.clearCache()
|
||||
let clearedSummary = await sut.cacheStorageSummary()
|
||||
XCTAssertEqual(clearedSummary.itemCount, 0)
|
||||
}
|
||||
|
||||
// MARK: - Full sync
|
||||
|
||||
func testPerformFullSyncNeverRequestsMoreThan100CollectionsPerPage() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
var requestedLimits: [Int] = []
|
||||
// 105 collections across two pages (100 + 5) — regression test for a
|
||||
// real bug: this used to ask for `limit: 250` in one shot, which
|
||||
// Outline's server rejects outright ("Pagination limit is too large
|
||||
// (max 100)"), turning the whole sync into a single silent failure.
|
||||
stub.listCollectionsHandler = { offset, limit in
|
||||
requestedLimits.append(limit)
|
||||
let remaining = max(0, 105 - offset)
|
||||
let count = min(limit, remaining)
|
||||
return (0..<count).map { self.makeCollection(id: "col-\(offset + $0)") }
|
||||
}
|
||||
stub.listDocumentsHandler = { _, _, _, _ in [] }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
let summary = await sut.performFullSync()
|
||||
|
||||
XCTAssertEqual(summary.collectionsCount, 105)
|
||||
XCTAssertTrue(summary.errors.isEmpty)
|
||||
XCTAssertTrue(requestedLimits.allSatisfy { $0 <= 100 }, "requested limits: \(requestedLimits)")
|
||||
}
|
||||
|
||||
func testPerformFullSyncCachesEachDocumentIndividually() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
|
||||
// Must return empty for any non-nil parentDocumentId (no children) —
|
||||
// performFullSync now recurses into every document's own children,
|
||||
// so a stub that ignores parentDocumentId and always returns the
|
||||
// same root documents regardless would recurse into itself forever.
|
||||
stub.listDocumentsHandler = { _, parentDocumentId, offset, _ in
|
||||
guard parentDocumentId == nil else { return [] }
|
||||
return offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
|
||||
}
|
||||
let cache = try makeCache()
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
|
||||
|
||||
let summary = await sut.performFullSync()
|
||||
XCTAssertEqual(summary.documentsCount, 2)
|
||||
|
||||
// A plain documentInfo read (no live call available) should now hit
|
||||
// the cache full sync populated, not just the list-shaped cache key.
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
let cachedDoc = try await sut.documentInfo(id: "doc-2")
|
||||
XCTAssertEqual(cachedDoc.id, "doc-2")
|
||||
}
|
||||
|
||||
func testPerformFullSyncRecursesIntoNestedDocuments() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
|
||||
// doc-1 (root) -> doc-2 (child of doc-1) -> doc-3 (grandchild) —
|
||||
// regression test for the real gap this fixed: only root-level
|
||||
// documents were ever cached before, so a document's own
|
||||
// sub-documents were never reachable offline at all unless
|
||||
// something else happened to open them individually first.
|
||||
stub.listDocumentsHandler = { _, parentDocumentId, offset, _ in
|
||||
guard offset == 0 else { return [] }
|
||||
switch parentDocumentId {
|
||||
case nil: return [self.makeDocument(id: "doc-1")]
|
||||
case "doc-1": return [self.makeDocument(id: "doc-2")]
|
||||
case "doc-2": return [self.makeDocument(id: "doc-3")]
|
||||
default: return []
|
||||
}
|
||||
}
|
||||
let cache = try makeCache()
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
|
||||
|
||||
let summary = await sut.performFullSync()
|
||||
|
||||
XCTAssertEqual(summary.documentsCount, 3)
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
let cachedGrandchild = try await sut.documentInfo(id: "doc-3")
|
||||
XCTAssertEqual(cachedGrandchild.id, "doc-3")
|
||||
}
|
||||
|
||||
// MARK: - Offline document creation
|
||||
|
||||
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
let created = try await sut.createDocument(
|
||||
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
||||
)
|
||||
|
||||
XCTAssertTrue(created.id.hasPrefix("pending-"))
|
||||
XCTAssertEqual(created.title, "New Doc")
|
||||
XCTAssertEqual(created.text, "hello")
|
||||
|
||||
// It's immediately readable, same as any other cached document.
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
let reopened = try await sut.documentInfo(id: created.id)
|
||||
XCTAssertEqual(reopened.title, "New Doc")
|
||||
|
||||
let pending = await sut.pendingOperations()
|
||||
XCTAssertEqual(pending.count, 1)
|
||||
XCTAssertEqual(pending.first?.kind, "createDocument")
|
||||
}
|
||||
|
||||
func testEditingAnUnsyncedCreatedDocumentFoldsIntoTheCreateInsteadOfQueuingAnUpdate() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
let created = try await sut.createDocument(
|
||||
CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1")
|
||||
)
|
||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: created.id, title: "Real Title", text: "Real body"))
|
||||
|
||||
let pending = await sut.pendingOperations()
|
||||
XCTAssertEqual(pending.count, 1, "the edit should have folded into the pending create, not added a second operation")
|
||||
XCTAssertEqual(pending.first?.kind, "createDocument")
|
||||
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
let reopened = try await sut.documentInfo(id: created.id)
|
||||
XCTAssertEqual(reopened.title, "Real Title")
|
||||
XCTAssertEqual(reopened.text, "Real body")
|
||||
}
|
||||
|
||||
func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
let created = try await sut.createDocument(
|
||||
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
||||
)
|
||||
let realDocument = makeDocument(id: "real-server-id", title: "New Doc")
|
||||
stub.createDocumentHandler = { _ in realDocument }
|
||||
|
||||
let summary = await sut.flushPendingOperations()
|
||||
XCTAssertEqual(summary.succeeded, 1)
|
||||
XCTAssertEqual(summary.failed, 0)
|
||||
|
||||
// The real document is now readable under its real id...
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
let byRealId = try await sut.documentInfo(id: "real-server-id")
|
||||
XCTAssertEqual(byRealId.id, "real-server-id")
|
||||
|
||||
// ...and the placeholder is gone rather than left as a dead orphan.
|
||||
do {
|
||||
_ = try await sut.documentInfo(id: created.id)
|
||||
XCTFail("Expected the placeholder cache entry to have been removed")
|
||||
} catch {
|
||||
// expected — nothing left under the old id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -846,6 +846,137 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/views.list")
|
||||
}
|
||||
|
||||
func testListCommentsDecodesCommentsAndExtractsPlainTextFromProseMirrorData() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "comment-1",
|
||||
"documentId": "doc-1",
|
||||
"parentCommentId": null,
|
||||
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||
"createdBy": { "id": "user-1", "name": "Jane Doe" },
|
||||
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||
"resolvedAt": null,
|
||||
"resolvedBy": null,
|
||||
"reactions": [
|
||||
{ "emoji": "\u{1F44D}", "userIds": ["user-2", "user-3"] }
|
||||
],
|
||||
"data": {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{ "type": "text", "text": "Sounds great" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let comments = try await client.listComments(ListCommentsRequest(documentId: "doc-1"))
|
||||
|
||||
XCTAssertEqual(comments.first?.id, "comment-1")
|
||||
XCTAssertEqual(comments.first?.bodyText, "Sounds great")
|
||||
XCTAssertFalse(comments.first?.isResolved ?? true)
|
||||
XCTAssertEqual(comments.first?.reactions.first?.emoji, "\u{1F44D}")
|
||||
XCTAssertEqual(comments.first?.reactions.first?.userIds, ["user-2", "user-3"])
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.list")
|
||||
}
|
||||
|
||||
func testResolveCommentDecodesResolvedComment() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "comment-1",
|
||||
"documentId": "doc-1",
|
||||
"parentCommentId": null,
|
||||
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||
"createdBy": { "id": "user-1", "name": "Jane Doe" },
|
||||
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||
"resolvedAt": "2026-01-02T00:00:00.000Z",
|
||||
"resolvedBy": { "id": "user-2", "name": "John Roe" },
|
||||
"reactions": [],
|
||||
"data": { "type": "doc", "content": [] }
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let comment = try await client.resolveComment(id: "comment-1")
|
||||
|
||||
XCTAssertTrue(comment.isResolved)
|
||||
XCTAssertEqual(comment.resolvedBy?.name, "John Roe")
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.resolve")
|
||||
}
|
||||
|
||||
func testCreateCommentSendsParentCommentIdForAReply() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "comment-2",
|
||||
"documentId": "doc-1",
|
||||
"parentCommentId": "comment-1",
|
||||
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||
"createdBy": { "id": "user-1", "name": "Jane Doe" },
|
||||
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||
"resolvedAt": null,
|
||||
"resolvedBy": null,
|
||||
"reactions": [],
|
||||
"data": { "type": "doc", "content": [] }
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let reply = try await client.createComment(
|
||||
CreateCommentRequest(documentId: "doc-1", parentCommentId: "comment-1", text: "Agreed")
|
||||
)
|
||||
|
||||
XCTAssertEqual(reply.parentCommentId, "comment-1")
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.create")
|
||||
}
|
||||
|
||||
func testAddReactionPostsIdAndEmoji() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{ "success": true }
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
try await client.addReaction(commentId: "comment-1", emoji: "\u{1F44D}")
|
||||
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.add_reaction")
|
||||
}
|
||||
|
||||
func testCreatePinDecodesPin() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
@@ -993,6 +1124,490 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.list")
|
||||
}
|
||||
|
||||
func testUpdateUserAvatarSendsExplicitNullWhenRemoving() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "user-1",
|
||||
"name": "Jane Doe",
|
||||
"avatarUrl": null
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let user = try await client.updateUserAvatar(UpdateUserAvatarRequest(id: "user-1", avatarUrl: nil))
|
||||
|
||||
XCTAssertNil(user.avatarUrl)
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
|
||||
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
|
||||
let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8))
|
||||
// The whole point of UpdateUserAvatarRequest's custom encode(to:) —
|
||||
// Swift's synthesized Encodable would have omitted the key entirely
|
||||
// for a nil Optional instead of sending a literal null.
|
||||
XCTAssertTrue(sentJSON.contains("\"avatarUrl\":null"), "expected an explicit null, got: \(sentJSON)")
|
||||
}
|
||||
|
||||
func testUpdateUserAvatarSendsTheNewURLWhenSet() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "user-1",
|
||||
"name": "Jane Doe",
|
||||
"avatarUrl": "/api/attachments.redirect?id=abc"
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let user = try await client.updateUserAvatar(
|
||||
UpdateUserAvatarRequest(id: "user-1", avatarUrl: "/api/attachments.redirect?id=abc")
|
||||
)
|
||||
|
||||
XCTAssertEqual(user.avatarUrl, "/api/attachments.redirect?id=abc")
|
||||
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
|
||||
let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8))
|
||||
XCTAssertTrue(sentJSON.contains("\"id\":\"user-1\""))
|
||||
}
|
||||
|
||||
func testUpdateUserNameSendsRequestAndDecodesResult() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "user-1",
|
||||
"name": "New Name"
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let user = try await client.updateUserName(UpdateUserNameRequest(id: "user-1", name: "New Name"))
|
||||
|
||||
XCTAssertEqual(user.name, "New Name")
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
|
||||
}
|
||||
|
||||
func testUpdateUserLanguageSendsRequestAndDecodesResult() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "user-1",
|
||||
"name": "Jane Doe",
|
||||
"language": "fr_FR"
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let user = try await client.updateUserLanguage(UpdateUserLanguageRequest(id: "user-1", language: "fr_FR"))
|
||||
|
||||
XCTAssertEqual(user.language, "fr_FR")
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
|
||||
}
|
||||
|
||||
func testUpdateUserPreferencesSendsWholeObjectAndDecodesResult() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "user-1",
|
||||
"name": "Jane Doe",
|
||||
"preferences": { "rememberLastPath": true, "useCursorPointer": true }
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let preferences = OutlineUserPreferences(rememberLastPath: true, useCursorPointer: true)
|
||||
let user = try await client.updateUserPreferences(UpdateUserPreferencesRequest(id: "user-1", preferences: preferences))
|
||||
|
||||
XCTAssertEqual(user.preferences?.rememberLastPath, true)
|
||||
XCTAssertEqual(user.preferences?.useCursorPointer, true)
|
||||
|
||||
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||
let sentPreferences = sentBody?["preferences"] as? [String: Any]
|
||||
XCTAssertEqual(sentPreferences?["rememberLastPath"] as? Bool, true)
|
||||
XCTAssertEqual(sentPreferences?["useCursorPointer"] as? Bool, true)
|
||||
}
|
||||
|
||||
/// Locks in the wire mapping confirmed against a live server's own web
|
||||
/// app traffic: `seamlessEdit`/`commentsInGutter`/`enableSmartText` are
|
||||
/// the real keys (not `separateEditing`/`showCommentMarker`/`smartText`
|
||||
/// this struct exposes), and `seamlessEdit` is the *negation* of this
|
||||
/// app's `separateEditing`.
|
||||
func testOutlineUserPreferencesDecodesRealWireKeys() throws {
|
||||
let json = """
|
||||
{
|
||||
"seamlessEdit": false,
|
||||
"commentsInGutter": true,
|
||||
"enableSmartText": true,
|
||||
"rememberLastPath": true,
|
||||
"useCursorPointer": true,
|
||||
"codeBlockLineNumbers": false,
|
||||
"notificationBadge": "indicator",
|
||||
"fullWidthDocuments": true
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let preferences = try JSONDecoder().decode(OutlineUserPreferences.self, from: json)
|
||||
|
||||
XCTAssertEqual(preferences.separateEditing, true, "seamlessEdit: false means separate editing is ON")
|
||||
XCTAssertEqual(preferences.showCommentMarker, true)
|
||||
XCTAssertEqual(preferences.smartText, true)
|
||||
XCTAssertEqual(preferences.rememberLastPath, true)
|
||||
XCTAssertEqual(preferences.useCursorPointer, true)
|
||||
XCTAssertEqual(preferences.codeBlockLineNumbers, false)
|
||||
XCTAssertEqual(preferences.notificationBadge, "indicator")
|
||||
XCTAssertEqual(preferences.fullWidthDocuments, true)
|
||||
}
|
||||
|
||||
func testOutlineUserPreferencesEncodesRealWireKeysAndInvertsSeparateEditing() throws {
|
||||
var preferences = OutlineUserPreferences()
|
||||
preferences.separateEditing = true
|
||||
preferences.showCommentMarker = false
|
||||
preferences.smartText = true
|
||||
preferences.fullWidthDocuments = true
|
||||
|
||||
let data = try JSONEncoder().encode(preferences)
|
||||
let object = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
|
||||
XCTAssertEqual(object?["seamlessEdit"] as? Bool, false, "separateEditing: true must encode as seamlessEdit: false")
|
||||
XCTAssertEqual(object?["commentsInGutter"] as? Bool, false)
|
||||
XCTAssertEqual(object?["enableSmartText"] as? Bool, true)
|
||||
XCTAssertEqual(object?["fullWidthDocuments"] as? Bool, true)
|
||||
XCTAssertNil(object?["separateEditing"], "must not leak this app's own field name onto the wire")
|
||||
XCTAssertNil(object?["showCommentMarker"])
|
||||
XCTAssertNil(object?["smartText"])
|
||||
}
|
||||
|
||||
func testSubscribeToNotificationsSendsEventTypeAndDecodesResult() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "user-1",
|
||||
"name": "Jane Doe",
|
||||
"notificationSettings": { "documents.publish": true }
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let user = try await client.subscribeToNotifications(eventType: .documentPublish)
|
||||
|
||||
XCTAssertEqual(user.notificationSettings?["documents.publish"], true)
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsSubscribe")
|
||||
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||
XCTAssertEqual(sentBody?["eventType"] as? String, "documents.publish")
|
||||
}
|
||||
|
||||
func testUnsubscribeFromNotificationsWithNilEventTypeTargetsAll() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "user-1",
|
||||
"name": "Jane Doe",
|
||||
"notificationSettings": { "documents.publish": false }
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
_ = try await client.unsubscribeFromNotifications(eventType: nil)
|
||||
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsUnsubscribe")
|
||||
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||
XCTAssertNil(sentBody?["eventType"])
|
||||
}
|
||||
|
||||
func testListApiKeysDecodesResult() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"pagination": { "limit": 25, "offset": 0 },
|
||||
"data": [
|
||||
{
|
||||
"id": "c3eec545-6d38-4065-90dc-b6c96a551445",
|
||||
"name": "Outpost",
|
||||
"scope": null,
|
||||
"last4": "dl1h",
|
||||
"createdAt": "2026-08-12T18:44:32.467Z",
|
||||
"updatedAt": "2026-08-12T18:44:32.467Z",
|
||||
"expiresAt": null,
|
||||
"lastActiveAt": "2026-08-18T01:01:36.553Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let keys = try await client.listApiKeys(ListApiKeysRequest())
|
||||
|
||||
XCTAssertEqual(keys.count, 1)
|
||||
XCTAssertEqual(keys.first?.name, "Outpost")
|
||||
XCTAssertEqual(keys.first?.last4, "dl1h")
|
||||
XCTAssertNil(keys.first?.expiresAt)
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.list")
|
||||
}
|
||||
|
||||
func testInstallationInfoDecodesResult() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": { "version": "1.9.2", "latestVersion": "1.9.2", "versionsBehind": 0 },
|
||||
"policies": []
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let info = try await client.installationInfo()
|
||||
|
||||
XCTAssertEqual(info.version, "1.9.2")
|
||||
XCTAssertEqual(info.versionsBehind, 0)
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/installation.info")
|
||||
}
|
||||
|
||||
func testCreateApiKeyOmitsExpiresAtWhenNilAndDecodesValue() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683",
|
||||
"name": "test",
|
||||
"scope": null,
|
||||
"value": "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv",
|
||||
"last4": "0DGv",
|
||||
"createdAt": "2026-08-18T15:39:29.872Z",
|
||||
"expiresAt": null,
|
||||
"lastActiveAt": null
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let key = try await client.createApiKey(CreateApiKeyRequest(name: "test"))
|
||||
|
||||
XCTAssertEqual(key.value, "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv")
|
||||
XCTAssertNil(key.expiresAt)
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.create")
|
||||
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||
XCTAssertEqual(sentBody?["name"] as? String, "test")
|
||||
XCTAssertNil(sentBody?["expiresAt"], "omitting expiresAt (not sending null) is what produces a non-expiring key")
|
||||
XCTAssertNil(sentBody?["scope"])
|
||||
}
|
||||
|
||||
func testCreateApiKeySendsExpiresAtWhenProvided() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "04e204fb-51a4-4b54-aed1-2056dfd576d7",
|
||||
"name": "Test",
|
||||
"scope": null,
|
||||
"value": "ol_api_aeYcOts7I2sJXw3zaztjydRM3W3W89TBAtcLts",
|
||||
"last4": "cLts",
|
||||
"createdAt": "2026-08-18T15:38:22.928Z",
|
||||
"expiresAt": "2026-11-16T23:59:59.999Z",
|
||||
"lastActiveAt": null
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let expiresAt = Date(timeIntervalSince1970: 1_795_000_000)
|
||||
_ = try await client.createApiKey(CreateApiKeyRequest(name: "Test", expiresAt: expiresAt))
|
||||
|
||||
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||
XCTAssertNotNil(sentBody?["expiresAt"])
|
||||
}
|
||||
|
||||
func testDeleteApiKeySendsRequest() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{ "success": true }
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
try await client.deleteApiKey(id: "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683")
|
||||
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.delete")
|
||||
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||
XCTAssertEqual(sentBody?["id"] as? String, "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683")
|
||||
}
|
||||
|
||||
func testDeleteAccountSendsRequest() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{ "success": true }
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
try await client.deleteAccount()
|
||||
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.delete")
|
||||
}
|
||||
|
||||
func testDeleteAttachmentSendsRequest() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{ "success": true }
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
try await client.deleteAttachment(id: "attach-1")
|
||||
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.delete")
|
||||
}
|
||||
|
||||
func testCreateAttachmentDecodesUploadTargetAndFormFields() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"attachment": {
|
||||
"id": "attach-1",
|
||||
"documentId": null,
|
||||
"contentType": "image/jpeg",
|
||||
"name": "avatar.jpg",
|
||||
"url": "/api/attachments.redirect?id=attach-1",
|
||||
"size": "59304"
|
||||
},
|
||||
"uploadUrl": "/api/files.create",
|
||||
"form": {
|
||||
"Content-Type": "image/jpeg",
|
||||
"key": "uploads/user-1/attach-1/avatar.jpg",
|
||||
"acl": "public-read"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let result = try await client.createAttachment(
|
||||
CreateAttachmentRequest(name: "avatar.jpg", contentType: "image/jpeg", size: 59304)
|
||||
)
|
||||
|
||||
XCTAssertEqual(result.attachment.id, "attach-1")
|
||||
XCTAssertEqual(result.attachment.size, "59304")
|
||||
XCTAssertEqual(result.uploadUrl, "/api/files.create")
|
||||
XCTAssertEqual(result.form["acl"], "public-read")
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.create")
|
||||
}
|
||||
|
||||
func testUploadAttachmentFilePostsToTheResolvedRelativeURL() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{ "success": true }
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let uploadTarget = CreateAttachmentResult(
|
||||
attachment: OutlineAttachment(
|
||||
id: "attach-1",
|
||||
documentId: nil,
|
||||
contentType: "image/jpeg",
|
||||
name: "avatar.jpg",
|
||||
url: "/api/attachments.redirect?id=attach-1",
|
||||
size: "3"
|
||||
),
|
||||
uploadUrl: "/api/files.create",
|
||||
form: ["Content-Type": "image/jpeg", "key": "uploads/attach-1"]
|
||||
)
|
||||
|
||||
try await client.uploadAttachmentFile(uploadTarget, fileData: Data("abc".utf8))
|
||||
|
||||
// Resolved against baseURL's host, not appended onto "/api/<baseURL-relative-path>".
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.absoluteString, "https://outline.example.com/api/files.create")
|
||||
XCTAssertNil(httpClient.lastRequest?.value(forHTTPHeaderField: "Authorization"))
|
||||
let contentType = httpClient.lastRequest?.value(forHTTPHeaderField: "Content-Type")
|
||||
XCTAssertTrue(contentType?.hasPrefix("multipart/form-data; boundary=") ?? false)
|
||||
}
|
||||
|
||||
func testMissingTokenThrowsTokenUnavailable() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
let client = LiveOutlineAPIClient(
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import XCTest
|
||||
@testable import OutlineKit
|
||||
|
||||
final class MultipartFormDataBuilderTests: XCTestCase {
|
||||
func testBuildIncludesEveryFieldAndTheFile() throws {
|
||||
let fileData = Data("fake-jpeg-bytes".utf8)
|
||||
let (body, contentType) = MultipartFormDataBuilder.build(
|
||||
fields: ["key": "uploads/abc", "acl": "public-read", "Content-Type": "image/jpeg"],
|
||||
fileFieldName: "file",
|
||||
fileName: "avatar.jpg",
|
||||
fileData: fileData,
|
||||
fileContentType: "image/jpeg",
|
||||
boundary: "TestBoundary"
|
||||
)
|
||||
|
||||
let bodyString = String(decoding: body, as: UTF8.self)
|
||||
|
||||
XCTAssertEqual(contentType, "multipart/form-data; boundary=TestBoundary")
|
||||
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"key\""))
|
||||
XCTAssertTrue(bodyString.contains("uploads/abc"))
|
||||
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"acl\""))
|
||||
XCTAssertTrue(bodyString.contains("public-read"))
|
||||
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"file\"; filename=\"avatar.jpg\""))
|
||||
XCTAssertTrue(bodyString.contains("fake-jpeg-bytes"))
|
||||
XCTAssertTrue(bodyString.hasPrefix("--TestBoundary\r\n"))
|
||||
XCTAssertTrue(bodyString.hasSuffix("--TestBoundary--\r\n"))
|
||||
}
|
||||
|
||||
func testFileFieldComesAfterAllFormFields() throws {
|
||||
let (body, _) = MultipartFormDataBuilder.build(
|
||||
fields: ["a": "1", "b": "2"],
|
||||
fileFieldName: "file",
|
||||
fileName: "x.jpg",
|
||||
fileData: Data("bytes".utf8),
|
||||
fileContentType: "image/jpeg",
|
||||
boundary: "B"
|
||||
)
|
||||
let bodyString = String(decoding: body, as: UTF8.self)
|
||||
|
||||
let fieldsRange = bodyString.range(of: "name=\"a\"")
|
||||
let fileRange = bodyString.range(of: "name=\"file\"")
|
||||
XCTAssertNotNil(fieldsRange)
|
||||
XCTAssertNotNil(fileRange)
|
||||
if let fieldsRange, let fileRange {
|
||||
XCTAssertTrue(fieldsRange.lowerBound < fileRange.lowerBound)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,11 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
FA7596B230366A1D0000167E /* MarkdownEngine in Frameworks */ = {isa = PBXBuildFile; productRef = FA7596B130366A1D0000167E /* MarkdownEngine */; };
|
||||
FA7596B430366A1D0000167E /* MarkdownEngineCodeBlocks in Frameworks */ = {isa = PBXBuildFile; productRef = FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */; };
|
||||
FA7596B630366A1D0000167E /* MarkdownEngineLatex in Frameworks */ = {isa = PBXBuildFile; productRef = FA7596B530366A1D0000167E /* MarkdownEngineLatex */; };
|
||||
FADBE087303734FE001E69F0 /* ImagePlayground.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FADBE086303734FE001E69F0 /* ImagePlayground.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
|
||||
FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99C43302CF1BD00C9949F /* OutlineKit */; };
|
||||
FAF99CAF302D120500C9949F /* MarkdownEngine in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CAE302D120500C9949F /* MarkdownEngine */; };
|
||||
FAF99CB1302D120500C9949F /* MarkdownEngineCodeBlocks in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */; };
|
||||
FAF99CB3302D120500C9949F /* MarkdownEngineLatex in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CB2302D120500C9949F /* MarkdownEngineLatex */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -31,6 +32,7 @@
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
FADBE086303734FE001E69F0 /* ImagePlayground.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ImagePlayground.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS27.0.sdk/System/Library/Frameworks/ImagePlayground.framework; sourceTree = DEVELOPER_DIR; };
|
||||
FAF99C18302CE96100C9949F /* Outpost.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Outpost.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FAF99C27302CE96200C9949F /* OutpostTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OutpostTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FAF99C31302CE96200C9949F /* OutpostUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OutpostUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -59,9 +61,10 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
files = (
|
||||
FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */,
|
||||
FAF99CB1302D120500C9949F /* MarkdownEngineCodeBlocks in Frameworks */,
|
||||
FAF99CB3302D120500C9949F /* MarkdownEngineLatex in Frameworks */,
|
||||
FAF99CAF302D120500C9949F /* MarkdownEngine in Frameworks */,
|
||||
FA7596B430366A1D0000167E /* MarkdownEngineCodeBlocks in Frameworks */,
|
||||
FA7596B630366A1D0000167E /* MarkdownEngineLatex in Frameworks */,
|
||||
FADBE087303734FE001E69F0 /* ImagePlayground.framework in Frameworks */,
|
||||
FA7596B230366A1D0000167E /* MarkdownEngine in Frameworks */,
|
||||
);
|
||||
};
|
||||
FAF99C24302CE96200C9949F /* Frameworks */ = {
|
||||
@@ -77,12 +80,21 @@
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
FADBE085303734FE001E69F0 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FADBE086303734FE001E69F0 /* ImagePlayground.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FAF99C0F302CE96100C9949F = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FAF99C1A302CE96100C9949F /* Outpost */,
|
||||
FAF99C2A302CE96200C9949F /* OutpostTests */,
|
||||
FAF99C34302CE96200C9949F /* OutpostUITests */,
|
||||
FADBE085303734FE001E69F0 /* Frameworks */,
|
||||
FAF99C19302CE96100C9949F /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -116,9 +128,9 @@
|
||||
name = Outpost;
|
||||
packageProductDependencies = (
|
||||
FAF99C43302CF1BD00C9949F /* OutlineKit */,
|
||||
FAF99CAE302D120500C9949F /* MarkdownEngine */,
|
||||
FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */,
|
||||
FAF99CB2302D120500C9949F /* MarkdownEngineLatex */,
|
||||
FA7596B130366A1D0000167E /* MarkdownEngine */,
|
||||
FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */,
|
||||
FA7596B530366A1D0000167E /* MarkdownEngineLatex */,
|
||||
);
|
||||
productName = Outpost;
|
||||
productReference = FAF99C18302CE96100C9949F /* Outpost.app */;
|
||||
@@ -200,7 +212,7 @@
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */,
|
||||
FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */,
|
||||
FA7596B030366A1D0000167E /* XCLocalSwiftPackageReference "Vendor/swift-markdown-engine" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = FAF99C19302CE96100C9949F /* Products */;
|
||||
@@ -299,7 +311,7 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -361,7 +373,7 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -385,15 +397,20 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Outpost;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
@@ -408,19 +425,21 @@
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
MARKETING_VERSION = 0.0.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
||||
MARKETING_VERSION = 0.0.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
XROS_DEPLOYMENT_TARGET = 27.0;
|
||||
};
|
||||
name = Debug;
|
||||
@@ -430,15 +449,20 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Outpost;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
@@ -453,19 +477,21 @@
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
MARKETING_VERSION = 0.0.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
||||
MARKETING_VERSION = 0.0.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
XROS_DEPLOYMENT_TARGET = 27.0;
|
||||
};
|
||||
name = Release;
|
||||
@@ -476,7 +502,7 @@
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
@@ -502,7 +528,7 @@
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
@@ -527,7 +553,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
@@ -552,7 +578,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
@@ -610,43 +636,33 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
FA7596B030366A1D0000167E /* XCLocalSwiftPackageReference "Vendor/swift-markdown-engine" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = "Vendor/swift-markdown-engine";
|
||||
};
|
||||
FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = OutlineKit;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/nodes-app/swift-markdown-engine";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.12.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
FA7596B130366A1D0000167E /* MarkdownEngine */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = MarkdownEngine;
|
||||
};
|
||||
FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = MarkdownEngineCodeBlocks;
|
||||
};
|
||||
FA7596B530366A1D0000167E /* MarkdownEngineLatex */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = MarkdownEngineLatex;
|
||||
};
|
||||
FAF99C43302CF1BD00C9949F /* OutlineKit */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = OutlineKit;
|
||||
};
|
||||
FAF99CAE302D120500C9949F /* MarkdownEngine */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */;
|
||||
productName = MarkdownEngine;
|
||||
};
|
||||
FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */;
|
||||
productName = MarkdownEngineCodeBlocks;
|
||||
};
|
||||
FAF99CB2302D120500C9949F /* MarkdownEngineLatex */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */;
|
||||
productName = MarkdownEngineLatex;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = FAF99C10302CE96100C9949F /* Project object */;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "f233fa96f0c6bdcdbf87f726af38f25704f6d46a156a27dfac47541baa63bf97",
|
||||
"originHash" : "4127e8224149bef00a33500e8db49748a735e1c07626f2255faea52554afef5e",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "highlighterswift",
|
||||
@@ -10,15 +10,6 @@
|
||||
"version" : "3.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-markdown-engine",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/nodes-app/swift-markdown-engine",
|
||||
"state" : {
|
||||
"revision" : "e5f7607fc4021181056ef7a09dbb7573dc0237d9",
|
||||
"version" : "0.12.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftmath",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "outpost-ios-1024.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 440 KiB |
@@ -2,25 +2,17 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct AboutView: View {
|
||||
/// Bare content (icon, name, version, links) with no window chrome — the
|
||||
/// macOS "About Outpost" app-menu command now opens Settings' own About
|
||||
/// section directly (no separate popup window), so this is its only caller.
|
||||
struct AboutInfoView: View {
|
||||
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
|
||||
private let releasesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/releases")!
|
||||
|
||||
private var appName: String {
|
||||
var appName: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost"
|
||||
}
|
||||
|
||||
/// Bumped alongside `MARKETING_VERSION` in the Xcode project — kept out
|
||||
/// of the bundle version itself since `CFBundleShortVersionString` is
|
||||
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
|
||||
private let releaseStage = "ALPHA"
|
||||
|
||||
private var versionString: String {
|
||||
let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
|
||||
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
||||
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
|
||||
return "Version \(shortVersion)\(stageSuffix) (\(buildNumber))"
|
||||
}
|
||||
var versionString: String { OutpostVersion.fullVersionString }
|
||||
|
||||
private var copyrightYear: String {
|
||||
String(Calendar.current.component(.year, from: Date()))
|
||||
@@ -51,29 +43,15 @@ struct AboutView: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Link(destination: repositoryURL) {
|
||||
Label("View Source on Git", systemImage: "link")
|
||||
}
|
||||
.font(.callout)
|
||||
|
||||
Button("Check for Updates…") {
|
||||
checkForUpdates()
|
||||
}
|
||||
Link(destination: repositoryURL) {
|
||||
Label("View Source on Git", systemImage: "link")
|
||||
}
|
||||
.font(.callout)
|
||||
|
||||
Text("© \(copyrightYear) Puranjay Savar Mattas")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(32)
|
||||
.frame(width: 320)
|
||||
}
|
||||
|
||||
// No Sparkle-style in-app updater yet — this just opens the releases page
|
||||
// on the self-hosted Gitea instance so the user can check/download manually.
|
||||
private func checkForUpdates() {
|
||||
NSWorkspace.shared.open(releasesURL)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label
|
||||
/// contains the avatar image reliably broke its own sizing on click (even after
|
||||
@@ -7,10 +8,11 @@ import SwiftUI
|
||||
/// label rendering, which a `Button` doesn't go through.
|
||||
struct AccountFooter: View {
|
||||
@Environment(SessionStore.self) private var session
|
||||
@Environment(AppNavigation.self) private var navigation
|
||||
@Environment(\.openURL) private var openURL
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||
@State private var isMenuPresented = false
|
||||
@State private var isShowingLogoutConfirmation = false
|
||||
@State private var isShowingProfile = false
|
||||
@@ -93,8 +95,17 @@ struct AccountFooter: View {
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
menuItem("Profile…") { isShowingProfile = true }
|
||||
menuItem("Preferences…") { openSettings() }
|
||||
// Deferred a tick: setting this synchronously in the same call
|
||||
// that dismisses this popover collides two AppKit window/layer
|
||||
// transactions in the same runloop turn (visible in the console
|
||||
// as "Invalid attempt to open a new transaction during CA
|
||||
// commit") — letting the popover's dismissal finish first avoids it.
|
||||
menuItem("Settings…") { Task { @MainActor in navigation.isShowingSettings = true } }
|
||||
|
||||
Divider()
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Crop/rotate/zoom editor shown after picking a photo, before it's
|
||||
/// uploaded — pan (drag), zoom (pinch or the slider), and 90°-increment
|
||||
/// rotate, all inside a circular mask matching how the avatar actually
|
||||
/// renders everywhere else in the app.
|
||||
///
|
||||
/// The on-screen preview and the final exported image are built from the
|
||||
/// exact same view composition (`avatarContent`), just instantiated once
|
||||
/// for display and once inside an `ImageRenderer` — that's deliberate:
|
||||
/// hand-deriving a separate set of crop-math for a higher-resolution
|
||||
/// render would risk it silently disagreeing with what the user actually
|
||||
/// saw and confirmed, and there's no way to visually verify that
|
||||
/// agreement without running the app. Reusing the identical view tree
|
||||
/// makes the export WYSIWYG by construction instead of by careful math.
|
||||
struct AvatarCropperView: View {
|
||||
let sourceImage: NSImage
|
||||
let onConfirm: (Data) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@State private var scale: CGFloat = 1
|
||||
@State private var offset: CGSize = .zero
|
||||
@State private var rotationDegrees: Double = 0
|
||||
@GestureState private var dragTranslation: CGSize = .zero
|
||||
|
||||
/// Used for both the live preview and the exported image — see the
|
||||
/// type-level doc comment for why that's the same size, not two.
|
||||
private let diameter: CGFloat = 320
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("Edit Photo")
|
||||
.font(.headline)
|
||||
|
||||
ZStack {
|
||||
avatarContent
|
||||
.clipShape(Circle())
|
||||
Circle()
|
||||
.strokeBorder(Color.primary.opacity(0.15), lineWidth: 1)
|
||||
}
|
||||
.frame(width: diameter, height: diameter)
|
||||
.contentShape(Circle())
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.updating($dragTranslation) { value, state, _ in state = value.translation }
|
||||
.onEnded { value in
|
||||
offset.width += value.translation.width
|
||||
offset.height += value.translation.height
|
||||
}
|
||||
)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees -= 90 }
|
||||
} label: {
|
||||
Image(systemName: "rotate.left")
|
||||
}
|
||||
.help("Rotate left")
|
||||
|
||||
Slider(value: $scale, in: 1...4)
|
||||
.frame(width: 140)
|
||||
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees += 90 }
|
||||
} label: {
|
||||
Image(systemName: "rotate.right")
|
||||
}
|
||||
.help("Rotate right")
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Cancel", role: .cancel, action: onCancel)
|
||||
Spacer()
|
||||
Button("Use Photo") {
|
||||
if let data = renderFinalImage() {
|
||||
onConfirm(data)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 360)
|
||||
}
|
||||
|
||||
/// Aspect-fills `sourceImage` into a `diameter`×`diameter` square, then
|
||||
/// applies the user's pan/zoom/rotation on top — identical between the
|
||||
/// live preview and the final render (see the type-level doc comment).
|
||||
private var avatarContent: some View {
|
||||
Image(nsImage: sourceImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: diameter, height: diameter)
|
||||
.scaleEffect(scale)
|
||||
.rotationEffect(.degrees(rotationDegrees))
|
||||
.offset(x: offset.width + dragTranslation.width, y: offset.height + dragTranslation.height)
|
||||
.frame(width: diameter, height: diameter)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func renderFinalImage() -> Data? {
|
||||
let content = avatarContent
|
||||
.clipShape(Circle())
|
||||
.frame(width: diameter, height: diameter)
|
||||
let renderer = ImageRenderer(content: content)
|
||||
renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays
|
||||
guard let nsImage = renderer.nsImage else { return nil }
|
||||
return nsImage.jpegData(compressionQuality: 0.9)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,40 +0,0 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
|
||||
struct PreferencesView: View {
|
||||
@Environment(SessionStore.self) private var session
|
||||
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
||||
@State private var isShowingLogoutConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Appearance") {
|
||||
Picker("Appearance", selection: $appearance) {
|
||||
ForEach(AppAppearance.allCases) { option in
|
||||
Text(option.label).tag(option)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
Section("Account") {
|
||||
LabeledContent("Signed in as", value: session.userName ?? "—")
|
||||
if let email = session.userEmail {
|
||||
LabeledContent("Email", value: email)
|
||||
}
|
||||
if let teamName = session.teamName {
|
||||
LabeledContent("Workspace", value: teamName)
|
||||
}
|
||||
|
||||
Button("Log Out…", role: .destructive) {
|
||||
isShowingLogoutConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.frame(width: 380, height: 300)
|
||||
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,115 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// Swapped into the real sidebar's content slot (search field, collections
|
||||
/// tree, account footer) while Settings is open — same sidebar, different
|
||||
/// content, rather than a separate mini sidebar nested inside a page. "Done"
|
||||
/// clears `AppNavigation.isShowingSettings`, which puts the collections tree
|
||||
/// back.
|
||||
///
|
||||
/// Grouped by `SettingsCategory` — `general` (ours) sits under an "Outpost"
|
||||
/// header at the top, then Outline's own Account/Workspace groups, matching
|
||||
/// the settings page structure of the Outline web app. Outline's own server
|
||||
/// version has no dedicated section (there used to be an Integrations &
|
||||
/// Installation category for just that) — it's cheap enough to show
|
||||
/// unconditionally in the footer here instead, alongside Outpost's own
|
||||
/// version.
|
||||
struct SettingsSidebarList: View {
|
||||
@Binding var selection: SettingsSection?
|
||||
let onDone: () -> Void
|
||||
|
||||
@Environment(SessionStore.self) private var session
|
||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||
@State private var outlineVersion: String?
|
||||
|
||||
/// Mirrors `SettingsView`'s own check — a real dropped connection or
|
||||
/// the manual Offline Mode toggle both mean there's no server to ask.
|
||||
private var isEffectivelyOnline: Bool {
|
||||
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Settings")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Divider()
|
||||
|
||||
List(selection: $selection) {
|
||||
ForEach(SettingsCategory.allCases) { category in
|
||||
let sections = SettingsSection.allCases.filter { $0.category == category }
|
||||
Section {
|
||||
ForEach(sections) { section in
|
||||
Label(section.title, systemImage: section.icon)
|
||||
.tag(section)
|
||||
}
|
||||
} header: {
|
||||
if let title = category.title {
|
||||
HStack(spacing: 6) {
|
||||
Text(title)
|
||||
// Not hardcoded to `.workspace` specifically —
|
||||
// stays correct on its own as sections get
|
||||
// built, only shows while every section in
|
||||
// the category is still `!isImplemented`.
|
||||
if sections.allSatisfy({ !$0.isImplemented }) {
|
||||
Text("Coming Soon")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(.secondary.opacity(0.15), in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
|
||||
Divider()
|
||||
|
||||
versionFooter
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Done", action: onDone)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(12)
|
||||
}
|
||||
// Keyed to connectivity, not a one-shot `.task {}` — reconnecting
|
||||
// (or turning the manual Offline Mode toggle back off) re-fires
|
||||
// this automatically instead of leaving the footer stuck on
|
||||
// whatever it last knew, or blank, until Settings is reopened.
|
||||
.task(id: isEffectivelyOnline) { await refreshOutlineVersion() }
|
||||
}
|
||||
|
||||
private var versionFooter: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Outpost \(OutpostVersion.displayString)")
|
||||
if let outlineVersion {
|
||||
Text("Outline \(outlineVersion)")
|
||||
} else if !isEffectivelyOnline {
|
||||
Text("Outline — offline")
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func refreshOutlineVersion() async {
|
||||
guard isEffectivelyOnline, let apiClient = session.apiClient else { return }
|
||||
outlineVersion = try? await apiClient.installationInfo().version
|
||||
}
|
||||
}
|
||||
#endif
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,21 +3,17 @@ import SwiftUI
|
||||
struct AuthHeaderView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.accentColor, Color.accentColor.opacity(0.6)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 64, height: 64)
|
||||
Image(systemName: "text.book.closed.fill")
|
||||
.font(.system(size: 26, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.shadow(color: Color.accentColor.opacity(0.35), radius: 12, y: 6)
|
||||
// A plain Image Set, not the AppIcon *app icon* asset — App Icon
|
||||
// sets aren't reliably resolvable through Image(_:)/UIImage
|
||||
// (named:) at runtime (confirmed live: showed nothing). This is
|
||||
// the same source artwork (outpost-ios-1024.png) duplicated
|
||||
// into a normal image set so SwiftUI can actually load it.
|
||||
Image("AppLogo")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 72 * 0.2237, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text("Welcome to Outpost")
|
||||
|
||||
@@ -157,6 +157,7 @@ private struct DocumentNodeRow: View {
|
||||
.frame(width: 12)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.pointerCursorOnHover()
|
||||
}
|
||||
|
||||
Button {
|
||||
@@ -186,6 +187,7 @@ private struct DocumentNodeRow: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.pointerCursorOnHover()
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
@@ -34,6 +34,7 @@ struct CollectionListContent: View {
|
||||
CollectionRowView(collection: collection)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.pointerCursorOnHover()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import MarkdownEngine
|
||||
import MarkdownEngineCodeBlocks
|
||||
import OutlineKit
|
||||
|
||||
/// Read-only for now — document/overview editing isn't wired up yet. Uses
|
||||
@@ -9,21 +10,34 @@ import OutlineKit
|
||||
/// `isSelectable` and link-opening both still need to work.
|
||||
struct CollectionOverviewContent: View {
|
||||
@State private var markdown: String
|
||||
@State private var imageProvider: OutlineImageProvider
|
||||
/// See `DocumentReaderView`'s `imageReloadTick`.
|
||||
@State private var imageReloadTick = 0
|
||||
|
||||
init(collection: OutlineCollection) {
|
||||
init(apiClient: OutlineAPIClient, collection: OutlineCollection) {
|
||||
_markdown = State(initialValue: collection.description ?? "")
|
||||
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
NativeTextViewWrapper(
|
||||
text: $markdown,
|
||||
configuration: .init(heightBehavior: .fitsContent),
|
||||
configuration: .init(
|
||||
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||
heightBehavior: .fitsContent
|
||||
),
|
||||
isEditable: false
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.animation(nil, value: imageReloadTick)
|
||||
.task {
|
||||
imageProvider.onImageLoaded = {
|
||||
Task { @MainActor in imageReloadTick += 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
struct CollectionOverviewView: View {
|
||||
let apiClient: OutlineAPIClient
|
||||
let collection: OutlineCollection
|
||||
@State private var viewModel: DocumentsViewModel
|
||||
@State private var selectedTab: CollectionTab = .overview
|
||||
@@ -25,6 +26,7 @@ struct CollectionOverviewView: View {
|
||||
searchQuery: Binding<String>,
|
||||
onOpenDocument: @escaping (OutlineDocument) -> Void
|
||||
) {
|
||||
self.apiClient = apiClient
|
||||
self.collection = collection
|
||||
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
||||
_searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id))
|
||||
@@ -59,7 +61,7 @@ struct CollectionOverviewView: View {
|
||||
if !trimmedSearchQuery.isEmpty {
|
||||
searchResultsList
|
||||
} else if selectedTab == .overview {
|
||||
CollectionOverviewContent(collection: collection)
|
||||
CollectionOverviewContent(apiClient: apiClient, collection: collection)
|
||||
} else {
|
||||
documentList
|
||||
}
|
||||
@@ -114,6 +116,7 @@ struct CollectionOverviewView: View {
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.pointerCursorOnHover()
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
@@ -150,6 +153,7 @@ struct CollectionOverviewView: View {
|
||||
DocumentRowView(document: document)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.pointerCursorOnHover()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,6 +186,7 @@ struct CollectionOverviewView: View {
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.pointerCursorOnHover()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ struct CollectionRowView: View {
|
||||
} icon: {
|
||||
if let emoji = collection.emojiIcon {
|
||||
Text(emoji)
|
||||
} else if let symbolName = collection.icon.flatMap(OutlineIconMapping.sfSymbolName) {
|
||||
} else if let symbolName = collection.icon.flatMap({ OutlineIconMapping.sfSymbolName(for: $0) }) {
|
||||
Image(systemName: symbolName)
|
||||
.foregroundStyle(tintColor)
|
||||
} else {
|
||||
|
||||
@@ -56,6 +56,7 @@ struct CollectionTreeRow: View {
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.pointerCursorOnHover()
|
||||
.animation(.easeInOut(duration: 0.15), value: isExpanded)
|
||||
.contextMenu { contextMenuContent }
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// ⌘K. Settings → Editor → Command Palette. Always searches locally, never a
|
||||
/// per-keystroke network request. Two data-source modes:
|
||||
///
|
||||
/// - Lightweight (default): a live `listCollections` + `listViewedDocuments`
|
||||
/// fetch once when the palette opens — two small requests, near-instant,
|
||||
/// works with no setup.
|
||||
/// - Full Workspace: reads `CachingOutlineAPIClient`'s local SwiftData cache
|
||||
/// directly (`cachedDocumentsIndex()`/`cachedCollectionsIndex()`) — zero
|
||||
/// network calls at all, and includes every nested sub-document, not just
|
||||
/// collection roots. Requires Full Local Sync to actually have populated
|
||||
/// that cache first (gated in Settings — the toggle here is disabled
|
||||
/// without it); this view doesn't trigger a sync itself.
|
||||
struct CommandPaletteView: View {
|
||||
let apiClient: OutlineAPIClient
|
||||
let cachingClient: CachingOutlineAPIClient?
|
||||
let fullWorkspaceSearch: Bool
|
||||
let onSelectDocument: (OutlineDocument) -> Void
|
||||
let onSelectCollection: (OutlineCollection) -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@State private var query = ""
|
||||
@State private var collections: [OutlineCollection] = []
|
||||
@State private var documents: [OutlineDocument] = []
|
||||
@State private var isLoading = true
|
||||
@State private var selectedIndex = 0
|
||||
@FocusState private var isSearchFieldFocused: Bool
|
||||
|
||||
private enum Result: Identifiable {
|
||||
case collection(OutlineCollection)
|
||||
case document(OutlineDocument)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .collection(let collection): return "collection-\(collection.id)"
|
||||
case .document(let document): return "document-\(document.id)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var results: [Result] {
|
||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
// No query yet: surface collections first, then the most
|
||||
// recent/full-workspace documents as-is, capped so the panel
|
||||
// doesn't dump the entire workspace with nothing typed.
|
||||
return (collections.map(Result.collection) + documents.map(Result.document))
|
||||
.prefix(20)
|
||||
.map { $0 }
|
||||
}
|
||||
let scored: [(Result, Int)] = collections.compactMap { collection in
|
||||
matchScore(collection.name, query: trimmed).map { (Result.collection(collection), $0) }
|
||||
} + documents.compactMap { document in
|
||||
matchScore(document.title, query: trimmed).map { (Result.document(document), $0) }
|
||||
}
|
||||
return scored.sorted { $0.1 < $1.1 }.prefix(30).map(\.0)
|
||||
}
|
||||
|
||||
/// Lower is better — exact match, then prefix match, then earliest
|
||||
/// contiguous-substring position, then (for multi-word queries) every
|
||||
/// word present somewhere in the title in any order. That last tier is
|
||||
/// what makes "test document" find a title like "Test Plan Document" —
|
||||
/// requiring the exact phrase contiguously (the previous behavior)
|
||||
/// meant a title with anything between the words never matched at all,
|
||||
/// which looked like "documents never show up, only collections" any
|
||||
/// time the real title didn't happen to contain the typed phrase
|
||||
/// verbatim. `nil` means no match at all. Still deliberately not a full
|
||||
/// fuzzy/Levenshtein algorithm — good enough for document/collection
|
||||
/// titles without the unpredictability that brings.
|
||||
private func matchScore(_ title: String, query: String) -> Int? {
|
||||
let haystack = title.lowercased()
|
||||
let needle = query.lowercased()
|
||||
if haystack == needle { return 0 }
|
||||
if haystack.hasPrefix(needle) { return 1 }
|
||||
if let range = haystack.range(of: needle) {
|
||||
return 2 + haystack.distance(from: haystack.startIndex, to: range.lowerBound)
|
||||
}
|
||||
let words = needle.split(separator: " ").map(String.init)
|
||||
guard words.count > 1, words.allSatisfy({ haystack.contains($0) }) else { return nil }
|
||||
let totalPosition = words.reduce(0) { partial, word in
|
||||
guard let range = haystack.range(of: word) else { return partial }
|
||||
return partial + haystack.distance(from: haystack.startIndex, to: range.lowerBound)
|
||||
}
|
||||
return 100 + totalPosition
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.opacity(0.001) // catches clicks outside the card to dismiss
|
||||
.onTapGesture { onDismiss() }
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Search documents and collections…", text: $query)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.title3)
|
||||
.focused($isSearchFieldFocused)
|
||||
.onChange(of: query) { selectedIndex = 0 }
|
||||
.onSubmit { selectCurrent() }
|
||||
// Attached directly on the field itself, not an
|
||||
// ancestor — confirmed live that .onKeyPress on the
|
||||
// outer card never saw arrow-key events at all while
|
||||
// this TextField actually held focus, the up/down
|
||||
// presses just went nowhere. Escape still needs its
|
||||
// own handler below since this one only covers
|
||||
// whichever view is actually focused.
|
||||
.onKeyPress(.downArrow) { moveSelection(by: 1); return .handled }
|
||||
.onKeyPress(.upArrow) { moveSelection(by: -1); return .handled }
|
||||
.onKeyPress(.escape) { onDismiss(); return .handled }
|
||||
if isLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
|
||||
Divider()
|
||||
|
||||
if results.isEmpty {
|
||||
ContentUnavailableView(
|
||||
isLoading ? "Loading…" : "No Results",
|
||||
systemImage: isLoading ? "ellipsis" : "magnifyingglass"
|
||||
)
|
||||
.frame(height: 160)
|
||||
} else {
|
||||
ScrollViewReader { scrollProxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(results.enumerated()), id: \.element.id) { index, result in
|
||||
resultRow(result, isSelected: index == selectedIndex)
|
||||
.id(index)
|
||||
.contentShape(Rectangle())
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -4,6 +4,9 @@ import OutlineKit
|
||||
|
||||
struct ContentView_macOS: View {
|
||||
@Environment(SessionStore.self) private var session
|
||||
@Environment(AppNavigation.self) private var navigation
|
||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||
@AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false
|
||||
/// The landing state — no collection selected yet is what Home actually
|
||||
/// means, so this starts `true` rather than auto-selecting the first
|
||||
/// collection the way this used to work.
|
||||
@@ -21,16 +24,83 @@ struct ContentView_macOS: View {
|
||||
/// Home's "New Document" buttons) — every expanded sidebar row reloads
|
||||
/// itself in response. See `CollectionDocumentsOutline.externalRefreshToken`.
|
||||
@State private var documentsChangedToken = 0
|
||||
/// Guards the restore-on-launch attempt to exactly once per app launch
|
||||
/// — without this, `mainContent`'s `.task` would re-run (and
|
||||
/// re-navigate out from under the user) every time it reappears, e.g.
|
||||
/// after a trip through Settings.
|
||||
@State private var hasAttemptedLocationRestore = false
|
||||
|
||||
private var trimmedGlobalQuery: String {
|
||||
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
/// `@Environment(AppNavigation.self)` doesn't hand out `$`-bindings on its
|
||||
/// own (that's `@Bindable`'s job, and introducing one in `body` would
|
||||
/// mean restructuring it away from a single implicit-return expression)
|
||||
/// — a manually-built `Binding` over the same reference is simpler here.
|
||||
private var selectedSettingsSectionBinding: Binding<SettingsSection?> {
|
||||
Binding(
|
||||
get: { navigation.selectedSettingsSection },
|
||||
set: { navigation.selectedSettingsSection = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// A totally separate top-level branch, not content swapped inside
|
||||
// one persistent `NavigationSplitView` — that was tried first (an
|
||||
// `if/else` inside a single split view, then an explicit `.id()` on
|
||||
// just the detail pane) and neither reliably replaced the detail
|
||||
// pane's content on macOS: `NavigationSplitView` bridges to
|
||||
// `NSSplitViewController`, and swapping a `NavigationStack` with
|
||||
// real push history for a plain view inside one persisting instance
|
||||
// doesn't propagate the way plain SwiftUI identity rules would
|
||||
// suggest. A different `if` branch is a genuinely different view
|
||||
// hierarchy, so there's no existing split view instance for AppKit
|
||||
// to get confused about reusing.
|
||||
if navigation.isShowingSettings {
|
||||
settingsContent
|
||||
} else {
|
||||
mainContent
|
||||
}
|
||||
}
|
||||
|
||||
private var settingsContent: some View {
|
||||
NavigationSplitView {
|
||||
SettingsSidebarList(
|
||||
selection: selectedSettingsSectionBinding,
|
||||
onDone: { navigation.isShowingSettings = false }
|
||||
)
|
||||
.navigationSplitViewColumnWidth(min: 220, ideal: 260)
|
||||
} detail: {
|
||||
SettingsView(section: navigation.selectedSettingsSection ?? .appearance)
|
||||
}
|
||||
.navigationTitle("")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigation) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "gearshape.fill")
|
||||
Text("Settings")
|
||||
}
|
||||
.font(.headline)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(.fill.tertiary, in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var mainContent: some View {
|
||||
NavigationSplitView {
|
||||
VStack(spacing: 0) {
|
||||
SidebarSearchField(text: $globalSearchQuery)
|
||||
Divider()
|
||||
if isOfflineModeEnabled {
|
||||
OfflineBanner(isManual: true)
|
||||
Divider()
|
||||
} else if !session.networkMonitor.isOnline {
|
||||
OfflineConnectionPromptBanner(onEnableOfflineMode: { isOfflineModeEnabled = true })
|
||||
Divider()
|
||||
}
|
||||
sidebar
|
||||
AccountFooter()
|
||||
}
|
||||
@@ -83,6 +153,38 @@ struct ContentView_macOS: View {
|
||||
if newValue != nil {
|
||||
isShowingHome = false
|
||||
}
|
||||
persistLastLocationIfEnabled()
|
||||
}
|
||||
.onChange(of: documentPath) { _, _ in
|
||||
persistLastLocationIfEnabled()
|
||||
}
|
||||
.onChange(of: isShowingHome) { _, _ in
|
||||
persistLastLocationIfEnabled()
|
||||
}
|
||||
// Once per launch, before the user has a chance to navigate
|
||||
// manually — restores whatever `restoreLastLocationIfEnabled`
|
||||
// finds, or leaves today's Home default alone if there's nothing
|
||||
// to restore (preference off, nothing stored yet, or resolution
|
||||
// fails e.g. a deleted document/collection or being offline).
|
||||
.task {
|
||||
guard !hasAttemptedLocationRestore else { return }
|
||||
hasAttemptedLocationRestore = true
|
||||
await restoreLastLocationIfEnabled()
|
||||
}
|
||||
.overlay {
|
||||
if navigation.isShowingCommandPalette, let apiClient = session.apiClient {
|
||||
CommandPaletteView(
|
||||
apiClient: apiClient,
|
||||
cachingClient: session.cachingClient,
|
||||
fullWorkspaceSearch: isCommandPaletteFullWorkspaceSearch,
|
||||
onSelectDocument: openDocument,
|
||||
onSelectCollection: { collection in
|
||||
selectedCollection = collection
|
||||
replaceDocumentPath(with: [])
|
||||
},
|
||||
onDismiss: { navigation.isShowingCommandPalette = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,9 +194,67 @@ struct ContentView_macOS: View {
|
||||
isContextualSearchExpanded = false
|
||||
selectedCollection = nil
|
||||
isShowingHome = true
|
||||
navigation.isShowingSettings = false
|
||||
replaceDocumentPath(with: [])
|
||||
}
|
||||
|
||||
// MARK: - Remember previous location (Preferences → Remember previous location)
|
||||
|
||||
private static let lastLocationDefaultsKey = "outline.lastLocation"
|
||||
|
||||
/// What gets persisted — `isHome` disambiguates "was on Home" from "no
|
||||
/// collection selected yet" (the latter only otherwise happens on the
|
||||
/// brief `ContentUnavailableView` placeholder state), since both would
|
||||
/// otherwise look identical (`collectionId == nil`).
|
||||
private struct LastLocation: Codable {
|
||||
var isHome: Bool
|
||||
var collectionId: String?
|
||||
var documentIds: [String]
|
||||
}
|
||||
|
||||
/// Called from every navigation-changing `.onChange` — cheap to persist
|
||||
/// on every change rather than debouncing, this is just a small JSON
|
||||
/// blob in `UserDefaults`, not a network call.
|
||||
private func persistLastLocationIfEnabled() {
|
||||
guard session.userPreferences?.rememberLastPath == true else { return }
|
||||
let location = LastLocation(isHome: isShowingHome, collectionId: selectedCollection?.id, documentIds: documentPath.map(\.id))
|
||||
guard let data = try? JSONEncoder().encode(location) else { return }
|
||||
UserDefaults.standard.set(data, forKey: Self.lastLocationDefaultsKey)
|
||||
}
|
||||
|
||||
/// Resolves IDs back into real `OutlineCollection`/`OutlineDocument`
|
||||
/// objects via the API — stored IDs alone aren't enough to populate
|
||||
/// `selectedCollection`/`documentPath` directly. Resolves the document
|
||||
/// chain in order and stops at the first failure (deleted document,
|
||||
/// offline, etc.) rather than aborting the whole restore — whatever
|
||||
/// prefix of the chain resolved successfully is still a better landing
|
||||
/// spot than falling all the way back to Home.
|
||||
private func restoreLastLocationIfEnabled() async {
|
||||
guard session.userPreferences?.rememberLastPath == true,
|
||||
let apiClient = session.apiClient,
|
||||
let data = UserDefaults.standard.data(forKey: Self.lastLocationDefaultsKey),
|
||||
let location = try? JSONDecoder().decode(LastLocation.self, from: data)
|
||||
else { return }
|
||||
// A pure "was on Home, nothing pushed" location needs no action —
|
||||
// Home is already the default state before this ever runs.
|
||||
guard location.collectionId != nil || !location.documentIds.isEmpty else { return }
|
||||
|
||||
if let collectionId = location.collectionId {
|
||||
guard let collection = try? await apiClient.collectionInfo(id: collectionId) else { return }
|
||||
selectedCollection = collection
|
||||
isShowingHome = false
|
||||
}
|
||||
|
||||
var resolvedChain: [OutlineDocument] = []
|
||||
for documentId in location.documentIds {
|
||||
guard let document = try? await apiClient.documentInfo(id: documentId) else { break }
|
||||
resolvedChain.append(document)
|
||||
}
|
||||
if !resolvedChain.isEmpty {
|
||||
replaceDocumentPath(with: resolvedChain)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var contextualSearchField: some View {
|
||||
if isContextualSearchExpanded || !contextualSearchQuery.isEmpty {
|
||||
@@ -285,6 +445,13 @@ struct ContentView_macOS: View {
|
||||
if !documentPath.isEmpty {
|
||||
documentPath.removeLast()
|
||||
}
|
||||
// Delete/Archive/Unpublish/Move all change what
|
||||
// should show in the sidebar tree — this only
|
||||
// popped the reader before, leaving the sidebar
|
||||
// showing the document until some unrelated
|
||||
// trigger (the 45s poll, navigating away and
|
||||
// back) happened to refresh it.
|
||||
documentsChangedToken += 1
|
||||
},
|
||||
onDocumentCreated: { documentsChangedToken += 1 }
|
||||
)
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// Document-level and anchored comments + single-level replies + emoji
|
||||
/// reactions + resolve/unresolve. Composing new comments/replies is still
|
||||
/// plain text only (no bold/italic/lists/etc.) — sent through the `text`
|
||||
/// (markdown) convenience field, not a hand-built ProseMirror `data`
|
||||
/// document.
|
||||
@MainActor
|
||||
struct DocumentCommentsSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let apiClient: OutlineAPIClient
|
||||
let document: OutlineDocument
|
||||
/// Set when opened by tapping an inline anchor marker — scrolls to and
|
||||
/// briefly highlights that comment/reply. `nil` opens unfocused (the
|
||||
/// plain toolbar marker).
|
||||
var focusedCommentId: String? = nil
|
||||
/// Set when opened via "Comment on Selection…" from the editor's
|
||||
/// right-click menu — the selected text to anchor a NEW comment to.
|
||||
/// The first occurrence of this exact substring in the document is
|
||||
/// what the server (and later, this app's own inline marker) anchors
|
||||
/// to; no prefix/suffix disambiguation UI for multiple identical
|
||||
/// occurrences.
|
||||
var pendingAnchorText: String? = nil
|
||||
/// Called after any successful create/reply/resolve/reaction — lets the
|
||||
/// reader refresh its own lightweight copy (inline anchor markers, the
|
||||
/// toolbar badge count) without waiting for the document to be reopened.
|
||||
var onCommentsChanged: (() -> Void)? = nil
|
||||
|
||||
/// A small curated set rather than the full system emoji picker — quick
|
||||
/// taps for the common reactions, matching how most chat apps default.
|
||||
private static let quickReactions = ["👍", "❤️", "😂", "🎉", "😮", "😢"]
|
||||
|
||||
@State private var comments: [OutlineComment] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var actionErrorMessage: String?
|
||||
@State private var currentUserId: String?
|
||||
|
||||
@State private var newCommentText = ""
|
||||
@State private var isPostingNewComment = false
|
||||
/// Mutable mirror of `pendingAnchorText` — lets the user clear it (fall
|
||||
/// back to a plain document-level comment) without touching the init
|
||||
/// param itself.
|
||||
@State private var composingAnchorText: String?
|
||||
|
||||
@State private var replyingToThreadId: String?
|
||||
@State private var replyText = ""
|
||||
@State private var isPostingReply = false
|
||||
|
||||
@State private var resolvingCommentIds: Set<String> = []
|
||||
@State private var reactingCommentIds: Set<String> = []
|
||||
@State private var reactionPickerCommentId: String?
|
||||
|
||||
private struct CommentThread: Identifiable {
|
||||
let top: OutlineComment
|
||||
let replies: [OutlineComment]
|
||||
var id: String { top.id }
|
||||
}
|
||||
|
||||
private var threads: [CommentThread] {
|
||||
let topLevel = comments.filter { $0.parentCommentId == nil }.sorted { $0.createdAt < $1.createdAt }
|
||||
return topLevel.map { top in
|
||||
let replies = comments
|
||||
.filter { $0.parentCommentId == top.id }
|
||||
.sorted { $0.createdAt < $1.createdAt }
|
||||
return CommentThread(top: top, replies: replies)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text("Comments")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
.padding()
|
||||
|
||||
Divider()
|
||||
|
||||
Group {
|
||||
if isLoading && comments.isEmpty {
|
||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let errorMessage {
|
||||
ContentUnavailableView {
|
||||
Label("Couldn't Load Comments", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(errorMessage)
|
||||
} actions: {
|
||||
Button("Retry") { Task { await load() } }
|
||||
}
|
||||
} else if threads.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("No Comments", systemImage: "bubble.left.and.bubble.right")
|
||||
} description: {
|
||||
Text("This document has no comments yet.")
|
||||
}
|
||||
} else {
|
||||
ScrollViewReader { proxy in
|
||||
List(threads) { thread in
|
||||
threadSection(thread)
|
||||
}
|
||||
.task {
|
||||
guard let focusedCommentId else { return }
|
||||
// The list needs a beat to lay out before a
|
||||
// scrollTo lands correctly on first appear.
|
||||
try? await Task.sleep(for: .milliseconds(50))
|
||||
withAnimation {
|
||||
proxy.scrollTo(focusedCommentId, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
Divider()
|
||||
newCommentComposer
|
||||
}
|
||||
.frame(width: 480, height: 560)
|
||||
.task { await load() }
|
||||
.task { currentUserId = try? await apiClient.currentUser().id }
|
||||
.task { composingAnchorText = pendingAnchorText }
|
||||
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
|
||||
Button("OK") { actionErrorMessage = nil }
|
||||
} message: {
|
||||
Text(actionErrorMessage ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Thread
|
||||
|
||||
private func threadSection(_ thread: CommentThread) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
commentRow(thread.top, isReply: false)
|
||||
|
||||
ForEach(thread.replies) { reply in
|
||||
commentRow(reply, isReply: true)
|
||||
.padding(.leading, 20)
|
||||
}
|
||||
|
||||
if replyingToThreadId == thread.id {
|
||||
replyComposer(for: thread)
|
||||
.padding(.leading, 20)
|
||||
} else {
|
||||
Button("Reply") {
|
||||
replyingToThreadId = thread.id
|
||||
replyText = ""
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.blue)
|
||||
.padding(.leading, 20)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
private func commentRow(_ comment: OutlineComment, isReply: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
Text(comment.createdBy?.name ?? "Unknown")
|
||||
.font(.callout.weight(.semibold))
|
||||
Spacer()
|
||||
Text(comment.createdAt, format: .relative(presentation: .named))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(comment.bodyText.isEmpty ? "(empty)" : comment.bodyText)
|
||||
.font(.callout)
|
||||
.foregroundStyle(comment.bodyText.isEmpty ? .secondary : .primary)
|
||||
|
||||
if !comment.reactions.isEmpty {
|
||||
reactionChips(comment)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
reactionPickerCommentId = comment.id
|
||||
} label: {
|
||||
Image(systemName: "face.smiling")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.popover(isPresented: Binding(
|
||||
get: { reactionPickerCommentId == comment.id },
|
||||
set: { if !$0 { reactionPickerCommentId = nil } }
|
||||
)) {
|
||||
quickReactionPicker(comment)
|
||||
}
|
||||
|
||||
if !isReply {
|
||||
if comment.isResolved {
|
||||
Label("Resolved", systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
if resolvingCommentIds.contains(comment.id) {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Button(comment.isResolved ? "Unresolve" : "Resolve") {
|
||||
Task { await toggleResolved(comment) }
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
} else {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(6)
|
||||
.background(
|
||||
comment.id == focusedCommentId ? Color.accentColor.opacity(0.12) : Color.clear,
|
||||
in: RoundedRectangle(cornerRadius: 6)
|
||||
)
|
||||
.id(comment.id)
|
||||
}
|
||||
|
||||
private func reactionChips(_ comment: OutlineComment) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(comment.reactions, id: \.emoji) { reaction in
|
||||
let mine = currentUserId.map(reaction.userIds.contains) ?? false
|
||||
Button {
|
||||
Task { await toggleReaction(comment, emoji: reaction.emoji, currentlyReacted: mine) }
|
||||
} label: {
|
||||
Text("\(reaction.emoji) \(reaction.userIds.count)")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(mine ? Color.accentColor.opacity(0.2) : Color.gray.opacity(0.15), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(reactingCommentIds.contains(comment.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func quickReactionPicker(_ comment: OutlineComment) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Self.quickReactions, id: \.self) { emoji in
|
||||
let mine = currentUserId.map { userId in
|
||||
comment.reactions.first { $0.emoji == emoji }?.userIds.contains(userId) ?? false
|
||||
} ?? false
|
||||
Button {
|
||||
reactionPickerCommentId = nil
|
||||
Task { await toggleReaction(comment, emoji: emoji, currentlyReacted: mine) }
|
||||
} label: {
|
||||
Text(emoji)
|
||||
.font(.title2)
|
||||
.opacity(mine ? 1 : 0.5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
|
||||
// MARK: - Composers
|
||||
|
||||
private var newCommentComposer: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let composingAnchorText {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "text.quote")
|
||||
.foregroundStyle(.blue)
|
||||
Text(composingAnchorText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Spacer()
|
||||
Button {
|
||||
self.composingAnchorText = nil
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.help("Remove — this will post as a document-level comment instead")
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField(
|
||||
composingAnchorText == nil ? "Add a comment…" : "Comment on this text…",
|
||||
text: $newCommentText,
|
||||
axis: .vertical
|
||||
)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1...4)
|
||||
.onSubmit { Task { await postNewComment() } }
|
||||
if isPostingNewComment {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Button("Post") {
|
||||
Task { await postNewComment() }
|
||||
}
|
||||
.disabled(newCommentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func replyComposer(for thread: CommentThread) -> some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField("Reply…", text: $replyText, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1...4)
|
||||
.onSubmit { Task { await postReply(to: thread) } }
|
||||
if isPostingReply {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Button("Send") {
|
||||
Task { await postReply(to: thread) }
|
||||
}
|
||||
.disabled(replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
Button("Cancel") {
|
||||
replyingToThreadId = nil
|
||||
replyText = ""
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func load() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
comments = try await apiClient.listComments(ListCommentsRequest(documentId: document.id))
|
||||
} catch {
|
||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load comments for this document.")
|
||||
}
|
||||
}
|
||||
|
||||
private func postNewComment() async {
|
||||
let text = newCommentText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
isPostingNewComment = true
|
||||
defer { isPostingNewComment = false }
|
||||
do {
|
||||
let created = try await apiClient.createComment(
|
||||
CreateCommentRequest(documentId: document.id, text: text, anchorText: composingAnchorText)
|
||||
)
|
||||
comments.append(created)
|
||||
composingAnchorText = nil
|
||||
newCommentText = ""
|
||||
onCommentsChanged?()
|
||||
} catch {
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't post this comment.")
|
||||
}
|
||||
}
|
||||
|
||||
private func postReply(to thread: CommentThread) async {
|
||||
let text = replyText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
isPostingReply = true
|
||||
defer { isPostingReply = false }
|
||||
do {
|
||||
let created = try await apiClient.createComment(
|
||||
CreateCommentRequest(documentId: document.id, parentCommentId: thread.id, text: text)
|
||||
)
|
||||
comments.append(created)
|
||||
replyText = ""
|
||||
replyingToThreadId = nil
|
||||
onCommentsChanged?()
|
||||
} catch {
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't post this reply.")
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleResolved(_ comment: OutlineComment) async {
|
||||
resolvingCommentIds.insert(comment.id)
|
||||
defer { resolvingCommentIds.remove(comment.id) }
|
||||
do {
|
||||
let updated = comment.isResolved
|
||||
? try await apiClient.unresolveComment(id: comment.id)
|
||||
: try await apiClient.resolveComment(id: comment.id)
|
||||
replace(updated)
|
||||
} catch {
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update this comment.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Reaction endpoints return `{success: true}`, not the updated comment
|
||||
/// (see `OutlineAPIClient.addReaction`) — refetch via `commentInfo` for
|
||||
/// the real post-toggle `reactions` array instead of guessing the merge
|
||||
/// locally (another user reacting concurrently would make a guess wrong).
|
||||
private func toggleReaction(_ comment: OutlineComment, emoji: String, currentlyReacted: Bool) async {
|
||||
reactingCommentIds.insert(comment.id)
|
||||
defer { reactingCommentIds.remove(comment.id) }
|
||||
do {
|
||||
if currentlyReacted {
|
||||
try await apiClient.removeReaction(commentId: comment.id, emoji: emoji)
|
||||
} else {
|
||||
try await apiClient.addReaction(commentId: comment.id, emoji: emoji)
|
||||
}
|
||||
let refreshed = try await apiClient.commentInfo(id: comment.id)
|
||||
replace(refreshed)
|
||||
} catch {
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update that reaction.")
|
||||
}
|
||||
}
|
||||
|
||||
private func replace(_ updated: OutlineComment) {
|
||||
if let index = comments.firstIndex(where: { $0.id == updated.id }) {
|
||||
comments[index] = updated
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,6 +1,7 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import MarkdownEngine
|
||||
import MarkdownEngineCodeBlocks
|
||||
import OutlineKit
|
||||
|
||||
/// Distraction-free reading view — no toolbar/sidebar chrome, larger type.
|
||||
@@ -16,11 +17,17 @@ struct DocumentPresentSheet: View {
|
||||
@State private var text: String
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var imageProvider: OutlineImageProvider
|
||||
/// See `DocumentReaderView`'s `imageReloadTick` — same "force an
|
||||
/// `updateNSView` re-pass so the engine notices `fingerprint()` changed"
|
||||
/// mechanism, needed here too since this sheet renders its own images.
|
||||
@State private var imageReloadTick = 0
|
||||
|
||||
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
|
||||
self.apiClient = apiClient
|
||||
self.document = document
|
||||
_text = State(initialValue: document.text)
|
||||
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -44,7 +51,10 @@ struct DocumentPresentSheet: View {
|
||||
.padding(.bottom, 8)
|
||||
NativeTextViewWrapper(
|
||||
text: $text,
|
||||
configuration: .init(heightBehavior: .fitsContent),
|
||||
configuration: .init(
|
||||
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||
heightBehavior: .fitsContent
|
||||
),
|
||||
isEditable: false
|
||||
)
|
||||
.font(.system(size: 18))
|
||||
@@ -67,6 +77,12 @@ struct DocumentPresentSheet: View {
|
||||
}
|
||||
.frame(minWidth: 800, minHeight: 600)
|
||||
.background(.background)
|
||||
.animation(nil, value: imageReloadTick)
|
||||
.task {
|
||||
imageProvider.onImageLoaded = {
|
||||
Task { @MainActor in imageReloadTick += 1 }
|
||||
}
|
||||
}
|
||||
.task { await load() }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ import AppKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import MarkdownEngine
|
||||
import MarkdownEngineCodeBlocks
|
||||
import OutlineKit
|
||||
#if canImport(ImagePlayground)
|
||||
import ImagePlayground
|
||||
#endif
|
||||
|
||||
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
|
||||
/// below must run on the main thread — see the identical note on
|
||||
@@ -12,6 +16,56 @@ import OutlineKit
|
||||
struct DocumentReaderView: View {
|
||||
@Environment(SessionStore.self) private var session
|
||||
@Environment(StarStore.self) private var starStore
|
||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||
/// Local-only Outpost setting (Settings → Editor), not synced to
|
||||
/// Outline — see `SettingsView.editorDetail`.
|
||||
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
|
||||
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
|
||||
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true
|
||||
@AppStorage("outpost.imagePlaygroundEnabled") private var isImagePlaygroundEnabled = true
|
||||
@AppStorage("outpost.pointerCursorEnabled") private var isPointerCursorEnabled = true
|
||||
|
||||
/// `ImagePlaygroundViewController.isAvailable` gates on both OS version
|
||||
/// (macOS 15.1+) and actual device/region support (Apple Intelligence
|
||||
/// eligibility) — a supported OS with an unsupported Mac still reports
|
||||
/// `false`, so this is the one check that matters, not just `#available`.
|
||||
private var isImagePlaygroundSupported: Bool {
|
||||
#if canImport(ImagePlayground)
|
||||
if #available(macOS 15.1, *) {
|
||||
return ImagePlaygroundViewController.isAvailable
|
||||
}
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
/// Outline's own "Show line numbers" preference (synced, read via
|
||||
/// `session.userPreferences`, not `@AppStorage` — this one's the
|
||||
/// server's, not a local-only Outpost setting). No `@Environment`-in-`init`
|
||||
/// problem here since this is read directly in the view, not the
|
||||
/// view model.
|
||||
private var showCodeBlockLineNumbers: Bool {
|
||||
session.userPreferences?.codeBlockLineNumbers ?? false
|
||||
}
|
||||
/// Widened left indent reserved for the number gutter when line numbers
|
||||
/// are on (default is 12pt, just enough margin, no room for digits).
|
||||
private static let lineNumberGutterWidth: CGFloat = 32
|
||||
private var editorCodeBlockStyle: CodeBlockStyle {
|
||||
showCodeBlockLineNumbers ? .init(horizontalIndent: Self.lineNumberGutterWidth) : .default
|
||||
}
|
||||
/// Outline's own "Smart text replacements" preference (synced) — smart
|
||||
/// quotes/dashes while typing. Only meaningful on the editable pane.
|
||||
private var editorTextSubstitution: TextSubstitutionPolicy {
|
||||
let enabled = session.userPreferences?.smartText ?? false
|
||||
return .init(quoteSubstitution: enabled, dashSubstitution: enabled)
|
||||
}
|
||||
/// Local-only Outpost settings (Settings → Editor) — not synced to
|
||||
/// Outline, same as Split View above.
|
||||
private var editorTextCompletion: TextCompletionPolicy {
|
||||
.init(isEnabled: isAutocompleteEnabled)
|
||||
}
|
||||
private var editorWritingTools: WritingToolsPolicy {
|
||||
.init(isEnabled: isWritingToolsEnabled)
|
||||
}
|
||||
|
||||
@State private var viewModel: DocumentReaderViewModel
|
||||
let apiClient: OutlineAPIClient
|
||||
@@ -30,16 +84,73 @@ struct DocumentReaderView: View {
|
||||
let onDocumentCreated: () -> Void
|
||||
|
||||
@State private var isShowingUnpublishConfirmation = false
|
||||
@State private var isShowingPublishSheet = false
|
||||
@State private var isShowingArchiveConfirmation = false
|
||||
@State private var isShowingDeleteConfirmation = false
|
||||
@State private var isShowingMoveSheet = false
|
||||
@State private var isShowingHistorySheet = false
|
||||
@State private var isShowingInsightsSheet = false
|
||||
@State private var isShowingCommentsSheet = false
|
||||
/// Fetched once on load, purely to drive the toolbar marker/badge and
|
||||
/// the inline anchor bars below — the sheet itself fetches its own copy
|
||||
/// independently (see `DocumentCommentsSheet`), same as every other
|
||||
/// self-contained sheet in this file.
|
||||
@State private var loadedComments: [OutlineComment] = []
|
||||
/// Comment id to scroll/focus to when the sheet opens — set when an
|
||||
/// inline anchor bar is tapped, `nil` for the plain toolbar marker.
|
||||
@State private var focusedCommentId: String?
|
||||
/// Resolved on-screen positions for each anchored comment's text, from
|
||||
/// `NativeTextViewWrapper.onCommentAnchorRectsChange`.
|
||||
@State private var commentAnchorRects: [CommentAnchorRect] = []
|
||||
/// Set from "Comment on Selection…" in the editor's right-click menu —
|
||||
/// the sheet opens straight into composing a new anchored comment.
|
||||
@State private var pendingCommentAnchorText: String?
|
||||
|
||||
private var commentCount: Int? {
|
||||
loadedComments.isEmpty ? nil : loadedComments.count
|
||||
}
|
||||
private var commentAnchorQueries: [CommentAnchorQuery] {
|
||||
loadedComments.compactMap { comment in
|
||||
guard let anchorText = comment.anchorText, !anchorText.isEmpty else { return nil }
|
||||
return CommentAnchorQuery(id: comment.id, anchorText: anchorText)
|
||||
}
|
||||
}
|
||||
@State private var isShowingPresentSheet = false
|
||||
@State private var isShowingSearchSheet = false
|
||||
@State private var isShowingShareSheet = false
|
||||
@State private var isShowingNewDocumentSheet = false
|
||||
@State private var isShowingImagePlayground = false
|
||||
@State private var actionErrorMessage: String?
|
||||
/// Pushed into the main editable pane's `NativeTextViewWrapper` to
|
||||
/// insert an Image Playground result's Markdown reference at the caret.
|
||||
@State private var pendingTextInsertion: TextInsertionRequest?
|
||||
/// Pushed by `CodeBlockLanguagePicker` to rewrite a code block's fence
|
||||
/// line when the user switches its language from the picker.
|
||||
@State private var pendingCodeBlockLanguageChange: TextRangeReplacementRequest?
|
||||
/// Live-updated by `onSelectedTextChange` on the main editable pane;
|
||||
/// `nil` when the selection is empty (caret only, nothing highlighted).
|
||||
@State private var currentSelectedText: String?
|
||||
/// Resolves `` images in this document — shared by both
|
||||
/// panes (main + split-view preview), since they render the same text.
|
||||
@State private var imageProvider: OutlineImageProvider
|
||||
/// Bumped by `imageProvider.onImageLoaded`. Not read for its value —
|
||||
/// just referenced via `.animation(nil, value:)` so SwiftUI re-evaluates
|
||||
/// this view (and so `NativeTextViewWrapper.updateNSView` re-runs and
|
||||
/// notices the provider's `fingerprint()` changed) once an async image
|
||||
/// load completes. The engine has no polling of its own for this.
|
||||
@State private var imageReloadTick = 0
|
||||
/// Snapshot of `currentSelectedText` taken the moment the Image
|
||||
/// Playground button is pressed — the sheet's seed shouldn't shift if
|
||||
/// the user's selection happens to change while it's open.
|
||||
@State private var imagePlaygroundSeedText: String?
|
||||
/// Populated live by `NativeTextViewWrapper`'s `onCodeBlockSelectionChange` —
|
||||
/// one array per instance (main pane, split-view preview pane), since
|
||||
/// each lays the same text out at a different width and gets different
|
||||
/// rects. Only non-empty when `showCodeBlockLineNumbers` is on (see its
|
||||
/// doc comment for why the gutter needs `codeBlock.horizontalIndent`
|
||||
/// widened, which is gated on the same flag).
|
||||
@State private var readerCodeBlocks: [CodeBlockSelection] = []
|
||||
@State private var previewCodeBlocks: [CodeBlockSelection] = []
|
||||
|
||||
init(
|
||||
apiClient: OutlineAPIClient,
|
||||
@@ -50,49 +161,47 @@ struct DocumentReaderView: View {
|
||||
) {
|
||||
self.apiClient = apiClient
|
||||
self.document = document
|
||||
// `separateEditingEnabled` can't be read from `@Environment` here —
|
||||
// environment values aren't populated yet inside a view's `init`,
|
||||
// only from `body` onward. Defaults to `true` (today's only
|
||||
// behavior) and gets set for real in `.task` below once `session`
|
||||
// is actually available.
|
||||
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
|
||||
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
|
||||
self.onOpenChild = onOpenChild
|
||||
self.onDeleted = onDeleted
|
||||
self.onDocumentCreated = onDocumentCreated
|
||||
}
|
||||
|
||||
/// New Document, editing, pin/star/subscribe, and Full Width all queue
|
||||
/// and sync later (see `CachingOutlineAPIClient`) — everything else here
|
||||
/// (sharing, permissions, move/archive/delete/duplicate/templatize,
|
||||
/// history, insights, export) hits the server directly with no offline
|
||||
/// path, so it's disabled rather than left to fail confusingly on tap.
|
||||
private var isEffectivelyOnline: Bool {
|
||||
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
||||
}
|
||||
|
||||
/// Split View needs the full window height (each pane scrolls itself),
|
||||
/// which an unbounded page-level `ScrollView` can't give it — a
|
||||
/// `minHeight` inside one just resolves to exactly that minimum, not
|
||||
/// "fill available space", since there's no bounded space to fill.
|
||||
/// Only switches over once there's real content to show; loading/error
|
||||
/// states still go through the normal scrolling layout.
|
||||
private var canShowSplitView: Bool {
|
||||
isSplitViewEnabled
|
||||
&& viewModel.isEffectivelyEditable
|
||||
&& viewModel.errorMessage == nil
|
||||
&& !(viewModel.isLoading && viewModel.text.isEmpty)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if viewModel.isEditing {
|
||||
TextField("Title", text: $viewModel.title)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.textFieldStyle(.plain)
|
||||
}
|
||||
|
||||
if viewModel.isLoading && viewModel.text.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else if let errorMessage = viewModel.errorMessage {
|
||||
ContentUnavailableView {
|
||||
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(errorMessage)
|
||||
} actions: {
|
||||
Button("Retry") {
|
||||
Task { await viewModel.loadFullContent() }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
NativeTextViewWrapper(
|
||||
text: $viewModel.text,
|
||||
configuration: .init(heightBehavior: .fitsContent),
|
||||
isEditable: viewModel.isEditing
|
||||
)
|
||||
|
||||
if !viewModel.children.isEmpty {
|
||||
childrenSection
|
||||
}
|
||||
}
|
||||
Group {
|
||||
if canShowSplitView {
|
||||
splitViewContent
|
||||
} else {
|
||||
scrollingReaderContent
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if viewModel.isLoading && !viewModel.text.isEmpty {
|
||||
@@ -110,21 +219,68 @@ struct DocumentReaderView: View {
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
.help("Share")
|
||||
.help(isEffectivelyOnline ? "Share" : "Sharing needs an internet connection")
|
||||
.disabled(!isEffectivelyOnline)
|
||||
.popover(isPresented: $isShowingShareSheet, arrowEdge: .bottom) {
|
||||
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await viewModel.toggleEditing() }
|
||||
} label: {
|
||||
if viewModel.isSaving {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text(viewModel.isEditing ? "Done" : "Edit")
|
||||
// Toolbar marker — only shows once there's actually
|
||||
// something to point at. Anchored comments additionally get
|
||||
// an inline vertical bar next to their text (see
|
||||
// `commentAnchorMarkers`/`onCommentAnchorRectsChange`
|
||||
// below) — this button opens the sheet unfocused, showing
|
||||
// every comment/thread.
|
||||
if let commentCount, commentCount > 0 {
|
||||
Button {
|
||||
focusedCommentId = nil
|
||||
pendingCommentAnchorText = nil
|
||||
isShowingCommentsSheet = true
|
||||
} label: {
|
||||
Image(systemName: "bubble.left.and.bubble.right")
|
||||
}
|
||||
.help("\(commentCount) Comment\(commentCount == 1 ? "" : "s")")
|
||||
.disabled(!isEffectivelyOnline)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
Text(commentCount > 10 ? "10+" : "\(commentCount)")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(2)
|
||||
.frame(minWidth: 12, minHeight: 12)
|
||||
.background(.blue, in: Circle())
|
||||
.offset(x: 0, y: -1)
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isSaving)
|
||||
|
||||
if isImagePlaygroundEnabled && isImagePlaygroundSupported {
|
||||
Button {
|
||||
imagePlaygroundSeedText = currentSelectedText
|
||||
isShowingImagePlayground = true
|
||||
} label: {
|
||||
Image(systemName: "sparkles")
|
||||
}
|
||||
.help("Create Image with Image Playground")
|
||||
.disabled(!viewModel.isEffectivelyEditable)
|
||||
}
|
||||
|
||||
if viewModel.separateEditingEnabled {
|
||||
Button {
|
||||
Task { await viewModel.toggleEditing() }
|
||||
} label: {
|
||||
if viewModel.isSaving {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text(viewModel.isEditing ? "Done" : "Edit")
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isSaving)
|
||||
} else if viewModel.isSaving {
|
||||
// No Edit/Done affordance when documents are always
|
||||
// editable — this is the only feedback that an autosave
|
||||
// is actually happening.
|
||||
ProgressView().controlSize(.small)
|
||||
.help("Saving…")
|
||||
}
|
||||
|
||||
Button {
|
||||
isShowingNewDocumentSheet = true
|
||||
@@ -147,13 +303,35 @@ struct DocumentReaderView: View {
|
||||
.id(menuIdentity)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
imageProvider.onImageLoaded = {
|
||||
Task { @MainActor in imageReloadTick += 1 }
|
||||
}
|
||||
}
|
||||
.animation(nil, value: imageReloadTick)
|
||||
.task { await viewModel.loadFullContent() }
|
||||
// See the doc comment on `DocumentReaderViewModel.separateEditingEnabled`
|
||||
// for why this can't just be read at `init` time.
|
||||
.task { viewModel.separateEditingEnabled = session.userPreferences?.separateEditing ?? true }
|
||||
.onChange(of: viewModel.text) {
|
||||
guard !viewModel.separateEditingEnabled else { return }
|
||||
viewModel.scheduleAutosave()
|
||||
}
|
||||
.onChange(of: viewModel.title) {
|
||||
guard !viewModel.separateEditingEnabled else { return }
|
||||
viewModel.scheduleAutosave()
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadPinAndSubscriptionState()
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadInsightsEnabledState()
|
||||
}
|
||||
.task {
|
||||
loadedComments = (try? await apiClient.listComments(
|
||||
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
|
||||
)) ?? []
|
||||
}
|
||||
.task {
|
||||
while !Task.isCancelled {
|
||||
await viewModel.loadViewers()
|
||||
@@ -201,6 +379,36 @@ struct DocumentReaderView: View {
|
||||
} message: {
|
||||
Text(actionErrorMessage ?? "")
|
||||
}
|
||||
.sheet(isPresented: $isShowingCommentsSheet) {
|
||||
DocumentCommentsSheet(
|
||||
apiClient: apiClient,
|
||||
document: document,
|
||||
focusedCommentId: focusedCommentId,
|
||||
pendingAnchorText: pendingCommentAnchorText,
|
||||
onCommentsChanged: {
|
||||
Task {
|
||||
loadedComments = (try? await apiClient.listComments(
|
||||
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
|
||||
)) ?? loadedComments
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $isShowingPublishSheet) {
|
||||
PublishDocumentSheet(apiClient: apiClient, document: document) {
|
||||
// Stays in the reader — unlike Move/Unpublish, publishing
|
||||
// doesn't make the document any less visible from here, so
|
||||
// there's no reason to pop back like onDeleted() does
|
||||
// elsewhere. Refresh from the server rather than guessing
|
||||
// the new collectionId locally (moveDocument may have run
|
||||
// as a second step inside the sheet).
|
||||
await viewModel.loadFullContent()
|
||||
// The doc previously had no collectionId (or a different
|
||||
// one) — the sidebar tree for its new collection needs to
|
||||
// know it exists now, same signal "New Document" sends.
|
||||
onDocumentCreated()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isShowingMoveSheet) {
|
||||
MoveDocumentSheet(apiClient: apiClient, document: document) {
|
||||
onDeleted()
|
||||
@@ -224,6 +432,59 @@ struct DocumentReaderView: View {
|
||||
onOpenChild(child)
|
||||
}
|
||||
}
|
||||
.modifier(ImagePlaygroundPresenter(
|
||||
isPresented: $isShowingImagePlayground,
|
||||
seedText: imagePlaygroundSeedText,
|
||||
seedTitle: viewModel.title.isEmpty ? "Untitled" : viewModel.title,
|
||||
onCompletion: { url in handleGeneratedImage(url) }
|
||||
))
|
||||
}
|
||||
|
||||
/// Adds "Comment on Selection…" to the right-click menu when there's an
|
||||
/// actual (non-empty) selection to anchor to — `currentSelectedText` is
|
||||
/// kept live by `onSelectedTextChange` on the same pane. Inserted at the
|
||||
/// top since it's the primary reason to right-click selected text here,
|
||||
/// matching Outline's own web editor surfacing comment as the first
|
||||
/// selection action.
|
||||
private func addCommentMenuItem(to menu: NSMenu) -> NSMenu {
|
||||
guard let selection = currentSelectedText, !selection.isEmpty else { return menu }
|
||||
let item = ClosureMenuItem(title: "Comment on Selection…") {
|
||||
pendingCommentAnchorText = selection
|
||||
focusedCommentId = nil
|
||||
isShowingCommentsSheet = true
|
||||
}
|
||||
menu.insertItem(item, at: 0)
|
||||
menu.insertItem(.separator(), at: 1)
|
||||
return menu
|
||||
}
|
||||
|
||||
/// Uploads an Image Playground result the same way the reader would any
|
||||
/// other attachment (`attachments.create` presigned target, then the
|
||||
/// direct file POST — see `OutlineAPIClient.uploadAttachmentFile`), then
|
||||
/// inserts the hosted image's Markdown reference at the caret.
|
||||
private func handleGeneratedImage(_ localURL: URL) {
|
||||
Task {
|
||||
do {
|
||||
let data = try Data(contentsOf: localURL)
|
||||
let created = try await apiClient.createAttachment(.init(
|
||||
name: localURL.lastPathComponent,
|
||||
contentType: "image/png",
|
||||
size: data.count,
|
||||
documentId: viewModel.documentId
|
||||
))
|
||||
try await apiClient.uploadAttachmentFile(created, fileData: data)
|
||||
// Outline's own editor never embeds the raw (presigned/storage)
|
||||
// upload URL in document Markdown — it writes this stable
|
||||
// redirect-by-id reference instead, which keeps resolving
|
||||
// correctly even if the underlying storage URL rotates/expires.
|
||||
pendingTextInsertion = TextInsertionRequest(
|
||||
documentId: viewModel.documentId,
|
||||
text: "\n\n)\n\n"
|
||||
)
|
||||
} catch {
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't add the generated image.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every toggle-backed piece of state shown as a checkmark inside
|
||||
@@ -235,7 +496,8 @@ struct DocumentReaderView: View {
|
||||
viewModel.isPinned,
|
||||
viewModel.isInsightsEnabled ?? false,
|
||||
viewModel.isFullWidth,
|
||||
viewModel.isEditing
|
||||
viewModel.isEditing,
|
||||
isEffectivelyOnline
|
||||
].map(String.init).joined(separator: "-")
|
||||
}
|
||||
|
||||
@@ -259,31 +521,166 @@ struct DocumentReaderView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var childrenSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
|
||||
Text("Sub-documents")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
ForEach(viewModel.children) { child in
|
||||
Button {
|
||||
onOpenChild(child)
|
||||
} label: {
|
||||
DocumentRowView(document: child)
|
||||
/// Today's single-pane layout — page-level `ScrollView` wrapping title +
|
||||
/// content, used for the normal reading/editing view, and for every
|
||||
/// loading/error state regardless of Split View.
|
||||
private var scrollingReaderContent: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if viewModel.isEffectivelyEditable {
|
||||
TextField("Title", text: $viewModel.title)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.textFieldStyle(.plain)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
if child.id != viewModel.children.last?.id {
|
||||
Divider()
|
||||
if viewModel.isLoading && viewModel.text.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else if let errorMessage = viewModel.errorMessage {
|
||||
ContentUnavailableView {
|
||||
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(errorMessage)
|
||||
} actions: {
|
||||
Button("Retry") {
|
||||
Task { await viewModel.loadFullContent() }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ZStack(alignment: .topLeading) {
|
||||
NativeTextViewWrapper(
|
||||
text: $viewModel.text,
|
||||
pendingTextInsertion: $pendingTextInsertion,
|
||||
pendingTextRangeReplacement: $pendingCodeBlockLanguageChange,
|
||||
configuration: .init(
|
||||
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||
codeBlock: editorCodeBlockStyle,
|
||||
textSubstitution: editorTextSubstitution,
|
||||
textCompletion: editorTextCompletion,
|
||||
writingTools: editorWritingTools,
|
||||
heightBehavior: .fitsContent,
|
||||
pointerCursorOverLinksWhileEditing: isPointerCursorEnabled
|
||||
),
|
||||
documentId: viewModel.documentId,
|
||||
isEditable: viewModel.isEffectivelyEditable,
|
||||
onBuildContextMenu: { menu, _ in addCommentMenuItem(to: menu) },
|
||||
onCodeBlockSelectionChange: { readerCodeBlocks = $0 },
|
||||
onSelectedTextChange: { currentSelectedText = $0 },
|
||||
commentAnchorQueries: commentAnchorQueries,
|
||||
onCommentAnchorRectsChange: { commentAnchorRects = $0 }
|
||||
)
|
||||
if showCodeBlockLineNumbers {
|
||||
ForEach(readerCodeBlocks) { selection in
|
||||
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
|
||||
}
|
||||
}
|
||||
if viewModel.isEffectivelyEditable {
|
||||
ForEach(readerCodeBlocks) { selection in
|
||||
CodeBlockLanguagePicker(selection: selection, documentId: viewModel.documentId) { request in
|
||||
pendingCodeBlockLanguageChange = request
|
||||
}
|
||||
}
|
||||
}
|
||||
ForEach(commentAnchorRects) { anchor in
|
||||
CommentAnchorMarker(rect: anchor.rect) {
|
||||
focusedCommentId = anchor.id
|
||||
pendingCommentAnchorText = nil
|
||||
isShowingCommentsSheet = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
/// Split View's layout — title fixed at the top (not part of either
|
||||
/// scrolling pane), `splitEditorView` filling every remaining pixel of
|
||||
/// the window below it. No outer `ScrollView` here on purpose: each
|
||||
/// pane already scrolls itself, and nesting that inside another
|
||||
/// unbounded scroll container is exactly what was capping both panes
|
||||
/// at a fixed height instead of spanning the window.
|
||||
private var splitViewContent: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
TextField("Title", text: $viewModel.title)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.textFieldStyle(.plain)
|
||||
.padding([.horizontal, .top])
|
||||
|
||||
splitEditorView
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
/// Left is the literal Markdown source in `rawSourceMode` (no syntax
|
||||
/// hiding/styling, but still the real engine — needed so selection
|
||||
/// tracking and caret-position insertion, e.g. from the Image Playground
|
||||
/// button, work here the same as everywhere else); right is the same
|
||||
/// rich rendering used everywhere else in the app, read-only, bound to
|
||||
/// the same `viewModel.text` so it updates live as the left side is
|
||||
/// typed into.
|
||||
///
|
||||
/// Scroll position between the two panes is **not** synchronized — the
|
||||
/// only way to do that would be reaching into `NativeTextViewWrapper`'s
|
||||
/// private internal view hierarchy to find its scroll view (the package
|
||||
/// exposes no scroll position/delegate hook at all), which is fragile
|
||||
/// enough to break silently on a package update. Flagged as a known
|
||||
/// follow-up, not attempted here.
|
||||
private var splitEditorView: some View {
|
||||
HSplitView {
|
||||
NativeTextViewWrapper(
|
||||
text: $viewModel.text,
|
||||
pendingTextInsertion: $pendingTextInsertion,
|
||||
configuration: .init(rawSourceMode: true),
|
||||
fontName: "SFMono-Regular",
|
||||
documentId: viewModel.documentId,
|
||||
isEditable: viewModel.isEffectivelyEditable,
|
||||
onSelectedTextChange: { currentSelectedText = $0 }
|
||||
)
|
||||
.padding(8)
|
||||
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
ScrollView {
|
||||
ZStack(alignment: .topLeading) {
|
||||
NativeTextViewWrapper(
|
||||
text: $viewModel.text,
|
||||
configuration: .init(
|
||||
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||
codeBlock: editorCodeBlockStyle,
|
||||
heightBehavior: .fitsContent
|
||||
),
|
||||
documentId: viewModel.documentId,
|
||||
isEditable: false,
|
||||
onBuildContextMenu: { menu, _ in addCommentMenuItem(to: menu) },
|
||||
onCodeBlockSelectionChange: { previewCodeBlocks = $0 },
|
||||
onSelectedTextChange: { currentSelectedText = $0 },
|
||||
commentAnchorQueries: commentAnchorQueries,
|
||||
onCommentAnchorRectsChange: { commentAnchorRects = $0 }
|
||||
)
|
||||
if showCodeBlockLineNumbers {
|
||||
ForEach(previewCodeBlocks) { selection in
|
||||
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
|
||||
}
|
||||
}
|
||||
ForEach(commentAnchorRects) { anchor in
|
||||
CommentAnchorMarker(rect: anchor.rect) {
|
||||
focusedCommentId = anchor.id
|
||||
pendingCommentAnchorText = nil
|
||||
isShowingCommentsSheet = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var menuContent: some View {
|
||||
Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") {
|
||||
@@ -296,8 +693,10 @@ struct DocumentReaderView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
|
||||
Task { await viewModel.toggleEditing() }
|
||||
if viewModel.separateEditingEnabled {
|
||||
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
|
||||
Task { await viewModel.toggleEditing() }
|
||||
}
|
||||
}
|
||||
// Membership management now lives in DocumentShareSheet's "People
|
||||
// with access" section, alongside the share link — same sheet,
|
||||
@@ -305,27 +704,40 @@ struct DocumentReaderView: View {
|
||||
Button("Permissions…") {
|
||||
isShowingShareSheet = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Templatize") {
|
||||
Task { await templatize() }
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
Button("Duplicate") {
|
||||
Task { await duplicate() }
|
||||
}
|
||||
Button("Unpublish") {
|
||||
isShowingUnpublishConfirmation = true
|
||||
.disabled(!isEffectivelyOnline)
|
||||
if viewModel.publishedAt == nil {
|
||||
Button("Publish…") {
|
||||
isShowingPublishSheet = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
} else {
|
||||
Button("Unpublish") {
|
||||
isShowingUnpublishConfirmation = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
}
|
||||
Button("Archive…") {
|
||||
isShowingArchiveConfirmation = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Move") {
|
||||
isShowingMoveSheet = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
// Multipart file upload is its own subsystem — deferred rather than
|
||||
// half-built here.
|
||||
Button("Import Document…") {}
|
||||
@@ -342,9 +754,13 @@ struct DocumentReaderView: View {
|
||||
Button("History") {
|
||||
isShowingHistorySheet = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
Button("Insights") {
|
||||
isShowingInsightsSheet = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
// Present/Search in Document both read `documents.info`, which is
|
||||
// read-through cached — they work offline on whatever's cached.
|
||||
Button("Present") {
|
||||
isShowingPresentSheet = true
|
||||
}
|
||||
@@ -354,6 +770,7 @@ struct DocumentReaderView: View {
|
||||
Button("Download") {
|
||||
Task { await download() }
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
Button("Copy") {
|
||||
Task { await copyMarkdown() }
|
||||
}
|
||||
@@ -370,6 +787,7 @@ struct DocumentReaderView: View {
|
||||
get: { viewModel.isInsightsEnabled ?? false },
|
||||
set: { _ in Task { await toggleInsights() } }
|
||||
))
|
||||
.disabled(!isEffectivelyOnline)
|
||||
// Confirmed against a live server: there's no per-document embeds
|
||||
// field. Only a workspace-level setting exists, and that's not
|
||||
// reachable via the API either (no `team.update` endpoint in the
|
||||
@@ -386,6 +804,7 @@ struct DocumentReaderView: View {
|
||||
Button("Delete…", role: .destructive) {
|
||||
isShowingDeleteConfirmation = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
}
|
||||
|
||||
private func star() async {
|
||||
@@ -505,4 +924,202 @@ struct DocumentReaderView: View {
|
||||
operation.run()
|
||||
}
|
||||
}
|
||||
|
||||
/// One code block's number gutter, positioned absolutely over a
|
||||
/// `NativeTextViewWrapper` via `CodeBlockSelection.rect` — same overlay
|
||||
/// pattern MarkdownEngine's own `CodeBlockButton` uses.
|
||||
///
|
||||
/// `selection.rect` spans the WHOLE fenced block (open fence line + content
|
||||
/// + close fence line), matching what the engine actually lays out — the
|
||||
/// fence lines render with invisible (`.clear`) text once the caret leaves
|
||||
/// the block, but they don't collapse to zero height, so the block is
|
||||
/// always exactly `content line count + 2` rows tall. `selection.code` is
|
||||
/// content only, so the row height and number positions below both account
|
||||
/// for that phantom top/bottom row explicitly instead of dividing by the
|
||||
/// content line count alone (which would drift the numbers upward, more so
|
||||
/// per line, the taller the block).
|
||||
///
|
||||
/// Known limitation, accepted rather than fixable app-side: a content line
|
||||
/// that soft-wraps onto a second visual row (MarkdownEngine always
|
||||
/// char-wraps code blocks, no way to opt out without forking the package)
|
||||
/// throws this off — every row below it reads one line low. Documented in
|
||||
/// TODO.local.md alongside the same package's other gaps.
|
||||
/// Thin vertical bar next to an anchored comment's text — same visual
|
||||
/// language as Word/Google Docs' margin comment indicators. Tapping opens
|
||||
/// the comments sheet focused to that thread.
|
||||
/// Top-left pill overlay on each code block — opposite corner from the
|
||||
/// engine's own copy button (top-right), same `Color.clear` + `.position()`
|
||||
/// sizing trick `CodeBlockButton` uses. Tapping opens a curated language
|
||||
/// list (not the full ~190-language highlight.js catalog — same "small
|
||||
/// curated set, not the whole thing" call as the emoji reaction picker);
|
||||
/// picking one rewrites just the fence line via
|
||||
/// `CodeBlockSelection.fenceRange`, leaving the block's content untouched.
|
||||
private struct CodeBlockLanguagePicker: View {
|
||||
let selection: CodeBlockSelection
|
||||
let documentId: String
|
||||
let onRequest: (TextRangeReplacementRequest) -> Void
|
||||
|
||||
private static let languages: [(label: String, tag: String?)] = [
|
||||
("Plain Text", nil),
|
||||
("Swift", "swift"),
|
||||
("Python", "python"),
|
||||
("JavaScript", "javascript"),
|
||||
("TypeScript", "typescript"),
|
||||
("Bash", "bash"),
|
||||
("JSON", "json"),
|
||||
("YAML", "yaml"),
|
||||
("HTML", "html"),
|
||||
("CSS", "css"),
|
||||
("SQL", "sql"),
|
||||
("Java", "java"),
|
||||
("Kotlin", "kotlin"),
|
||||
("C", "c"),
|
||||
("C++", "cpp"),
|
||||
("C#", "csharp"),
|
||||
("Go", "go"),
|
||||
("Rust", "rust"),
|
||||
("Ruby", "ruby"),
|
||||
("PHP", "php"),
|
||||
("Markdown", "markdown"),
|
||||
]
|
||||
|
||||
private var currentLabel: String {
|
||||
guard let language = selection.language, !language.isEmpty else { return "Plain Text" }
|
||||
return Self.languages.first { $0.tag == language }?.label ?? language.uppercased()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.frame(width: selection.rect.width, height: selection.rect.height)
|
||||
.overlay(alignment: .topLeading) {
|
||||
Menu {
|
||||
ForEach(Self.languages, id: \.label) { entry in
|
||||
Button(entry.label) {
|
||||
onRequest(TextRangeReplacementRequest(
|
||||
documentId: documentId,
|
||||
range: selection.fenceRange,
|
||||
replacement: "```\(entry.tag ?? "")\n"
|
||||
))
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(currentLabel.uppercased())
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.black.opacity(0.3), in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
.padding(.top, 6)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
.position(
|
||||
x: selection.rect.midX,
|
||||
y: selection.rect.midY
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CommentAnchorMarker: View {
|
||||
let rect: CGRect
|
||||
let onTap: () -> Void
|
||||
|
||||
private static let width: CGFloat = 3
|
||||
private static let gap: CGFloat = 4
|
||||
private static let hitTargetWidth: CGFloat = 16
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.frame(width: Self.hitTargetWidth, height: max(rect.height, 4))
|
||||
.contentShape(Rectangle())
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(Color.blue.opacity(0.6))
|
||||
.frame(width: Self.width)
|
||||
}
|
||||
.position(
|
||||
x: rect.minX - Self.gap - Self.width / 2,
|
||||
y: rect.minY + rect.height / 2
|
||||
)
|
||||
.onTapGesture(perform: onTap)
|
||||
.help("View comment")
|
||||
}
|
||||
}
|
||||
|
||||
private struct CodeBlockLineNumberGutter: View {
|
||||
let selection: CodeBlockSelection
|
||||
let gutterWidth: CGFloat
|
||||
|
||||
/// `selection.code` (`token.contentRange`) always ends with exactly one
|
||||
/// trailing `\n` per content line — the range runs right up to the
|
||||
/// start of the closing fence's own line, so the newline that ends the
|
||||
/// last content line is included, but there's never an unterminated
|
||||
/// final line to add one more for. Counting `\n` characters directly
|
||||
/// (not `.components(separatedBy:).count`, which is one too many
|
||||
/// whenever the string ends in the separator) is what makes a
|
||||
/// single-line block read "1", not "2".
|
||||
private var contentLineCount: Int {
|
||||
max(1, selection.code.reduce(into: 0) { count, char in if char == "\n" { count += 1 } })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let totalRows = CGFloat(contentLineCount + 2)
|
||||
let rowHeight = selection.rect.height / totalRows
|
||||
ForEach(0..<contentLineCount, id: \.self) { line in
|
||||
Text("\(line + 1)")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: gutterWidth - 6, alignment: .trailing)
|
||||
.position(
|
||||
x: selection.rect.minX + (gutterWidth - 6) / 2,
|
||||
// +1.5 rows: skip the invisible open-fence row, then
|
||||
// center within this content row.
|
||||
y: selection.rect.minY + rowHeight * (CGFloat(line) + 1.5)
|
||||
)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies `.imagePlaygroundSheet` only where it exists (macOS 15.1+, and
|
||||
/// only once the `ImagePlayground` framework is actually linked in Xcode —
|
||||
/// see `SETUP.md`). A no-op modifier everywhere else, so this file stays
|
||||
/// valid to build before that link-up happens.
|
||||
private struct ImagePlaygroundPresenter: ViewModifier {
|
||||
@Binding var isPresented: Bool
|
||||
/// Highlighted document text at the moment the button was pressed, if
|
||||
/// any — seeds Image Playground's prompt instead of opening blank.
|
||||
let seedText: String?
|
||||
/// Document title, used as the concept's title when `seedText` is used.
|
||||
let seedTitle: String
|
||||
let onCompletion: (URL) -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
#if canImport(ImagePlayground)
|
||||
if #available(macOS 15.1, *) {
|
||||
let concepts: [ImagePlaygroundConcept] = {
|
||||
guard let seedText, !seedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return []
|
||||
}
|
||||
return [ImagePlaygroundConcept.extracted(from: seedText, title: seedTitle)]
|
||||
}()
|
||||
content.imagePlaygroundSheet(
|
||||
isPresented: $isPresented,
|
||||
concepts: concepts,
|
||||
onCompletion: { url in
|
||||
isPresented = false
|
||||
onCompletion(url)
|
||||
},
|
||||
onCancellation: { isPresented = false }
|
||||
)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
#else
|
||||
content
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -9,8 +9,10 @@ final class DocumentReaderViewModel {
|
||||
var emoji: String?
|
||||
var text: String
|
||||
var collectionId: String?
|
||||
var parentDocumentId: String?
|
||||
/// `nil` = draft (not published/visible to other workspace members).
|
||||
var publishedAt: Date?
|
||||
var isFullWidth = false
|
||||
var children: [OutlineDocument] = []
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
|
||||
@@ -18,6 +20,38 @@ final class DocumentReaderViewModel {
|
||||
var isSaving = false
|
||||
var saveErrorMessage: String?
|
||||
|
||||
/// Snapshot of the preference, set once via `.task` right after the
|
||||
/// view appears (can't be read from `@Environment` inside the view's
|
||||
/// own `init`) rather than a live binding to `SessionStore` — matches
|
||||
/// how `isFullWidth` etc. are already seeded from the document at init
|
||||
/// rather than observed reactively. A change made in Settings while a
|
||||
/// document is already open takes effect the next document opened, not
|
||||
/// mid-session; an acceptable tradeoff for how rarely this gets
|
||||
/// toggled versus the complexity of threading a live preference
|
||||
/// reference through every reader instance.
|
||||
var separateEditingEnabled: Bool
|
||||
|
||||
/// The single source of truth the view reads for both "show the title
|
||||
/// field" and "is the text view editable" — when separate editing is
|
||||
/// off there's no Edit/Done mode at all, the document is just always
|
||||
/// editable (assuming permission; there's no per-document permission
|
||||
/// field to pre-check against, so an unauthorized edit simply fails to
|
||||
/// save rather than being blocked client-side up front).
|
||||
var isEffectivelyEditable: Bool {
|
||||
separateEditingEnabled ? isEditing : true
|
||||
}
|
||||
|
||||
private var autosaveTask: Task<Void, Never>?
|
||||
/// Tracks the last known-synced-with-the-server values so
|
||||
/// `scheduleAutosave()` can no-op when called just because `text`/
|
||||
/// `title` were reassigned *from* a server response (initial load, or
|
||||
/// a completed save) rather than actually edited — without this, every
|
||||
/// document open in the always-editable mode would fire one pointless
|
||||
/// autosave round-trip immediately, re-sending exactly what was just
|
||||
/// received.
|
||||
private var lastSyncedText: String
|
||||
private var lastSyncedTitle: String
|
||||
|
||||
/// Recent viewers, `views.list` filtered to entries that actually have a
|
||||
/// `lastViewedAt` — this is historical/aggregated view data, not live
|
||||
/// "viewing right now" presence (that needs the Hocuspocus collaboration
|
||||
@@ -38,14 +72,19 @@ final class DocumentReaderViewModel {
|
||||
let documentId: String
|
||||
private let apiClient: OutlineAPIClient
|
||||
|
||||
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
|
||||
init(apiClient: OutlineAPIClient, document: OutlineDocument, separateEditingEnabled: Bool = true) {
|
||||
self.apiClient = apiClient
|
||||
self.documentId = document.id
|
||||
self.title = document.title
|
||||
self.emoji = document.emoji
|
||||
self.text = document.text
|
||||
self.collectionId = document.collectionId
|
||||
self.parentDocumentId = document.parentDocumentId
|
||||
self.publishedAt = document.publishedAt
|
||||
self.isFullWidth = document.fullWidth ?? false
|
||||
self.separateEditingEnabled = separateEditingEnabled
|
||||
self.lastSyncedText = document.text
|
||||
self.lastSyncedTitle = document.title
|
||||
}
|
||||
|
||||
/// The list endpoint's copy of a document isn't guaranteed to be the full,
|
||||
@@ -61,17 +100,14 @@ final class DocumentReaderViewModel {
|
||||
emoji = full.emoji
|
||||
text = full.text
|
||||
collectionId = full.collectionId
|
||||
parentDocumentId = full.parentDocumentId
|
||||
publishedAt = full.publishedAt
|
||||
isFullWidth = full.fullWidth ?? false
|
||||
lastSyncedText = full.text
|
||||
lastSyncedTitle = full.title
|
||||
} catch {
|
||||
errorMessage = "Couldn't load this document. Check your connection and try again."
|
||||
}
|
||||
|
||||
children = (try? await apiClient.listDocuments(
|
||||
collectionId: nil,
|
||||
parentDocumentId: documentId,
|
||||
offset: 0,
|
||||
limit: 100
|
||||
)) ?? []
|
||||
}
|
||||
|
||||
func loadViewers() async {
|
||||
@@ -146,20 +182,55 @@ final class DocumentReaderViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Turning editing off saves; turning it on is just a mode switch.
|
||||
/// Turning editing off saves; turning it on is just a mode switch. Only
|
||||
/// meaningful when `separateEditingEnabled` — the always-editable path
|
||||
/// uses `scheduleAutosave()` instead.
|
||||
func toggleEditing() async {
|
||||
guard isEditing else {
|
||||
isEditing = true
|
||||
return
|
||||
}
|
||||
await save()
|
||||
if saveErrorMessage == nil {
|
||||
isEditing = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Debounced save for the always-editable (separate editing off) path —
|
||||
/// cancels any pending save and starts a fresh countdown on every call,
|
||||
/// so a save only actually fires once typing pauses, not on every
|
||||
/// keystroke. Goes through the same `updateDocument` call the explicit
|
||||
/// Done-button save uses, which is already offline-queue-aware
|
||||
/// (`CachingOutlineAPIClient`), so autosave while offline just queues
|
||||
/// like any other edit instead of needing separate handling here.
|
||||
func scheduleAutosave() {
|
||||
guard text != lastSyncedText || title != lastSyncedTitle else { return }
|
||||
autosaveTask?.cancel()
|
||||
autosaveTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(1.5))
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
await self.save()
|
||||
}
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
isSaving = true
|
||||
saveErrorMessage = nil
|
||||
defer { isSaving = false }
|
||||
let sentTitle = title
|
||||
let sentText = text
|
||||
do {
|
||||
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: title, text: text))
|
||||
title = updated.title
|
||||
text = updated.text
|
||||
isEditing = false
|
||||
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: sentTitle, text: sentText))
|
||||
// Only reconcile with the server's response if nothing changed
|
||||
// locally while the request was in flight — otherwise this
|
||||
// would clobber keystrokes typed during a debounced autosave's
|
||||
// round trip. Whatever's newer goes out on the next autosave
|
||||
// cycle regardless, since `scheduleAutosave()` keeps getting
|
||||
// re-triggered by continued typing.
|
||||
if title == sentTitle { title = updated.title }
|
||||
if text == sentText { text = updated.text }
|
||||
lastSyncedTitle = sentTitle
|
||||
lastSyncedText = sentText
|
||||
} catch {
|
||||
saveErrorMessage = outlineErrorMessage(error, fallback: "Couldn't save this document.")
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ struct NewDocumentSheet: View {
|
||||
ProgressView().frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Picker("Collection", selection: $selectedCollectionID) {
|
||||
Text("Choose a collection").tag(String?.none)
|
||||
Text("Draft (not published)").tag(String?.none)
|
||||
ForEach(collections) { collection in
|
||||
Text(collection.name).tag(Optional(collection.id))
|
||||
}
|
||||
@@ -86,6 +86,12 @@ struct NewDocumentSheet: View {
|
||||
}
|
||||
.labelsHidden()
|
||||
.disabled(selectedCollectionID == nil || isLoadingDestinationDocuments)
|
||||
|
||||
if selectedCollectionID != nil {
|
||||
Label("This will publish the document immediately, making it visible to your workspace.", systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
@@ -97,18 +103,24 @@ struct NewDocumentSheet: View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
Button("Create") {
|
||||
Button(selectedCollectionID == nil ? "Save as Draft" : "Create & Publish") {
|
||||
Task { await create() }
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(selectedCollectionID == nil || isCreating)
|
||||
.disabled(isCreating)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 380)
|
||||
.task {
|
||||
await loadCollections()
|
||||
selectedCollectionID = initialCollectionID ?? initialParentDocument?.collectionId ?? collections.first?.id
|
||||
// "By default" means a bare New Document (Home, reader
|
||||
// toolbar) starts as a draft — no `collections.first` fallback
|
||||
// like there used to be. A contextual entry point (right-click
|
||||
// a specific collection/document) still pre-fills that
|
||||
// location, since that already carries clear placement intent;
|
||||
// the publish warning above still applies to it either way.
|
||||
selectedCollectionID = initialCollectionID ?? initialParentDocument?.collectionId
|
||||
selectedParentID = initialParentDocument?.id
|
||||
isTitleFocused = true
|
||||
}
|
||||
@@ -147,7 +159,6 @@ struct NewDocumentSheet: View {
|
||||
}
|
||||
|
||||
private func create() async {
|
||||
guard let selectedCollectionID else { return }
|
||||
isCreating = true
|
||||
defer { isCreating = false }
|
||||
do {
|
||||
@@ -156,7 +167,12 @@ struct NewDocumentSheet: View {
|
||||
title: title.isEmpty ? "Untitled" : title,
|
||||
text: "",
|
||||
collectionId: selectedCollectionID,
|
||||
parentDocumentId: selectedParentID
|
||||
parentDocumentId: selectedParentID,
|
||||
// No collection/parent selected = draft; Outline can't
|
||||
// publish without one of the two regardless of this
|
||||
// flag, but setting it explicitly rather than relying
|
||||
// on that keeps intent obvious at the call site.
|
||||
publish: selectedCollectionID != nil
|
||||
)
|
||||
)
|
||||
onCreated(document)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
|
||||
/// Shown at the top of the sidebar whenever the app is offline — either for
|
||||
/// real (`NetworkMonitor` reports no connection) or because the user turned
|
||||
/// on the manual "Offline Mode" toggle. Cached collections/documents keep
|
||||
/// browsing working either way, but the user should know they might be
|
||||
/// looking at stale data.
|
||||
struct OfflineBanner: View {
|
||||
/// Whether this is the user's own "Offline Mode" toggle rather than a
|
||||
/// real dropped connection — same banner slot, different explanation.
|
||||
var isManual: Bool = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundStyle(.orange)
|
||||
Text(isManual ? "Offline Mode — showing cached content" : "Offline — showing cached content")
|
||||
.font(.callout)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.orange.opacity(0.12))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,30 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
|
||||
/// Shown in the sidebar when the network is actually down and the user
|
||||
/// hasn't turned on Offline Mode themselves yet. Deliberately doesn't change
|
||||
/// any fetch behavior on its own — reads still try live and fall back to
|
||||
/// cache the normal way (see `CachingOutlineAPIClient`) until the user
|
||||
/// actually taps the button here, same as `RemoteChangesBanner` never
|
||||
/// auto-refreshes on their behalf either.
|
||||
struct OfflineConnectionPromptBanner: View {
|
||||
let onEnableOfflineMode: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundStyle(.orange)
|
||||
Text("No connection")
|
||||
.font(.callout)
|
||||
Spacer(minLength: 8)
|
||||
Button("Turn On Offline Mode", action: onEnableOfflineMode)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.orange)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.orange.opacity(0.12))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,144 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// Destination picker for publishing a draft — same collection-then-
|
||||
/// optional-parent-document shape as `MoveDocumentSheet`, since Outline's
|
||||
/// own web app treats "where does this go" identically for both actions.
|
||||
/// Pre-selects the document's own `collectionId`/`parentDocumentId` when it
|
||||
/// already has one (a draft can already belong to a collection without
|
||||
/// being published) rather than starting blank, per explicit instruction.
|
||||
///
|
||||
/// Publishing itself is two calls, not one: `documents.update(publish:
|
||||
/// true, collectionId:)` does the actual publish and top-level collection
|
||||
/// placement in one round-trip (confirmed from a live network capture);
|
||||
/// nesting under a specific parent document isn't a documented
|
||||
/// `documents.update` field, so that part reuses `documents.move` — the
|
||||
/// same already-working mechanism `MoveDocumentSheet` uses — as a second
|
||||
/// step, only when a parent was actually chosen.
|
||||
@MainActor
|
||||
struct PublishDocumentSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let apiClient: OutlineAPIClient
|
||||
let document: OutlineDocument
|
||||
let onPublished: () async -> Void
|
||||
|
||||
@State private var collections: [OutlineCollection] = []
|
||||
@State private var selectedCollectionID: String?
|
||||
@State private var rootDocuments: [OutlineDocument] = []
|
||||
@State private var selectedParentID: String?
|
||||
@State private var isLoadingCollections = false
|
||||
@State private var isLoadingDestinationDocuments = false
|
||||
@State private var isPublishing = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Publish \"\(document.title.isEmpty ? "Untitled" : document.title)\"")
|
||||
.font(.headline)
|
||||
Text("Choose where this document should live once published.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if isLoadingCollections {
|
||||
ProgressView().frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Picker("Collection", selection: $selectedCollectionID) {
|
||||
Text("Choose a collection").tag(String?.none)
|
||||
ForEach(collections) { collection in
|
||||
Text(collection.name).tag(Optional(collection.id))
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
|
||||
Picker("Location", selection: $selectedParentID) {
|
||||
Text("Collection root").tag(String?.none)
|
||||
ForEach(rootDocuments.filter { $0.id != document.id }) { candidate in
|
||||
Text(candidate.title.isEmpty ? "Untitled" : candidate.title).tag(Optional(candidate.id))
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.disabled(selectedCollectionID == nil || isLoadingDestinationDocuments)
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
Button("Publish") {
|
||||
Task { await publish() }
|
||||
}
|
||||
.disabled(selectedCollectionID == nil || isPublishing)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 360)
|
||||
.task {
|
||||
await loadCollections()
|
||||
selectedCollectionID = document.collectionId
|
||||
selectedParentID = document.parentDocumentId
|
||||
}
|
||||
.task(id: selectedCollectionID) {
|
||||
await loadRootDocuments()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCollections() async {
|
||||
isLoadingCollections = true
|
||||
defer { isLoadingCollections = false }
|
||||
do {
|
||||
collections = try await apiClient.listCollections(offset: 0, limit: 100)
|
||||
} catch {
|
||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load collections.")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadRootDocuments() async {
|
||||
guard let selectedCollectionID else {
|
||||
rootDocuments = []
|
||||
return
|
||||
}
|
||||
isLoadingDestinationDocuments = true
|
||||
defer { isLoadingDestinationDocuments = false }
|
||||
do {
|
||||
rootDocuments = try await apiClient.listDocuments(
|
||||
collectionId: selectedCollectionID,
|
||||
parentDocumentId: nil,
|
||||
offset: 0,
|
||||
limit: 100
|
||||
)
|
||||
} catch {
|
||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load destination documents.")
|
||||
}
|
||||
}
|
||||
|
||||
private func publish() async {
|
||||
guard let selectedCollectionID else { return }
|
||||
isPublishing = true
|
||||
defer { isPublishing = false }
|
||||
do {
|
||||
_ = try await apiClient.updateDocument(
|
||||
UpdateDocumentRequest(id: document.id, collectionId: selectedCollectionID, publish: true)
|
||||
)
|
||||
// Only a documents.move call actually supports parentDocumentId —
|
||||
// skip it entirely when publishing straight to the collection root,
|
||||
// the update above already placed it there.
|
||||
if let selectedParentID {
|
||||
try await apiClient.moveDocument(
|
||||
MoveDocumentRequest(id: document.id, collectionId: selectedCollectionID, parentDocumentId: selectedParentID)
|
||||
)
|
||||
}
|
||||
await onPublished()
|
||||
dismiss()
|
||||
} catch {
|
||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't publish this document.")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -5,6 +5,7 @@ enum HomeTab: String, CaseIterable, Identifiable {
|
||||
case popular = "Popular"
|
||||
case recentlyUpdated = "Recently Updated"
|
||||
case createdByMe = "Created by Me"
|
||||
case drafts = "Drafts"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ struct HomeView: View {
|
||||
PinnedDocumentCard(document: document)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.pointerCursorOnHover()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
@@ -10,6 +10,7 @@ final class HomeViewModel {
|
||||
private(set) var popular: [OutlineDocument] = []
|
||||
private(set) var recentlyUpdated: [OutlineDocument] = []
|
||||
private(set) var createdByMe: [OutlineDocument] = []
|
||||
private(set) var drafts: [OutlineDocument] = []
|
||||
|
||||
var isLoadingPinned = false
|
||||
var isLoadingTab = false
|
||||
@@ -32,6 +33,7 @@ final class HomeViewModel {
|
||||
case .popular: popular
|
||||
case .recentlyUpdated: recentlyUpdated
|
||||
case .createdByMe: createdByMe
|
||||
case .drafts: drafts
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,25 +59,33 @@ final class HomeViewModel {
|
||||
/// Fetches fresh pinned docs and the current tab's documents to compare
|
||||
/// against what's displayed, without replacing either. Bails silently
|
||||
/// on a fetch failure rather than treating it as "changed" — a
|
||||
/// transient network hiccup shouldn't pop the refresh banner.
|
||||
/// transient network hiccup (or, offline, `listPins` failing outright —
|
||||
/// it isn't one of the cached endpoints) shouldn't pop the refresh
|
||||
/// banner. This needs the *throwing* pinned-fetch specifically: the
|
||||
/// plain `fetchPinned()` used elsewhere collapses any failure to `[]`,
|
||||
/// which used to read here as "pins changed" against whatever was
|
||||
/// already displayed and falsely popped the banner on every offline
|
||||
/// poll.
|
||||
func checkForRemoteChanges(tab: HomeTab) async {
|
||||
async let freshPinned = fetchPinned()
|
||||
async let freshPinnedTask = fetchPinnedThrowing()
|
||||
guard let freshTab = try? await fetch(tab: tab) else { return }
|
||||
let pinned = await freshPinned
|
||||
guard let pinned = try? await freshPinnedTask else { return }
|
||||
if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments)
|
||||
|| Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) {
|
||||
hasRemoteChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchPinned() async -> [OutlineDocument] {
|
||||
(try? await fetchPinnedThrowing()) ?? []
|
||||
}
|
||||
|
||||
/// `pins.list` only returns pin records, not the documents themselves —
|
||||
/// fetches each pinned document individually. Pins are a small curated
|
||||
/// set (unlike a full collection tree), so the N+1 here is acceptable
|
||||
/// where it wouldn't be in the sidebar.
|
||||
private func fetchPinned() async -> [OutlineDocument] {
|
||||
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)) else {
|
||||
return []
|
||||
}
|
||||
private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
|
||||
let pins = try await apiClient.listPins(ListPinsRequest(collectionId: nil))
|
||||
var documents: [OutlineDocument] = []
|
||||
for pin in pins {
|
||||
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
|
||||
@@ -107,6 +117,8 @@ final class HomeViewModel {
|
||||
return try await apiClient.documentsList(
|
||||
DocumentsListRequest(userId: userId, sort: "createdAt", direction: "DESC", limit: 25)
|
||||
)
|
||||
case .drafts:
|
||||
return try await apiClient.listDrafts(ListDraftsRequest(limit: 25))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +128,7 @@ final class HomeViewModel {
|
||||
case .popular: popular = documents
|
||||
case .recentlyUpdated: recentlyUpdated = documents
|
||||
case .createdByMe: createdByMe = documents
|
||||
case .drafts: drafts = documents
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,45 +2,57 @@
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// Deliberately distinct from `DocumentCardView` — the pinned section is for
|
||||
/// a quick scan of a small curated set, not browsing, so this is a dense
|
||||
/// single-line row rather than a tall card, with an explicit pin glyph so
|
||||
/// it doesn't read the same as the tab grids below it.
|
||||
/// Same tall-card shape as `DocumentCardView` (the tab grids below it) so the
|
||||
/// pinned section reads as a set of cards, not a row of tab-like chips — the
|
||||
/// accent-filled pin badge is the one thing that marks these as pinned.
|
||||
struct PinnedDocumentCard: View {
|
||||
@Environment(StarStore.self) private var starStore
|
||||
let document: OutlineDocument
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
if let emoji = document.emoji {
|
||||
Text(emoji)
|
||||
.font(.title3)
|
||||
} else {
|
||||
Image(systemName: "doc.text")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
if let emoji = document.emoji {
|
||||
Text(emoji)
|
||||
.font(.title2)
|
||||
} else {
|
||||
Image(systemName: "doc.text")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if starStore.isStarred(documentId: document.id) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
|
||||
Image(systemName: "pin.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white)
|
||||
.padding(5)
|
||||
.background(Color.accentColor, in: Circle())
|
||||
}
|
||||
|
||||
Text(document.title.isEmpty ? "Untitled" : document.title)
|
||||
.font(.callout.weight(.medium))
|
||||
.lineLimit(1)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if starStore.isStarred(documentId: document.id) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
|
||||
Image(systemName: "pin.fill")
|
||||
.font(.caption2)
|
||||
Text(document.updatedAt, format: .relative(presentation: .named))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8))
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, minHeight: 96, alignment: .topLeading)
|
||||
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -134,6 +134,7 @@ struct GlobalSearchResultsView: View {
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.pointerCursorOnHover()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
-13
@@ -14,17 +14,19 @@ import AppKit
|
||||
@main
|
||||
struct OutpostApp: App {
|
||||
@State private var session = SessionStore()
|
||||
@State private var navigation = AppNavigation()
|
||||
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
||||
|
||||
#if os(macOS)
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@State private var isShowingLogoutConfirmation = false
|
||||
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
|
||||
#endif
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.environment(session)
|
||||
.environment(navigation)
|
||||
#if os(iOS)
|
||||
.preferredColorScheme(appearance.colorScheme)
|
||||
#endif
|
||||
@@ -32,15 +34,27 @@ struct OutpostApp: App {
|
||||
.onAppear { applyMacAppearance() }
|
||||
.onChange(of: appearance) { _, _ in applyMacAppearance() }
|
||||
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
|
||||
.background(TransparentTitlebarWindowAccessor())
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.commands {
|
||||
CommandGroup(replacing: .appInfo) {
|
||||
Button("About Outpost") {
|
||||
openWindow(id: "about")
|
||||
navigation.selectedSettingsSection = .about
|
||||
navigation.isShowingSettings = true
|
||||
}
|
||||
}
|
||||
// No `Settings {}` scene anymore — Settings renders inside the
|
||||
// root window (see `AppNavigation`), not a separate popup, so
|
||||
// ⌘, has to be wired up by hand instead of coming for free.
|
||||
CommandGroup(replacing: .appSettings) {
|
||||
Button("Settings…") {
|
||||
navigation.isShowingSettings = true
|
||||
}
|
||||
.keyboardShortcut(",")
|
||||
.disabled(!session.isSignedIn)
|
||||
}
|
||||
CommandGroup(after: .appSettings) {
|
||||
Divider()
|
||||
Button("Log Out…") {
|
||||
@@ -48,26 +62,26 @@ struct OutpostApp: App {
|
||||
}
|
||||
.disabled(!session.isSignedIn)
|
||||
}
|
||||
// Settings → Editor → Command Palette gates this — disabled
|
||||
// (not just a no-op) when the user's turned it off, matching
|
||||
// how Settings…/Log Out already disable rather than silently
|
||||
// do nothing.
|
||||
CommandGroup(after: .newItem) {
|
||||
Button("Command Palette…") {
|
||||
navigation.isShowingCommandPalette = true
|
||||
}
|
||||
.keyboardShortcut("k")
|
||||
.disabled(!session.isSignedIn || !isCommandPaletteEnabled)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
Window("About Outpost", id: "about") {
|
||||
AboutView()
|
||||
.disablesFullScreen()
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
|
||||
Window("Keyboard Shortcuts", id: "keyboard-shortcuts") {
|
||||
KeyboardShortcutsView()
|
||||
.disablesFullScreen()
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
|
||||
Settings {
|
||||
PreferencesView()
|
||||
.environment(session)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -88,3 +102,27 @@ struct OutpostApp: App {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Makes the titlebar/toolbar strip blend into the sidebar's own background
|
||||
/// instead of reading as a separate bar — same look as Mail/Notes/Finder.
|
||||
/// SwiftUI's `WindowGroup` exposes no direct hook for this, so this reaches
|
||||
/// into the underlying `NSWindow` the way `applyMacAppearance()` above
|
||||
/// reaches into `NSApp` for the same reason (no SwiftUI-level API exists).
|
||||
/// A `View` (not the window itself) is what actually needs to extend under
|
||||
/// the now-transparent titlebar — `.fullSizeContentView` just makes room;
|
||||
/// the sidebar's `.background` already does the rest with no other change.
|
||||
private struct TransparentTitlebarWindowAccessor: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView()
|
||||
DispatchQueue.main.async {
|
||||
guard let window = view.window else { return }
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.styleMask.insert(.fullSizeContentView)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import Observation
|
||||
|
||||
/// Top-level groupings shown as section headers in `SettingsSidebarList`.
|
||||
/// `general` holds everything that's ours, not Outline's own settings
|
||||
/// categories — labeled "Outpost" so it reads as clearly distinct from the
|
||||
/// Outline-sourced groups below it.
|
||||
enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||
case general
|
||||
case account
|
||||
case workspace
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String? {
|
||||
switch self {
|
||||
case .general: return "Outpost"
|
||||
case .account: return "Account"
|
||||
case .workspace: return "Workspace"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry in the Settings sidebar. Mirrors Outline's own settings
|
||||
/// categories (Account/Workspace) so this app's settings read as a native
|
||||
/// counterpart to the web app's, plus a `general` group for things that are
|
||||
/// ours and don't map onto Outline's structure (offline/sync, advanced,
|
||||
/// about, appearance). Outline's own version info moved to the sidebar
|
||||
/// footer (`SettingsSidebarList`) instead of a standalone
|
||||
/// Integrations & Installation section.
|
||||
///
|
||||
/// Most of the Account/Workspace cases are navigation-only for now —
|
||||
/// `SettingsView` renders a "Coming Soon" placeholder for anything not
|
||||
/// explicitly built yet. Content lands section by section.
|
||||
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
// General (ours)
|
||||
case appearance, editor, navigation, offlineSync, advanced, about
|
||||
|
||||
// Account
|
||||
case profile, preferences, notifications, passkeys, apiAccess
|
||||
|
||||
// Workspace
|
||||
case details, authentication, security, ai, members, groups, templates, emojis, applications, shared, links, webhooks, importData, exportData
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var category: SettingsCategory {
|
||||
switch self {
|
||||
case .appearance, .editor, .navigation, .offlineSync, .advanced, .about:
|
||||
return .general
|
||||
case .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
||||
return .account
|
||||
case .details, .authentication, .security, .ai, .members, .groups, .templates, .emojis, .applications, .shared, .links, .webhooks, .importData, .exportData:
|
||||
return .workspace
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .appearance: return "Appearance"
|
||||
case .editor: return "Editor"
|
||||
case .navigation: return "Navigation"
|
||||
case .offlineSync: return "Offline & Sync"
|
||||
case .advanced: return "Advanced"
|
||||
case .about: return "About"
|
||||
case .profile: return "Profile"
|
||||
case .preferences: return "Preferences"
|
||||
case .notifications: return "Notifications"
|
||||
case .passkeys: return "Passkeys"
|
||||
case .apiAccess: return "API & Access"
|
||||
case .details: return "Details"
|
||||
case .authentication: return "Authentication"
|
||||
case .security: return "Security"
|
||||
case .ai: return "AI"
|
||||
case .members: return "Members"
|
||||
case .groups: return "Groups"
|
||||
case .templates: return "Templates"
|
||||
case .emojis: return "Emojis"
|
||||
case .applications: return "Applications"
|
||||
case .shared: return "Shared"
|
||||
case .links: return "Links"
|
||||
case .webhooks: return "Webhooks"
|
||||
case .importData: return "Import"
|
||||
case .exportData: return "Export"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .appearance: return "paintbrush"
|
||||
case .editor: return "square.split.2x1"
|
||||
case .navigation: return "command"
|
||||
case .offlineSync: return "arrow.triangle.2.circlepath"
|
||||
case .advanced: return "wrench.and.screwdriver"
|
||||
case .about: return "info.circle"
|
||||
case .profile: return "person.crop.circle"
|
||||
case .preferences: return "gearshape"
|
||||
case .notifications: return "bell"
|
||||
case .passkeys: return "key"
|
||||
case .apiAccess: return "chevron.left.forwardslash.chevron.right"
|
||||
case .details: return "building.2"
|
||||
case .authentication: return "lock"
|
||||
case .security: return "shield"
|
||||
case .ai: return "sparkles"
|
||||
case .members: return "person.2"
|
||||
case .groups: return "person.3"
|
||||
case .templates: return "doc.on.doc"
|
||||
case .emojis: return "face.smiling"
|
||||
case .applications: return "app.badge"
|
||||
case .shared: return "square.and.arrow.up.on.square"
|
||||
case .links: return "link"
|
||||
case .webhooks: return "bolt.horizontal"
|
||||
case .importData: return "square.and.arrow.down"
|
||||
case .exportData: return "square.and.arrow.up"
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything actually built so far — everything else in Account/
|
||||
/// Workspace renders a "Coming Soon" placeholder until its content is
|
||||
/// specified and built.
|
||||
var isImplemented: Bool {
|
||||
switch self {
|
||||
case .appearance, .editor, .navigation, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cross-cutting UI state that doesn't belong to any one screen. Lives at
|
||||
/// `OutpostApp` (so both `RootView`'s content and its `⌘,` command can reach
|
||||
/// it) and is read wherever something needs to open Settings (the profile
|
||||
/// menu) or render it — as a swap of the *existing* sidebar/detail panes in
|
||||
/// `ContentView_macOS`, not a separate popup window or an overlay that hides
|
||||
/// the sidebar.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class AppNavigation {
|
||||
var isShowingSettings = false
|
||||
var selectedSettingsSection: SettingsSection? = .appearance
|
||||
/// ⌘K, see `OutpostApp`'s `CommandGroup` and `CommandPaletteView`.
|
||||
var isShowingCommandPalette = false
|
||||
}
|
||||
@@ -5,6 +5,19 @@ struct RootView: View {
|
||||
@Environment(SessionStore.self) private var session
|
||||
@State private var welcomeName: String?
|
||||
@State private var starStore = StarStore()
|
||||
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
|
||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||
|
||||
/// Real connectivity is only half of "can talk to the server" — the
|
||||
/// manual Offline Mode toggle is the other half. Syncing needs to
|
||||
/// resume on either one clearing, not just a real reconnect: turning
|
||||
/// the toggle off while the network had been up the whole time never
|
||||
/// changes `networkMonitor.isOnline`, so keying the flush task off that
|
||||
/// alone left pending operations stuck until the next real network
|
||||
/// blip.
|
||||
private var isEffectivelyOnline: Bool {
|
||||
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -32,6 +45,48 @@ struct RootView: View {
|
||||
starStore.reset()
|
||||
}
|
||||
}
|
||||
// Replay whatever queued up while offline the moment either signal
|
||||
// clears — a real reconnect, or the user turning Offline Mode back
|
||||
// off — no need to wait for the user to open Settings and hit Retry.
|
||||
// Also catches Full Local Sync back up immediately, rather than
|
||||
// leaving it to wait out the rest of the periodic loop below.
|
||||
//
|
||||
// The flush itself gets a retry loop with a cooloff, not just one
|
||||
// attempt: a single transient failure (one bad operation, a blip
|
||||
// mid-flush) used to leave everything else stuck until the user
|
||||
// manually hit Retry or connectivity changed again. Keeps retrying
|
||||
// every 5 minutes for as long as *anything* is still pending and
|
||||
// this signal stays on — stops on its own once the queue is empty,
|
||||
// and `.task(id:)` cancels/restarts it automatically if
|
||||
// isEffectivelyOnline flips again in the meantime.
|
||||
.task(id: isEffectivelyOnline) {
|
||||
guard isEffectivelyOnline, let cachingClient = session.cachingClient else { return }
|
||||
if isFullLocalSyncEnabled {
|
||||
_ = await cachingClient.performFullSync()
|
||||
}
|
||||
while !Task.isCancelled {
|
||||
_ = await cachingClient.flushPendingOperations()
|
||||
let remaining = await cachingClient.pendingOperations()
|
||||
guard !remaining.isEmpty else { break }
|
||||
try? await Task.sleep(for: .seconds(300))
|
||||
}
|
||||
}
|
||||
// Fully automatic — this is the only place Full Local Sync actually
|
||||
// runs from (Settings' "Sync Now" is just an on-demand nudge at the
|
||||
// same call). Syncs immediately whenever this task (re)starts, which
|
||||
// covers both "just switched on" and "was already on at launch" —
|
||||
// `.task(id:)` restarts on either, an `@AppStorage`-backed toggle
|
||||
// changing anywhere updates every view reading that key — then every
|
||||
// 20 minutes after, for as long as it stays enabled.
|
||||
.task(id: isFullLocalSyncEnabled) {
|
||||
guard isFullLocalSyncEnabled else { return }
|
||||
while !Task.isCancelled {
|
||||
if isEffectivelyOnline, let cachingClient = session.cachingClient {
|
||||
_ = await cachingClient.performFullSync()
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(1200))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) {
|
||||
|
||||
@@ -6,17 +6,38 @@ import OutlineKit
|
||||
@Observable
|
||||
final class SessionStore {
|
||||
private static let serverURLDefaultsKey = "outline.serverURL"
|
||||
/// Preferences now drive real editor behavior (separate editing, etc.),
|
||||
/// not just a settings screen — they need to survive a cold launch with
|
||||
/// no network, not just live in memory from the last successful fetch.
|
||||
/// Still read-only while offline (Settings already gates every toggle
|
||||
/// on `isEffectivelyOnline`) — this only makes the *last known* values
|
||||
/// available, never lets them be changed without a server round-trip.
|
||||
private static let userPreferencesDefaultsKey = "outline.userPreferences"
|
||||
|
||||
private let tokenStore: TokenStoring
|
||||
private let defaults: UserDefaults
|
||||
|
||||
var isSignedIn: Bool
|
||||
private(set) var userId: String?
|
||||
var userName: String?
|
||||
var userEmail: String?
|
||||
var userAvatarURL: URL?
|
||||
var userLanguage: String?
|
||||
var userPreferences: OutlineUserPreferences?
|
||||
var userNotificationSettings: [String: Bool]?
|
||||
var teamName: String?
|
||||
var teamAvatarURL: URL?
|
||||
private(set) var apiClient: OutlineAPIClient?
|
||||
/// Same object as `apiClient` when the offline cache is available — kept
|
||||
/// as a separately-typed reference so Settings can reach cache/sync-queue
|
||||
/// specific methods (`flushPendingOperations`, `performFullSync`, etc.)
|
||||
/// without downcasting the protocol-typed `apiClient` everywhere.
|
||||
private(set) var cachingClient: CachingOutlineAPIClient?
|
||||
let networkMonitor = NetworkMonitor()
|
||||
/// `nil` only if the on-disk SwiftData store failed to open (e.g. disk
|
||||
/// full) — in that case `apiClient` falls back to talking to the server
|
||||
/// directly with no offline cache, rather than failing sign-in outright.
|
||||
private let cacheStore: OfflineCacheStore?
|
||||
|
||||
var serverURL: URL? {
|
||||
defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
|
||||
@@ -25,36 +46,90 @@ final class SessionStore {
|
||||
init(tokenStore: TokenStoring = KeychainTokenStore(), defaults: UserDefaults = .standard) {
|
||||
self.tokenStore = tokenStore
|
||||
self.defaults = defaults
|
||||
self.isSignedIn = (try? tokenStore.token()) != nil
|
||||
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
|
||||
|
||||
if isSignedIn, let serverURL {
|
||||
apiClient = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: serverURL),
|
||||
tokenStore: tokenStore
|
||||
)
|
||||
let hasToken = (try? tokenStore.token()) != nil
|
||||
let storedServerURL = defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
|
||||
|
||||
if hasToken, let storedServerURL {
|
||||
isSignedIn = true
|
||||
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore)
|
||||
userPreferences = Self.loadCachedPreferences(defaults: defaults)
|
||||
} else {
|
||||
// Keychain and the sandboxed UserDefaults container don't
|
||||
// always survive together — a Keychain item written by an
|
||||
// older-signed build can outlive a reinstall that wipes the
|
||||
// container (or vice versa), leaving a token with no server or
|
||||
// a server with no token. Clear whichever half survived rather
|
||||
// than showing a broken "signed in" UI with no working
|
||||
// apiClient — a fresh sign-in rewrites both consistently.
|
||||
if hasToken {
|
||||
try? tokenStore.clear()
|
||||
}
|
||||
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
|
||||
isSignedIn = false
|
||||
}
|
||||
}
|
||||
|
||||
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
|
||||
defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey)
|
||||
apiClient = LiveOutlineAPIClient(
|
||||
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore)
|
||||
apply(user: user, team: team, serverURL: serverURL)
|
||||
isSignedIn = true
|
||||
}
|
||||
|
||||
/// `static` (not an instance method) so `init` can call it before every
|
||||
/// stored property has a value — same reason `makeAPIClient` is static.
|
||||
private static func loadCachedPreferences(defaults: UserDefaults) -> OutlineUserPreferences? {
|
||||
guard let data = defaults.data(forKey: userPreferencesDefaultsKey) else { return nil }
|
||||
return try? JSONDecoder().decode(OutlineUserPreferences.self, from: data)
|
||||
}
|
||||
|
||||
/// `nil` clears the cache instead of writing a `null` — happens whenever
|
||||
/// a fresh fetch legitimately comes back with no preferences set, so a
|
||||
/// stale cached value from a previous account/state can't linger.
|
||||
private func cachePreferences(_ preferences: OutlineUserPreferences?) {
|
||||
guard let preferences, let data = try? JSONEncoder().encode(preferences) else {
|
||||
defaults.removeObject(forKey: Self.userPreferencesDefaultsKey)
|
||||
return
|
||||
}
|
||||
defaults.set(data, forKey: Self.userPreferencesDefaultsKey)
|
||||
}
|
||||
|
||||
private static func makeAPIClient(
|
||||
serverURL: URL,
|
||||
tokenStore: TokenStoring,
|
||||
cache: OfflineCacheStore?
|
||||
) -> (OutlineAPIClient, CachingOutlineAPIClient?) {
|
||||
let live = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: serverURL),
|
||||
tokenStore: tokenStore
|
||||
)
|
||||
apply(user: user, team: team, serverURL: serverURL)
|
||||
isSignedIn = true
|
||||
guard let cache else { return (live, nil) }
|
||||
let caching = CachingOutlineAPIClient(live: live, cache: cache)
|
||||
return (caching, caching)
|
||||
}
|
||||
|
||||
func signOut() {
|
||||
try? tokenStore.clear()
|
||||
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
|
||||
isSignedIn = false
|
||||
userId = nil
|
||||
userName = nil
|
||||
userEmail = nil
|
||||
userAvatarURL = nil
|
||||
userLanguage = nil
|
||||
userPreferences = nil
|
||||
userNotificationSettings = nil
|
||||
teamName = nil
|
||||
teamAvatarURL = nil
|
||||
apiClient = nil
|
||||
cachingClient = nil
|
||||
defaults.removeObject(forKey: Self.userPreferencesDefaultsKey)
|
||||
// Same key `ContentView_macOS` persists "Remember previous
|
||||
// location" under — cleared here too so switching accounts/servers
|
||||
// can't restore a stale location that belongs to a different sign-in.
|
||||
defaults.removeObject(forKey: "outline.lastLocation")
|
||||
}
|
||||
|
||||
/// Re-fetches user/workspace name/logo on relaunch, when the token survived but this
|
||||
@@ -65,13 +140,32 @@ final class SessionStore {
|
||||
apply(user: auth.user, team: auth.team, serverURL: serverURL)
|
||||
}
|
||||
|
||||
/// Settings calls this after a successful name/avatar change so the
|
||||
/// sidebar's account footer and everywhere else reading these reflect
|
||||
/// it immediately, without waiting for the next `auth.info` refresh.
|
||||
func applyUpdatedProfile(_ user: OutlineUser) {
|
||||
guard let serverURL else { return }
|
||||
userId = user.id
|
||||
userName = user.name
|
||||
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||
userLanguage = user.language
|
||||
userPreferences = user.preferences
|
||||
cachePreferences(user.preferences)
|
||||
userNotificationSettings = user.notificationSettings
|
||||
}
|
||||
|
||||
private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) {
|
||||
userId = user.id
|
||||
userName = user.name
|
||||
userEmail = user.email
|
||||
// Outline can return either an absolute URL or a server-relative path
|
||||
// (e.g. `/api/files.get?key=...`) for avatarUrl — resolve against the
|
||||
// configured server so relative paths don't fail as "unsupported URL".
|
||||
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||
userLanguage = user.language
|
||||
userPreferences = user.preferences
|
||||
cachePreferences(user.preferences)
|
||||
userNotificationSettings = user.notificationSettings
|
||||
teamName = team.name
|
||||
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
@@ -41,11 +42,46 @@ struct AvatarBadge: View {
|
||||
.task(id: avatarURL) {
|
||||
loadedImage = nil
|
||||
guard let avatarURL else { return }
|
||||
guard let (data, _) = try? await URLSession.shared.data(from: avatarURL) else { return }
|
||||
loadedImage = PlatformImage(data: data)
|
||||
loadedImage = await Self.loadImage(from: avatarURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// The real fix, confirmed against a live network capture: every other
|
||||
/// request this app makes attaches `Authorization: Bearer <token>` —
|
||||
/// this one never did, sending a bare unauthenticated GET. Outline's
|
||||
/// browser session authenticates `attachments.redirect` via cookies
|
||||
/// instead, which a native app doesn't have; the API-token equivalent
|
||||
/// is the same Bearer header every RPC call already uses. Almost
|
||||
/// certainly means no avatar image (not just a freshly-uploaded one)
|
||||
/// has ever actually loaded in this app — a 401 and a "no avatar set"
|
||||
/// look identical here, both just fall back to the placeholder icon
|
||||
/// with nothing on screen to flag it as an error.
|
||||
///
|
||||
/// The retry loop is a secondary, independent hardening — cheap
|
||||
/// insurance against a self-hosted reverse-proxied storage backend not
|
||||
/// being instantly consistent right after an upload — kept alongside
|
||||
/// the auth fix rather than instead of it.
|
||||
private static func loadImage(from url: URL) async -> PlatformImage? {
|
||||
var request = URLRequest(url: url)
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
if let token = try? KeychainTokenStore().token() {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
for attempt in 0..<3 {
|
||||
if attempt > 0 {
|
||||
try? await Task.sleep(for: .milliseconds(400))
|
||||
}
|
||||
if let (data, response) = try? await URLSession.shared.data(for: request),
|
||||
let httpResponse = response as? HTTPURLResponse,
|
||||
(200...299).contains(httpResponse.statusCode),
|
||||
let image = PlatformImage(data: data) {
|
||||
return image
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func platformImage(_ image: PlatformImage) -> Image {
|
||||
#if os(macOS)
|
||||
Image(nsImage: image)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
/// `NSMenuItem` has no closure-based initializer — the standard AppKit
|
||||
/// pattern is a small subclass that's its own target/action, so callers can
|
||||
/// just pass a Swift closure instead of wiring up a selector by hand.
|
||||
final class ClosureMenuItem: NSMenuItem {
|
||||
private let handler: () -> Void
|
||||
|
||||
init(title: String, handler: @escaping () -> Void) {
|
||||
self.handler = handler
|
||||
super.init(title: title, action: #selector(invokeHandler), keyEquivalent: "")
|
||||
self.target = self
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
@objc private func invokeHandler() {
|
||||
handler()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
#if os(macOS)
|
||||
import MarkdownEngineCodeBlocks
|
||||
|
||||
/// One `HighlighterSwiftBridge` for the whole app. It owns a JavaScriptCore
|
||||
/// context (expensive to spin up) plus its own highlight cache, so every
|
||||
/// `NativeTextViewWrapper` should share this instance rather than each
|
||||
/// constructing its own.
|
||||
enum CodeSyntaxHighlighting {
|
||||
static let shared = HighlighterSwiftBridge()
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,14 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
extension NSImage {
|
||||
/// `NSImage` has no built-in JPEG encoder (unlike `UIImage`) — routes
|
||||
/// through a bitmap representation to get one.
|
||||
func jpegData(compressionQuality: CGFloat) -> Data? {
|
||||
guard let tiffData = tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiffData) else {
|
||||
return nil
|
||||
}
|
||||
return bitmap.representation(using: .jpeg, properties: [.compressionFactor: compressionQuality])
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
|
||||
/// Backs the sidebar's offline badge — purely a UI signal. Unrelated to
|
||||
/// `CachingOutlineAPIClient`'s own fallback logic, which reacts to actual
|
||||
/// request failures rather than pre-checking reachability.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class NetworkMonitor {
|
||||
private(set) var isOnline = true
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
private let queue = DispatchQueue(label: "com.outpost.network-monitor")
|
||||
|
||||
init() {
|
||||
// Weak on the outer closure (it's held by `monitor` for the object's
|
||||
// whole lifetime, so a strong capture here would be a retain cycle),
|
||||
// then unwrapped once into a plain strong local — the inner `Task`
|
||||
// closure capturing that local `self` is what a nested closure needs
|
||||
// to be consistent about capture semantics with its enclosing one.
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
guard let self else { return }
|
||||
|
||||
let online = path.status == .satisfied
|
||||
Task { @MainActor in
|
||||
self.isOnline = online
|
||||
}
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
|
||||
deinit {
|
||||
monitor.cancel()
|
||||
}
|
||||
}
|
||||
@@ -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,31 @@
|
||||
import Foundation
|
||||
|
||||
/// Single source of truth for how Outpost's own version is formatted —
|
||||
/// used by both the About page and the Settings sidebar footer, so they
|
||||
/// can't drift out of sync the way `AboutInfoView` was already written to
|
||||
/// avoid for its own two call sites.
|
||||
enum OutpostVersion {
|
||||
/// Bumped alongside `MARKETING_VERSION` in the Xcode project — kept out
|
||||
/// of the bundle version itself since `CFBundleShortVersionString` is
|
||||
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
|
||||
static let releaseStage = "ALPHA"
|
||||
|
||||
static var shortVersion: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
|
||||
}
|
||||
|
||||
static var buildNumber: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
||||
}
|
||||
|
||||
/// e.g. `"0.0.3-ALPHA"` — for compact display (sidebar footer).
|
||||
static var displayString: String {
|
||||
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
|
||||
return "\(shortVersion)\(stageSuffix)"
|
||||
}
|
||||
|
||||
/// e.g. `"Version 0.0.3-ALPHA (1)"` — for the About page.
|
||||
static var fullVersionString: String {
|
||||
"Version \(displayString) (\(buildNumber))"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,27 +1,29 @@
|
||||
# Outpost
|
||||
<p align="center">
|
||||
<img src="Outpost/Assets.xcassets/AppLogo.imageset/outpost-ios-1024.png" width="120" alt="Outpost logo">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Outpost</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://testflight.apple.com/join/y1mYcYAM">
|
||||
<img src="https://img.shields.io/badge/Download-TestFlight-0D96F6?style=for-the-badge&logo=apple&logoColor=white" alt="Download on TestFlight">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
A native Apple ecosystem client for [Outline](https://github.com/outline/outline) — built for iOS, iPadOS, and macOS from a single SwiftUI codebase, aiming for full editing parity with Outline's web app, including realtime collaborative editing.
|
||||
|
||||
> **Early alpha — macOS only for now.** Expect missing features and rough edges. iOS/iPadOS support is planned but not in the current build. See the [releases page](https://git.psmattas.com/psmattas/Outpost/releases) for changelogs, and [open an issue](https://git.psmattas.com/psmattas/Outpost/issues) if you hit anything.
|
||||
|
||||
## Why
|
||||
|
||||
Outline's web app is great, but there's no native Apple client with full editing parity. This project connects to a self-hosted Outline instance over its REST API and realtime collaboration socket to provide a proper native experience across the Apple ecosystem.
|
||||
|
||||
## Status
|
||||
|
||||
Early development. See `CLAUDE.md` for the current architecture and phased build plan.
|
||||
|
||||
- [ ] Phase 1 — Auth, browse, search, REST-only editing
|
||||
- [ ] Phase 2 — Realtime collaborative editing (Yjs/Hocuspocus)
|
||||
- [ ] Phase 3 — Offline cache, tables, embeds, comments, macOS polish
|
||||
|
||||
## Requirements
|
||||
|
||||
- Xcode 16+
|
||||
- iOS 17+ / iPadOS 17+ / macOS 14+
|
||||
- Xcode 27+ (currently developed against an Xcode 27 beta — this is a hard minimum, not a suggestion)
|
||||
- macOS 27+. iOS/iPadOS support is planned but not in the current build (see the alpha note above) — same 27+ minimum will apply once it lands
|
||||
- A self-hosted (or hosted) Outline instance with API access
|
||||
|
||||
> They will be updated. These are old requirements.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Clone the repo and open the `.xcodeproj` in Xcode.
|
||||
@@ -40,7 +42,23 @@ Parts of this codebase are AI-assisted (built with the help of AI coding tools).
|
||||
|
||||
## License
|
||||
|
||||
This project is a client only — it does not include, vendor, or redistribute any of Outline's (BSL 1.1 licensed) server source. See [`LICENSE`](./LICENSE) for this repository's own license.
|
||||
Outpost itself is licensed under the [Business Source License 1.1](./LICENSE) — the same license family Outline's own server uses, for the same reason. In short: free to read, self-host, and modify for personal or non-commercial use; not free to repackage as a competing hosted product or to distribute under a name/branding that claims official or affiliated status. It converts automatically to Apache License 2.0 on the change date stated in [`LICENSE`](./LICENSE). "Outpost" and its logo are trademarks of the project — see the license's trademark notice.
|
||||
|
||||
This project is a client only — it does not include, vendor, or redistribute any of Outline's own (also BSL 1.1 licensed) server source.
|
||||
|
||||
## Credits
|
||||
|
||||
The native markdown editor is built on [swift-markdown-engine](https://github.com/nodes-app/swift-markdown-engine) by Luca Chen, licensed under Apache License 2.0. It's vendored directly in [`Vendor/swift-markdown-engine`](./Vendor/swift-markdown-engine) (see its [`LICENSE`](./Vendor/swift-markdown-engine/LICENSE)); local fixes made in that copy haven't been upstreamed yet.
|
||||
|
||||
## Privacy
|
||||
|
||||
Outpost collects nothing about you — no analytics, no telemetry, no crash reporting of its own, no age or demographic data, nothing. The only thing stored locally is your Outline server URL and API token (in the device Keychain) and, optionally, a local offline cache of what you've viewed. Everything else goes straight from your device to whatever Outline server you configure — there's no backend in between, and the developer has no access to your data or your server.
|
||||
|
||||
Full policy, terms of service, and data-processing statement are on the [wiki](https://git.psmattas.com/psmattas/Outpost/wiki):
|
||||
|
||||
- [Privacy Policy](https://git.psmattas.com/psmattas/Outpost/wiki/Privacy-Policy.-)
|
||||
- [Terms of Service](https://git.psmattas.com/psmattas/Outpost/wiki/Terms-of-Service.-)
|
||||
- [Data Processing Statement](https://git.psmattas.com/psmattas/Outpost/wiki/Data-Processing-Statement.-)
|
||||
|
||||
## Not affiliated with Outline
|
||||
|
||||
|
||||
@@ -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