Compare commits
159
Commits
0.0.1-alpha
...
main
@@ -0,0 +1,43 @@
|
|||||||
|
name: Bug Report
|
||||||
|
about: Report a bug in Outpost
|
||||||
|
labels:
|
||||||
|
- "type: bug"
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Outpost is early — please check the version in About (or your build's commit) is current before filing, and mention which platform (macOS only, for now) and OS version you're on.
|
||||||
|
- type: input
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: Summary
|
||||||
|
placeholder: Brief description of the bug
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce
|
||||||
|
value: |
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: Actual Behavior
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Logs, screenshots, macOS version, Outline server version, etc.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
name: Documentation
|
||||||
|
about: Report missing, incorrect, or outdated documentation
|
||||||
|
labels:
|
||||||
|
- "type: docs"
|
||||||
|
body:
|
||||||
|
- type: input
|
||||||
|
id: page
|
||||||
|
attributes:
|
||||||
|
label: Affected Page / File
|
||||||
|
placeholder: "e.g. SETUP.md, docs/ARCHITECTURE.md, CLAUDE.md"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: type
|
||||||
|
attributes:
|
||||||
|
label: Type
|
||||||
|
options:
|
||||||
|
- Missing documentation
|
||||||
|
- Incorrect / outdated information
|
||||||
|
- Unclear or confusing
|
||||||
|
- Typo / formatting
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: What needs to change and why?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
name: Feature Request
|
||||||
|
about: Suggest something for Outpost
|
||||||
|
labels:
|
||||||
|
- "type: feature"
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Outpost is early and tracking Outline's own web app for parity (see [`CLAUDE.md`](../../CLAUDE.md) for the phased build order) — a request that's "just do what web Outline does" is easier to act on than a net-new idea.
|
||||||
|
- type: input
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: Summary
|
||||||
|
placeholder: What do you want Outpost to do?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: motivation
|
||||||
|
attributes:
|
||||||
|
label: Motivation
|
||||||
|
description: What's the use case? What can't you do today without this?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: Proposed Behavior
|
||||||
|
description: How should it work? Point at how Outline's web app does it, if relevant.
|
||||||
|
- type: dropdown
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: Platform
|
||||||
|
options:
|
||||||
|
- macOS
|
||||||
|
- iOS / iPadOS
|
||||||
|
- Both / platform-agnostic
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
name: Question
|
||||||
|
about: Ask something about setup, usage, or how Outpost works
|
||||||
|
labels:
|
||||||
|
- "type: question"
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Check [`SETUP.md`](../../SETUP.md) first — most "how do I get this running" questions are answered there.
|
||||||
|
- type: textarea
|
||||||
|
id: question
|
||||||
|
attributes:
|
||||||
|
label: Question
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Context
|
||||||
|
description: What are you trying to do? Anything you've already tried?
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
name: Security Vulnerability
|
||||||
|
about: Report a security vulnerability in this repository
|
||||||
|
labels:
|
||||||
|
- "type: security"
|
||||||
|
- "priority: critical"
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
**Please do not disclose sensitive details publicly.** If this is a critical vulnerability,
|
||||||
|
email security@psmattas.com directly instead of filing this issue — see [`SECURITY.md`](../../SECURITY.md).
|
||||||
|
- type: input
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: Summary
|
||||||
|
placeholder: Brief description of the vulnerability
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: severity
|
||||||
|
attributes:
|
||||||
|
label: Severity
|
||||||
|
options:
|
||||||
|
- Critical — active exploit / data exposure
|
||||||
|
- High — exploitable with moderate effort
|
||||||
|
- Medium — limited impact or requires specific conditions
|
||||||
|
- Low — informational / hardening suggestion
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: What is the vulnerability and how can it be exploited?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: reproduction
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce
|
||||||
|
description: Provide enough detail for someone to verify the issue.
|
||||||
|
- type: textarea
|
||||||
|
id: remediation
|
||||||
|
attributes:
|
||||||
|
label: Suggested Remediation
|
||||||
|
description: If you have a fix in mind, describe it here.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
name: Pull Request
|
||||||
|
about: Standard pull request template
|
||||||
|
body:
|
||||||
|
- type: input
|
||||||
|
id: ticket
|
||||||
|
attributes:
|
||||||
|
label: Related Issue
|
||||||
|
description: "Leave blank if there isn't one."
|
||||||
|
placeholder: "Resolves #"
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: Summary
|
||||||
|
description: "One or two sentences — what and why."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: changes
|
||||||
|
attributes:
|
||||||
|
label: Changes
|
||||||
|
value: "-\n-"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
options:
|
||||||
|
- label: Self-reviewed
|
||||||
|
required: true
|
||||||
|
- label: "`OutlineKit` tests added/updated and passing (`swift test`), if this touches the REST layer"
|
||||||
|
required: false
|
||||||
|
- label: Verified in Xcode (there's no reliable CLI build for the app target)
|
||||||
|
required: true
|
||||||
|
- label: Docs updated (if applicable)
|
||||||
@@ -60,3 +60,9 @@ fastlane/report.xml
|
|||||||
fastlane/Preview.html
|
fastlane/Preview.html
|
||||||
fastlane/screenshots/**/*.png
|
fastlane/screenshots/**/*.png
|
||||||
fastlane/test_output
|
fastlane/test_output
|
||||||
|
|
||||||
|
# scripts/package-dmg.sh output
|
||||||
|
/dist/
|
||||||
|
|
||||||
|
# Local working checklist - intentionally never version controlled
|
||||||
|
/TODO.local.md
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
# Outpost Code Owners
|
||||||
|
# These users are automatically requested for review on PRs.
|
||||||
|
# Format: path @username
|
||||||
|
#
|
||||||
|
# Rules are evaluated in order, last match wins — so the specific paths
|
||||||
|
# below stay pinned to @psmattas even if `*` is ever opened up to other
|
||||||
|
# contributors/reviewers as the project grows. These are the
|
||||||
|
# supply-chain, governance, and CI-relevant files where an accidental or
|
||||||
|
# malicious change has outsized blast radius for a public repo.
|
||||||
|
|
||||||
|
* @psmattas
|
||||||
|
|
||||||
|
# Repo governance / legal — changes here affect every contributor.
|
||||||
|
/LICENSE @psmattas
|
||||||
|
/CODEOWNERS @psmattas
|
||||||
|
/CONTRIBUTING.md @psmattas
|
||||||
|
/SECURITY.md @psmattas
|
||||||
|
/SETUP.md @psmattas
|
||||||
|
|
||||||
|
# CI, issue/PR automation, review requirements — tampering here can
|
||||||
|
# bypass the protections this very file is trying to set up.
|
||||||
|
/.gitea/ @psmattas
|
||||||
|
|
||||||
|
# Dependency supply chain — a swapped or re-pinned package here can pull
|
||||||
|
# in arbitrary code at build time.
|
||||||
|
/OutlineKit/Package.swift @psmattas
|
||||||
|
/OutlineKit/Package.resolved @psmattas
|
||||||
|
/Outpost.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @psmattas
|
||||||
|
|
||||||
|
# Build/signing/versioning config and release tooling.
|
||||||
|
/Outpost.xcodeproj/project.pbxproj @psmattas
|
||||||
|
/scripts/ @psmattas
|
||||||
|
|
||||||
|
# Project direction — architecture/scope decisions shouldn't drift via a
|
||||||
|
# drive-by PR.
|
||||||
|
/CLAUDE.md @psmattas
|
||||||
|
/docs/ARCHITECTURE.md @psmattas
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Contributing to Outpost
|
||||||
|
|
||||||
|
Thank you for contributing. Please read this guide before opening issues or PRs.
|
||||||
|
|
||||||
|
Outpost is early (`0.1.x`) — expect the codebase and conventions here to keep evolving as later phases (see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)) land. If something in this guide is stale, flag it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branching
|
||||||
|
|
||||||
|
Branch from `main` using `type/short-description`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b feature/document-permissions
|
||||||
|
git checkout -b fix/sidebar-context-menu
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit Messages
|
||||||
|
|
||||||
|
Follow the Conventional Commits standard: `type(scope): message`.
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `feat` | New feature |
|
||||||
|
| `fix` | Bug fix |
|
||||||
|
| `docs` | Documentation |
|
||||||
|
| `style` | Formatting |
|
||||||
|
| `refactor` | Refactor |
|
||||||
|
| `perf` | Performance |
|
||||||
|
| `test` | Tests |
|
||||||
|
| `build` | Build system |
|
||||||
|
| `ci` | CI/CD config |
|
||||||
|
| `chore` | Maintenance |
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
- `feat(collections): add document right-click context menu`
|
||||||
|
- `fix(reader): correct off-main AppKit calls in save action`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pull Requests
|
||||||
|
|
||||||
|
- Link the related issue in your PR description, if any
|
||||||
|
- Keep PRs focused — one feature or fix per PR
|
||||||
|
- Self-review before requesting review
|
||||||
|
- Run the tests that apply to what you touched (see below) and confirm the app still launches and behaves correctly in Xcode
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- **`OutlineKit`** (the REST client package) has real unit test coverage:
|
||||||
|
```bash
|
||||||
|
cd OutlineKit && swift test
|
||||||
|
```
|
||||||
|
- **The `Outpost` app target** has no meaningful CLI build path — `xcodebuild` from the command line is not a reliable way to verify it in this project's current setup. Build and run through Xcode, and manually verify the feature you changed (and anything obviously adjacent) before opening a PR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Labels
|
||||||
|
|
||||||
|
Issues and PRs use two label prefixes:
|
||||||
|
|
||||||
|
- `type:` — what kind of change/issue this is (`type: bug`, `type: docs`, `type: security`, ...)
|
||||||
|
- `priority:` — how urgent it is (used mainly for security reports)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
Licensor: Puranjay Savar Mattas
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
Licensed Work: Outpost
|
||||||
in the Software without restriction, including without limitation the rights
|
The Licensed Work is (c) 2026 Puranjay Savar Mattas
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
Additional Use Grant: You may use, copy, modify, and self-host the Licensed
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
Work, and build and distribute your own modified or
|
||||||
furnished to do so, subject to the following conditions:
|
unmodified copies of it, for personal, educational, or
|
||||||
|
internal non-commercial purposes.
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
You may not, without a separate commercial agreement
|
||||||
copies or substantial portions of the Software.
|
with the Licensor:
|
||||||
|
(a) offer the Licensed Work, or any modified or
|
||||||
|
unmodified version of it, as a hosted or
|
||||||
|
distributed product or service to third parties,
|
||||||
|
whether for a fee or free of charge; or
|
||||||
|
(b) distribute the Licensed Work, or any modified or
|
||||||
|
unmodified version of it, under a name, logo, or
|
||||||
|
branding that states or implies it is an official,
|
||||||
|
endorsed, or affiliated product of Outline, Inc.
|
||||||
|
or of any other third party, or that removes or
|
||||||
|
obscures its origin as an independent,
|
||||||
|
unaffiliated project.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
Change Date: 2036-08-20
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
|
|
||||||
|
Change License: Apache License, Version 2.0
|
||||||
|
|
||||||
|
For information about alternative licensing arrangements for the Licensed
|
||||||
|
Work, contact the Licensor.
|
||||||
|
|
||||||
|
Notice
|
||||||
|
|
||||||
|
The Business Source License (this document, or the "License") is not an
|
||||||
|
Open Source license. However, the Licensed Work will eventually be made
|
||||||
|
available under an Open Source License, as stated in this License.
|
||||||
|
|
||||||
|
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
|
||||||
|
"Business Source License" is a trademark of MariaDB Corporation Ab.
|
||||||
|
|
||||||
|
-----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Business Source License 1.1
|
||||||
|
|
||||||
|
Terms
|
||||||
|
|
||||||
|
The Licensor hereby grants you the right to copy, modify, create derivative
|
||||||
|
works, redistribute, and make non-production use of the Licensed Work. The
|
||||||
|
Licensor may make an Additional Use Grant, above, permitting limited
|
||||||
|
production use.
|
||||||
|
|
||||||
|
Effective on the Change Date, or the fourth anniversary of the first
|
||||||
|
publicly available distribution of a specific version of the Licensed Work
|
||||||
|
under this License, whichever comes first, the Licensor hereby grants you
|
||||||
|
rights under the terms of the Change License, and the rights granted in the
|
||||||
|
paragraph above terminate.
|
||||||
|
|
||||||
|
If your use of the Licensed Work does not comply with the requirements
|
||||||
|
currently in effect as described in this License, you must purchase a
|
||||||
|
commercial license from the Licensor, its affiliated entities, or authorized
|
||||||
|
resellers, or you must refrain from using the Licensed Work.
|
||||||
|
|
||||||
|
All copies of the original and modified Licensed Work, and derivative works
|
||||||
|
of the Licensed Work, are subject to this License. This License applies
|
||||||
|
separately for each version of the Licensed Work and the Change Date may
|
||||||
|
vary for each version of the Licensed Work released by Licensor.
|
||||||
|
|
||||||
|
You must conspicuously display this License on each original or modified
|
||||||
|
copy of the Licensed Work. If you receive the Licensed Work in original or
|
||||||
|
modified form from a third party, the terms and conditions set forth in
|
||||||
|
this License apply to your use of that work.
|
||||||
|
|
||||||
|
Any use of the Licensed Work in violation of this License will automatically
|
||||||
|
terminate your rights under this License for the current and all other
|
||||||
|
versions of the Licensed Work.
|
||||||
|
|
||||||
|
This License does not grant you any right in any trademark or logo of
|
||||||
|
Licensor or its affiliates (provided that you may use a trademark or logo
|
||||||
|
of Licensor as expressly required by this License).
|
||||||
|
|
||||||
|
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
||||||
|
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
|
||||||
|
TITLE.
|
||||||
|
|
||||||
|
MariaDB hereby grants you permission to use this License's text to license
|
||||||
|
your works, and to refer to it using the trademark "Business Source
|
||||||
|
License", as long as you comply with the Covenants of Licensor below.
|
||||||
|
|
||||||
|
Covenants of Licensor
|
||||||
|
|
||||||
|
In consideration of the right to use this License's text and the "Business
|
||||||
|
Source License" name and trademark, Licensor covenants to MariaDB, and to
|
||||||
|
all other recipients of the licensed work to be provided by Licensor:
|
||||||
|
|
||||||
|
1. To specify as the Change License the GPL Version 2.0 or any later
|
||||||
|
version, or a license that is compatible with GPL Version 2.0 or a later
|
||||||
|
version, where "compatible" means that software provided under the
|
||||||
|
Change License can be included in a program with software provided
|
||||||
|
under GPL Version 2.0 or a later version. Licensor may specify
|
||||||
|
additional Change Licenses without limitation.
|
||||||
|
|
||||||
|
2. To either: (a) specify an additional grant of rights to use that does
|
||||||
|
not impose any additional restriction on the right granted in this
|
||||||
|
License, as the Additional Use Grant; or (b) insert the text "None" to
|
||||||
|
specify a Change License.
|
||||||
|
|
||||||
|
3. To specify a Change Date.
|
||||||
|
|
||||||
|
4. Not to modify this License in any other way.
|
||||||
|
|
||||||
|
-----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Trademark Notice
|
||||||
|
|
||||||
|
"Outpost" and any associated logo are trademarks of the Licensor. This
|
||||||
|
License does not grant permission to use them to identify or market any
|
||||||
|
product, service, or distribution of the Licensed Work — modified or
|
||||||
|
unmodified — that is not published by the Licensor, including forks. See
|
||||||
|
the Additional Use Grant above.
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
/// Boundary over wherever the offline cache's symmetric encryption key
|
||||||
|
/// lives. Mirrors `TokenStoring` — same reasoning, different secret.
|
||||||
|
public protocol CacheEncryptionKeyStoring: Sendable {
|
||||||
|
/// Returns the existing key, generating and persisting a new random one
|
||||||
|
/// on first use if none exists yet.
|
||||||
|
func key() throws -> SymmetricKey
|
||||||
|
/// Called on sign-out. Anything still encrypted with the cleared key
|
||||||
|
/// becomes permanently unreadable — that's the point, not a bug: the
|
||||||
|
/// next person signed in on this machine shouldn't be able to read a
|
||||||
|
/// previous account's cached content just because the on-disk rows
|
||||||
|
/// happen to still be there.
|
||||||
|
func clear() throws
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
/// AES-GCM at the boundary where `CachingOutlineAPIClient` writes to/reads
|
||||||
|
/// from `OfflineCacheStore`. Encryption lives at this layer rather than
|
||||||
|
/// inside `OfflineCacheStore` itself — that stays a dumb opaque-blob store;
|
||||||
|
/// its `key`/`id`/`kind` columns can't be encrypted without breaking the
|
||||||
|
/// `#Predicate` queries built directly against them.
|
||||||
|
enum CachePayloadCryptor {
|
||||||
|
enum CryptoError: Error {
|
||||||
|
case sealingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
static func encrypt(_ data: Data, key: SymmetricKey) throws -> Data {
|
||||||
|
let sealedBox = try AES.GCM.seal(data, using: key)
|
||||||
|
guard let combined = sealedBox.combined else { throw CryptoError.sealingFailed }
|
||||||
|
return combined
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decrypt(_ data: Data, key: SymmetricKey) throws -> Data {
|
||||||
|
let sealedBox = try AES.GCM.SealedBox(combined: data)
|
||||||
|
return try AES.GCM.open(sealedBox, using: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,960 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
private let encryptionKeyStore: CacheEncryptionKeyStoring
|
||||||
|
/// Resolved lazily and kept for this instance's lifetime — a fresh
|
||||||
|
/// instance is created on every sign-in/sign-out anyway (see
|
||||||
|
/// `SessionStore.makeAPIClient`), so there's no staleness risk, just
|
||||||
|
/// one fewer Keychain round-trip per cache read/write.
|
||||||
|
private var cachedEncryptionKey: SymmetricKey?
|
||||||
|
|
||||||
|
/// Recent failure timestamps per category — see `recordFailure` /
|
||||||
|
/// `repeatedFailureSummaries()`. A category only shows up there once it's
|
||||||
|
/// failed `failureThreshold` times within `failureWindow`; a single
|
||||||
|
/// transient blip (which `RetryPolicy` already tries to absorb) never
|
||||||
|
/// reaches this at all.
|
||||||
|
private var failureLog: [String: [Date]] = [:]
|
||||||
|
private var lastFailureMessage: [String: String] = [:]
|
||||||
|
private let failureWindow: TimeInterval = 300
|
||||||
|
private let failureThreshold: Int = 3
|
||||||
|
|
||||||
|
public init(
|
||||||
|
live: OutlineAPIClient,
|
||||||
|
cache: OfflineCacheStore,
|
||||||
|
defaults: UserDefaults = .standard,
|
||||||
|
encryptionKeyStore: CacheEncryptionKeyStoring = KeychainCacheEncryptionKeyStore()
|
||||||
|
) {
|
||||||
|
self.live = live
|
||||||
|
self.cache = cache
|
||||||
|
self.defaults = defaults
|
||||||
|
self.encryptionKeyStore = encryptionKeyStore
|
||||||
|
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
|
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)", category: "document") { 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, category: "documents") {
|
||||||
|
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), category: "documents") { try await self.live.documentsList(request) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] {
|
||||||
|
try await cachedFetch(key: "documentsViewed:\(offset):\(limit)", category: "documents-viewed") {
|
||||||
|
try await self.live.listViewedDocuments(offset: offset, limit: limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] {
|
||||||
|
try await cachedFetch(key: "documentsDrafts:\(request.offset):\(request.limit)", category: "drafts") {
|
||||||
|
try await self.live.listDrafts(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
|
||||||
|
try await cachedFetch(key: "collections:\(offset):\(limit)", category: "collections") {
|
||||||
|
try await self.live.listCollections(offset: offset, limit: limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func collectionInfo(id: String) async throws -> OutlineCollection {
|
||||||
|
try await cachedFetch(key: "collection:\(id)", category: "collections") { 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 RetryPolicy.withRetry { try await self.live.createDocument(request) }
|
||||||
|
recordSuccess(category: "documents-write")
|
||||||
|
await cacheDocument(result)
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "documents-write", error)
|
||||||
|
return await queueDocumentCreate(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await queueDocumentCreate(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
||||||
|
if !isManualOfflineModeEnabled {
|
||||||
|
do {
|
||||||
|
let result = try await RetryPolicy.withRetry { try await self.live.updateDocument(request) }
|
||||||
|
recordSuccess(category: "documents-write")
|
||||||
|
await cacheDocument(result)
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "documents-write", error)
|
||||||
|
return try await queueDocumentUpdate(request, dueTo: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try await queueDocumentUpdate(request, dueTo: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
|
||||||
|
if !isManualOfflineModeEnabled {
|
||||||
|
do {
|
||||||
|
let result = try await RetryPolicy.withRetry { try await self.live.updateCollection(request) }
|
||||||
|
recordSuccess(category: "collections-write")
|
||||||
|
await cacheCollection(result)
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "collections-write", error)
|
||||||
|
return try await queueCollectionUpdate(request, dueTo: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try await queueCollectionUpdate(request, dueTo: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
|
||||||
|
if !isManualOfflineModeEnabled {
|
||||||
|
do {
|
||||||
|
let result = try await RetryPolicy.withRetry { try await self.live.createPin(request) }
|
||||||
|
recordSuccess(category: "pins")
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "pins", error)
|
||||||
|
return await queuePinCreate(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await queuePinCreate(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deletePin(id: String) async throws {
|
||||||
|
if await cancelIfNeverSynced(id: id) { return }
|
||||||
|
if !isManualOfflineModeEnabled {
|
||||||
|
do {
|
||||||
|
try await RetryPolicy.withRetry { try await self.live.deletePin(id: id) }
|
||||||
|
recordSuccess(category: "pins")
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "pins", error)
|
||||||
|
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
|
||||||
|
if !isManualOfflineModeEnabled {
|
||||||
|
do {
|
||||||
|
let result = try await RetryPolicy.withRetry { try await self.live.createSubscription(request) }
|
||||||
|
recordSuccess(category: "subscriptions")
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "subscriptions", error)
|
||||||
|
return await queueSubscriptionCreate(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await queueSubscriptionCreate(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteSubscription(id: String) async throws {
|
||||||
|
if await cancelIfNeverSynced(id: id) { return }
|
||||||
|
if !isManualOfflineModeEnabled {
|
||||||
|
do {
|
||||||
|
try await RetryPolicy.withRetry { try await self.live.deleteSubscription(id: id) }
|
||||||
|
recordSuccess(category: "subscriptions")
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "subscriptions", error)
|
||||||
|
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
|
||||||
|
if !isManualOfflineModeEnabled {
|
||||||
|
do {
|
||||||
|
let result = try await RetryPolicy.withRetry { try await self.live.starDocument(request) }
|
||||||
|
recordSuccess(category: "stars")
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "stars", error)
|
||||||
|
return await queueStarDocumentCreate(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await queueStarDocumentCreate(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
|
||||||
|
if !isManualOfflineModeEnabled {
|
||||||
|
do {
|
||||||
|
let result = try await RetryPolicy.withRetry { try await self.live.starCollection(request) }
|
||||||
|
recordSuccess(category: "stars")
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "stars", error)
|
||||||
|
return await queueStarCollectionCreate(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await queueStarCollectionCreate(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteStar(id: String) async throws {
|
||||||
|
if await cancelIfNeverSynced(id: id) { return }
|
||||||
|
if !isManualOfflineModeEnabled {
|
||||||
|
do {
|
||||||
|
try await RetryPolicy.withRetry { try await self.live.deleteStar(id: id) }
|
||||||
|
recordSuccess(category: "stars")
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "stars", error)
|
||||||
|
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign-out only — see `OfflineCacheStore.clearEverything()` and
|
||||||
|
/// `CacheEncryptionKeyStoring.clear()`. Callers must clear the
|
||||||
|
/// encryption key too (this actor doesn't own that decision); wiping
|
||||||
|
/// the storage here without it would leave the key to be reused by
|
||||||
|
/// whoever signs in next.
|
||||||
|
public func clearEverythingForSignOut() async {
|
||||||
|
await cache.clearEverything()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replays every queued operation against `live`, in the order they were
|
||||||
|
/// queued. Each is independent — one failing doesn't block the rest.
|
||||||
|
public func flushPendingOperations() async -> SyncFlushSummary {
|
||||||
|
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 { decryptedDecode(OutlineDocument.self, from: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every individually cached collection from the last Full Local Sync.
|
||||||
|
public func cachedCollectionsIndex() async -> [OutlineCollection] {
|
||||||
|
let payloads = await cache.loadAll(keyPrefix: "collection:")
|
||||||
|
return payloads.compactMap { decryptedDecode(OutlineCollection.self, from: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func cachedFetch<T: Codable>(key: String, category: String, fetch: () async throws -> T) async throws -> T {
|
||||||
|
// Manual offline mode means "skip the network entirely," not just
|
||||||
|
// "prefer it" — without this check, a read would still hit `live`
|
||||||
|
// (and succeed, showing content beyond whatever's cached) any time
|
||||||
|
// the device actually had a connection, defeating the point of
|
||||||
|
// deliberately testing/working as if offline.
|
||||||
|
if isManualOfflineModeEnabled {
|
||||||
|
if let data = await cache.load(forKey: key), let cached = decryptedDecode(T.self, from: data) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let result = try await RetryPolicy.withRetry { try await fetch() }
|
||||||
|
recordSuccess(category: category)
|
||||||
|
if let data = encryptedEncode(result) {
|
||||||
|
await cache.save(data, forKey: key)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
// Only a structural failure (decode/auth/server — the server
|
||||||
|
// answered, but something's actually wrong) counts toward the
|
||||||
|
// repeated-failure log. Plain connectivity loss already has its
|
||||||
|
// own offline UI elsewhere; logging it here too would just be a
|
||||||
|
// second banner for the same thing every time Wi-Fi drops.
|
||||||
|
if !RetryPolicy.isRetryable(error) {
|
||||||
|
recordFailure(category: category, message: errorDescription(error))
|
||||||
|
}
|
||||||
|
if let data = await cache.load(forKey: key), let cached = decryptedDecode(T.self, from: data) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedEncryptionKey() throws -> SymmetricKey {
|
||||||
|
if let cachedEncryptionKey { return cachedEncryptionKey }
|
||||||
|
let key = try encryptionKeyStore.key()
|
||||||
|
cachedEncryptionKey = key
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypts before it ever reaches SwiftData. `nil` on any failure
|
||||||
|
/// (matches the shape of the plain `try? encoder.encode(...)` this
|
||||||
|
/// replaces) — a Keychain hiccup here should behave exactly like an
|
||||||
|
/// encode failure already did: skip caching this one value, not crash.
|
||||||
|
private func encryptedEncode(_ value: some Encodable) -> Data? {
|
||||||
|
guard let plain = try? encoder.encode(value), let key = try? resolvedEncryptionKey() else { return nil }
|
||||||
|
return try? CachePayloadCryptor.encrypt(plain, key: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypts + decodes a value previously written by `encryptedEncode`.
|
||||||
|
/// A row written before this feature shipped (still plaintext JSON, or
|
||||||
|
/// anything encrypted under a key that's since been cleared by
|
||||||
|
/// sign-out) fails to decrypt and returns `nil` here — same as any
|
||||||
|
/// other decode failure, so `cachedFetch` treats it as a cache miss and
|
||||||
|
/// refetches, not a crash.
|
||||||
|
private func decryptedDecode<T: Decodable>(_ type: T.Type, from data: Data) -> T? {
|
||||||
|
guard let key = try? resolvedEncryptionKey(), let plain = try? CachePayloadCryptor.decrypt(data, key: key) else { return nil }
|
||||||
|
return try? decoder.decode(type, from: plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Throwing counterpart for `replay(_:)`, where a genuine decode
|
||||||
|
/// failure needs to propagate (so `flushPendingOperations` records it
|
||||||
|
/// as a failed sync attempt) instead of silently vanishing.
|
||||||
|
private func decryptedDecodeThrowing<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
|
||||||
|
let plain = try CachePayloadCryptor.decrypt(data, key: try resolvedEncryptionKey())
|
||||||
|
return try decoder.decode(type, from: plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestKey(_ prefix: String, _ request: some Encodable) -> String {
|
||||||
|
guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else {
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
return "\(prefix):\(json)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cacheDocument(_ document: OutlineDocument) async {
|
||||||
|
if let data = encryptedEncode(document) {
|
||||||
|
await cache.save(data, forKey: "document:\(document.id)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cacheCollection(_ collection: OutlineCollection) async {
|
||||||
|
if let data = encryptedEncode(collection) {
|
||||||
|
await cache.save(data, forKey: "collection:\(collection.id)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async {
|
||||||
|
guard let data = encryptedEncode(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 = decryptedDecode(OutlineDocument.self, from: baseData) else {
|
||||||
|
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||||
|
}
|
||||||
|
let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text)
|
||||||
|
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 = decryptedDecode(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 = decryptedDecode(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 decryptedDecodeThrowing(CreateDocumentRequest.self, from: operation.payload)
|
||||||
|
let result = try await live.createDocument(request)
|
||||||
|
await cacheDocument(result)
|
||||||
|
// The placeholder id (== operation.id) is now a dead orphan —
|
||||||
|
// nothing server-side will ever answer to it again.
|
||||||
|
await cache.removeCacheEntry(forKey: "document:\(operation.id)")
|
||||||
|
case .updateDocument:
|
||||||
|
let request = try decryptedDecodeThrowing(UpdateDocumentRequest.self, from: operation.payload)
|
||||||
|
let result = try await live.updateDocument(request)
|
||||||
|
await cacheDocument(result)
|
||||||
|
case .updateCollection:
|
||||||
|
let request = try decryptedDecodeThrowing(UpdateCollectionRequest.self, from: operation.payload)
|
||||||
|
let result = try await live.updateCollection(request)
|
||||||
|
await cacheCollection(result)
|
||||||
|
case .createPin:
|
||||||
|
let request = try decryptedDecodeThrowing(CreatePinRequest.self, from: operation.payload)
|
||||||
|
_ = try await live.createPin(request)
|
||||||
|
case .deletePin:
|
||||||
|
let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
|
||||||
|
try await live.deletePin(id: request.id)
|
||||||
|
case .createSubscription:
|
||||||
|
let request = try decryptedDecodeThrowing(CreateSubscriptionRequest.self, from: operation.payload)
|
||||||
|
_ = try await live.createSubscription(request)
|
||||||
|
case .deleteSubscription:
|
||||||
|
let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
|
||||||
|
try await live.deleteSubscription(id: request.id)
|
||||||
|
case .starDocument:
|
||||||
|
let request = try decryptedDecodeThrowing(StarDocumentRequest.self, from: operation.payload)
|
||||||
|
_ = try await live.starDocument(request)
|
||||||
|
case .deleteStar:
|
||||||
|
let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
|
||||||
|
try await live.deleteStar(id: request.id)
|
||||||
|
case .starCollection:
|
||||||
|
let request = try decryptedDecodeThrowing(StarCollectionRequest.self, from: operation.payload)
|
||||||
|
_ = try await live.starCollection(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes always queue on any failure (existing behavior, unchanged) —
|
||||||
|
/// this only decides whether the failure is worth logging. A structural
|
||||||
|
/// error (decode/auth/server) queuing for later replay will likely just
|
||||||
|
/// fail the same way again next sync; a transient one might not. Either
|
||||||
|
/// way the queue doesn't change, only whether it's counted toward
|
||||||
|
/// `repeatedFailureSummaries()`.
|
||||||
|
private func recordWriteFailureIfStructural(category: String, _ error: Error) {
|
||||||
|
guard !RetryPolicy.isRetryable(error) else { return }
|
||||||
|
recordFailure(category: category, message: errorDescription(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recordFailure(category: String, message: String) {
|
||||||
|
let now = Date()
|
||||||
|
var timestamps = (failureLog[category] ?? []).filter { now.timeIntervalSince($0) < failureWindow }
|
||||||
|
timestamps.append(now)
|
||||||
|
failureLog[category] = timestamps
|
||||||
|
lastFailureMessage[category] = message
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recordSuccess(category: String) {
|
||||||
|
failureLog[category] = nil
|
||||||
|
lastFailureMessage[category] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Categories that have failed `failureThreshold`+ times within the last
|
||||||
|
/// `failureWindow` seconds — meant to be polled periodically (see
|
||||||
|
/// `RootView`), not pushed, since this actor has no UI-facing dependency
|
||||||
|
/// of its own. A single blip never shows up here: `RetryPolicy` absorbs
|
||||||
|
/// transient failures before they're ever logged, and only structural
|
||||||
|
/// ones (decode/auth/server) get logged at all — see
|
||||||
|
/// `recordWriteFailureIfStructural` and `cachedFetch`.
|
||||||
|
public func repeatedFailureSummaries() async -> [RepeatedFailure] {
|
||||||
|
let now = Date()
|
||||||
|
return failureLog.compactMap { category, timestamps in
|
||||||
|
let recent = timestamps.filter { now.timeIntervalSince($0) < failureWindow }
|
||||||
|
guard recent.count >= failureThreshold, let message = lastFailureMessage[category] else { return nil }
|
||||||
|
return RepeatedFailure(category: category, message: message, count: recent.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func errorDescription(_ error: Error) -> String {
|
||||||
|
if let apiError = error as? OutlineAPIError {
|
||||||
|
switch apiError {
|
||||||
|
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,76 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// Real Keychain-backed `CacheEncryptionKeyStoring`. The key is a plain
|
||||||
|
/// random 256-bit value — deliberately NOT derived from anything guessable
|
||||||
|
/// (bundle id, device id, etc.). It doesn't need deriving to survive an
|
||||||
|
/// uninstall/reinstall of the app either: Keychain items are scoped to the
|
||||||
|
/// requesting app's code signature (bundle id + team id), not its on-disk
|
||||||
|
/// presence, so reinstalling the same app regains access to the same
|
||||||
|
/// Keychain item automatically — exactly how `KeychainTokenStore`'s saved
|
||||||
|
/// token already survives a reinstall today. If the on-disk cache also
|
||||||
|
/// happens to survive (macOS doesn't clean out `~/Library/Containers` just
|
||||||
|
/// because the .app bundle was dragged to the Trash), the reinstalled app
|
||||||
|
/// can still decrypt it; a different app, or the same app after an explicit
|
||||||
|
/// sign-out (`clear()`), can't.
|
||||||
|
public final class KeychainCacheEncryptionKeyStore: CacheEncryptionKeyStoring, @unchecked Sendable {
|
||||||
|
private let service: String
|
||||||
|
private let account: String
|
||||||
|
|
||||||
|
public init(service: String = "com.outpost.outlinekit", account: String = "offline-cache-encryption-key") {
|
||||||
|
self.service = service
|
||||||
|
self.account = account
|
||||||
|
}
|
||||||
|
|
||||||
|
public func key() throws -> SymmetricKey {
|
||||||
|
if let existing = try? readKey() { return existing }
|
||||||
|
let generated = SymmetricKey(size: .bits256)
|
||||||
|
try store(generated)
|
||||||
|
return generated
|
||||||
|
}
|
||||||
|
|
||||||
|
public func clear() throws {
|
||||||
|
let status = SecItemDelete(baseQuery() as CFDictionary)
|
||||||
|
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||||
|
throw TokenStoreError.deleteFailed(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func readKey() throws -> SymmetricKey {
|
||||||
|
var query = baseQuery()
|
||||||
|
query[kSecReturnData as String] = true
|
||||||
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
guard status == errSecSuccess, let data = result as? Data else {
|
||||||
|
throw TokenStoreError.notFound
|
||||||
|
}
|
||||||
|
return SymmetricKey(data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func store(_ key: SymmetricKey) throws {
|
||||||
|
let data = key.withUnsafeBytes { Data($0) }
|
||||||
|
let query = baseQuery()
|
||||||
|
|
||||||
|
let existsStatus = SecItemCopyMatching(query as CFDictionary, nil)
|
||||||
|
if existsStatus == errSecSuccess {
|
||||||
|
let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary)
|
||||||
|
guard updateStatus == errSecSuccess else { throw TokenStoreError.storeFailed(updateStatus) }
|
||||||
|
} else {
|
||||||
|
var addQuery = query
|
||||||
|
addQuery[kSecValueData as String] = data
|
||||||
|
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
||||||
|
guard addStatus == errSecSuccess else { throw TokenStoreError.storeFailed(addStatus) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func baseQuery() -> [String: Any] {
|
||||||
|
[
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: account
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wipes both the read-through cache AND the pending write queue —
|
||||||
|
/// used only on sign-out (see `SessionStore.signOut()`), since the
|
||||||
|
/// encryption key backing every row here is about to be cleared too.
|
||||||
|
/// Anything left un-wiped would just become permanently undecryptable
|
||||||
|
/// garbage instead of readable by the next account signed in on this
|
||||||
|
/// machine — deliberately more thorough than `clearAll()` (Settings'
|
||||||
|
/// "Clear All Cache", which never touches pending writes; that button
|
||||||
|
/// shouldn't silently discard someone's unsynced edits).
|
||||||
|
public func clearEverything() {
|
||||||
|
let cachedDescriptor = FetchDescriptor<CachedPayload>()
|
||||||
|
(try? modelContext.fetch(cachedDescriptor))?.forEach { modelContext.delete($0) }
|
||||||
|
let pendingDescriptor = FetchDescriptor<PendingOperation>()
|
||||||
|
(try? modelContext.fetch(pendingDescriptor))?.forEach { modelContext.delete($0) }
|
||||||
|
try? modelContext.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Offline write queue
|
||||||
|
|
||||||
|
/// Upserts by `id` — a second call with the same id (an edit coalescing
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
@@ -9,6 +9,13 @@ public protocol OutlineAPIClient: Sendable {
|
|||||||
|
|
||||||
func documentInfo(id: String) async throws -> OutlineDocument
|
func documentInfo(id: String) async throws -> OutlineDocument
|
||||||
func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument]
|
func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument]
|
||||||
|
/// Richer filtering (sort/direction/userId) for the Home page's tabs. See `DocumentsListRequest`.
|
||||||
|
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`.
|
/// Full-text search with snippets/ranking. Backed by `documents.search`.
|
||||||
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult]
|
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult]
|
||||||
/// Title-only search — faster, no snippets. Backed by `documents.search_titles`.
|
/// Title-only search — faster, no snippets. Backed by `documents.search_titles`.
|
||||||
@@ -31,6 +38,8 @@ public protocol OutlineAPIClient: Sendable {
|
|||||||
func createShare(_ request: CreateShareRequest) async throws -> OutlineShare
|
func createShare(_ request: CreateShareRequest) async throws -> OutlineShare
|
||||||
func shareInfo(documentId: String) async throws -> OutlineShare?
|
func shareInfo(documentId: String) async throws -> OutlineShare?
|
||||||
func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare
|
func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare
|
||||||
|
func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare]
|
||||||
|
func revokeShare(id: String) async throws
|
||||||
/// See `OutlinePin` — best-effort, not in the vendored spec.
|
/// See `OutlinePin` — best-effort, not in the vendored spec.
|
||||||
func createPin(_ request: CreatePinRequest) async throws -> OutlinePin
|
func createPin(_ request: CreatePinRequest) async throws -> OutlinePin
|
||||||
func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin]
|
func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin]
|
||||||
@@ -42,6 +51,28 @@ public protocol OutlineAPIClient: Sendable {
|
|||||||
/// Historical view records, not live presence. Backed by `views.list`.
|
/// Historical view records, not live presence. Backed by `views.list`.
|
||||||
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView]
|
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView]
|
||||||
|
|
||||||
|
/// See `OutlineComment`. `resolve`/`unresolve`/`add_reaction`/
|
||||||
|
/// `remove_reaction` are confirmed real against Outline's own server
|
||||||
|
/// source but aren't in the vendored spec.
|
||||||
|
func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment]
|
||||||
|
func commentInfo(id: String) async throws -> OutlineComment
|
||||||
|
func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment
|
||||||
|
func resolveComment(id: String) async throws -> OutlineComment
|
||||||
|
func unresolveComment(id: String) async throws -> OutlineComment
|
||||||
|
/// Reaction endpoints return `{success: true}`, not the updated
|
||||||
|
/// comment — callers refetch via `commentInfo` for the fresh
|
||||||
|
/// `reactions` array.
|
||||||
|
func addReaction(commentId: String, emoji: String) async throws
|
||||||
|
func removeReaction(commentId: String, emoji: String) async throws
|
||||||
|
|
||||||
|
/// See `OutlineMembership`/`OutlineDocumentMember` — `add` is confirmed
|
||||||
|
/// from Outline's official docs, the rest are best-effort.
|
||||||
|
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership
|
||||||
|
func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws
|
||||||
|
func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember]
|
||||||
|
/// For searching workspace members to invite. Backed by `users.list`.
|
||||||
|
func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser]
|
||||||
|
|
||||||
func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection]
|
func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection]
|
||||||
func collectionInfo(id: String) async throws -> OutlineCollection
|
func collectionInfo(id: String) async throws -> OutlineCollection
|
||||||
func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection
|
func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection
|
||||||
@@ -55,4 +86,48 @@ public protocol OutlineAPIClient: Sendable {
|
|||||||
func deleteStar(id: String) async throws
|
func deleteStar(id: String) async throws
|
||||||
|
|
||||||
func currentUser() async throws -> OutlineUser
|
func currentUser() async throws -> OutlineUser
|
||||||
|
|
||||||
|
/// Two-step presigned upload: this requests where/how to upload,
|
||||||
|
/// `uploadAttachmentFile` performs the actual multipart POST to that
|
||||||
|
/// target. See `OutlineAttachment`/`CreateAttachmentResult`.
|
||||||
|
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult
|
||||||
|
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws
|
||||||
|
/// 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,18 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] {
|
||||||
|
try await post("documents.list", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] {
|
||||||
|
try await post("documents.viewed", body: PaginationParams(offset: offset, limit: limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] {
|
||||||
|
try await post("documents.drafts", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
|
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
|
||||||
try await post("documents.search", body: request)
|
try await post("documents.search", body: request)
|
||||||
}
|
}
|
||||||
@@ -113,23 +125,38 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func shareInfo(documentId: String) async throws -> OutlineShare? {
|
public func shareInfo(documentId: String) async throws -> OutlineShare? {
|
||||||
do {
|
// Real shape confirmed against a live server: `data` is
|
||||||
return try await post("shares.info", body: ShareInfoRequest(documentId: documentId))
|
// `{ shares: [...] }`, not the bare share object the docs imply —
|
||||||
} catch OutlineAPIError.notFound {
|
// same pattern as `pins.list`. A document could in principle have
|
||||||
return nil
|
// more than one share record; the first is what the reader's
|
||||||
}
|
// share sheet cares about.
|
||||||
|
let payload: SharesInfoPayload? = try await postOptional("shares.info", body: ShareInfoRequest(documentId: documentId))
|
||||||
|
return payload?.shares.first
|
||||||
}
|
}
|
||||||
|
|
||||||
public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare {
|
public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare {
|
||||||
try await post("shares.update", body: request)
|
try await post("shares.update", body: request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] {
|
||||||
|
try await post("shares.list", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func revokeShare(id: String) async throws {
|
||||||
|
try await postForSuccess("shares.revoke", body: StarIDParams(id: id))
|
||||||
|
}
|
||||||
|
|
||||||
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
|
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
|
||||||
try await post("pins.create", body: request)
|
try await post("pins.create", body: request)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
|
public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
|
||||||
try await post("pins.list", body: request)
|
// `data` here is `{ pins: [...], documents: [...] }`, not a bare
|
||||||
|
// array — confirmed against a live server. Decoding straight to
|
||||||
|
// `[OutlinePin]` throws on every call, which `try?` at call sites
|
||||||
|
// swallows silently, so pins never showed up anywhere.
|
||||||
|
let payload: PinsListPayload = try await post("pins.list", body: request)
|
||||||
|
return payload.pins
|
||||||
}
|
}
|
||||||
|
|
||||||
public func deletePin(id: String) async throws {
|
public func deletePin(id: String) async throws {
|
||||||
@@ -152,6 +179,50 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await post("views.list", body: request)
|
try await post("views.list", body: request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] {
|
||||||
|
try await post("comments.list", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func commentInfo(id: String) async throws -> OutlineComment {
|
||||||
|
try await post("comments.info", body: StarIDParams(id: id))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment {
|
||||||
|
try await post("comments.create", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func resolveComment(id: String) async throws -> OutlineComment {
|
||||||
|
try await post("comments.resolve", body: StarIDParams(id: id))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func unresolveComment(id: String) async throws -> OutlineComment {
|
||||||
|
try await post("comments.unresolve", body: StarIDParams(id: id))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func addReaction(commentId: String, emoji: String) async throws {
|
||||||
|
try await postForSuccess("comments.add_reaction", body: CommentReactionParams(id: commentId, emoji: emoji))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func removeReaction(commentId: String, emoji: String) async throws {
|
||||||
|
try await postForSuccess("comments.remove_reaction", body: CommentReactionParams(id: commentId, emoji: emoji))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
|
||||||
|
try await post("documents.add_user", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws {
|
||||||
|
try await postForSuccess("documents.remove_user", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] {
|
||||||
|
try await post("documents.users", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] {
|
||||||
|
try await post("users.list", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
|
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
|
||||||
try await post("collections.list", body: CollectionListParams(offset: offset, limit: limit))
|
try await post("collections.list", body: CollectionListParams(offset: offset, limit: limit))
|
||||||
}
|
}
|
||||||
@@ -190,6 +261,142 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await post("users.info", body: EmptyParams())
|
try await post("users.info", body: EmptyParams())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
|
||||||
|
try await post("attachments.create", body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No Bearer/CSRF header attached here, deliberately — the presigned
|
||||||
|
/// `form.sig` field (short-lived, scoped to this exact upload key) is
|
||||||
|
/// what authorizes this specific request, the same way an S3 presigned
|
||||||
|
/// POST works. Best-effort against a live server: if it turns out the
|
||||||
|
/// self-hosted local-storage backend also wants a bearer token here,
|
||||||
|
/// that's a one-line addition once confirmed, not a design change.
|
||||||
|
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
|
||||||
|
let (body, contentType) = MultipartFormDataBuilder.build(
|
||||||
|
fields: result.form,
|
||||||
|
fileFieldName: "file",
|
||||||
|
fileName: result.attachment.name ?? "avatar",
|
||||||
|
fileData: fileData,
|
||||||
|
fileContentType: result.form["Content-Type"] ?? "application/octet-stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
// `uploadUrl` is a host-relative path (e.g. "/api/files.create") on
|
||||||
|
// a self-hosted local-storage backend, not an absolute S3 URL —
|
||||||
|
// resolving against `baseURL` handles both: `URL(string:relativeTo:)`
|
||||||
|
// replaces the whole path for a leading-slash relative string per
|
||||||
|
// RFC 3986, same resolution already used for user/team avatar URLs.
|
||||||
|
guard let uploadURL = URL(string: result.uploadUrl, relativeTo: baseURL)?.absoluteURL else {
|
||||||
|
throw OutlineAPIError.transport(URLError(.badURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: uploadURL)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = body
|
||||||
|
|
||||||
|
let data: Data
|
||||||
|
let response: HTTPURLResponse
|
||||||
|
do {
|
||||||
|
(data, response) = try await httpClient.send(request)
|
||||||
|
} catch let error as OutlineAPIError {
|
||||||
|
throw error
|
||||||
|
} catch {
|
||||||
|
throw OutlineAPIError.transport(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard (200...299).contains(response.statusCode) else {
|
||||||
|
let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data)
|
||||||
|
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func 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 {
|
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
|
||||||
guard let token = try? tokenStore.token() else {
|
guard let token = try? tokenStore.token() else {
|
||||||
throw OutlineAPIError.tokenUnavailable
|
throw OutlineAPIError.tokenUnavailable
|
||||||
@@ -230,6 +437,52 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like `post(_:body:)`, but for endpoints where "no result" comes back
|
||||||
|
/// as a 200 with a completely empty body instead of a real 404 —
|
||||||
|
/// confirmed against a live server for `shares.info` (no share yet for
|
||||||
|
/// a given document). A 404 is still treated as nil too.
|
||||||
|
private func postOptional<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response? {
|
||||||
|
guard let token = try? tokenStore.token() else {
|
||||||
|
throw OutlineAPIError.tokenUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: baseURL.appendingPathComponent("api/\(path)"))
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
request.httpBody = try encoder.encode(body)
|
||||||
|
|
||||||
|
let data: Data
|
||||||
|
let response: HTTPURLResponse
|
||||||
|
do {
|
||||||
|
(data, response) = try await httpClient.send(request)
|
||||||
|
} catch let error as OutlineAPIError {
|
||||||
|
throw error
|
||||||
|
} catch {
|
||||||
|
throw OutlineAPIError.transport(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard (200...299).contains(response.statusCode) else {
|
||||||
|
let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data)
|
||||||
|
switch response.statusCode {
|
||||||
|
case 401:
|
||||||
|
throw OutlineAPIError.unauthorized
|
||||||
|
case 404:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !data.isEmpty else { return nil }
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try decoder.decode(OutlineEnvelope<Response>.self, from: data).data
|
||||||
|
} catch {
|
||||||
|
throw OutlineAPIError.decoding(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// For endpoints shaped `{ "success": true }` instead of `{ "data": ... }`
|
/// For endpoints shaped `{ "success": true }` instead of `{ "data": ... }`
|
||||||
/// (e.g. `collections.delete`) — `post(_:body:)`'s envelope decode doesn't fit.
|
/// (e.g. `collections.delete`) — `post(_:body:)`'s envelope decode doesn't fit.
|
||||||
private func postForSuccess<Body: Encodable>(_ path: String, body: Body) async throws {
|
private func postForSuccess<Body: Encodable>(_ path: String, body: Body) async throws {
|
||||||
@@ -286,6 +539,15 @@ private struct DuplicateDocumentResponse: Decodable {
|
|||||||
let documents: [OutlineDocument]
|
let documents: [OutlineDocument]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct PinsListPayload: Decodable {
|
||||||
|
let pins: [OutlinePin]
|
||||||
|
let documents: [OutlineDocument]
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SharesInfoPayload: Decodable {
|
||||||
|
let shares: [OutlineShare]
|
||||||
|
}
|
||||||
|
|
||||||
private struct ListStarsResponse: Decodable {
|
private struct ListStarsResponse: Decodable {
|
||||||
let stars: [OutlineStar]
|
let stars: [OutlineStar]
|
||||||
}
|
}
|
||||||
@@ -294,6 +556,11 @@ private struct StarIDParams: Encodable {
|
|||||||
let id: String
|
let id: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct CommentReactionParams: Encodable {
|
||||||
|
let id: String
|
||||||
|
let emoji: String
|
||||||
|
}
|
||||||
|
|
||||||
private struct MoveDocumentResponse: Decodable {
|
private struct MoveDocumentResponse: Decodable {
|
||||||
let documents: [OutlineDocument]?
|
let documents: [OutlineDocument]?
|
||||||
let collections: [OutlineCollection]?
|
let collections: [OutlineCollection]?
|
||||||
@@ -314,6 +581,11 @@ private struct DocumentListParams: Encodable {
|
|||||||
let limit: Int
|
let limit: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct PaginationParams: Encodable {
|
||||||
|
let offset: Int
|
||||||
|
let limit: Int
|
||||||
|
}
|
||||||
|
|
||||||
private struct CollectionListParams: Encodable {
|
private struct CollectionListParams: Encodable {
|
||||||
let offset: Int
|
let offset: Int
|
||||||
let limit: Int
|
let limit: Int
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A user's explicit permission grant on a document, backed by
|
||||||
|
/// `documents.add_user`/`documents.remove_user`. Not in the vendored spec —
|
||||||
|
/// only `documents.add_user`'s shape is confirmed from Outline's official
|
||||||
|
/// docs; `remove` and the response shape here follow the create/delete and
|
||||||
|
/// `{data: ...}` conventions used throughout the rest of this API, but
|
||||||
|
/// aren't verified against a live server yet.
|
||||||
|
public struct OutlineMembership: Decodable, Identifiable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let userId: String
|
||||||
|
public let documentId: String?
|
||||||
|
/// `"read"` or `"read_write"` per the documented enum.
|
||||||
|
public let permission: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A workspace member as returned by `documents.users` in the context of a
|
||||||
|
/// specific document — same identity fields as `OutlineUser`, plus (if the
|
||||||
|
/// server includes it) the permission they hold on that document. Modeled
|
||||||
|
/// separately from `OutlineUser` rather than adding an optional field there,
|
||||||
|
/// since `permission` only makes sense in this document-scoped context.
|
||||||
|
/// Speculative — `documents.users` isn't in the vendored spec, its name is
|
||||||
|
/// inferred from Outline's usual `<resource>.<verb>` convention and hasn't
|
||||||
|
/// been confirmed against a live server.
|
||||||
|
public struct OutlineDocumentMember: Decodable, Identifiable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let name: String
|
||||||
|
public let email: String?
|
||||||
|
public let avatarUrl: String?
|
||||||
|
public let permission: String?
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// A document pinned to the top of a collection (or the team home), backed by
|
/// A document pinned to the top of a collection, or to team Home when
|
||||||
/// `pins.*`. Not in the vendored OpenAPI spec (`docs/reference/outline-openapi`)
|
/// `collectionId` is `nil`. Backed by `pins.*` — not in the vendored OpenAPI
|
||||||
/// — that spec has no `Pins` tag at all — but the endpoint exists on Outline's
|
/// spec (`docs/reference/outline-openapi`, no `Pins` tag at all), but
|
||||||
/// actual server (`server/routes/api/pins.ts` upstream). Shape reconstructed
|
/// confirmed real against a live server's network traffic. `collectionId: nil`
|
||||||
/// from general knowledge of Outline's API, not verified against this spec;
|
/// is what "Pin to Home" actually sends; a non-nil value is "Pin to
|
||||||
/// treat field names as best-effort until confirmed against a live server.
|
/// Collection", a distinct action.
|
||||||
public struct OutlinePin: Decodable, Identifiable, Sendable {
|
public struct OutlinePin: Decodable, Identifiable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let documentId: String
|
public let documentId: String
|
||||||
|
|||||||
@@ -1,10 +1,41 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// A public share link for a document or collection, backed by `shares.*`.
|
/// A public share link for a document or collection, backed by `shares.*`.
|
||||||
|
///
|
||||||
|
/// Only `id` and `published` are guaranteed present on every real share
|
||||||
|
/// object — everything else is modeled as optional even where the official
|
||||||
|
/// API docs don't explicitly mark it nullable, since self-hosted servers
|
||||||
|
/// have repeatedly diverged from the hosted app's documented response shape
|
||||||
|
/// (see `OutlinePin`'s history) and a single unexpectedly-null field used to
|
||||||
|
/// crash this decode entirely ("Got an unexpected response from the
|
||||||
|
/// server").
|
||||||
public struct OutlineShare: Decodable, Identifiable, Sendable {
|
public struct OutlineShare: Decodable, Identifiable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
|
public let published: Bool
|
||||||
|
|
||||||
public let documentId: String?
|
public let documentId: String?
|
||||||
public let collectionId: String?
|
public let collectionId: String?
|
||||||
public let url: String
|
public let documentTitle: String?
|
||||||
public let published: Bool
|
public let documentUrl: String?
|
||||||
|
public let sourceTitle: String?
|
||||||
|
public let sourcePath: String?
|
||||||
|
public let urlId: String?
|
||||||
|
public let url: String?
|
||||||
|
public let domain: String?
|
||||||
|
/// Overrides the source document/collection title on the publicly
|
||||||
|
/// shared page, if set.
|
||||||
|
public let title: String?
|
||||||
|
/// Overrides the workspace branding icon on the publicly shared page,
|
||||||
|
/// if set.
|
||||||
|
public let iconUrl: String?
|
||||||
|
public let includeChildDocuments: Bool?
|
||||||
|
public let allowSubscriptions: Bool?
|
||||||
|
public let allowIndexing: Bool?
|
||||||
|
public let showLastUpdated: Bool?
|
||||||
|
public let showTOC: Bool?
|
||||||
|
public let views: Int?
|
||||||
|
public let createdBy: OutlineUser?
|
||||||
|
public let createdAt: Date?
|
||||||
|
public let updatedAt: Date?
|
||||||
|
public let lastAccessedAt: Date?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,18 +6,30 @@ public struct OutlineUser: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public let email: String?
|
public let email: String?
|
||||||
public let avatarUrl: String?
|
public let avatarUrl: String?
|
||||||
public let role: String?
|
public let role: String?
|
||||||
|
public let language: String?
|
||||||
|
public let preferences: OutlineUserPreferences?
|
||||||
|
/// Keyed by `NotificationEventType`'s raw values, plus event types this
|
||||||
|
/// app has no UI for — kept as a flexible dictionary rather than a
|
||||||
|
/// fixed struct for that reason. `true` means subscribed.
|
||||||
|
public let notificationSettings: [String: Bool]?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
email: String? = nil,
|
email: String? = nil,
|
||||||
avatarUrl: String? = nil,
|
avatarUrl: String? = nil,
|
||||||
role: String? = nil
|
role: String? = nil,
|
||||||
|
language: String? = nil,
|
||||||
|
preferences: OutlineUserPreferences? = nil,
|
||||||
|
notificationSettings: [String: Bool]? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
self.email = email
|
self.email = email
|
||||||
self.avatarUrl = avatarUrl
|
self.avatarUrl = avatarUrl
|
||||||
self.role = role
|
self.role = role
|
||||||
|
self.language = language
|
||||||
|
self.preferences = preferences
|
||||||
|
self.notificationSettings = notificationSettings
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `User.preferences` — a free-form JSON blob on Outline's own `User` row,
|
||||||
|
/// not a fixed-shape API resource. Wire key names below are confirmed
|
||||||
|
/// against a live server's own web app traffic (captured toggling every
|
||||||
|
/// Preferences setting one at a time), not guessed — see the `CodingKeys`
|
||||||
|
/// mapping for the two that don't match this struct's own property names.
|
||||||
|
///
|
||||||
|
/// The server also validates `preferences` against a known key allowlist
|
||||||
|
/// and rejects the whole `users.update` call (not just the bad field) if
|
||||||
|
/// any key it doesn't recognize is present — confirmed live via a
|
||||||
|
/// `"notificationBadge: Invalid Input"` error when an earlier, wrong value
|
||||||
|
/// was sent. That's also why `fullWidthDocuments` is kept here even though
|
||||||
|
/// this app has no UI for it yet: this app always sends the *whole*
|
||||||
|
/// preferences object back on every save (see
|
||||||
|
/// `UpdateUserPreferencesRequest`), so silently dropping an unknown key
|
||||||
|
/// during decode would permanently clear it the next time any other
|
||||||
|
/// preference here gets saved.
|
||||||
|
public struct OutlineUserPreferences: Codable, Hashable, Sendable {
|
||||||
|
public var rememberLastPath: Bool?
|
||||||
|
/// App-facing polarity: `true` means "separate editing mode is on" —
|
||||||
|
/// the opposite of the wire's own `seamlessEdit` (seamless editing and
|
||||||
|
/// separate editing modes are each other's negation), inverted in
|
||||||
|
/// `init(from:)`/`encode(to:)` so nothing outside this file has to
|
||||||
|
/// remember that.
|
||||||
|
public var separateEditing: Bool?
|
||||||
|
public var useCursorPointer: Bool?
|
||||||
|
public var codeBlockLineNumbers: Bool?
|
||||||
|
public var showCommentMarker: Bool?
|
||||||
|
public var smartText: Bool?
|
||||||
|
/// One of `NotificationBadgeStyle`'s raw values.
|
||||||
|
public var notificationBadge: String?
|
||||||
|
/// No UI in this app yet — preserved purely so saving any other
|
||||||
|
/// preference here doesn't clobber it. See the type doc comment.
|
||||||
|
public var fullWidthDocuments: Bool?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
rememberLastPath: Bool? = nil,
|
||||||
|
separateEditing: Bool? = nil,
|
||||||
|
useCursorPointer: Bool? = nil,
|
||||||
|
codeBlockLineNumbers: Bool? = nil,
|
||||||
|
showCommentMarker: Bool? = nil,
|
||||||
|
smartText: Bool? = nil,
|
||||||
|
notificationBadge: String? = nil,
|
||||||
|
fullWidthDocuments: Bool? = nil
|
||||||
|
) {
|
||||||
|
self.rememberLastPath = rememberLastPath
|
||||||
|
self.separateEditing = separateEditing
|
||||||
|
self.useCursorPointer = useCursorPointer
|
||||||
|
self.codeBlockLineNumbers = codeBlockLineNumbers
|
||||||
|
self.showCommentMarker = showCommentMarker
|
||||||
|
self.smartText = smartText
|
||||||
|
self.notificationBadge = notificationBadge
|
||||||
|
self.fullWidthDocuments = fullWidthDocuments
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case rememberLastPath
|
||||||
|
case seamlessEdit
|
||||||
|
case useCursorPointer
|
||||||
|
case codeBlockLineNumbers
|
||||||
|
case commentsInGutter
|
||||||
|
case enableSmartText
|
||||||
|
case notificationBadge
|
||||||
|
case fullWidthDocuments
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
rememberLastPath = try container.decodeIfPresent(Bool.self, forKey: .rememberLastPath)
|
||||||
|
separateEditing = try container.decodeIfPresent(Bool.self, forKey: .seamlessEdit).map { !$0 }
|
||||||
|
useCursorPointer = try container.decodeIfPresent(Bool.self, forKey: .useCursorPointer)
|
||||||
|
codeBlockLineNumbers = try container.decodeIfPresent(Bool.self, forKey: .codeBlockLineNumbers)
|
||||||
|
showCommentMarker = try container.decodeIfPresent(Bool.self, forKey: .commentsInGutter)
|
||||||
|
smartText = try container.decodeIfPresent(Bool.self, forKey: .enableSmartText)
|
||||||
|
notificationBadge = try container.decodeIfPresent(String.self, forKey: .notificationBadge)
|
||||||
|
fullWidthDocuments = try container.decodeIfPresent(Bool.self, forKey: .fullWidthDocuments)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encodeIfPresent(rememberLastPath, forKey: .rememberLastPath)
|
||||||
|
try container.encodeIfPresent(separateEditing.map { !$0 }, forKey: .seamlessEdit)
|
||||||
|
try container.encodeIfPresent(useCursorPointer, forKey: .useCursorPointer)
|
||||||
|
try container.encodeIfPresent(codeBlockLineNumbers, forKey: .codeBlockLineNumbers)
|
||||||
|
try container.encodeIfPresent(showCommentMarker, forKey: .commentsInGutter)
|
||||||
|
try container.encodeIfPresent(smartText, forKey: .enableSmartText)
|
||||||
|
try container.encodeIfPresent(notificationBadge, forKey: .notificationBadge)
|
||||||
|
try container.encodeIfPresent(fullWidthDocuments, forKey: .fullWidthDocuments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App-icon unread indicator style. Wire values confirmed live (captured
|
||||||
|
/// setting all three from Outline's own web app).
|
||||||
|
public enum NotificationBadgeStyle: String, CaseIterable, Identifiable, Sendable {
|
||||||
|
case none = "disabled"
|
||||||
|
case unreadIndicator = "indicator"
|
||||||
|
case unreadCount = "count"
|
||||||
|
|
||||||
|
public var id: String { rawValue }
|
||||||
|
|
||||||
|
public var label: String {
|
||||||
|
switch self {
|
||||||
|
case .none: return "None"
|
||||||
|
case .unreadIndicator: return "Unread Indicator"
|
||||||
|
case .unreadCount: return "Unread Count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A category of API call that's failed repeatedly within a short window —
|
||||||
|
/// see `CachingOutlineAPIClient.repeatedFailureSummaries()`. Deliberately
|
||||||
|
/// carries no request/response payload, document content, or server URL:
|
||||||
|
/// this is meant to be safe to show a user or attach to a bug report as-is.
|
||||||
|
public struct RepeatedFailure: Sendable, Identifiable, Equatable {
|
||||||
|
public let category: String
|
||||||
|
public let message: String
|
||||||
|
public let count: Int
|
||||||
|
|
||||||
|
public var id: String { category }
|
||||||
|
|
||||||
|
public init(category: String, message: String, count: Int) {
|
||||||
|
self.category = category
|
||||||
|
self.message = message
|
||||||
|
self.count = count
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,16 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// See `OutlineMembership`. Confirmed shape from Outline's official
|
||||||
|
/// `documents.add_user` docs.
|
||||||
|
public struct AddDocumentUserRequest: Encodable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let userId: String
|
||||||
|
/// `"read"` or `"read_write"`.
|
||||||
|
public let permission: String
|
||||||
|
|
||||||
|
public init(id: String, userId: String, permission: String) {
|
||||||
|
self.id = id
|
||||||
|
self.userId = userId
|
||||||
|
self.permission = permission
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
import Foundation
|
||||||
|
|
||||||
public struct CreateDocumentRequest: Encodable, Sendable {
|
public struct CreateDocumentRequest: Codable, Sendable {
|
||||||
public let title: String
|
public let title: String
|
||||||
public let text: String
|
public let text: String
|
||||||
public let collectionId: String
|
/// `nil` (with `parentDocumentId` also `nil`) creates a draft — Outline
|
||||||
|
/// requires one of the two to publish at all, regardless of `publish`.
|
||||||
|
public let collectionId: String?
|
||||||
public let parentDocumentId: String?
|
public let parentDocumentId: String?
|
||||||
public let publish: Bool
|
public let publish: Bool
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
title: String,
|
title: String,
|
||||||
text: String,
|
text: String,
|
||||||
collectionId: String,
|
collectionId: String? = nil,
|
||||||
parentDocumentId: String? = nil,
|
parentDocumentId: String? = nil,
|
||||||
publish: Bool = true
|
publish: Bool = true
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// See `OutlinePin` — best-effort shape, not in the vendored spec.
|
/// See `OutlinePin`. `collectionId: nil` = "Pin to Home", non-nil = "Pin to
|
||||||
public struct CreatePinRequest: Encodable, Sendable {
|
/// Collection" — these are distinct actions on the real server.
|
||||||
|
public struct CreatePinRequest: Codable, Sendable {
|
||||||
public let documentId: String
|
public let documentId: String
|
||||||
public let collectionId: String?
|
public let collectionId: String?
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// See `OutlineSubscription` — best-effort shape, not in the vendored spec.
|
/// 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 documentId: String
|
||||||
public let event: String
|
public let event: String
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Richer `documents.list` query than `OutlineAPIClient.listDocuments` covers
|
||||||
|
/// (that one's kept as-is for its existing simple callers) — adds the
|
||||||
|
/// sort/direction/userId filters the Home page's tabs need.
|
||||||
|
public struct DocumentsListRequest: Encodable, Sendable {
|
||||||
|
public let collectionId: String?
|
||||||
|
public let userId: String?
|
||||||
|
public let sort: String?
|
||||||
|
public let direction: String?
|
||||||
|
public let offset: Int
|
||||||
|
public let limit: Int
|
||||||
|
|
||||||
|
public init(
|
||||||
|
collectionId: String? = nil,
|
||||||
|
userId: String? = nil,
|
||||||
|
sort: String? = nil,
|
||||||
|
direction: String? = nil,
|
||||||
|
offset: Int = 0,
|
||||||
|
limit: Int = 25
|
||||||
|
) {
|
||||||
|
self.collectionId = collectionId
|
||||||
|
self.userId = userId
|
||||||
|
self.sort = sort
|
||||||
|
self.direction = direction
|
||||||
|
self.offset = offset
|
||||||
|
self.limit = limit
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,17 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// See `OutlineDocumentMember`. Speculative — `documents.users` isn't in
|
||||||
|
/// the vendored spec.
|
||||||
|
public struct ListDocumentUsersRequest: Encodable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let query: String?
|
||||||
|
public let offset: Int
|
||||||
|
public let limit: Int
|
||||||
|
|
||||||
|
public init(id: String, query: String? = nil, offset: Int = 0, limit: Int = 25) {
|
||||||
|
self.id = id
|
||||||
|
self.query = query
|
||||||
|
self.offset = offset
|
||||||
|
self.limit = limit
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// See `OutlinePin` — best-effort shape, not in the vendored spec.
|
/// See `OutlinePin`. `collectionId: nil` lists Home pins only.
|
||||||
public struct ListPinsRequest: Encodable, Sendable {
|
public struct ListPinsRequest: Encodable, Sendable {
|
||||||
public let collectionId: String?
|
public let collectionId: String?
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct ListSharesRequest: Encodable, Sendable {
|
||||||
|
public let offset: Int
|
||||||
|
public let limit: Int
|
||||||
|
public let sort: String?
|
||||||
|
public let direction: String?
|
||||||
|
/// Filter to shared documents matching a search query.
|
||||||
|
public let query: String?
|
||||||
|
|
||||||
|
public init(offset: Int = 0, limit: Int = 25, sort: String? = nil, direction: String? = nil, query: String? = nil) {
|
||||||
|
self.offset = offset
|
||||||
|
self.limit = limit
|
||||||
|
self.sort = sort
|
||||||
|
self.direction = direction
|
||||||
|
self.query = query
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// For searching workspace members to invite to a document. Backed by
|
||||||
|
/// `users.list`.
|
||||||
|
public struct ListUsersRequest: Encodable, Sendable {
|
||||||
|
public let query: String?
|
||||||
|
public let offset: Int
|
||||||
|
public let limit: Int
|
||||||
|
|
||||||
|
public init(query: String? = nil, offset: Int = 0, limit: Int = 25) {
|
||||||
|
self.query = query
|
||||||
|
self.offset = offset
|
||||||
|
self.limit = limit
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `users.notificationsSubscribe` / `users.notificationsUnsubscribe`. A
|
||||||
|
/// `nil` `eventType` targets every notification event at once — confirmed
|
||||||
|
/// live via Outline's own "All notifications" master toggle, which sends
|
||||||
|
/// no `eventType` at all.
|
||||||
|
public struct NotificationSubscriptionRequest: Encodable, Sendable {
|
||||||
|
public let eventType: String?
|
||||||
|
|
||||||
|
public init(eventType: String? = nil) {
|
||||||
|
self.eventType = eventType
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// See `OutlineMembership`. Speculative — follows the create/delete pairing
|
||||||
|
/// convention used elsewhere in this API (`documents.add_user` /
|
||||||
|
/// `documents.remove_user`), not confirmed against a live server yet.
|
||||||
|
public struct RemoveDocumentUserRequest: Encodable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let userId: String
|
||||||
|
|
||||||
|
public init(id: String, userId: String) {
|
||||||
|
self.id = id
|
||||||
|
self.userId = userId
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct StarCollectionRequest: Encodable, Sendable {
|
public struct StarCollectionRequest: Codable, Sendable {
|
||||||
public let collectionId: String
|
public let collectionId: String
|
||||||
|
|
||||||
public init(collectionId: String) {
|
public init(collectionId: String) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct StarDocumentRequest: Encodable, Sendable {
|
public struct StarDocumentRequest: Codable, Sendable {
|
||||||
public let documentId: String
|
public let documentId: String
|
||||||
|
|
||||||
public init(documentId: String) {
|
public init(documentId: String) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct UpdateCollectionRequest: Encodable, Sendable {
|
public struct UpdateCollectionRequest: Codable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let name: String?
|
public let name: String?
|
||||||
public let description: String?
|
public let description: String?
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct UpdateDocumentRequest: Encodable, Sendable {
|
public struct UpdateDocumentRequest: Codable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let title: String?
|
public let title: String?
|
||||||
public let text: String?
|
public let text: String?
|
||||||
public let append: Bool?
|
public let append: Bool?
|
||||||
public let fullWidth: Bool?
|
public let fullWidth: Bool?
|
||||||
public let insightsEnabled: Bool?
|
public let insightsEnabled: Bool?
|
||||||
/// Not in the vendored spec's `Document`/`documents.update` shape at all
|
/// Moves the document to this collection. Combined with `publish: true`,
|
||||||
/// (only a workspace-level `documentEmbeds` flag exists there) — included
|
/// this is how a draft (no `collectionId`, or one it just hasn't left
|
||||||
/// speculatively since the field may exist on newer self-hosted servers.
|
/// yet) gets published into a specific collection in one call.
|
||||||
/// Unrecognized fields are typically ignored server-side rather than
|
public let collectionId: String?
|
||||||
/// rejected, so this is low-risk even if unsupported.
|
/// Publishes a draft, making it visible to other workspace members.
|
||||||
public let documentEmbeds: Bool?
|
/// Documented as a no-op if the document is already published.
|
||||||
|
public let publish: Bool?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
@@ -21,7 +22,8 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
|
|||||||
append: Bool? = nil,
|
append: Bool? = nil,
|
||||||
fullWidth: Bool? = nil,
|
fullWidth: Bool? = nil,
|
||||||
insightsEnabled: Bool? = nil,
|
insightsEnabled: Bool? = nil,
|
||||||
documentEmbeds: Bool? = nil
|
collectionId: String? = nil,
|
||||||
|
publish: Bool? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
@@ -29,6 +31,7 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
|
|||||||
self.append = append
|
self.append = append
|
||||||
self.fullWidth = fullWidth
|
self.fullWidth = fullWidth
|
||||||
self.insightsEnabled = insightsEnabled
|
self.insightsEnabled = insightsEnabled
|
||||||
self.documentEmbeds = documentEmbeds
|
self.collectionId = collectionId
|
||||||
|
self.publish = publish
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ import Foundation
|
|||||||
public struct UpdateShareRequest: Encodable, Sendable {
|
public struct UpdateShareRequest: Encodable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let published: Bool
|
public let published: Bool
|
||||||
|
/// Overrides the title displayed on the publicly shared page. `nil`
|
||||||
|
/// leaves it unset in the payload (server keeps whatever it already
|
||||||
|
/// has) rather than clearing it — send an empty string to clear.
|
||||||
|
public let title: String?
|
||||||
|
public let iconUrl: String?
|
||||||
|
|
||||||
public init(id: String, published: Bool) {
|
public init(id: String, published: Bool, title: String? = nil, iconUrl: String? = nil) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.published = published
|
self.published = published
|
||||||
|
self.title = title
|
||||||
|
self.iconUrl = iconUrl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `users.update`, scoped to just the avatar. Custom `encode(to:)` because
|
||||||
|
/// Swift's synthesized `Encodable` uses `encodeIfPresent` for `Optional`
|
||||||
|
/// properties, which *omits* the key entirely when the value is `nil` —
|
||||||
|
/// removing the avatar needs a literal `"avatarUrl": null` in the request
|
||||||
|
/// body, not the key missing. `id` is required: confirmed live (the
|
||||||
|
/// captured request body's length only matches `{"id": "...", "avatarUrl":
|
||||||
|
/// ...}`, not a shorter shape without it).
|
||||||
|
public struct UpdateUserAvatarRequest: Encodable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let avatarUrl: String?
|
||||||
|
|
||||||
|
public init(id: String, avatarUrl: String?) {
|
||||||
|
self.id = id
|
||||||
|
self.avatarUrl = avatarUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, avatarUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(avatarUrl, forKey: .avatarUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `users.update`, language only.
|
||||||
|
public struct UpdateUserLanguageRequest: Encodable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let language: String
|
||||||
|
|
||||||
|
public init(id: String, language: String) {
|
||||||
|
self.id = id
|
||||||
|
self.language = language
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `users.update`, name only.
|
||||||
|
public struct UpdateUserNameRequest: Encodable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let name: String
|
||||||
|
|
||||||
|
public init(id: String, name: String) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `users.update`, preferences only. Sends the *whole* preferences object
|
||||||
|
/// back (not a single changed key) — avoids needing to know whether the
|
||||||
|
/// server deep-merges a partial `preferences` body or replaces it outright.
|
||||||
|
public struct UpdateUserPreferencesRequest: Encodable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let preferences: OutlineUserPreferences
|
||||||
|
|
||||||
|
public init(id: String, preferences: OutlineUserPreferences) {
|
||||||
|
self.id = id
|
||||||
|
self.preferences = preferences
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Automatic retry-with-backoff for API calls, so a single transient network
|
||||||
|
/// blip doesn't turn into a user-visible failure (or a silently swallowed
|
||||||
|
/// one) the way one `try?` used to.
|
||||||
|
///
|
||||||
|
/// Only retries `OutlineAPIError.transport` — a dropped connection or
|
||||||
|
/// timeout might succeed a second later. Everything else (`.decoding`,
|
||||||
|
/// `.unauthorized`, `.notFound`, `.server`, `.tokenUnavailable`) is retried
|
||||||
|
/// zero times: a response-shape mismatch or a 404 will look exactly the same
|
||||||
|
/// on attempt two, so retrying just burns the cooldown window for nothing —
|
||||||
|
/// callers should treat those as immediate failures instead.
|
||||||
|
public enum RetryPolicy {
|
||||||
|
public static func withRetry<T: Sendable>(
|
||||||
|
maxAttempts: Int = 3,
|
||||||
|
initialDelay: Duration = .seconds(1),
|
||||||
|
_ operation: () async throws -> T
|
||||||
|
) async throws -> T {
|
||||||
|
var attempt = 1
|
||||||
|
var delay = initialDelay
|
||||||
|
while true {
|
||||||
|
do {
|
||||||
|
return try await operation()
|
||||||
|
} catch {
|
||||||
|
guard attempt < maxAttempts, isRetryable(error) else { throw error }
|
||||||
|
attempt += 1
|
||||||
|
try? await Task.sleep(for: delay)
|
||||||
|
delay *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isRetryable(_ error: Error) -> Bool {
|
||||||
|
guard let apiError = error as? OutlineAPIError else { return false }
|
||||||
|
if case .transport = apiError { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,738 @@
|
|||||||
|
import XCTest
|
||||||
|
import CryptoKit
|
||||||
|
@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 {}
|
||||||
|
|
||||||
|
/// In-memory `CacheEncryptionKeyStoring` — tests must never touch the real
|
||||||
|
/// Keychain (would pollute the developer's machine and can hang/fail in a
|
||||||
|
/// sandboxed CI runner with no Keychain access).
|
||||||
|
private final class StaticCacheEncryptionKeyStore: CacheEncryptionKeyStoring, @unchecked Sendable {
|
||||||
|
private var stored: SymmetricKey?
|
||||||
|
|
||||||
|
func key() throws -> SymmetricKey {
|
||||||
|
if let stored { return stored }
|
||||||
|
let generated = SymmetricKey(size: .bits256)
|
||||||
|
stored = generated
|
||||||
|
return generated
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() throws { stored = nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
final class CachingOutlineAPIClientTests: XCTestCase {
|
||||||
|
private func makeCache() throws -> OfflineCacheStore {
|
||||||
|
OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
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(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
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(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
// 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(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
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(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
_ = 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(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
_ = 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(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
// 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(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
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(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
_ = 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(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
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(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
_ = 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(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
_ = 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, encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
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, encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
// 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(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
_ = 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(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
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, encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
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, encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
let summary = await sut.performFullSync()
|
||||||
|
|
||||||
|
XCTAssertEqual(summary.documentsCount, 3)
|
||||||
|
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||||
|
let cachedGrandchild = try await sut.documentInfo(id: "doc-3")
|
||||||
|
XCTAssertEqual(cachedGrandchild.id, "doc-3")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Offline document creation
|
||||||
|
|
||||||
|
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
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(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
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(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Repeated-failure tracking
|
||||||
|
|
||||||
|
/// A structural failure (decode/auth/server) isn't retried — it fails
|
||||||
|
/// the same way every time, so `RetryPolicy` gives up after one attempt
|
||||||
|
/// and it's logged immediately. No cache entry means no fallback either,
|
||||||
|
/// so every call rethrows.
|
||||||
|
func testRepeatedDecodingFailuresSurfaceAfterThreshold() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
for _ in 0..<3 {
|
||||||
|
_ = try? await sut.documentInfo(id: "doc-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
let summaries = await sut.repeatedFailureSummaries()
|
||||||
|
XCTAssertEqual(summaries.count, 1)
|
||||||
|
XCTAssertEqual(summaries.first?.category, "document")
|
||||||
|
XCTAssertEqual(summaries.first?.count, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two failures alone shouldn't trip the banner — only three or more
|
||||||
|
/// within the window counts as "repeated."
|
||||||
|
func testFewerThanThresholdFailuresDoNotSurface() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
for _ in 0..<2 {
|
||||||
|
_ = try? await sut.documentInfo(id: "doc-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
let summaries = await sut.repeatedFailureSummaries()
|
||||||
|
XCTAssertTrue(summaries.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A later success clears the category entirely — a transient run of
|
||||||
|
/// bad luck shouldn't leave a stale banner up after things recover.
|
||||||
|
func testSuccessAfterRepeatedFailuresClearsTheLog() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
let document = makeDocument()
|
||||||
|
var callCount = 0
|
||||||
|
stub.documentInfoHandler = { _ in
|
||||||
|
callCount += 1
|
||||||
|
if callCount <= 3 { throw OutlineAPIError.decoding(NotStubbed()) }
|
||||||
|
return document
|
||||||
|
}
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
for _ in 0..<3 {
|
||||||
|
_ = try? await sut.documentInfo(id: "doc-1")
|
||||||
|
}
|
||||||
|
let beforeRecovery = await sut.repeatedFailureSummaries()
|
||||||
|
XCTAssertEqual(beforeRecovery.count, 1)
|
||||||
|
|
||||||
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
|
|
||||||
|
let afterRecovery = await sut.repeatedFailureSummaries()
|
||||||
|
XCTAssertTrue(afterRecovery.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain connectivity loss (`OutlineAPIError.transport`) already has its
|
||||||
|
/// own offline UI elsewhere — it shouldn't also pile up in the repeated-
|
||||||
|
/// failure log and pop a second, redundant banner.
|
||||||
|
func testTransportFailuresDoNotCountTowardTheRepeatedFailureLog() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.documentInfoHandler = { _ in throw OutlineAPIError.transport(URLError(.notConnectedToInternet)) }
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
_ = try? await sut.documentInfo(id: "doc-1")
|
||||||
|
|
||||||
|
let summaries = await sut.repeatedFailureSummaries()
|
||||||
|
XCTAssertTrue(summaries.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Different categories (document reads vs. pin writes) track
|
||||||
|
/// independently — a broken pins endpoint shouldn't mask, or be masked
|
||||||
|
/// by, unrelated document failures, and one crossing the threshold
|
||||||
|
/// shouldn't drag an unrelated one along with it.
|
||||||
|
func testFailuresInDifferentCategoriesDoNotMix() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
||||||
|
stub.createPinHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
// Document reads cross the threshold...
|
||||||
|
for _ in 0..<3 {
|
||||||
|
_ = try? await sut.documentInfo(id: "doc-1")
|
||||||
|
}
|
||||||
|
// ...pin creates don't.
|
||||||
|
for _ in 0..<2 {
|
||||||
|
_ = try? await sut.createPin(CreatePinRequest(documentId: "doc-1", collectionId: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
let summaries = await sut.repeatedFailureSummaries()
|
||||||
|
XCTAssertEqual(summaries.map(\.category), ["document"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - At-rest encryption
|
||||||
|
|
||||||
|
func testCachedPayloadIsNotStoredAsPlaintextJSON() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
let document = makeDocument(title: "Secret Title")
|
||||||
|
stub.documentInfoHandler = { _ in document }
|
||||||
|
let cache = try makeCache()
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
|
|
||||||
|
let raw = await cache.load(forKey: "document:doc-1")
|
||||||
|
XCTAssertNotNil(raw)
|
||||||
|
// A plain JSON encode would contain the literal title text in the
|
||||||
|
// clear - ciphertext shouldn't, and shouldn't even parse as JSON.
|
||||||
|
XCTAssertNil(String(data: raw!, encoding: .utf8)?.range(of: "Secret Title"))
|
||||||
|
XCTAssertThrowsError(try JSONDecoder().decode(OutlineDocument.self, from: raw!))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simulates sign-out (key cleared) followed by a fresh sign-in (a new
|
||||||
|
/// `CachingOutlineAPIClient` instance, same underlying on-disk cache,
|
||||||
|
/// same shape as `SessionStore.makeAPIClient` always creating a new
|
||||||
|
/// instance) — anything still on disk from before is unreadable under
|
||||||
|
/// the new key, which is the entire point of clearing it on sign-out.
|
||||||
|
func testCacheIsUnreadableAfterTheEncryptionKeyIsCleared() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.documentInfoHandler = { _ in self.makeDocument() }
|
||||||
|
let cache = try makeCache()
|
||||||
|
let keyStore = StaticCacheEncryptionKeyStore()
|
||||||
|
let beforeSignOut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: keyStore)
|
||||||
|
_ = try await beforeSignOut.documentInfo(id: "doc-1")
|
||||||
|
|
||||||
|
try keyStore.clear()
|
||||||
|
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||||
|
let afterSignIn = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: keyStore)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await afterSignIn.documentInfo(id: "doc-1")
|
||||||
|
XCTFail("Expected the now-undecryptable cache entry to be unusable")
|
||||||
|
} catch is StubTransportError {
|
||||||
|
// expected — live fails, and the leftover cache entry can't be
|
||||||
|
// decrypted under the new key either, so there's no fallback.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClearEverythingForSignOutWipesBothCacheAndPendingQueue() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.documentInfoHandler = { _ in self.makeDocument() }
|
||||||
|
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
|
_ = try? await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Offline edit"))
|
||||||
|
|
||||||
|
let summaryBefore = await sut.cacheStorageSummary()
|
||||||
|
let pendingBefore = await sut.pendingOperations()
|
||||||
|
XCTAssertGreaterThan(summaryBefore.itemCount, 0)
|
||||||
|
XCTAssertFalse(pendingBefore.isEmpty)
|
||||||
|
|
||||||
|
await sut.clearEverythingForSignOut()
|
||||||
|
|
||||||
|
let summaryAfter = await sut.cacheStorageSummary()
|
||||||
|
let pendingAfter = await sut.pendingOperations()
|
||||||
|
XCTAssertEqual(summaryAfter.itemCount, 0)
|
||||||
|
XCTAssertTrue(pendingAfter.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -280,6 +280,65 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(decodedBody.parentDocumentId, "doc-1")
|
XCTAssertEqual(decodedBody.parentDocumentId, "doc-1")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDocumentsListSendsSortDirectionAndUserId() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{ "data": [] }
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = try await client.documentsList(
|
||||||
|
DocumentsListRequest(userId: "user-1", sort: "updatedAt", direction: "DESC")
|
||||||
|
)
|
||||||
|
|
||||||
|
struct SentBody: Decodable {
|
||||||
|
let userId: String?
|
||||||
|
let sort: String?
|
||||||
|
let direction: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
|
||||||
|
let decodedBody = try JSONDecoder().decode(SentBody.self, from: sentBody)
|
||||||
|
XCTAssertEqual(decodedBody.userId, "user-1")
|
||||||
|
XCTAssertEqual(decodedBody.sort, "updatedAt")
|
||||||
|
XCTAssertEqual(decodedBody.direction, "DESC")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.list")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListViewedDocumentsDecodesDocuments() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "doc-1",
|
||||||
|
"title": "Hello",
|
||||||
|
"text": "World",
|
||||||
|
"url": "/doc/hello-doc-1",
|
||||||
|
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-01-02T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let documents = try await client.listViewedDocuments(offset: 0, limit: 25)
|
||||||
|
|
||||||
|
XCTAssertEqual(documents.first?.id, "doc-1")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.viewed")
|
||||||
|
}
|
||||||
|
|
||||||
func testDocumentInfoDecodesEnvelopeAndSetsAuthHeader() async throws {
|
func testDocumentInfoDecodesEnvelopeAndSetsAuthHeader() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
httpClient.responseData = """
|
httpClient.responseData = """
|
||||||
@@ -610,6 +669,117 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.create")
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.create")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testShareInfoDecodesRealShareArrayShape() async throws {
|
||||||
|
// Real shape confirmed against a live server — `data` is
|
||||||
|
// `{ shares: [...] }`, not the bare share object the official docs
|
||||||
|
// imply (same pattern as `pins.list`). This is the exact payload
|
||||||
|
// captured for an existing, unpublished share.
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"shares": [
|
||||||
|
{
|
||||||
|
"id": "861559ac-906b-4dec-9c1f-3a2d2000745d",
|
||||||
|
"sourceTitle": "Test Document 3",
|
||||||
|
"sourcePath": "/doc/test-document-3-VHxABl5RaD",
|
||||||
|
"collectionId": null,
|
||||||
|
"documentId": "b1196971-4239-4e35-970f-7fbbc60af044",
|
||||||
|
"documentTitle": "Test Document 3",
|
||||||
|
"documentUrl": "/doc/test-document-3-VHxABl5RaD",
|
||||||
|
"published": false,
|
||||||
|
"url": "https://docs.psmattas.com/s/861559ac-906b-4dec-9c1f-3a2d2000745d",
|
||||||
|
"urlId": null,
|
||||||
|
"createdBy": {
|
||||||
|
"id": "1e2ef39c-aa82-475b-b5af-d76bb4f023ed",
|
||||||
|
"name": "Puranjay Savar Mattas",
|
||||||
|
"avatarUrl": "/api/files.get?key=public/avatar.png",
|
||||||
|
"color": "#1c9152",
|
||||||
|
"role": "admin",
|
||||||
|
"isSuspended": false,
|
||||||
|
"createdAt": "2025-07-30T17:36:58.560Z",
|
||||||
|
"updatedAt": "2026-08-14T16:47:45.037Z",
|
||||||
|
"deletedAt": null,
|
||||||
|
"lastActiveAt": "2026-08-14T16:47:45.037Z",
|
||||||
|
"timezone": "Europe/Dublin"
|
||||||
|
},
|
||||||
|
"includeChildDocuments": false,
|
||||||
|
"allowIndexing": false,
|
||||||
|
"allowSubscriptions": true,
|
||||||
|
"showLastUpdated": false,
|
||||||
|
"showTOC": false,
|
||||||
|
"title": null,
|
||||||
|
"iconUrl": null,
|
||||||
|
"views": 0,
|
||||||
|
"domain": null,
|
||||||
|
"createdAt": "2026-08-14T16:49:40.146Z",
|
||||||
|
"updatedAt": "2026-08-14T16:49:40.146Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"policies": [
|
||||||
|
{ "id": "861559ac-906b-4dec-9c1f-3a2d2000745d", "abilities": { "read": true, "update": false, "revoke": true } }
|
||||||
|
],
|
||||||
|
"status": 200,
|
||||||
|
"ok": true
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let share = try await client.shareInfo(documentId: "doc-1")
|
||||||
|
|
||||||
|
XCTAssertEqual(share?.id, "861559ac-906b-4dec-9c1f-3a2d2000745d")
|
||||||
|
XCTAssertEqual(share?.published, false)
|
||||||
|
XCTAssertEqual(share?.views, 0)
|
||||||
|
XCTAssertEqual(share?.createdBy?.name, "Puranjay Savar Mattas")
|
||||||
|
XCTAssertEqual(share?.documentId, "b1196971-4239-4e35-970f-7fbbc60af044")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListSharesDecodesSharesArray() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{ "id": "share-1", "documentId": "doc-1", "collectionId": null, "url": "https://outline.example.com/s/share-1", "published": true }
|
||||||
|
],
|
||||||
|
"pagination": { "offset": 0, "limit": 25 }
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let shares = try await client.listShares(ListSharesRequest())
|
||||||
|
|
||||||
|
XCTAssertEqual(shares.first?.id, "share-1")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.list")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRevokeShareSendsRequest() 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.revokeShare(id: "share-1")
|
||||||
|
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.revoke")
|
||||||
|
}
|
||||||
|
|
||||||
func testShareInfoReturnsNilOnNotFound() async throws {
|
func testShareInfoReturnsNilOnNotFound() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
httpClient.statusCode = 404
|
httpClient.statusCode = 404
|
||||||
@@ -628,6 +798,24 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertNil(share)
|
XCTAssertNil(share)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testShareInfoReturnsNilOnEmptyBody() async throws {
|
||||||
|
// Confirmed against a live server: shares.info returns a 200 with a
|
||||||
|
// completely empty body (not a 404) when no share exists yet for a
|
||||||
|
// given document.
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = Data()
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let share = try await client.shareInfo(documentId: "doc-1")
|
||||||
|
|
||||||
|
XCTAssertNil(share)
|
||||||
|
}
|
||||||
|
|
||||||
func testListViewsDecodesViewsAndFiltersNilLastViewedAt() async throws {
|
func testListViewsDecodesViewsAndFiltersNilLastViewedAt() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
httpClient.responseData = """
|
httpClient.responseData = """
|
||||||
@@ -658,6 +846,137 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/views.list")
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/views.list")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testListCommentsDecodesCommentsAndExtractsPlainTextFromProseMirrorData() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "comment-1",
|
||||||
|
"documentId": "doc-1",
|
||||||
|
"parentCommentId": null,
|
||||||
|
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||||
|
"createdBy": { "id": "user-1", "name": "Jane Doe" },
|
||||||
|
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||||
|
"resolvedAt": null,
|
||||||
|
"resolvedBy": null,
|
||||||
|
"reactions": [
|
||||||
|
{ "emoji": "\u{1F44D}", "userIds": ["user-2", "user-3"] }
|
||||||
|
],
|
||||||
|
"data": {
|
||||||
|
"type": "doc",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": "Sounds great" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let comments = try await client.listComments(ListCommentsRequest(documentId: "doc-1"))
|
||||||
|
|
||||||
|
XCTAssertEqual(comments.first?.id, "comment-1")
|
||||||
|
XCTAssertEqual(comments.first?.bodyText, "Sounds great")
|
||||||
|
XCTAssertFalse(comments.first?.isResolved ?? true)
|
||||||
|
XCTAssertEqual(comments.first?.reactions.first?.emoji, "\u{1F44D}")
|
||||||
|
XCTAssertEqual(comments.first?.reactions.first?.userIds, ["user-2", "user-3"])
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.list")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testResolveCommentDecodesResolvedComment() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "comment-1",
|
||||||
|
"documentId": "doc-1",
|
||||||
|
"parentCommentId": null,
|
||||||
|
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||||
|
"createdBy": { "id": "user-1", "name": "Jane Doe" },
|
||||||
|
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||||
|
"resolvedAt": "2026-01-02T00:00:00.000Z",
|
||||||
|
"resolvedBy": { "id": "user-2", "name": "John Roe" },
|
||||||
|
"reactions": [],
|
||||||
|
"data": { "type": "doc", "content": [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let comment = try await client.resolveComment(id: "comment-1")
|
||||||
|
|
||||||
|
XCTAssertTrue(comment.isResolved)
|
||||||
|
XCTAssertEqual(comment.resolvedBy?.name, "John Roe")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.resolve")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCreateCommentSendsParentCommentIdForAReply() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "comment-2",
|
||||||
|
"documentId": "doc-1",
|
||||||
|
"parentCommentId": "comment-1",
|
||||||
|
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||||
|
"createdBy": { "id": "user-1", "name": "Jane Doe" },
|
||||||
|
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||||
|
"resolvedAt": null,
|
||||||
|
"resolvedBy": null,
|
||||||
|
"reactions": [],
|
||||||
|
"data": { "type": "doc", "content": [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let reply = try await client.createComment(
|
||||||
|
CreateCommentRequest(documentId: "doc-1", parentCommentId: "comment-1", text: "Agreed")
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(reply.parentCommentId, "comment-1")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.create")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAddReactionPostsIdAndEmoji() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{ "success": true }
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
try await client.addReaction(commentId: "comment-1", emoji: "\u{1F44D}")
|
||||||
|
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.add_reaction")
|
||||||
|
}
|
||||||
|
|
||||||
func testCreatePinDecodesPin() async throws {
|
func testCreatePinDecodesPin() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
httpClient.responseData = """
|
httpClient.responseData = """
|
||||||
@@ -676,6 +995,34 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.create")
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.create")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testListPinsDecodesNestedPinsArray() async throws {
|
||||||
|
// Real shape confirmed against a live server: `data` is
|
||||||
|
// `{ pins: [...], documents: [...] }`, not a bare array.
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"pagination": { "limit": 25, "offset": 0 },
|
||||||
|
"data": {
|
||||||
|
"pins": [
|
||||||
|
{ "id": "pin-1", "documentId": "doc-1", "collectionId": null, "index": "h" }
|
||||||
|
],
|
||||||
|
"documents": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let pins = try await client.listPins(ListPinsRequest(collectionId: nil))
|
||||||
|
|
||||||
|
XCTAssertEqual(pins.first?.id, "pin-1")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.list")
|
||||||
|
}
|
||||||
|
|
||||||
func testCreateSubscriptionDecodesSubscription() async throws {
|
func testCreateSubscriptionDecodesSubscription() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
httpClient.responseData = """
|
httpClient.responseData = """
|
||||||
@@ -694,6 +1041,573 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/subscriptions.create")
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/subscriptions.create")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAddDocumentUserDecodesMembership() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{ "data": { "id": "mem-1", "userId": "user-1", "documentId": "doc-1", "permission": "read" } }
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let membership = try await client.addDocumentUser(
|
||||||
|
AddDocumentUserRequest(id: "doc-1", userId: "user-1", permission: "read")
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(membership.id, "mem-1")
|
||||||
|
XCTAssertEqual(membership.permission, "read")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.add_user")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRemoveDocumentUserSendsRequest() 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.removeDocumentUser(RemoveDocumentUserRequest(id: "doc-1", userId: "user-1"))
|
||||||
|
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.remove_user")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDocumentUsersDecodesMembersArray() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{ "id": "user-1", "name": "Jane Doe", "email": "jane@example.com", "avatarUrl": null, "permission": "read_write" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let members = try await client.documentUsers(ListDocumentUsersRequest(id: "doc-1"))
|
||||||
|
|
||||||
|
XCTAssertEqual(members.first?.name, "Jane Doe")
|
||||||
|
XCTAssertEqual(members.first?.permission, "read_write")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.users")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListUsersDecodesUsersArray() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{ "id": "user-1", "name": "Jane Doe", "email": "jane@example.com", "role": "member" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let users = try await client.listUsers(ListUsersRequest(query: "Jane"))
|
||||||
|
|
||||||
|
XCTAssertEqual(users.first?.name, "Jane Doe")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.list")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUpdateUserAvatarSendsExplicitNullWhenRemoving() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "user-1",
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"avatarUrl": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let user = try await client.updateUserAvatar(UpdateUserAvatarRequest(id: "user-1", avatarUrl: nil))
|
||||||
|
|
||||||
|
XCTAssertNil(user.avatarUrl)
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
|
||||||
|
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
|
||||||
|
let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8))
|
||||||
|
// The whole point of UpdateUserAvatarRequest's custom encode(to:) —
|
||||||
|
// Swift's synthesized Encodable would have omitted the key entirely
|
||||||
|
// for a nil Optional instead of sending a literal null.
|
||||||
|
XCTAssertTrue(sentJSON.contains("\"avatarUrl\":null"), "expected an explicit null, got: \(sentJSON)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUpdateUserAvatarSendsTheNewURLWhenSet() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "user-1",
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"avatarUrl": "/api/attachments.redirect?id=abc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let user = try await client.updateUserAvatar(
|
||||||
|
UpdateUserAvatarRequest(id: "user-1", avatarUrl: "/api/attachments.redirect?id=abc")
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(user.avatarUrl, "/api/attachments.redirect?id=abc")
|
||||||
|
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
|
||||||
|
let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8))
|
||||||
|
XCTAssertTrue(sentJSON.contains("\"id\":\"user-1\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUpdateUserNameSendsRequestAndDecodesResult() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "user-1",
|
||||||
|
"name": "New Name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let user = try await client.updateUserName(UpdateUserNameRequest(id: "user-1", name: "New Name"))
|
||||||
|
|
||||||
|
XCTAssertEqual(user.name, "New Name")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUpdateUserLanguageSendsRequestAndDecodesResult() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "user-1",
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"language": "fr_FR"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let user = try await client.updateUserLanguage(UpdateUserLanguageRequest(id: "user-1", language: "fr_FR"))
|
||||||
|
|
||||||
|
XCTAssertEqual(user.language, "fr_FR")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUpdateUserPreferencesSendsWholeObjectAndDecodesResult() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "user-1",
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"preferences": { "rememberLastPath": true, "useCursorPointer": true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let preferences = OutlineUserPreferences(rememberLastPath: true, useCursorPointer: true)
|
||||||
|
let user = try await client.updateUserPreferences(UpdateUserPreferencesRequest(id: "user-1", preferences: preferences))
|
||||||
|
|
||||||
|
XCTAssertEqual(user.preferences?.rememberLastPath, true)
|
||||||
|
XCTAssertEqual(user.preferences?.useCursorPointer, true)
|
||||||
|
|
||||||
|
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||||
|
let sentPreferences = sentBody?["preferences"] as? [String: Any]
|
||||||
|
XCTAssertEqual(sentPreferences?["rememberLastPath"] as? Bool, true)
|
||||||
|
XCTAssertEqual(sentPreferences?["useCursorPointer"] as? Bool, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locks in the wire mapping confirmed against a live server's own web
|
||||||
|
/// app traffic: `seamlessEdit`/`commentsInGutter`/`enableSmartText` are
|
||||||
|
/// the real keys (not `separateEditing`/`showCommentMarker`/`smartText`
|
||||||
|
/// this struct exposes), and `seamlessEdit` is the *negation* of this
|
||||||
|
/// app's `separateEditing`.
|
||||||
|
func testOutlineUserPreferencesDecodesRealWireKeys() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"seamlessEdit": false,
|
||||||
|
"commentsInGutter": true,
|
||||||
|
"enableSmartText": true,
|
||||||
|
"rememberLastPath": true,
|
||||||
|
"useCursorPointer": true,
|
||||||
|
"codeBlockLineNumbers": false,
|
||||||
|
"notificationBadge": "indicator",
|
||||||
|
"fullWidthDocuments": true
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let preferences = try JSONDecoder().decode(OutlineUserPreferences.self, from: json)
|
||||||
|
|
||||||
|
XCTAssertEqual(preferences.separateEditing, true, "seamlessEdit: false means separate editing is ON")
|
||||||
|
XCTAssertEqual(preferences.showCommentMarker, true)
|
||||||
|
XCTAssertEqual(preferences.smartText, true)
|
||||||
|
XCTAssertEqual(preferences.rememberLastPath, true)
|
||||||
|
XCTAssertEqual(preferences.useCursorPointer, true)
|
||||||
|
XCTAssertEqual(preferences.codeBlockLineNumbers, false)
|
||||||
|
XCTAssertEqual(preferences.notificationBadge, "indicator")
|
||||||
|
XCTAssertEqual(preferences.fullWidthDocuments, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOutlineUserPreferencesEncodesRealWireKeysAndInvertsSeparateEditing() throws {
|
||||||
|
var preferences = OutlineUserPreferences()
|
||||||
|
preferences.separateEditing = true
|
||||||
|
preferences.showCommentMarker = false
|
||||||
|
preferences.smartText = true
|
||||||
|
preferences.fullWidthDocuments = true
|
||||||
|
|
||||||
|
let data = try JSONEncoder().encode(preferences)
|
||||||
|
let object = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
|
||||||
|
XCTAssertEqual(object?["seamlessEdit"] as? Bool, false, "separateEditing: true must encode as seamlessEdit: false")
|
||||||
|
XCTAssertEqual(object?["commentsInGutter"] as? Bool, false)
|
||||||
|
XCTAssertEqual(object?["enableSmartText"] as? Bool, true)
|
||||||
|
XCTAssertEqual(object?["fullWidthDocuments"] as? Bool, true)
|
||||||
|
XCTAssertNil(object?["separateEditing"], "must not leak this app's own field name onto the wire")
|
||||||
|
XCTAssertNil(object?["showCommentMarker"])
|
||||||
|
XCTAssertNil(object?["smartText"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSubscribeToNotificationsSendsEventTypeAndDecodesResult() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "user-1",
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"notificationSettings": { "documents.publish": true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let user = try await client.subscribeToNotifications(eventType: .documentPublish)
|
||||||
|
|
||||||
|
XCTAssertEqual(user.notificationSettings?["documents.publish"], true)
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsSubscribe")
|
||||||
|
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||||
|
XCTAssertEqual(sentBody?["eventType"] as? String, "documents.publish")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnsubscribeFromNotificationsWithNilEventTypeTargetsAll() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "user-1",
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"notificationSettings": { "documents.publish": false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = try await client.unsubscribeFromNotifications(eventType: nil)
|
||||||
|
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsUnsubscribe")
|
||||||
|
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||||
|
XCTAssertNil(sentBody?["eventType"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListApiKeysDecodesResult() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"pagination": { "limit": 25, "offset": 0 },
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "c3eec545-6d38-4065-90dc-b6c96a551445",
|
||||||
|
"name": "Outpost",
|
||||||
|
"scope": null,
|
||||||
|
"last4": "dl1h",
|
||||||
|
"createdAt": "2026-08-12T18:44:32.467Z",
|
||||||
|
"updatedAt": "2026-08-12T18:44:32.467Z",
|
||||||
|
"expiresAt": null,
|
||||||
|
"lastActiveAt": "2026-08-18T01:01:36.553Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let keys = try await client.listApiKeys(ListApiKeysRequest())
|
||||||
|
|
||||||
|
XCTAssertEqual(keys.count, 1)
|
||||||
|
XCTAssertEqual(keys.first?.name, "Outpost")
|
||||||
|
XCTAssertEqual(keys.first?.last4, "dl1h")
|
||||||
|
XCTAssertNil(keys.first?.expiresAt)
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.list")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInstallationInfoDecodesResult() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": { "version": "1.9.2", "latestVersion": "1.9.2", "versionsBehind": 0 },
|
||||||
|
"policies": []
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let info = try await client.installationInfo()
|
||||||
|
|
||||||
|
XCTAssertEqual(info.version, "1.9.2")
|
||||||
|
XCTAssertEqual(info.versionsBehind, 0)
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/installation.info")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCreateApiKeyOmitsExpiresAtWhenNilAndDecodesValue() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683",
|
||||||
|
"name": "test",
|
||||||
|
"scope": null,
|
||||||
|
"value": "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv",
|
||||||
|
"last4": "0DGv",
|
||||||
|
"createdAt": "2026-08-18T15:39:29.872Z",
|
||||||
|
"expiresAt": null,
|
||||||
|
"lastActiveAt": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let key = try await client.createApiKey(CreateApiKeyRequest(name: "test"))
|
||||||
|
|
||||||
|
XCTAssertEqual(key.value, "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv")
|
||||||
|
XCTAssertNil(key.expiresAt)
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.create")
|
||||||
|
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||||
|
XCTAssertEqual(sentBody?["name"] as? String, "test")
|
||||||
|
XCTAssertNil(sentBody?["expiresAt"], "omitting expiresAt (not sending null) is what produces a non-expiring key")
|
||||||
|
XCTAssertNil(sentBody?["scope"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCreateApiKeySendsExpiresAtWhenProvided() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "04e204fb-51a4-4b54-aed1-2056dfd576d7",
|
||||||
|
"name": "Test",
|
||||||
|
"scope": null,
|
||||||
|
"value": "ol_api_aeYcOts7I2sJXw3zaztjydRM3W3W89TBAtcLts",
|
||||||
|
"last4": "cLts",
|
||||||
|
"createdAt": "2026-08-18T15:38:22.928Z",
|
||||||
|
"expiresAt": "2026-11-16T23:59:59.999Z",
|
||||||
|
"lastActiveAt": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let expiresAt = Date(timeIntervalSince1970: 1_795_000_000)
|
||||||
|
_ = try await client.createApiKey(CreateApiKeyRequest(name: "Test", expiresAt: expiresAt))
|
||||||
|
|
||||||
|
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||||
|
XCTAssertNotNil(sentBody?["expiresAt"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeleteApiKeySendsRequest() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{ "success": true }
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
try await client.deleteApiKey(id: "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683")
|
||||||
|
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.delete")
|
||||||
|
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||||
|
XCTAssertEqual(sentBody?["id"] as? String, "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeleteAccountSendsRequest() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{ "success": true }
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
try await client.deleteAccount()
|
||||||
|
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.delete")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeleteAttachmentSendsRequest() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{ "success": true }
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
try await client.deleteAttachment(id: "attach-1")
|
||||||
|
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.delete")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCreateAttachmentDecodesUploadTargetAndFormFields() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"attachment": {
|
||||||
|
"id": "attach-1",
|
||||||
|
"documentId": null,
|
||||||
|
"contentType": "image/jpeg",
|
||||||
|
"name": "avatar.jpg",
|
||||||
|
"url": "/api/attachments.redirect?id=attach-1",
|
||||||
|
"size": "59304"
|
||||||
|
},
|
||||||
|
"uploadUrl": "/api/files.create",
|
||||||
|
"form": {
|
||||||
|
"Content-Type": "image/jpeg",
|
||||||
|
"key": "uploads/user-1/attach-1/avatar.jpg",
|
||||||
|
"acl": "public-read"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = try await client.createAttachment(
|
||||||
|
CreateAttachmentRequest(name: "avatar.jpg", contentType: "image/jpeg", size: 59304)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(result.attachment.id, "attach-1")
|
||||||
|
XCTAssertEqual(result.attachment.size, "59304")
|
||||||
|
XCTAssertEqual(result.uploadUrl, "/api/files.create")
|
||||||
|
XCTAssertEqual(result.form["acl"], "public-read")
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.create")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUploadAttachmentFilePostsToTheResolvedRelativeURL() async throws {
|
||||||
|
let httpClient = MockHTTPClient()
|
||||||
|
httpClient.responseData = """
|
||||||
|
{ "success": true }
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let client = LiveOutlineAPIClient(
|
||||||
|
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||||
|
tokenStore: StaticTokenStore(),
|
||||||
|
httpClient: httpClient
|
||||||
|
)
|
||||||
|
|
||||||
|
let uploadTarget = CreateAttachmentResult(
|
||||||
|
attachment: OutlineAttachment(
|
||||||
|
id: "attach-1",
|
||||||
|
documentId: nil,
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
name: "avatar.jpg",
|
||||||
|
url: "/api/attachments.redirect?id=attach-1",
|
||||||
|
size: "3"
|
||||||
|
),
|
||||||
|
uploadUrl: "/api/files.create",
|
||||||
|
form: ["Content-Type": "image/jpeg", "key": "uploads/attach-1"]
|
||||||
|
)
|
||||||
|
|
||||||
|
try await client.uploadAttachmentFile(uploadTarget, fileData: Data("abc".utf8))
|
||||||
|
|
||||||
|
// Resolved against baseURL's host, not appended onto "/api/<baseURL-relative-path>".
|
||||||
|
XCTAssertEqual(httpClient.lastRequest?.url?.absoluteString, "https://outline.example.com/api/files.create")
|
||||||
|
XCTAssertNil(httpClient.lastRequest?.value(forHTTPHeaderField: "Authorization"))
|
||||||
|
let contentType = httpClient.lastRequest?.value(forHTTPHeaderField: "Content-Type")
|
||||||
|
XCTAssertTrue(contentType?.hasPrefix("multipart/form-data; boundary=") ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
func testMissingTokenThrowsTokenUnavailable() async throws {
|
func testMissingTokenThrowsTokenUnavailable() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
let client = LiveOutlineAPIClient(
|
let client = LiveOutlineAPIClient(
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import OutlineKit
|
||||||
|
|
||||||
|
final class MultipartFormDataBuilderTests: XCTestCase {
|
||||||
|
func testBuildIncludesEveryFieldAndTheFile() throws {
|
||||||
|
let fileData = Data("fake-jpeg-bytes".utf8)
|
||||||
|
let (body, contentType) = MultipartFormDataBuilder.build(
|
||||||
|
fields: ["key": "uploads/abc", "acl": "public-read", "Content-Type": "image/jpeg"],
|
||||||
|
fileFieldName: "file",
|
||||||
|
fileName: "avatar.jpg",
|
||||||
|
fileData: fileData,
|
||||||
|
fileContentType: "image/jpeg",
|
||||||
|
boundary: "TestBoundary"
|
||||||
|
)
|
||||||
|
|
||||||
|
let bodyString = String(decoding: body, as: UTF8.self)
|
||||||
|
|
||||||
|
XCTAssertEqual(contentType, "multipart/form-data; boundary=TestBoundary")
|
||||||
|
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"key\""))
|
||||||
|
XCTAssertTrue(bodyString.contains("uploads/abc"))
|
||||||
|
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"acl\""))
|
||||||
|
XCTAssertTrue(bodyString.contains("public-read"))
|
||||||
|
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"file\"; filename=\"avatar.jpg\""))
|
||||||
|
XCTAssertTrue(bodyString.contains("fake-jpeg-bytes"))
|
||||||
|
XCTAssertTrue(bodyString.hasPrefix("--TestBoundary\r\n"))
|
||||||
|
XCTAssertTrue(bodyString.hasSuffix("--TestBoundary--\r\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFileFieldComesAfterAllFormFields() throws {
|
||||||
|
let (body, _) = MultipartFormDataBuilder.build(
|
||||||
|
fields: ["a": "1", "b": "2"],
|
||||||
|
fileFieldName: "file",
|
||||||
|
fileName: "x.jpg",
|
||||||
|
fileData: Data("bytes".utf8),
|
||||||
|
fileContentType: "image/jpeg",
|
||||||
|
boundary: "B"
|
||||||
|
)
|
||||||
|
let bodyString = String(decoding: body, as: UTF8.self)
|
||||||
|
|
||||||
|
let fieldsRange = bodyString.range(of: "name=\"a\"")
|
||||||
|
let fileRange = bodyString.range(of: "name=\"file\"")
|
||||||
|
XCTAssertNotNil(fieldsRange)
|
||||||
|
XCTAssertNotNil(fileRange)
|
||||||
|
if let fieldsRange, let fileRange {
|
||||||
|
XCTAssertTrue(fieldsRange.lowerBound < fileRange.lowerBound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import OutlineKit
|
||||||
|
|
||||||
|
private struct PlainError: Error {}
|
||||||
|
|
||||||
|
final class RetryPolicyTests: XCTestCase {
|
||||||
|
func testSucceedsOnFirstAttemptWithoutRetrying() async throws {
|
||||||
|
var callCount = 0
|
||||||
|
let result = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) {
|
||||||
|
callCount += 1
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
XCTAssertEqual(result, "ok")
|
||||||
|
XCTAssertEqual(callCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRetriesTransportErrorsAndSucceedsOnceItStopsFailing() async throws {
|
||||||
|
var callCount = 0
|
||||||
|
let result = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in
|
||||||
|
callCount += 1
|
||||||
|
if callCount < 3 { throw OutlineAPIError.transport(URLError(.timedOut)) }
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
XCTAssertEqual(result, "ok")
|
||||||
|
XCTAssertEqual(callCount, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGivesUpAfterMaxAttemptsAndRethrowsTheLastError() async throws {
|
||||||
|
var callCount = 0
|
||||||
|
do {
|
||||||
|
_ = try await RetryPolicy.withRetry(maxAttempts: 3, initialDelay: .milliseconds(1)) { () -> String in
|
||||||
|
callCount += 1
|
||||||
|
throw OutlineAPIError.transport(URLError(.timedOut))
|
||||||
|
}
|
||||||
|
XCTFail("Expected the persistent failure to be rethrown")
|
||||||
|
} catch {
|
||||||
|
XCTAssertEqual(callCount, 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A decode failure means the response is structurally wrong — trying
|
||||||
|
/// again gets the exact same wrong response, so it isn't worth the
|
||||||
|
/// cooldown window the way a network blip is.
|
||||||
|
func testDoesNotRetryNonTransportErrors() async throws {
|
||||||
|
var callCount = 0
|
||||||
|
do {
|
||||||
|
_ = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in
|
||||||
|
callCount += 1
|
||||||
|
throw OutlineAPIError.decoding(PlainError())
|
||||||
|
}
|
||||||
|
XCTFail("Expected the decoding error to be rethrown without retrying")
|
||||||
|
} catch {
|
||||||
|
XCTAssertEqual(callCount, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDoesNotRetryErrorsThatAreNotOutlineAPIErrors() async throws {
|
||||||
|
var callCount = 0
|
||||||
|
do {
|
||||||
|
_ = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in
|
||||||
|
callCount += 1
|
||||||
|
throw PlainError()
|
||||||
|
}
|
||||||
|
XCTFail("Expected the error to be rethrown without retrying")
|
||||||
|
} catch {
|
||||||
|
XCTAssertEqual(callCount, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,14 +3,15 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 110;
|
objectVersion = 77;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
FA7596B230366A1D0000167E /* MarkdownEngine in Frameworks */ = {isa = PBXBuildFile; productRef = FA7596B130366A1D0000167E /* MarkdownEngine */; };
|
||||||
|
FA7596B430366A1D0000167E /* MarkdownEngineCodeBlocks in Frameworks */ = {isa = PBXBuildFile; productRef = FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */; };
|
||||||
|
FA7596B630366A1D0000167E /* MarkdownEngineLatex in Frameworks */ = {isa = PBXBuildFile; productRef = FA7596B530366A1D0000167E /* MarkdownEngineLatex */; };
|
||||||
|
FADBE087303734FE001E69F0 /* ImagePlayground.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FADBE086303734FE001E69F0 /* ImagePlayground.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
|
||||||
FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99C43302CF1BD00C9949F /* OutlineKit */; };
|
FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99C43302CF1BD00C9949F /* OutlineKit */; };
|
||||||
FAF99CAF302D120500C9949F /* MarkdownEngine in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CAE302D120500C9949F /* MarkdownEngine */; };
|
|
||||||
FAF99CB1302D120500C9949F /* MarkdownEngineCodeBlocks in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */; };
|
|
||||||
FAF99CB3302D120500C9949F /* MarkdownEngineLatex in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CB2302D120500C9949F /* MarkdownEngineLatex */; };
|
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
FADBE086303734FE001E69F0 /* ImagePlayground.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ImagePlayground.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS27.0.sdk/System/Library/Frameworks/ImagePlayground.framework; sourceTree = DEVELOPER_DIR; };
|
||||||
FAF99C18302CE96100C9949F /* Outpost.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Outpost.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
FAF99C18302CE96100C9949F /* Outpost.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Outpost.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
FAF99C27302CE96200C9949F /* OutpostTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OutpostTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
FAF99C27302CE96200C9949F /* OutpostTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OutpostTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
FAF99C31302CE96200C9949F /* OutpostUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OutpostUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
FAF99C31302CE96200C9949F /* OutpostUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OutpostUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
@@ -59,9 +61,10 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */,
|
FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */,
|
||||||
FAF99CB1302D120500C9949F /* MarkdownEngineCodeBlocks in Frameworks */,
|
FA7596B430366A1D0000167E /* MarkdownEngineCodeBlocks in Frameworks */,
|
||||||
FAF99CB3302D120500C9949F /* MarkdownEngineLatex in Frameworks */,
|
FA7596B630366A1D0000167E /* MarkdownEngineLatex in Frameworks */,
|
||||||
FAF99CAF302D120500C9949F /* MarkdownEngine in Frameworks */,
|
FADBE087303734FE001E69F0 /* ImagePlayground.framework in Frameworks */,
|
||||||
|
FA7596B230366A1D0000167E /* MarkdownEngine in Frameworks */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
FAF99C24302CE96200C9949F /* Frameworks */ = {
|
FAF99C24302CE96200C9949F /* Frameworks */ = {
|
||||||
@@ -77,12 +80,21 @@
|
|||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
FADBE085303734FE001E69F0 /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
FADBE086303734FE001E69F0 /* ImagePlayground.framework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
FAF99C0F302CE96100C9949F = {
|
FAF99C0F302CE96100C9949F = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
FAF99C1A302CE96100C9949F /* Outpost */,
|
FAF99C1A302CE96100C9949F /* Outpost */,
|
||||||
FAF99C2A302CE96200C9949F /* OutpostTests */,
|
FAF99C2A302CE96200C9949F /* OutpostTests */,
|
||||||
FAF99C34302CE96200C9949F /* OutpostUITests */,
|
FAF99C34302CE96200C9949F /* OutpostUITests */,
|
||||||
|
FADBE085303734FE001E69F0 /* Frameworks */,
|
||||||
FAF99C19302CE96100C9949F /* Products */,
|
FAF99C19302CE96100C9949F /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -116,9 +128,9 @@
|
|||||||
name = Outpost;
|
name = Outpost;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
FAF99C43302CF1BD00C9949F /* OutlineKit */,
|
FAF99C43302CF1BD00C9949F /* OutlineKit */,
|
||||||
FAF99CAE302D120500C9949F /* MarkdownEngine */,
|
FA7596B130366A1D0000167E /* MarkdownEngine */,
|
||||||
FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */,
|
FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */,
|
||||||
FAF99CB2302D120500C9949F /* MarkdownEngineLatex */,
|
FA7596B530366A1D0000167E /* MarkdownEngineLatex */,
|
||||||
);
|
);
|
||||||
productName = Outpost;
|
productName = Outpost;
|
||||||
productReference = FAF99C18302CE96100C9949F /* Outpost.app */;
|
productReference = FAF99C18302CE96100C9949F /* Outpost.app */;
|
||||||
@@ -200,7 +212,7 @@
|
|||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */,
|
FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */,
|
||||||
FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */,
|
FA7596B030366A1D0000167E /* XCLocalSwiftPackageReference "Vendor/swift-markdown-engine" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = FAF99C19302CE96100C9949F /* Products */;
|
productRefGroup = FAF99C19302CE96100C9949F /* Products */;
|
||||||
@@ -299,7 +311,7 @@
|
|||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -361,7 +373,7 @@
|
|||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -385,15 +397,20 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Outpost;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||||
@@ -407,20 +424,22 @@
|
|||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 0.0.1;
|
MARKETING_VERSION = 0.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
XROS_DEPLOYMENT_TARGET = 27.0;
|
XROS_DEPLOYMENT_TARGET = 27.0;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
@@ -430,15 +449,20 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Outpost;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||||
@@ -452,20 +476,22 @@
|
|||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 0.0.1;
|
MARKETING_VERSION = 0.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
XROS_DEPLOYMENT_TARGET = 27.0;
|
XROS_DEPLOYMENT_TARGET = 27.0;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
@@ -476,10 +502,10 @@
|
|||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -502,10 +528,10 @@
|
|||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -527,10 +553,10 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -552,10 +578,10 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -610,43 +636,33 @@
|
|||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
FA7596B030366A1D0000167E /* XCLocalSwiftPackageReference "Vendor/swift-markdown-engine" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = "Vendor/swift-markdown-engine";
|
||||||
|
};
|
||||||
FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */ = {
|
FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */ = {
|
||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = OutlineKit;
|
relativePath = OutlineKit;
|
||||||
};
|
};
|
||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
|
||||||
FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */ = {
|
|
||||||
isa = XCRemoteSwiftPackageReference;
|
|
||||||
repositoryURL = "https://github.com/nodes-app/swift-markdown-engine";
|
|
||||||
requirement = {
|
|
||||||
kind = upToNextMajorVersion;
|
|
||||||
minimumVersion = 0.12.0;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
FA7596B130366A1D0000167E /* MarkdownEngine */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = MarkdownEngine;
|
||||||
|
};
|
||||||
|
FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = MarkdownEngineCodeBlocks;
|
||||||
|
};
|
||||||
|
FA7596B530366A1D0000167E /* MarkdownEngineLatex */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = MarkdownEngineLatex;
|
||||||
|
};
|
||||||
FAF99C43302CF1BD00C9949F /* OutlineKit */ = {
|
FAF99C43302CF1BD00C9949F /* OutlineKit */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = OutlineKit;
|
productName = OutlineKit;
|
||||||
};
|
};
|
||||||
FAF99CAE302D120500C9949F /* MarkdownEngine */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */;
|
|
||||||
productName = MarkdownEngine;
|
|
||||||
};
|
|
||||||
FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */;
|
|
||||||
productName = MarkdownEngineCodeBlocks;
|
|
||||||
};
|
|
||||||
FAF99CB2302D120500C9949F /* MarkdownEngineLatex */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */;
|
|
||||||
productName = MarkdownEngineLatex;
|
|
||||||
};
|
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = FAF99C10302CE96100C9949F /* Project object */;
|
rootObject = FAF99C10302CE96100C9949F /* Project object */;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "f233fa96f0c6bdcdbf87f726af38f25704f6d46a156a27dfac47541baa63bf97",
|
"originHash" : "4127e8224149bef00a33500e8db49748a735e1c07626f2255faea52554afef5e",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "highlighterswift",
|
"identity" : "highlighterswift",
|
||||||
@@ -10,15 +10,6 @@
|
|||||||
"version" : "3.1.0"
|
"version" : "3.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "swift-markdown-engine",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/nodes-app/swift-markdown-engine",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "e5f7607fc4021181056ef7a09dbb7573dc0237d9",
|
|
||||||
"version" : "0.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "swiftmath",
|
"identity" : "swiftmath",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2700"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "FAF99C17302CE96100C9949F"
|
||||||
|
BuildableName = "Outpost.app"
|
||||||
|
ReferencedContainer = "container:Outpost.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "FAF99C26302CE96200C9949F"
|
||||||
|
BuildableName = "OutpostTests.xctest"
|
||||||
|
ReferencedContainer = "container:Outpost.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "FAF99C30302CE96200C9949F"
|
||||||
|
BuildableName = "OutpostUITests.xctest"
|
||||||
|
ReferencedContainer = "container:Outpost.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES"
|
||||||
|
queueDebuggingEnabled = "No">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "FAF99C17302CE96100C9949F"
|
||||||
|
BuildableName = "Outpost.app"
|
||||||
|
ReferencedContainer = "container:Outpost.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<StoreKitConfigurationFileReference
|
||||||
|
identifier = "../../Outpost/Configuration.storekit">
|
||||||
|
</StoreKitConfigurationFileReference>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "FAF99C17302CE96100C9949F"
|
||||||
|
BuildableName = "Outpost.app"
|
||||||
|
ReferencedContainer = "container:Outpost.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>SchemeUserState</key>
|
|
||||||
<dict>
|
|
||||||
<key>Outpost.xcscheme_^#shared#^_</key>
|
|
||||||
<dict>
|
|
||||||
<key>orderHint</key>
|
|
||||||
<integer>1</integer>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "outpost-ios-1024.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 440 KiB |
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"identifier" : "12E4A6D1-6B8A-4C2E-9F3A-0D1B2C3E4F5A",
|
||||||
|
"nonRenewingSubscriptions" : [],
|
||||||
|
"products" : [
|
||||||
|
{
|
||||||
|
"displayPrice" : "0.99",
|
||||||
|
"familyShareable" : false,
|
||||||
|
"internalID" : "992ABE97-F5C4-4F80-8FB0-382BDF47DAB6",
|
||||||
|
"localizations" : [
|
||||||
|
{
|
||||||
|
"description" : "A small tip to support Outpost's development.",
|
||||||
|
"displayName" : "Small Tip",
|
||||||
|
"locale" : "en_US"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"productID" : "com.psmattas.OutpostApp.tip.small",
|
||||||
|
"referenceName" : "Small Tip",
|
||||||
|
"type" : "Consumable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"displayPrice" : "2.99",
|
||||||
|
"familyShareable" : false,
|
||||||
|
"internalID" : "316DEB73-6BA3-4F6B-BD54-D17A1CE61938",
|
||||||
|
"localizations" : [
|
||||||
|
{
|
||||||
|
"description" : "A medium tip to support Outpost's development.",
|
||||||
|
"displayName" : "Medium Tip",
|
||||||
|
"locale" : "en_US"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"productID" : "com.psmattas.OutpostApp.tip.medium",
|
||||||
|
"referenceName" : "Medium Tip",
|
||||||
|
"type" : "Consumable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"displayPrice" : "4.99",
|
||||||
|
"familyShareable" : false,
|
||||||
|
"internalID" : "37936F94-AC21-4A39-BC85-CAE6609E170D",
|
||||||
|
"localizations" : [
|
||||||
|
{
|
||||||
|
"description" : "A large tip to support Outpost's development.",
|
||||||
|
"displayName" : "Large Tip",
|
||||||
|
"locale" : "en_US"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"productID" : "com.psmattas.OutpostApp.tip.large",
|
||||||
|
"referenceName" : "Large Tip",
|
||||||
|
"type" : "Consumable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"displayPrice" : "9.99",
|
||||||
|
"familyShareable" : false,
|
||||||
|
"internalID" : "E072C1AC-2719-483E-8A54-F4924808B724",
|
||||||
|
"localizations" : [
|
||||||
|
{
|
||||||
|
"description" : "A generous tip to support Outpost's development.",
|
||||||
|
"displayName" : "Generous Tip",
|
||||||
|
"locale" : "en_US"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"productID" : "com.psmattas.OutpostApp.tip.generous",
|
||||||
|
"referenceName" : "Generous Tip",
|
||||||
|
"type" : "Consumable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings" : {
|
||||||
|
"_askToBuyEnabled" : false
|
||||||
|
},
|
||||||
|
"subscriptionGroups" : [],
|
||||||
|
"version" : {
|
||||||
|
"major" : 3,
|
||||||
|
"minor" : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,25 +2,17 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import SwiftUI
|
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 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"
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bumped alongside `MARKETING_VERSION` in the Xcode project — kept out
|
var versionString: String { OutpostVersion.fullVersionString }
|
||||||
/// of the bundle version itself since `CFBundleShortVersionString` is
|
|
||||||
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
|
|
||||||
private let releaseStage = "ALPHA"
|
|
||||||
|
|
||||||
private var versionString: String {
|
|
||||||
let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
|
|
||||||
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
|
||||||
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
|
|
||||||
return "Version \(shortVersion)\(stageSuffix) (\(buildNumber))"
|
|
||||||
}
|
|
||||||
|
|
||||||
private var copyrightYear: String {
|
private var copyrightYear: String {
|
||||||
String(Calendar.current.component(.year, from: Date()))
|
String(Calendar.current.component(.year, from: Date()))
|
||||||
@@ -51,29 +43,20 @@ struct AboutView: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
VStack(spacing: 10) {
|
Link(destination: repositoryURL) {
|
||||||
Link(destination: repositoryURL) {
|
Label("View Source on Git", systemImage: "link")
|
||||||
Label("View Source on Git", systemImage: "link")
|
|
||||||
}
|
|
||||||
.font(.callout)
|
|
||||||
|
|
||||||
Button("Check for Updates…") {
|
|
||||||
checkForUpdates()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.font(.callout)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.frame(maxWidth: 240)
|
||||||
|
|
||||||
|
TipJarView()
|
||||||
|
|
||||||
Text("© \(copyrightYear) Puranjay Savar Mattas")
|
Text("© \(copyrightYear) Puranjay Savar Mattas")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
}
|
}
|
||||||
.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
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import SwiftUI
|
||||||
|
import StoreKit
|
||||||
|
|
||||||
|
/// One button per consumable tip tier — no "restore purchases" (nothing to
|
||||||
|
/// restore, consumables aren't entitlements) and no manual retry: a failed
|
||||||
|
/// load just shows a message, tapping a tier again re-attempts naturally.
|
||||||
|
struct TipJarView: View {
|
||||||
|
@State private var store = TipJarStore()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("Support Outpost")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
|
||||||
|
if store.isLoading && store.products.isEmpty {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
} else if !store.products.isEmpty {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(store.products) { product in
|
||||||
|
tipButton(for: product)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch store.purchaseState {
|
||||||
|
case .thankYou:
|
||||||
|
Label("Thank you!", systemImage: "heart.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.pink)
|
||||||
|
case .failed(let message):
|
||||||
|
Text(message)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
case .idle, .purchasing:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await store.loadProductsIfNeeded() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tipButton(for product: Product) -> some View {
|
||||||
|
Button {
|
||||||
|
Task { await store.purchase(product) }
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
if isPurchasing(product) {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text(product.displayPrice)
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
}
|
||||||
|
Text(product.displayName)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 64)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(isAnyPurchaseInFlight)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isPurchasing(_ product: Product) -> Bool {
|
||||||
|
store.purchaseState == .purchasing(product.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isAnyPurchaseInFlight: Bool {
|
||||||
|
if case .purchasing = store.purchaseState { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import StoreKit
|
||||||
|
import OutlineKit
|
||||||
|
|
||||||
/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label
|
/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label
|
||||||
/// contains the avatar image reliably broke its own sizing on click (even after
|
/// contains the avatar image reliably broke its own sizing on click (even after
|
||||||
@@ -7,21 +9,14 @@ import SwiftUI
|
|||||||
/// label rendering, which a `Button` doesn't go through.
|
/// label rendering, which a `Button` doesn't go through.
|
||||||
struct AccountFooter: View {
|
struct AccountFooter: View {
|
||||||
@Environment(SessionStore.self) private var session
|
@Environment(SessionStore.self) private var session
|
||||||
@Environment(\.openURL) private var openURL
|
@Environment(AppNavigation.self) private var navigation
|
||||||
@Environment(\.openWindow) private var openWindow
|
@Environment(\.requestReview) private var requestReview
|
||||||
@Environment(\.openSettings) private var openSettings
|
|
||||||
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
||||||
|
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||||
@State private var isMenuPresented = false
|
@State private var isMenuPresented = false
|
||||||
@State private var isShowingLogoutConfirmation = false
|
@State private var isShowingLogoutConfirmation = false
|
||||||
@State private var isShowingProfile = false
|
@State private var isShowingProfile = false
|
||||||
|
|
||||||
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
|
|
||||||
private let issuesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/issues")!
|
|
||||||
|
|
||||||
private var apiDocumentationURL: URL? {
|
|
||||||
session.serverURL?.appendingPathComponent("developers")
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button {
|
Button {
|
||||||
isMenuPresented = true
|
isMenuPresented = true
|
||||||
@@ -61,20 +56,10 @@ struct AccountFooter: View {
|
|||||||
|
|
||||||
private var menuContent: some View {
|
private var menuContent: some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
menuItem("Keyboard Shortcuts…") { openWindow(id: "keyboard-shortcuts") }
|
// Apple's own review/feedback prompt — there's no separate
|
||||||
|
// native channel for "bug" vs. "feedback", so one button covers
|
||||||
Divider()
|
// both.
|
||||||
|
menuItem("Leave Us Feedback") { requestReview() }
|
||||||
menuItem("Documentation") { openURL(repositoryURL) }
|
|
||||||
if let apiDocumentationURL {
|
|
||||||
menuItem("API Documentation") { openURL(apiDocumentationURL) }
|
|
||||||
}
|
|
||||||
menuItem("Changelog") { openURL(repositoryURL) }
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
menuItem("Send Us Feedback") { openURL(issuesURL) }
|
|
||||||
menuItem("Report a Bug") { openURL(issuesURL) }
|
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
@@ -93,8 +78,25 @@ struct AccountFooter: View {
|
|||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
|
|
||||||
|
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
|
||||||
menuItem("Profile…") { isShowingProfile = true }
|
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 } }
|
||||||
|
// Same deferred-Task reasoning as Settings… above — this also
|
||||||
|
// sets isShowingSettings synchronously.
|
||||||
|
menuItem("Support Outpost") {
|
||||||
|
Task { @MainActor in
|
||||||
|
navigation.selectedSettingsSection = .about
|
||||||
|
navigation.isShowingSettings = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Crop/rotate/zoom editor shown after picking a photo, before it's
|
||||||
|
/// uploaded — pan (drag), zoom (pinch or the slider), and 90°-increment
|
||||||
|
/// rotate, all inside a circular mask matching how the avatar actually
|
||||||
|
/// renders everywhere else in the app.
|
||||||
|
///
|
||||||
|
/// The on-screen preview and the final exported image are built from the
|
||||||
|
/// exact same view composition (`avatarContent`), just instantiated once
|
||||||
|
/// for display and once inside an `ImageRenderer` — that's deliberate:
|
||||||
|
/// hand-deriving a separate set of crop-math for a higher-resolution
|
||||||
|
/// render would risk it silently disagreeing with what the user actually
|
||||||
|
/// saw and confirmed, and there's no way to visually verify that
|
||||||
|
/// agreement without running the app. Reusing the identical view tree
|
||||||
|
/// makes the export WYSIWYG by construction instead of by careful math.
|
||||||
|
struct AvatarCropperView: View {
|
||||||
|
let sourceImage: NSImage
|
||||||
|
let onConfirm: (Data) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
@State private var scale: CGFloat = 1
|
||||||
|
@State private var offset: CGSize = .zero
|
||||||
|
@State private var rotationDegrees: Double = 0
|
||||||
|
@GestureState private var dragTranslation: CGSize = .zero
|
||||||
|
|
||||||
|
/// Used for both the live preview and the exported image — see the
|
||||||
|
/// type-level doc comment for why that's the same size, not two.
|
||||||
|
private let diameter: CGFloat = 320
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Text("Edit Photo")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
avatarContent
|
||||||
|
.clipShape(Circle())
|
||||||
|
Circle()
|
||||||
|
.strokeBorder(Color.primary.opacity(0.15), lineWidth: 1)
|
||||||
|
}
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
.contentShape(Circle())
|
||||||
|
.gesture(
|
||||||
|
DragGesture()
|
||||||
|
.updating($dragTranslation) { value, state, _ in state = value.translation }
|
||||||
|
.onEnded { value in
|
||||||
|
offset.width += value.translation.width
|
||||||
|
offset.height += value.translation.height
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees -= 90 }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "rotate.left")
|
||||||
|
}
|
||||||
|
.help("Rotate left")
|
||||||
|
|
||||||
|
Slider(value: $scale, in: 1...4)
|
||||||
|
.frame(width: 140)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees += 90 }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "rotate.right")
|
||||||
|
}
|
||||||
|
.help("Rotate right")
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Cancel", role: .cancel, action: onCancel)
|
||||||
|
Spacer()
|
||||||
|
Button("Use Photo") {
|
||||||
|
Task {
|
||||||
|
if let data = await renderFinalImage() {
|
||||||
|
onConfirm(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.frame(width: 360)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aspect-fills `sourceImage` into a `diameter`×`diameter` square, then
|
||||||
|
/// applies the user's pan/zoom/rotation on top — identical between the
|
||||||
|
/// live preview and the final render (see the type-level doc comment).
|
||||||
|
private var avatarContent: some View {
|
||||||
|
Image(nsImage: sourceImage)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
.scaleEffect(scale)
|
||||||
|
.rotationEffect(.degrees(rotationDegrees))
|
||||||
|
.offset(x: offset.width + dragTranslation.width, y: offset.height + dragTranslation.height)
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
.clipped()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ImageRenderer` itself has to run on the main actor (it captures live
|
||||||
|
/// SwiftUI view state), but JPEG compression on the bitmap it produces
|
||||||
|
/// is pure CPU work with no SwiftUI dependency left — hopping off for
|
||||||
|
/// just that part avoids a visible hitch on tapping "Use Photo".
|
||||||
|
/// `tiffRepresentation` (plain `Data`, unlike `NSImage` itself) is what
|
||||||
|
/// actually crosses the actor boundary; mirrors `NSImage.jpegData(
|
||||||
|
/// compressionQuality:)`'s own logic rather than calling it directly, so
|
||||||
|
/// crossing doesn't require handing a non-Sendable `NSImage` to a
|
||||||
|
/// detached task.
|
||||||
|
@MainActor
|
||||||
|
private func renderFinalImage() async -> Data? {
|
||||||
|
let content = avatarContent
|
||||||
|
.clipShape(Circle())
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
let renderer = ImageRenderer(content: content)
|
||||||
|
renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays
|
||||||
|
guard let tiffData = renderer.nsImage?.tiffRepresentation else { return nil }
|
||||||
|
return await Task.detached(priority: .userInitiated) {
|
||||||
|
NSBitmapImageRep(data: tiffData)?.representation(using: .jpeg, properties: [.compressionFactor: 0.9])
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
#if os(macOS)
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct KeyboardShortcutsView: View {
|
|
||||||
private struct Shortcut: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
let action: String
|
|
||||||
let keys: String
|
|
||||||
}
|
|
||||||
|
|
||||||
private let shortcuts: [Shortcut] = [
|
|
||||||
Shortcut(action: "Sign In", keys: "⏎"),
|
|
||||||
Shortcut(action: "Preferences", keys: "⌘ ,"),
|
|
||||||
Shortcut(action: "Close Window", keys: "⌘ W"),
|
|
||||||
Shortcut(action: "Quit Outpost", keys: "⌘ Q")
|
|
||||||
]
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
Text("Keyboard Shortcuts")
|
|
||||||
.font(.title3.bold())
|
|
||||||
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
ForEach(shortcuts) { shortcut in
|
|
||||||
HStack {
|
|
||||||
Text(shortcut.action)
|
|
||||||
Spacer()
|
|
||||||
Text(shortcut.keys)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.monospaced()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(24)
|
|
||||||
.frame(width: 280)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,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,131 @@
|
|||||||
|
#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) {
|
||||||
|
// TODO: Workspace is entirely `!isImplemented` placeholders
|
||||||
|
// right now (details/authentication/security/ai/members/
|
||||||
|
// groups/templates/emojis/applications/shared/links/
|
||||||
|
// webhooks/importData/exportData) — App Store review won't
|
||||||
|
// accept a section that's just "Coming Soon" rows, so it's
|
||||||
|
// filtered out of the sidebar below (via `visibleCategories`)
|
||||||
|
// until real content lands. Remove the filter once at least
|
||||||
|
// one Workspace section is built.
|
||||||
|
ForEach(visibleCategories) { category in
|
||||||
|
let sections = SettingsSection.allCases.filter { $0.category == category }
|
||||||
|
Section {
|
||||||
|
ForEach(sections) { section in
|
||||||
|
Label(section.title, systemImage: section.icon)
|
||||||
|
.tag(section)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
if let title = category.title {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(title)
|
||||||
|
// Not hardcoded to `.workspace` specifically —
|
||||||
|
// stays correct on its own as sections get
|
||||||
|
// built, only shows while every section in
|
||||||
|
// the category is still `!isImplemented`.
|
||||||
|
if sections.allSatisfy({ !$0.isImplemented }) {
|
||||||
|
Text("Coming Soon")
|
||||||
|
.font(.system(size: 9, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(.secondary.opacity(0.15), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.sidebar)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
versionFooter
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button("Done", action: onDone)
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(12)
|
||||||
|
}
|
||||||
|
// Keyed to connectivity, not a one-shot `.task {}` — reconnecting
|
||||||
|
// (or turning the manual Offline Mode toggle back off) re-fires
|
||||||
|
// this automatically instead of leaving the footer stuck on
|
||||||
|
// whatever it last knew, or blank, until Settings is reopened.
|
||||||
|
.task(id: isEffectivelyOnline) { await refreshOutlineVersion() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Categories with at least one built (`isImplemented`) section — see the
|
||||||
|
/// TODO above the `ForEach` that uses this.
|
||||||
|
private var visibleCategories: [SettingsCategory] {
|
||||||
|
SettingsCategory.allCases.filter { category in
|
||||||
|
SettingsSection.allCases.contains { $0.category == category && $0.isImplemented }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var versionFooter: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Outpost \(OutpostVersion.displayString)")
|
||||||
|
if let outlineVersion {
|
||||||
|
Text("Outline \(outlineVersion)")
|
||||||
|
} else if !isEffectivelyOnline {
|
||||||
|
Text("Outline — offline")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshOutlineVersion() async {
|
||||||
|
guard isEffectivelyOnline, let apiClient = session.apiClient else { return }
|
||||||
|
outlineVersion = try? await RetryPolicy.withRetry({ try await apiClient.installationInfo().version })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,21 +3,17 @@ import SwiftUI
|
|||||||
struct AuthHeaderView: View {
|
struct AuthHeaderView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
ZStack {
|
// A plain Image Set, not the AppIcon *app icon* asset — App Icon
|
||||||
Circle()
|
// sets aren't reliably resolvable through Image(_:)/UIImage
|
||||||
.fill(
|
// (named:) at runtime (confirmed live: showed nothing). This is
|
||||||
LinearGradient(
|
// the same source artwork (outpost-ios-1024.png) duplicated
|
||||||
colors: [Color.accentColor, Color.accentColor.opacity(0.6)],
|
// into a normal image set so SwiftUI can actually load it.
|
||||||
startPoint: .topLeading,
|
Image("AppLogo")
|
||||||
endPoint: .bottomTrailing
|
.resizable()
|
||||||
)
|
.scaledToFit()
|
||||||
)
|
.frame(width: 72, height: 72)
|
||||||
.frame(width: 64, height: 64)
|
.clipShape(RoundedRectangle(cornerRadius: 72 * 0.2237, style: .continuous))
|
||||||
Image(systemName: "text.book.closed.fill")
|
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||||
.font(.system(size: 26, weight: .semibold))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
}
|
|
||||||
.shadow(color: Color.accentColor.opacity(0.35), radius: 12, y: 6)
|
|
||||||
|
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
Text("Welcome to Outpost")
|
Text("Welcome to Outpost")
|
||||||
|
|||||||
@@ -9,16 +9,36 @@ import OutlineKit
|
|||||||
/// client-side rather than `collections.documents`.
|
/// client-side rather than `collections.documents`.
|
||||||
struct CollectionDocumentsOutline: View {
|
struct CollectionDocumentsOutline: View {
|
||||||
let apiClient: OutlineAPIClient
|
let apiClient: OutlineAPIClient
|
||||||
|
let collection: OutlineCollection
|
||||||
@State private var viewModel: DocumentsViewModel
|
@State private var viewModel: DocumentsViewModel
|
||||||
let sortOption: SidebarSortOption
|
let sortOption: SidebarSortOption
|
||||||
let refreshToken: Int
|
let refreshToken: Int
|
||||||
|
/// Bumped from `ContentView_macOS` whenever a document is created from
|
||||||
|
/// somewhere that has no direct handle on this row — the reader's
|
||||||
|
/// toolbar "New Document" button and Home's, specifically. Those can't
|
||||||
|
/// call `onDocumentsChanged()` the way a same-row sheet does, since they
|
||||||
|
/// don't know which (if any) sidebar row corresponds to where the new
|
||||||
|
/// document landed, so every expanded row just reloads itself.
|
||||||
|
let externalRefreshToken: Int
|
||||||
let selectedDocumentID: String?
|
let selectedDocumentID: String?
|
||||||
/// Full chain from root to the clicked document (inclusive) — lets the
|
/// Full chain from root to the clicked document (inclusive) — lets the
|
||||||
/// toolbar render the real hierarchy instead of just the leaf title.
|
/// toolbar render the real hierarchy instead of just the leaf title.
|
||||||
let onSelectDocument: ([OutlineDocument]) -> Void
|
let onSelectDocument: ([OutlineDocument]) -> Void
|
||||||
|
|
||||||
private var tree: [DocumentNode] {
|
/// Loaded once per collection (`pins.list` is collection-scoped) rather
|
||||||
buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
|
/// than per-row — a per-row `pins.list`/lookup would be an N+1 call for
|
||||||
|
/// every document in the tree.
|
||||||
|
@State private var pinsByDocumentID: [String: OutlinePin] = [:]
|
||||||
|
|
||||||
|
/// Recomputed only when `viewModel.documents`/`sortOption` actually change
|
||||||
|
/// (below) instead of being a computed property — this rebuilt the whole
|
||||||
|
/// dictionary-grouped, recursively-sorted tree on every `body` evaluation,
|
||||||
|
/// including renders triggered by unrelated state (selection, hover,
|
||||||
|
/// pins) that don't change the tree's shape at all.
|
||||||
|
@State private var tree: [DocumentNode] = []
|
||||||
|
|
||||||
|
private func rebuildTree() {
|
||||||
|
tree = buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -26,13 +46,16 @@ struct CollectionDocumentsOutline: View {
|
|||||||
collection: OutlineCollection,
|
collection: OutlineCollection,
|
||||||
sortOption: SidebarSortOption,
|
sortOption: SidebarSortOption,
|
||||||
refreshToken: Int,
|
refreshToken: Int,
|
||||||
|
externalRefreshToken: Int,
|
||||||
selectedDocumentID: String?,
|
selectedDocumentID: String?,
|
||||||
onSelectDocument: @escaping ([OutlineDocument]) -> Void
|
onSelectDocument: @escaping ([OutlineDocument]) -> Void
|
||||||
) {
|
) {
|
||||||
self.apiClient = apiClient
|
self.apiClient = apiClient
|
||||||
|
self.collection = collection
|
||||||
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
||||||
self.sortOption = sortOption
|
self.sortOption = sortOption
|
||||||
self.refreshToken = refreshToken
|
self.refreshToken = refreshToken
|
||||||
|
self.externalRefreshToken = externalRefreshToken
|
||||||
self.selectedDocumentID = selectedDocumentID
|
self.selectedDocumentID = selectedDocumentID
|
||||||
self.onSelectDocument = onSelectDocument
|
self.onSelectDocument = onSelectDocument
|
||||||
}
|
}
|
||||||
@@ -56,13 +79,29 @@ struct CollectionDocumentsOutline: View {
|
|||||||
depth: 0,
|
depth: 0,
|
||||||
ancestors: [],
|
ancestors: [],
|
||||||
selectedDocumentID: selectedDocumentID,
|
selectedDocumentID: selectedDocumentID,
|
||||||
|
pinsByDocumentID: pinsByDocumentID,
|
||||||
onSelectDocument: onSelectDocument,
|
onSelectDocument: onSelectDocument,
|
||||||
onDocumentsChanged: { await viewModel.load() }
|
onDocumentsChanged: { await viewModel.load() },
|
||||||
|
onPinsChanged: { await loadPins() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task(id: refreshToken) { await viewModel.load() }
|
// Combined into one identity rather than two separate `.task(id:)`
|
||||||
|
// modifiers — each of those fires once unconditionally on first
|
||||||
|
// appear, so two of them would double the initial load.
|
||||||
|
.task(id: "\(refreshToken)-\(externalRefreshToken)") {
|
||||||
|
await viewModel.load()
|
||||||
|
await loadPins()
|
||||||
|
rebuildTree()
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.documents) { rebuildTree() }
|
||||||
|
.onChange(of: sortOption) { rebuildTree() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPins() async {
|
||||||
|
guard let pins = try? await RetryPolicy.withRetry({ try await apiClient.listPins(ListPinsRequest(collectionId: collection.id)) }) else { return }
|
||||||
|
pinsByDocumentID = Dictionary(uniqueKeysWithValues: pins.map { ($0.documentId, $0) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,8 +120,10 @@ private struct DocumentNodeRow: View {
|
|||||||
/// Chain from root down to (not including) this node.
|
/// Chain from root down to (not including) this node.
|
||||||
let ancestors: [OutlineDocument]
|
let ancestors: [OutlineDocument]
|
||||||
let selectedDocumentID: String?
|
let selectedDocumentID: String?
|
||||||
|
let pinsByDocumentID: [String: OutlinePin]
|
||||||
let onSelectDocument: ([OutlineDocument]) -> Void
|
let onSelectDocument: ([OutlineDocument]) -> Void
|
||||||
let onDocumentsChanged: () async -> Void
|
let onDocumentsChanged: () async -> Void
|
||||||
|
let onPinsChanged: () async -> Void
|
||||||
|
|
||||||
@State private var isExpanded = false
|
@State private var isExpanded = false
|
||||||
|
|
||||||
@@ -96,6 +137,7 @@ private struct DocumentNodeRow: View {
|
|||||||
@State private var isShowingInsightsSheet = false
|
@State private var isShowingInsightsSheet = false
|
||||||
@State private var isShowingPresentSheet = false
|
@State private var isShowingPresentSheet = false
|
||||||
@State private var isShowingSearchSheet = false
|
@State private var isShowingSearchSheet = false
|
||||||
|
@State private var isShowingNewDocumentSheet = false
|
||||||
@State private var actionErrorMessage: String?
|
@State private var actionErrorMessage: String?
|
||||||
|
|
||||||
private var isSelected: Bool {
|
private var isSelected: Bool {
|
||||||
@@ -125,6 +167,7 @@ private struct DocumentNodeRow: View {
|
|||||||
.frame(width: 12)
|
.frame(width: 12)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
@@ -154,6 +197,7 @@ private struct DocumentNodeRow: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
}
|
}
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
@@ -172,8 +216,10 @@ private struct DocumentNodeRow: View {
|
|||||||
depth: depth + 1,
|
depth: depth + 1,
|
||||||
ancestors: ancestors + [node.document],
|
ancestors: ancestors + [node.document],
|
||||||
selectedDocumentID: selectedDocumentID,
|
selectedDocumentID: selectedDocumentID,
|
||||||
|
pinsByDocumentID: pinsByDocumentID,
|
||||||
onSelectDocument: onSelectDocument,
|
onSelectDocument: onSelectDocument,
|
||||||
onDocumentsChanged: onDocumentsChanged
|
onDocumentsChanged: onDocumentsChanged,
|
||||||
|
onPinsChanged: onPinsChanged
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,6 +298,11 @@ private struct DocumentNodeRow: View {
|
|||||||
.sheet(isPresented: $isShowingSearchSheet) {
|
.sheet(isPresented: $isShowingSearchSheet) {
|
||||||
DocumentSearchSheet(apiClient: apiClient, document: node.document)
|
DocumentSearchSheet(apiClient: apiClient, document: node.document)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $isShowingNewDocumentSheet) {
|
||||||
|
NewDocumentSheet(apiClient: apiClient, initialParentDocument: node.document) { _ in
|
||||||
|
Task { await onDocumentsChanged() }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -259,7 +310,11 @@ private struct DocumentNodeRow: View {
|
|||||||
Button(starStore.isStarred(documentId: node.document.id) ? "Unstar" : "Star") {
|
Button(starStore.isStarred(documentId: node.document.id) ? "Unstar" : "Star") {
|
||||||
Task { await star() }
|
Task { await star() }
|
||||||
}
|
}
|
||||||
// No `subscriptions.*` endpoint in the API — nothing to back this with.
|
// subscriptions.* does exist and works (confirmed against a live
|
||||||
|
// server via the reader's menu) — not shown here because
|
||||||
|
// subscriptions.list is per-document, so reflecting accurate
|
||||||
|
// per-row state for every document in the tree would mean an N+1
|
||||||
|
// call storm. Use the reader's ⋯ menu instead.
|
||||||
Button("Unsubscribe") {}
|
Button("Unsubscribe") {}
|
||||||
.disabled(true)
|
.disabled(true)
|
||||||
|
|
||||||
@@ -273,8 +328,9 @@ private struct DocumentNodeRow: View {
|
|||||||
renameText = node.document.title
|
renameText = node.document.title
|
||||||
isShowingRenameAlert = true
|
isShowingRenameAlert = true
|
||||||
}
|
}
|
||||||
// Sharing/membership management is its own subsystem, not a one-off
|
// DocumentShareSheet now has a real "People with access" section —
|
||||||
// action — deferred rather than half-built here.
|
// this row just doesn't have a sheet wired up to present it yet
|
||||||
|
// (only the reader toolbar does). Use the reader's ⋯ menu instead.
|
||||||
Button("Permissions…") {}
|
Button("Permissions…") {}
|
||||||
.disabled(true)
|
.disabled(true)
|
||||||
|
|
||||||
@@ -303,11 +359,13 @@ private struct DocumentNodeRow: View {
|
|||||||
Button("Import Document…") {}
|
Button("Import Document…") {}
|
||||||
.disabled(true)
|
.disabled(true)
|
||||||
Button("New Document") {
|
Button("New Document") {
|
||||||
Task { await createChildDocument() }
|
isShowingNewDocumentSheet = true
|
||||||
|
}
|
||||||
|
// Scoped to this collection (`collection.id`) — "Pin to Collection",
|
||||||
|
// distinct from the reader toolbar's "Pin to Home" (collectionId: nil).
|
||||||
|
Button(pinsByDocumentID[node.document.id] != nil ? "Unpin from Collection" : "Pin to Collection") {
|
||||||
|
Task { await togglePin() }
|
||||||
}
|
}
|
||||||
// No `pins.*` endpoint in the API — nothing to back this with.
|
|
||||||
Button("Pin") {}
|
|
||||||
.disabled(true)
|
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
@@ -351,6 +409,19 @@ private struct DocumentNodeRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func togglePin() async {
|
||||||
|
do {
|
||||||
|
if let pin = pinsByDocumentID[node.document.id] {
|
||||||
|
try await apiClient.deletePin(id: pin.id)
|
||||||
|
} else {
|
||||||
|
_ = try await apiClient.createPin(CreatePinRequest(documentId: node.document.id, collectionId: node.document.collectionId))
|
||||||
|
}
|
||||||
|
await onPinsChanged()
|
||||||
|
} catch {
|
||||||
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update pin state.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func rename() async {
|
private func rename() async {
|
||||||
do {
|
do {
|
||||||
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: node.document.id, title: renameText))
|
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: node.document.id, title: renameText))
|
||||||
@@ -395,26 +466,6 @@ private struct DocumentNodeRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createChildDocument() async {
|
|
||||||
guard let collectionId = node.document.collectionId else {
|
|
||||||
actionErrorMessage = "This document isn't in a collection."
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try await apiClient.createDocument(
|
|
||||||
CreateDocumentRequest(
|
|
||||||
title: "Untitled",
|
|
||||||
text: "",
|
|
||||||
collectionId: collectionId,
|
|
||||||
parentDocumentId: node.document.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await onDocumentsChanged()
|
|
||||||
} catch {
|
|
||||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new document.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func delete() async {
|
private func delete() async {
|
||||||
do {
|
do {
|
||||||
try await apiClient.deleteDocument(DeleteDocumentRequest(id: node.document.id))
|
try await apiClient.deleteDocument(DeleteDocumentRequest(id: node.document.id))
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ struct CollectionListContent: View {
|
|||||||
CollectionRowView(collection: collection)
|
CollectionRowView(collection: collection)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import MarkdownEngine
|
import MarkdownEngine
|
||||||
|
import MarkdownEngineCodeBlocks
|
||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
|
||||||
/// Read-only for now — document/overview editing isn't wired up yet. Uses
|
/// Read-only for now — document/overview editing isn't wired up yet. Uses
|
||||||
@@ -9,21 +10,34 @@ import OutlineKit
|
|||||||
/// `isSelectable` and link-opening both still need to work.
|
/// `isSelectable` and link-opening both still need to work.
|
||||||
struct CollectionOverviewContent: View {
|
struct CollectionOverviewContent: View {
|
||||||
@State private var markdown: String
|
@State private var markdown: String
|
||||||
|
@State private var imageProvider: OutlineImageProvider
|
||||||
|
/// See `DocumentReaderView`'s `imageReloadTick`.
|
||||||
|
@State private var imageReloadTick = 0
|
||||||
|
|
||||||
init(collection: OutlineCollection) {
|
init(apiClient: OutlineAPIClient, collection: OutlineCollection) {
|
||||||
_markdown = State(initialValue: collection.description ?? "")
|
_markdown = State(initialValue: collection.description ?? "")
|
||||||
|
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
NativeTextViewWrapper(
|
NativeTextViewWrapper(
|
||||||
text: $markdown,
|
text: $markdown,
|
||||||
configuration: .init(heightBehavior: .fitsContent),
|
configuration: .init(
|
||||||
|
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||||
|
heightBehavior: .fitsContent
|
||||||
|
),
|
||||||
isEditable: false
|
isEditable: false
|
||||||
)
|
)
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.animation(nil, value: imageReloadTick)
|
||||||
|
.task {
|
||||||
|
imageProvider.onImageLoaded = {
|
||||||
|
Task { @MainActor in imageReloadTick += 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
|
||||||
struct CollectionOverviewView: View {
|
struct CollectionOverviewView: View {
|
||||||
|
let apiClient: OutlineAPIClient
|
||||||
let collection: OutlineCollection
|
let collection: OutlineCollection
|
||||||
@State private var viewModel: DocumentsViewModel
|
@State private var viewModel: DocumentsViewModel
|
||||||
@State private var selectedTab: CollectionTab = .overview
|
@State private var selectedTab: CollectionTab = .overview
|
||||||
@@ -25,6 +26,7 @@ struct CollectionOverviewView: View {
|
|||||||
searchQuery: Binding<String>,
|
searchQuery: Binding<String>,
|
||||||
onOpenDocument: @escaping (OutlineDocument) -> Void
|
onOpenDocument: @escaping (OutlineDocument) -> Void
|
||||||
) {
|
) {
|
||||||
|
self.apiClient = apiClient
|
||||||
self.collection = collection
|
self.collection = collection
|
||||||
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
||||||
_searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id))
|
_searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id))
|
||||||
@@ -32,8 +34,15 @@ struct CollectionOverviewView: View {
|
|||||||
self.onOpenDocument = onOpenDocument
|
self.onOpenDocument = onOpenDocument
|
||||||
}
|
}
|
||||||
|
|
||||||
private var sortedDocuments: [OutlineDocument] {
|
/// Recomputed only when `viewModel.documents`/`selectedTab` actually
|
||||||
selectedTab.sorted(viewModel.documents)
|
/// change (below) instead of being a computed property re-sorted on
|
||||||
|
/// every render — capped at 100 documents per collection page, so lower
|
||||||
|
/// blast radius than the sidebar/command-palette versions of this same
|
||||||
|
/// pattern, but the same fix.
|
||||||
|
@State private var sortedDocuments: [OutlineDocument] = []
|
||||||
|
|
||||||
|
private func resortDocuments() {
|
||||||
|
sortedDocuments = selectedTab.sorted(viewModel.documents)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -59,7 +68,7 @@ struct CollectionOverviewView: View {
|
|||||||
if !trimmedSearchQuery.isEmpty {
|
if !trimmedSearchQuery.isEmpty {
|
||||||
searchResultsList
|
searchResultsList
|
||||||
} else if selectedTab == .overview {
|
} else if selectedTab == .overview {
|
||||||
CollectionOverviewContent(collection: collection)
|
CollectionOverviewContent(apiClient: apiClient, collection: collection)
|
||||||
} else {
|
} else {
|
||||||
documentList
|
documentList
|
||||||
}
|
}
|
||||||
@@ -92,6 +101,8 @@ struct CollectionOverviewView: View {
|
|||||||
await viewModel.checkForRemoteChanges()
|
await viewModel.checkForRemoteChanges()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: viewModel.documents) { resortDocuments() }
|
||||||
|
.onChange(of: selectedTab) { resortDocuments() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spans the full window width, centered, directly under the toolbar —
|
// Spans the full window width, centered, directly under the toolbar —
|
||||||
@@ -114,6 +125,7 @@ struct CollectionOverviewView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
@@ -150,6 +162,7 @@ struct CollectionOverviewView: View {
|
|||||||
DocumentRowView(document: document)
|
DocumentRowView(document: document)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,6 +195,7 @@ struct CollectionOverviewView: View {
|
|||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ struct CollectionRowView: View {
|
|||||||
} icon: {
|
} icon: {
|
||||||
if let emoji = collection.emojiIcon {
|
if let emoji = collection.emojiIcon {
|
||||||
Text(emoji)
|
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)
|
Image(systemName: symbolName)
|
||||||
.foregroundStyle(tintColor)
|
.foregroundStyle(tintColor)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ struct CollectionTreeRow: View {
|
|||||||
let isExpanded: Bool
|
let isExpanded: Bool
|
||||||
let isSelected: Bool
|
let isSelected: Bool
|
||||||
let selectedDocumentID: String?
|
let selectedDocumentID: String?
|
||||||
|
/// See the identical parameter on `CollectionDocumentsOutline`.
|
||||||
|
let externalRefreshToken: Int
|
||||||
let onToggle: () -> Void
|
let onToggle: () -> Void
|
||||||
let onSelectDocument: ([OutlineDocument]) -> Void
|
let onSelectDocument: ([OutlineDocument]) -> Void
|
||||||
let onSearchInCollection: (OutlineCollection) -> Void
|
let onSearchInCollection: (OutlineCollection) -> Void
|
||||||
@@ -22,6 +24,7 @@ struct CollectionTreeRow: View {
|
|||||||
@State private var isShowingRenameAlert = false
|
@State private var isShowingRenameAlert = false
|
||||||
@State private var renameText = ""
|
@State private var renameText = ""
|
||||||
@State private var isShowingDeleteConfirmation = false
|
@State private var isShowingDeleteConfirmation = false
|
||||||
|
@State private var isShowingNewDocumentSheet = false
|
||||||
@State private var actionErrorMessage: String?
|
@State private var actionErrorMessage: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -53,6 +56,7 @@ struct CollectionTreeRow: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
.animation(.easeInOut(duration: 0.15), value: isExpanded)
|
.animation(.easeInOut(duration: 0.15), value: isExpanded)
|
||||||
.contextMenu { contextMenuContent }
|
.contextMenu { contextMenuContent }
|
||||||
|
|
||||||
@@ -62,6 +66,7 @@ struct CollectionTreeRow: View {
|
|||||||
collection: collection,
|
collection: collection,
|
||||||
sortOption: sortOption,
|
sortOption: sortOption,
|
||||||
refreshToken: documentsRefreshToken,
|
refreshToken: documentsRefreshToken,
|
||||||
|
externalRefreshToken: externalRefreshToken,
|
||||||
selectedDocumentID: selectedDocumentID,
|
selectedDocumentID: selectedDocumentID,
|
||||||
onSelectDocument: onSelectDocument
|
onSelectDocument: onSelectDocument
|
||||||
)
|
)
|
||||||
@@ -92,6 +97,12 @@ struct CollectionTreeRow: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text(actionErrorMessage ?? "")
|
Text(actionErrorMessage ?? "")
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $isShowingNewDocumentSheet) {
|
||||||
|
NewDocumentSheet(apiClient: apiClient, initialCollectionID: collection.id) { _ in
|
||||||
|
documentsRefreshToken += 1
|
||||||
|
Task { await onCollectionsChanged() }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -103,7 +114,7 @@ struct CollectionTreeRow: View {
|
|||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Button("New Document") {
|
Button("New Document") {
|
||||||
Task { await createDocument() }
|
isShowingNewDocumentSheet = true
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
@@ -148,18 +159,6 @@ struct CollectionTreeRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createDocument() async {
|
|
||||||
do {
|
|
||||||
_ = try await apiClient.createDocument(
|
|
||||||
CreateDocumentRequest(title: "Untitled", text: "", collectionId: collection.id)
|
|
||||||
)
|
|
||||||
documentsRefreshToken += 1
|
|
||||||
await onCollectionsChanged()
|
|
||||||
} catch {
|
|
||||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new document.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func rename() async {
|
private func rename() async {
|
||||||
do {
|
do {
|
||||||
_ = try await apiClient.updateCollection(UpdateCollectionRequest(id: collection.id, name: renameText))
|
_ = try await apiClient.updateCollection(UpdateCollectionRequest(id: collection.id, name: renameText))
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ struct CollectionsTreeView: View {
|
|||||||
@Binding private var selectedCollection: OutlineCollection?
|
@Binding private var selectedCollection: OutlineCollection?
|
||||||
let selectedDocumentID: String?
|
let selectedDocumentID: String?
|
||||||
@State private var expandedCollectionIDs: Set<String> = []
|
@State private var expandedCollectionIDs: Set<String> = []
|
||||||
|
/// Bumped from `ContentView_macOS` whenever a document is created
|
||||||
|
/// somewhere with no direct handle on the sidebar row it belongs
|
||||||
|
/// under — see the identical parameter on `CollectionDocumentsOutline`.
|
||||||
|
let externalRefreshToken: Int
|
||||||
|
let isShowingHome: Bool
|
||||||
|
let onSelectHome: () -> Void
|
||||||
let onSelectDocument: (OutlineCollection, [OutlineDocument]) -> Void
|
let onSelectDocument: (OutlineCollection, [OutlineDocument]) -> Void
|
||||||
let onSearchInCollection: (OutlineCollection) -> Void
|
let onSearchInCollection: (OutlineCollection) -> Void
|
||||||
|
|
||||||
@@ -14,12 +20,18 @@ struct CollectionsTreeView: View {
|
|||||||
apiClient: OutlineAPIClient,
|
apiClient: OutlineAPIClient,
|
||||||
selectedCollection: Binding<OutlineCollection?>,
|
selectedCollection: Binding<OutlineCollection?>,
|
||||||
selectedDocumentID: String?,
|
selectedDocumentID: String?,
|
||||||
|
externalRefreshToken: Int,
|
||||||
|
isShowingHome: Bool,
|
||||||
|
onSelectHome: @escaping () -> Void,
|
||||||
onSelectDocument: @escaping (OutlineCollection, [OutlineDocument]) -> Void,
|
onSelectDocument: @escaping (OutlineCollection, [OutlineDocument]) -> Void,
|
||||||
onSearchInCollection: @escaping (OutlineCollection) -> Void
|
onSearchInCollection: @escaping (OutlineCollection) -> Void
|
||||||
) {
|
) {
|
||||||
_viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient))
|
_viewModel = State(initialValue: CollectionsViewModel(apiClient: apiClient))
|
||||||
_selectedCollection = selectedCollection
|
_selectedCollection = selectedCollection
|
||||||
self.selectedDocumentID = selectedDocumentID
|
self.selectedDocumentID = selectedDocumentID
|
||||||
|
self.externalRefreshToken = externalRefreshToken
|
||||||
|
self.isShowingHome = isShowingHome
|
||||||
|
self.onSelectHome = onSelectHome
|
||||||
self.onSelectDocument = onSelectDocument
|
self.onSelectDocument = onSelectDocument
|
||||||
self.onSearchInCollection = onSearchInCollection
|
self.onSearchInCollection = onSearchInCollection
|
||||||
}
|
}
|
||||||
@@ -31,6 +43,7 @@ struct CollectionsTreeView: View {
|
|||||||
Task { await viewModel.load() }
|
Task { await viewModel.load() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
homeRow
|
||||||
content
|
content
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
@@ -42,6 +55,32 @@ struct CollectionsTreeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pinned above the collections list, not inside the scroll region —
|
||||||
|
/// Home isn't a collection, so it doesn't belong in `viewModel.collections`
|
||||||
|
/// or compete with them for scroll space.
|
||||||
|
private var homeRow: some View {
|
||||||
|
Button(action: onSelectHome) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "house.fill")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Home")
|
||||||
|
.font(.body)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.background(
|
||||||
|
isShowingHome ? Color.accentColor.opacity(0.15) : Color.clear,
|
||||||
|
in: RoundedRectangle(cornerRadius: 6)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var content: some View {
|
private var content: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -81,6 +120,7 @@ struct CollectionsTreeView: View {
|
|||||||
isExpanded: expandedCollectionIDs.contains(collection.id),
|
isExpanded: expandedCollectionIDs.contains(collection.id),
|
||||||
isSelected: selectedCollection?.id == collection.id,
|
isSelected: selectedCollection?.id == collection.id,
|
||||||
selectedDocumentID: selectedDocumentID,
|
selectedDocumentID: selectedDocumentID,
|
||||||
|
externalRefreshToken: externalRefreshToken,
|
||||||
onToggle: { toggle(collection) },
|
onToggle: { toggle(collection) },
|
||||||
onSelectDocument: { chain in onSelectDocument(collection, chain) },
|
onSelectDocument: { chain in onSelectDocument(collection, chain) },
|
||||||
onSearchInCollection: onSearchInCollection,
|
onSearchInCollection: onSearchInCollection,
|
||||||
@@ -95,13 +135,6 @@ struct CollectionsTreeView: View {
|
|||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await viewModel.load()
|
await viewModel.load()
|
||||||
// Stand-in for Outline's own configured "Start view" — we don't have
|
|
||||||
// a confirmed schema for `team.preferences` to read the actual
|
|
||||||
// setting, so this defaults to the first collection instead of
|
|
||||||
// landing on an empty "No Collection Selected" placeholder.
|
|
||||||
if selectedCollection == nil, let first = viewModel.collections.first {
|
|
||||||
selectedCollection = first
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// A document opened from outside the sidebar (detail pane's list,
|
// A document opened from outside the sidebar (detail pane's list,
|
||||||
// global search) wouldn't otherwise expand its collection here, so
|
// global search) wouldn't otherwise expand its collection here, so
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import SwiftUI
|
||||||
|
import OutlineKit
|
||||||
|
|
||||||
|
/// ⌘K. Settings → Editor → Command Palette. Always searches locally, never a
|
||||||
|
/// per-keystroke network request. Two data-source modes:
|
||||||
|
///
|
||||||
|
/// - Lightweight (default): a live `listCollections` + `listViewedDocuments`
|
||||||
|
/// fetch once when the palette opens — two small requests, near-instant,
|
||||||
|
/// works with no setup.
|
||||||
|
/// - Full Workspace: reads `CachingOutlineAPIClient`'s local SwiftData cache
|
||||||
|
/// directly (`cachedDocumentsIndex()`/`cachedCollectionsIndex()`) — zero
|
||||||
|
/// network calls at all, and includes every nested sub-document, not just
|
||||||
|
/// collection roots. Requires Full Local Sync to actually have populated
|
||||||
|
/// that cache first (gated in Settings — the toggle here is disabled
|
||||||
|
/// without it); this view doesn't trigger a sync itself.
|
||||||
|
struct CommandPaletteView: View {
|
||||||
|
let apiClient: OutlineAPIClient
|
||||||
|
let cachingClient: CachingOutlineAPIClient?
|
||||||
|
let fullWorkspaceSearch: Bool
|
||||||
|
let onSelectDocument: (OutlineDocument) -> Void
|
||||||
|
let onSelectCollection: (OutlineCollection) -> Void
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
@State private var query = ""
|
||||||
|
@State private var collections: [OutlineCollection] = []
|
||||||
|
@State private var documents: [OutlineDocument] = []
|
||||||
|
@State private var isLoading = true
|
||||||
|
@State private var selectedIndex = 0
|
||||||
|
@FocusState private var isSearchFieldFocused: Bool
|
||||||
|
|
||||||
|
private enum Result: Identifiable {
|
||||||
|
case collection(OutlineCollection)
|
||||||
|
case document(OutlineDocument)
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .collection(let collection): return "collection-\(collection.id)"
|
||||||
|
case .document(let document): return "document-\(document.id)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recomputed only when `query`/`collections`/`documents` actually change
|
||||||
|
/// (below) instead of being a computed property — Full Workspace mode's
|
||||||
|
/// index can be large (every document in the local cache, sub-documents
|
||||||
|
/// included), and this was re-scanning + re-sorting the entire thing on
|
||||||
|
/// every render, including ones triggered by unrelated state like
|
||||||
|
/// `selectedIndex` changing as arrow keys move the selection.
|
||||||
|
@State private var results: [Result] = []
|
||||||
|
|
||||||
|
private func recomputeResults() {
|
||||||
|
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else {
|
||||||
|
// No query yet: surface collections first, then the most
|
||||||
|
// recent/full-workspace documents as-is, capped so the panel
|
||||||
|
// doesn't dump the entire workspace with nothing typed.
|
||||||
|
results = (collections.map(Result.collection) + documents.map(Result.document))
|
||||||
|
.prefix(20)
|
||||||
|
.map { $0 }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let scored: [(Result, Int)] = collections.compactMap { collection in
|
||||||
|
matchScore(collection.name, query: trimmed).map { (Result.collection(collection), $0) }
|
||||||
|
} + documents.compactMap { document in
|
||||||
|
matchScore(document.title, query: trimmed).map { (Result.document(document), $0) }
|
||||||
|
}
|
||||||
|
results = scored.sorted { $0.1 < $1.1 }.prefix(30).map(\.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lower is better — exact match, then prefix match, then earliest
|
||||||
|
/// contiguous-substring position, then (for multi-word queries) every
|
||||||
|
/// word present somewhere in the title in any order. That last tier is
|
||||||
|
/// what makes "test document" find a title like "Test Plan Document" —
|
||||||
|
/// requiring the exact phrase contiguously (the previous behavior)
|
||||||
|
/// meant a title with anything between the words never matched at all,
|
||||||
|
/// which looked like "documents never show up, only collections" any
|
||||||
|
/// time the real title didn't happen to contain the typed phrase
|
||||||
|
/// verbatim. `nil` means no match at all. Still deliberately not a full
|
||||||
|
/// fuzzy/Levenshtein algorithm — good enough for document/collection
|
||||||
|
/// titles without the unpredictability that brings.
|
||||||
|
private func matchScore(_ title: String, query: String) -> Int? {
|
||||||
|
let haystack = title.lowercased()
|
||||||
|
let needle = query.lowercased()
|
||||||
|
if haystack == needle { return 0 }
|
||||||
|
if haystack.hasPrefix(needle) { return 1 }
|
||||||
|
if let range = haystack.range(of: needle) {
|
||||||
|
return 2 + haystack.distance(from: haystack.startIndex, to: range.lowerBound)
|
||||||
|
}
|
||||||
|
let words = needle.split(separator: " ").map(String.init)
|
||||||
|
guard words.count > 1, words.allSatisfy({ haystack.contains($0) }) else { return nil }
|
||||||
|
let totalPosition = words.reduce(0) { partial, word in
|
||||||
|
guard let range = haystack.range(of: word) else { return partial }
|
||||||
|
return partial + haystack.distance(from: haystack.startIndex, to: range.lowerBound)
|
||||||
|
}
|
||||||
|
return 100 + totalPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black.opacity(0.001) // catches clicks outside the card to dismiss
|
||||||
|
.onTapGesture { onDismiss() }
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("Search documents and collections…", text: $query)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.font(.title3)
|
||||||
|
.focused($isSearchFieldFocused)
|
||||||
|
.onChange(of: query) {
|
||||||
|
selectedIndex = 0
|
||||||
|
recomputeResults()
|
||||||
|
}
|
||||||
|
.onSubmit { selectCurrent() }
|
||||||
|
// Attached directly on the field itself, not an
|
||||||
|
// ancestor — confirmed live that .onKeyPress on the
|
||||||
|
// outer card never saw arrow-key events at all while
|
||||||
|
// this TextField actually held focus, the up/down
|
||||||
|
// presses just went nowhere. Escape still needs its
|
||||||
|
// own handler below since this one only covers
|
||||||
|
// whichever view is actually focused.
|
||||||
|
.onKeyPress(.downArrow) { moveSelection(by: 1); return .handled }
|
||||||
|
.onKeyPress(.upArrow) { moveSelection(by: -1); return .handled }
|
||||||
|
.onKeyPress(.escape) { onDismiss(); return .handled }
|
||||||
|
if isLoading {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
if results.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
isLoading ? "Loading…" : "No Results",
|
||||||
|
systemImage: isLoading ? "ellipsis" : "magnifyingglass"
|
||||||
|
)
|
||||||
|
.frame(height: 160)
|
||||||
|
} else {
|
||||||
|
ScrollViewReader { scrollProxy in
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
|
ForEach(Array(results.enumerated()), id: \.element.id) { index, result in
|
||||||
|
resultRow(result, isSelected: index == selectedIndex)
|
||||||
|
.id(index)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.pointerCursorOnHover()
|
||||||
|
.onTapGesture {
|
||||||
|
selectedIndex = index
|
||||||
|
selectCurrent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(6)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 360)
|
||||||
|
.onChange(of: selectedIndex) { _, newValue in
|
||||||
|
scrollProxy.scrollTo(newValue, anchor: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(.separator))
|
||||||
|
.frame(width: 560)
|
||||||
|
.shadow(color: .black.opacity(0.3), radius: 24, y: 12)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
// The window/responder chain isn't always ready to accept a
|
||||||
|
// first-responder change in the same instant this view is
|
||||||
|
// inserted — confirmed live: setting this synchronously on
|
||||||
|
// appear left the field unfocused until manually clicked
|
||||||
|
// (also the likely source of several "entangle context after
|
||||||
|
// pre-commit" / CA-transaction warnings in the console, which
|
||||||
|
// are exactly what fighting AppKit for first-responder status
|
||||||
|
// mid-commit looks like). A one-frame-ish delay is enough for
|
||||||
|
// the overlay's insertion to settle first.
|
||||||
|
try? await Task.sleep(for: .milliseconds(50))
|
||||||
|
isSearchFieldFocused = true
|
||||||
|
await loadResults()
|
||||||
|
}
|
||||||
|
.onChange(of: collections) { recomputeResults() }
|
||||||
|
.onChange(of: documents) { recomputeResults() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resultRow(_ result: Result, isSelected: Bool) -> some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
switch result {
|
||||||
|
case .collection(let collection):
|
||||||
|
// Reuses the sidebar's own icon logic (emoji vs Outline's
|
||||||
|
// icon-key-to-SF-Symbol mapping vs fallback) instead of
|
||||||
|
// guessing — `collection.icon` isn't a raw SF Symbol name.
|
||||||
|
CollectionRowView(collection: collection)
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.frame(width: 20)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(collection.name)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text("Collection")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
case .document(let document):
|
||||||
|
if let emoji = document.emoji {
|
||||||
|
Text(emoji).frame(width: 20)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "doc.text")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 20)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(document.title.isEmpty ? "Untitled" : document.title)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text("Document")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(isSelected ? Color.accentColor.opacity(0.15) : .clear, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func moveSelection(by delta: Int) {
|
||||||
|
guard !results.isEmpty else { return }
|
||||||
|
selectedIndex = max(0, min(results.count - 1, selectedIndex + delta))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectCurrent() {
|
||||||
|
guard results.indices.contains(selectedIndex) else { return }
|
||||||
|
switch results[selectedIndex] {
|
||||||
|
case .collection(let collection): onSelectCollection(collection)
|
||||||
|
case .document(let document): onSelectDocument(document)
|
||||||
|
}
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadResults() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
if fullWorkspaceSearch {
|
||||||
|
// Purely local SwiftData reads — no network at all, and (since
|
||||||
|
// Full Local Sync now recurses into every document's children)
|
||||||
|
// this includes nested sub-documents the live per-collection
|
||||||
|
// fetch never could. Empty if a sync has never actually run.
|
||||||
|
collections = await cachingClient?.cachedCollectionsIndex() ?? []
|
||||||
|
documents = await cachingClient?.cachedDocumentsIndex() ?? []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
async let fetchedCollections = (try? apiClient.listCollections(offset: 0, limit: 250)) ?? []
|
||||||
|
async let fetchedRecent = (try? apiClient.listViewedDocuments(offset: 0, limit: 30)) ?? []
|
||||||
|
collections = await fetchedCollections
|
||||||
|
documents = await fetchedRecent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -4,6 +4,13 @@ import OutlineKit
|
|||||||
|
|
||||||
struct ContentView_macOS: View {
|
struct ContentView_macOS: View {
|
||||||
@Environment(SessionStore.self) private var session
|
@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.
|
||||||
|
@State private var isShowingHome = true
|
||||||
@State private var selectedCollection: OutlineCollection?
|
@State private var selectedCollection: OutlineCollection?
|
||||||
/// The real navigation stack, root to leaf — also the source of truth for
|
/// The real navigation stack, root to leaf — also the source of truth for
|
||||||
/// the toolbar breadcrumb, so the two can't drift out of sync.
|
/// the toolbar breadcrumb, so the two can't drift out of sync.
|
||||||
@@ -12,16 +19,88 @@ struct ContentView_macOS: View {
|
|||||||
@State private var contextualSearchQuery = ""
|
@State private var contextualSearchQuery = ""
|
||||||
@State private var isContextualSearchExpanded = false
|
@State private var isContextualSearchExpanded = false
|
||||||
@FocusState private var isContextualSearchFocused: Bool
|
@FocusState private var isContextualSearchFocused: Bool
|
||||||
|
/// Bumped whenever a document is created from somewhere with no direct
|
||||||
|
/// handle on the sidebar row it belongs under (the reader toolbar's and
|
||||||
|
/// 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 {
|
private var trimmedGlobalQuery: String {
|
||||||
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
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 {
|
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 {
|
NavigationSplitView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
SidebarSearchField(text: $globalSearchQuery)
|
SidebarSearchField(text: $globalSearchQuery)
|
||||||
Divider()
|
Divider()
|
||||||
|
if isOfflineModeEnabled {
|
||||||
|
OfflineBanner(isManual: true)
|
||||||
|
Divider()
|
||||||
|
} else if !session.networkMonitor.isOnline {
|
||||||
|
OfflineConnectionPromptBanner(onEnableOfflineMode: { isOfflineModeEnabled = true })
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
sidebar
|
sidebar
|
||||||
AccountFooter()
|
AccountFooter()
|
||||||
}
|
}
|
||||||
@@ -43,17 +122,137 @@ struct ContentView_macOS: View {
|
|||||||
// mutually exclusive: whenever a document's pushed (back button
|
// mutually exclusive: whenever a document's pushed (back button
|
||||||
// visible), this shows the document hierarchy instead of falling
|
// visible), this shows the document hierarchy instead of falling
|
||||||
// back to the workspace badge.
|
// back to the workspace badge.
|
||||||
|
ToolbarItem(placement: .navigation) {
|
||||||
|
Button {
|
||||||
|
goHome()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "house")
|
||||||
|
}
|
||||||
|
.help("Home")
|
||||||
|
}
|
||||||
ToolbarItem(placement: .navigation) {
|
ToolbarItem(placement: .navigation) {
|
||||||
leadingToolbarContent
|
leadingToolbarContent
|
||||||
}
|
}
|
||||||
// Custom instead of `.searchable`: that modifier always renders a
|
// Custom instead of `.searchable`: that modifier always renders a
|
||||||
// full-width field, but this is meant to sit alongside the other
|
// full-width field, but this is meant to sit alongside the other
|
||||||
// per-document toolbar buttons as a plain icon that only expands
|
// per-document toolbar buttons as a plain icon that only expands
|
||||||
// into a field once clicked.
|
// into a field once clicked. Hidden on the Home landing page
|
||||||
ToolbarItem(placement: .primaryAction) {
|
// itself — `contextualSearchQuery` is only ever read by
|
||||||
contextualSearchField
|
// `CollectionOverviewView`, so on Home it was a dead end: a
|
||||||
|
// user could click it, type, and nothing would happen. The
|
||||||
|
// sidebar's global search already covers "search everything."
|
||||||
|
if !(isShowingHome && documentPath.isEmpty) {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
contextualSearchField
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Any explicit collection pick — sidebar click, "Search in
|
||||||
|
// Collection" — means the user has navigated away from Home.
|
||||||
|
.onChange(of: selectedCollection) { _, newValue in
|
||||||
|
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func goHome() {
|
||||||
|
globalSearchQuery = ""
|
||||||
|
contextualSearchQuery = ""
|
||||||
|
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
|
@ViewBuilder
|
||||||
@@ -106,12 +305,19 @@ struct ContentView_macOS: View {
|
|||||||
Image(systemName: "magnifyingglass")
|
Image(systemName: "magnifyingglass")
|
||||||
Text("Search")
|
Text("Search")
|
||||||
}
|
}
|
||||||
} else if !documentPath.isEmpty, let selectedCollection {
|
} else if !documentPath.isEmpty {
|
||||||
// Collection → every ancestor (icon only) → current document
|
// Origin (collection, or Home if opened from there) → every
|
||||||
// (icon + full title) — ancestors stay icon-only so a deep
|
// ancestor (icon only) → current document (icon + full
|
||||||
// chain doesn't blow out the toolbar width.
|
// title) — ancestors stay icon-only so a deep chain doesn't
|
||||||
|
// blow out the toolbar width. Checked before `isShowingHome`
|
||||||
|
// since opening a document from Home still leaves that flag
|
||||||
|
// set — the pushed document should win either way.
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
CollectionRowView(collection: selectedCollection)
|
if let selectedCollection {
|
||||||
|
CollectionRowView(collection: selectedCollection)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "house.fill")
|
||||||
|
}
|
||||||
|
|
||||||
ForEach(Array(documentPath.enumerated()), id: \.element.id) { index, document in
|
ForEach(Array(documentPath.enumerated()), id: \.element.id) { index, document in
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
@@ -130,6 +336,11 @@ struct ContentView_macOS: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if isShowingHome {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "house.fill")
|
||||||
|
Text("Home")
|
||||||
|
}
|
||||||
} else if let selectedCollection {
|
} else if let selectedCollection {
|
||||||
CollectionRowView(collection: selectedCollection)
|
CollectionRowView(collection: selectedCollection)
|
||||||
} else {
|
} else {
|
||||||
@@ -153,6 +364,9 @@ struct ContentView_macOS: View {
|
|||||||
apiClient: apiClient,
|
apiClient: apiClient,
|
||||||
selectedCollection: $selectedCollection,
|
selectedCollection: $selectedCollection,
|
||||||
selectedDocumentID: documentPath.last?.id,
|
selectedDocumentID: documentPath.last?.id,
|
||||||
|
externalRefreshToken: documentsChangedToken,
|
||||||
|
isShowingHome: isShowingHome,
|
||||||
|
onSelectHome: goHome,
|
||||||
onSelectDocument: selectDocumentChain,
|
onSelectDocument: selectDocumentChain,
|
||||||
onSearchInCollection: searchInCollection
|
onSearchInCollection: searchInCollection
|
||||||
)
|
)
|
||||||
@@ -205,6 +419,8 @@ struct ContentView_macOS: View {
|
|||||||
Group {
|
Group {
|
||||||
if !trimmedGlobalQuery.isEmpty {
|
if !trimmedGlobalQuery.isEmpty {
|
||||||
GlobalSearchResultsView(apiClient: apiClient, query: trimmedGlobalQuery, onOpenDocument: openDocument)
|
GlobalSearchResultsView(apiClient: apiClient, query: trimmedGlobalQuery, onOpenDocument: openDocument)
|
||||||
|
} else if isShowingHome {
|
||||||
|
HomeView(apiClient: apiClient, onOpenDocument: openDocument, onDocumentCreated: { documentsChangedToken += 1 })
|
||||||
} else if let selectedCollection {
|
} else if let selectedCollection {
|
||||||
CollectionOverviewView(
|
CollectionOverviewView(
|
||||||
apiClient: apiClient,
|
apiClient: apiClient,
|
||||||
@@ -229,11 +445,19 @@ struct ContentView_macOS: View {
|
|||||||
if !documentPath.isEmpty {
|
if !documentPath.isEmpty {
|
||||||
documentPath.removeLast()
|
documentPath.removeLast()
|
||||||
}
|
}
|
||||||
}
|
// Delete/Archive/Unpublish/Move all change what
|
||||||
|
// should show in the sidebar tree — this only
|
||||||
|
// popped the reader before, leaving the sidebar
|
||||||
|
// showing the document until some unrelated
|
||||||
|
// trigger (the 45s poll, navigating away and
|
||||||
|
// back) happened to refresh it.
|
||||||
|
documentsChangedToken += 1
|
||||||
|
},
|
||||||
|
onDocumentCreated: { documentsChangedToken += 1 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.id(trimmedGlobalQuery.isEmpty ? (selectedCollection?.id ?? "none") : "search")
|
.id(trimmedGlobalQuery.isEmpty ? (isShowingHome ? "home" : (selectedCollection?.id ?? "none")) : "search")
|
||||||
} else {
|
} else {
|
||||||
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
|
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,425 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import SwiftUI
|
||||||
|
import OutlineKit
|
||||||
|
|
||||||
|
/// Document-level and anchored comments + single-level replies + emoji
|
||||||
|
/// reactions + resolve/unresolve. Composing new comments/replies is still
|
||||||
|
/// plain text only (no bold/italic/lists/etc.) — sent through the `text`
|
||||||
|
/// (markdown) convenience field, not a hand-built ProseMirror `data`
|
||||||
|
/// document.
|
||||||
|
@MainActor
|
||||||
|
struct DocumentCommentsSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let apiClient: OutlineAPIClient
|
||||||
|
let document: OutlineDocument
|
||||||
|
/// Set when opened by tapping an inline anchor marker — scrolls to and
|
||||||
|
/// briefly highlights that comment/reply. `nil` opens unfocused (the
|
||||||
|
/// plain toolbar marker).
|
||||||
|
var focusedCommentId: String? = nil
|
||||||
|
/// Set when opened via "Comment on Selection…" from the editor's
|
||||||
|
/// right-click menu — the selected text to anchor a NEW comment to.
|
||||||
|
/// The first occurrence of this exact substring in the document is
|
||||||
|
/// what the server (and later, this app's own inline marker) anchors
|
||||||
|
/// to; no prefix/suffix disambiguation UI for multiple identical
|
||||||
|
/// occurrences.
|
||||||
|
var pendingAnchorText: String? = nil
|
||||||
|
/// Called after any successful create/reply/resolve/reaction — lets the
|
||||||
|
/// reader refresh its own lightweight copy (inline anchor markers, the
|
||||||
|
/// toolbar badge count) without waiting for the document to be reopened.
|
||||||
|
var onCommentsChanged: (() -> Void)? = nil
|
||||||
|
|
||||||
|
/// A small curated set rather than the full system emoji picker — quick
|
||||||
|
/// taps for the common reactions, matching how most chat apps default.
|
||||||
|
private static let quickReactions = ["👍", "❤️", "😂", "🎉", "😮", "😢"]
|
||||||
|
|
||||||
|
@State private var comments: [OutlineComment] = []
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
@State private var actionErrorMessage: String?
|
||||||
|
@State private var currentUserId: String?
|
||||||
|
|
||||||
|
@State private var newCommentText = ""
|
||||||
|
@State private var isPostingNewComment = false
|
||||||
|
/// Mutable mirror of `pendingAnchorText` — lets the user clear it (fall
|
||||||
|
/// back to a plain document-level comment) without touching the init
|
||||||
|
/// param itself.
|
||||||
|
@State private var composingAnchorText: String?
|
||||||
|
|
||||||
|
@State private var replyingToThreadId: String?
|
||||||
|
@State private var replyText = ""
|
||||||
|
@State private var isPostingReply = false
|
||||||
|
|
||||||
|
@State private var resolvingCommentIds: Set<String> = []
|
||||||
|
@State private var reactingCommentIds: Set<String> = []
|
||||||
|
@State private var reactionPickerCommentId: String?
|
||||||
|
|
||||||
|
private struct CommentThread: Identifiable {
|
||||||
|
let top: OutlineComment
|
||||||
|
let replies: [OutlineComment]
|
||||||
|
var id: String { top.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var threads: [CommentThread] {
|
||||||
|
let topLevel = comments.filter { $0.parentCommentId == nil }.sorted { $0.createdAt < $1.createdAt }
|
||||||
|
return topLevel.map { top in
|
||||||
|
let replies = comments
|
||||||
|
.filter { $0.parentCommentId == top.id }
|
||||||
|
.sorted { $0.createdAt < $1.createdAt }
|
||||||
|
return CommentThread(top: top, replies: replies)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text("Comments")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Group {
|
||||||
|
if isLoading && comments.isEmpty {
|
||||||
|
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if let errorMessage {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label("Couldn't Load Comments", systemImage: "exclamationmark.triangle")
|
||||||
|
} description: {
|
||||||
|
Text(errorMessage)
|
||||||
|
} actions: {
|
||||||
|
Button("Retry") { Task { await load() } }
|
||||||
|
}
|
||||||
|
} else if threads.isEmpty {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label("No Comments", systemImage: "bubble.left.and.bubble.right")
|
||||||
|
} description: {
|
||||||
|
Text("This document has no comments yet.")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
List(threads) { thread in
|
||||||
|
threadSection(thread)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
guard let focusedCommentId else { return }
|
||||||
|
// The list needs a beat to lay out before a
|
||||||
|
// scrollTo lands correctly on first appear.
|
||||||
|
try? await Task.sleep(for: .milliseconds(50))
|
||||||
|
withAnimation {
|
||||||
|
proxy.scrollTo(focusedCommentId, anchor: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
newCommentComposer
|
||||||
|
}
|
||||||
|
.frame(width: 480, height: 560)
|
||||||
|
.task { await load() }
|
||||||
|
.task { currentUserId = try? await RetryPolicy.withRetry({ try await apiClient.currentUser().id }) }
|
||||||
|
.task { composingAnchorText = pendingAnchorText }
|
||||||
|
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
|
||||||
|
Button("OK") { actionErrorMessage = nil }
|
||||||
|
} message: {
|
||||||
|
Text(actionErrorMessage ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Thread
|
||||||
|
|
||||||
|
private func threadSection(_ thread: CommentThread) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
commentRow(thread.top, isReply: false)
|
||||||
|
|
||||||
|
ForEach(thread.replies) { reply in
|
||||||
|
commentRow(reply, isReply: true)
|
||||||
|
.padding(.leading, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
if replyingToThreadId == thread.id {
|
||||||
|
replyComposer(for: thread)
|
||||||
|
.padding(.leading, 20)
|
||||||
|
} else {
|
||||||
|
Button("Reply") {
|
||||||
|
replyingToThreadId = thread.id
|
||||||
|
replyText = ""
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.padding(.leading, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func commentRow(_ comment: OutlineComment, isReply: Bool) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(comment.createdBy?.name ?? "Unknown")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
Spacer()
|
||||||
|
Text(comment.createdAt, format: .relative(presentation: .named))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(comment.bodyText.isEmpty ? "(empty)" : comment.bodyText)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(comment.bodyText.isEmpty ? .secondary : .primary)
|
||||||
|
|
||||||
|
if !comment.reactions.isEmpty {
|
||||||
|
reactionChips(comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button {
|
||||||
|
reactionPickerCommentId = comment.id
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "face.smiling")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.popover(isPresented: Binding(
|
||||||
|
get: { reactionPickerCommentId == comment.id },
|
||||||
|
set: { if !$0 { reactionPickerCommentId = nil } }
|
||||||
|
)) {
|
||||||
|
quickReactionPicker(comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isReply {
|
||||||
|
if comment.isResolved {
|
||||||
|
Label("Resolved", systemImage: "checkmark.circle.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if resolvingCommentIds.contains(comment.id) {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Button(comment.isResolved ? "Unresolve" : "Resolve") {
|
||||||
|
Task { await toggleResolved(comment) }
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(6)
|
||||||
|
.background(
|
||||||
|
comment.id == focusedCommentId ? Color.accentColor.opacity(0.12) : Color.clear,
|
||||||
|
in: RoundedRectangle(cornerRadius: 6)
|
||||||
|
)
|
||||||
|
.id(comment.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reactionChips(_ comment: OutlineComment) -> some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
ForEach(comment.reactions, id: \.emoji) { reaction in
|
||||||
|
let mine = currentUserId.map(reaction.userIds.contains) ?? false
|
||||||
|
Button {
|
||||||
|
Task { await toggleReaction(comment, emoji: reaction.emoji, currentlyReacted: mine) }
|
||||||
|
} label: {
|
||||||
|
Text("\(reaction.emoji) \(reaction.userIds.count)")
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(mine ? Color.accentColor.opacity(0.2) : Color.gray.opacity(0.15), in: Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(reactingCommentIds.contains(comment.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func quickReactionPicker(_ comment: OutlineComment) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(Self.quickReactions, id: \.self) { emoji in
|
||||||
|
let mine = currentUserId.map { userId in
|
||||||
|
comment.reactions.first { $0.emoji == emoji }?.userIds.contains(userId) ?? false
|
||||||
|
} ?? false
|
||||||
|
Button {
|
||||||
|
reactionPickerCommentId = nil
|
||||||
|
Task { await toggleReaction(comment, emoji: emoji, currentlyReacted: mine) }
|
||||||
|
} label: {
|
||||||
|
Text(emoji)
|
||||||
|
.font(.title2)
|
||||||
|
.opacity(mine ? 1 : 0.5)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Composers
|
||||||
|
|
||||||
|
private var newCommentComposer: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
if let composingAnchorText {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "text.quote")
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
Text(composingAnchorText)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
self.composingAnchorText = nil
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.help("Remove — this will post as a document-level comment instead")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
HStack(alignment: .bottom, spacing: 8) {
|
||||||
|
TextField(
|
||||||
|
composingAnchorText == nil ? "Add a comment…" : "Comment on this text…",
|
||||||
|
text: $newCommentText,
|
||||||
|
axis: .vertical
|
||||||
|
)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.lineLimit(1...4)
|
||||||
|
.onSubmit { Task { await postNewComment() } }
|
||||||
|
if isPostingNewComment {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Button("Post") {
|
||||||
|
Task { await postNewComment() }
|
||||||
|
}
|
||||||
|
.disabled(newCommentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func replyComposer(for thread: CommentThread) -> some View {
|
||||||
|
HStack(alignment: .bottom, spacing: 8) {
|
||||||
|
TextField("Reply…", text: $replyText, axis: .vertical)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.lineLimit(1...4)
|
||||||
|
.onSubmit { Task { await postReply(to: thread) } }
|
||||||
|
if isPostingReply {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Button("Send") {
|
||||||
|
Task { await postReply(to: thread) }
|
||||||
|
}
|
||||||
|
.disabled(replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
Button("Cancel") {
|
||||||
|
replyingToThreadId = nil
|
||||||
|
replyText = ""
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
do {
|
||||||
|
comments = try await apiClient.listComments(ListCommentsRequest(documentId: document.id))
|
||||||
|
} catch {
|
||||||
|
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load comments for this document.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func postNewComment() async {
|
||||||
|
let text = newCommentText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else { return }
|
||||||
|
isPostingNewComment = true
|
||||||
|
defer { isPostingNewComment = false }
|
||||||
|
do {
|
||||||
|
let created = try await apiClient.createComment(
|
||||||
|
CreateCommentRequest(documentId: document.id, text: text, anchorText: composingAnchorText)
|
||||||
|
)
|
||||||
|
comments.append(created)
|
||||||
|
composingAnchorText = nil
|
||||||
|
newCommentText = ""
|
||||||
|
onCommentsChanged?()
|
||||||
|
} catch {
|
||||||
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't post this comment.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func postReply(to thread: CommentThread) async {
|
||||||
|
let text = replyText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else { return }
|
||||||
|
isPostingReply = true
|
||||||
|
defer { isPostingReply = false }
|
||||||
|
do {
|
||||||
|
let created = try await apiClient.createComment(
|
||||||
|
CreateCommentRequest(documentId: document.id, parentCommentId: thread.id, text: text)
|
||||||
|
)
|
||||||
|
comments.append(created)
|
||||||
|
replyText = ""
|
||||||
|
replyingToThreadId = nil
|
||||||
|
onCommentsChanged?()
|
||||||
|
} catch {
|
||||||
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't post this reply.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleResolved(_ comment: OutlineComment) async {
|
||||||
|
resolvingCommentIds.insert(comment.id)
|
||||||
|
defer { resolvingCommentIds.remove(comment.id) }
|
||||||
|
do {
|
||||||
|
let updated = comment.isResolved
|
||||||
|
? try await apiClient.unresolveComment(id: comment.id)
|
||||||
|
: try await apiClient.resolveComment(id: comment.id)
|
||||||
|
replace(updated)
|
||||||
|
} catch {
|
||||||
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update this comment.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reaction endpoints return `{success: true}`, not the updated comment
|
||||||
|
/// (see `OutlineAPIClient.addReaction`) — refetch via `commentInfo` for
|
||||||
|
/// the real post-toggle `reactions` array instead of guessing the merge
|
||||||
|
/// locally (another user reacting concurrently would make a guess wrong).
|
||||||
|
private func toggleReaction(_ comment: OutlineComment, emoji: String, currentlyReacted: Bool) async {
|
||||||
|
reactingCommentIds.insert(comment.id)
|
||||||
|
defer { reactingCommentIds.remove(comment.id) }
|
||||||
|
do {
|
||||||
|
if currentlyReacted {
|
||||||
|
try await apiClient.removeReaction(commentId: comment.id, emoji: emoji)
|
||||||
|
} else {
|
||||||
|
try await apiClient.addReaction(commentId: comment.id, emoji: emoji)
|
||||||
|
}
|
||||||
|
let refreshed = try await apiClient.commentInfo(id: comment.id)
|
||||||
|
replace(refreshed)
|
||||||
|
} catch {
|
||||||
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update that reaction.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func replace(_ updated: OutlineComment) {
|
||||||
|
if let index = comments.firstIndex(where: { $0.id == updated.id }) {
|
||||||
|
comments[index] = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import MarkdownEngine
|
import MarkdownEngine
|
||||||
|
import MarkdownEngineCodeBlocks
|
||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
|
||||||
/// Distraction-free reading view — no toolbar/sidebar chrome, larger type.
|
/// Distraction-free reading view — no toolbar/sidebar chrome, larger type.
|
||||||
@@ -16,11 +17,17 @@ struct DocumentPresentSheet: View {
|
|||||||
@State private var text: String
|
@State private var text: String
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
@State private var imageProvider: OutlineImageProvider
|
||||||
|
/// See `DocumentReaderView`'s `imageReloadTick` — same "force an
|
||||||
|
/// `updateNSView` re-pass so the engine notices `fingerprint()` changed"
|
||||||
|
/// mechanism, needed here too since this sheet renders its own images.
|
||||||
|
@State private var imageReloadTick = 0
|
||||||
|
|
||||||
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
|
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
|
||||||
self.apiClient = apiClient
|
self.apiClient = apiClient
|
||||||
self.document = document
|
self.document = document
|
||||||
_text = State(initialValue: document.text)
|
_text = State(initialValue: document.text)
|
||||||
|
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -44,7 +51,10 @@ struct DocumentPresentSheet: View {
|
|||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
NativeTextViewWrapper(
|
NativeTextViewWrapper(
|
||||||
text: $text,
|
text: $text,
|
||||||
configuration: .init(heightBehavior: .fitsContent),
|
configuration: .init(
|
||||||
|
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||||
|
heightBehavior: .fitsContent
|
||||||
|
),
|
||||||
isEditable: false
|
isEditable: false
|
||||||
)
|
)
|
||||||
.font(.system(size: 18))
|
.font(.system(size: 18))
|
||||||
@@ -67,6 +77,12 @@ struct DocumentPresentSheet: View {
|
|||||||
}
|
}
|
||||||
.frame(minWidth: 800, minHeight: 600)
|
.frame(minWidth: 800, minHeight: 600)
|
||||||
.background(.background)
|
.background(.background)
|
||||||
|
.animation(nil, value: imageReloadTick)
|
||||||
|
.task {
|
||||||
|
imageProvider.onImageLoaded = {
|
||||||
|
Task { @MainActor in imageReloadTick += 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
.task { await load() }
|
.task { await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,10 @@ final class DocumentReaderViewModel {
|
|||||||
var emoji: String?
|
var emoji: String?
|
||||||
var text: String
|
var text: String
|
||||||
var collectionId: String?
|
var collectionId: String?
|
||||||
|
var parentDocumentId: String?
|
||||||
|
/// `nil` = draft (not published/visible to other workspace members).
|
||||||
|
var publishedAt: Date?
|
||||||
var isFullWidth = false
|
var isFullWidth = false
|
||||||
var children: [OutlineDocument] = []
|
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
|
|
||||||
@@ -18,6 +20,38 @@ final class DocumentReaderViewModel {
|
|||||||
var isSaving = false
|
var isSaving = false
|
||||||
var saveErrorMessage: String?
|
var saveErrorMessage: String?
|
||||||
|
|
||||||
|
/// Snapshot of the preference, set once via `.task` right after the
|
||||||
|
/// view appears (can't be read from `@Environment` inside the view's
|
||||||
|
/// own `init`) rather than a live binding to `SessionStore` — matches
|
||||||
|
/// how `isFullWidth` etc. are already seeded from the document at init
|
||||||
|
/// rather than observed reactively. A change made in Settings while a
|
||||||
|
/// document is already open takes effect the next document opened, not
|
||||||
|
/// mid-session; an acceptable tradeoff for how rarely this gets
|
||||||
|
/// toggled versus the complexity of threading a live preference
|
||||||
|
/// reference through every reader instance.
|
||||||
|
var separateEditingEnabled: Bool
|
||||||
|
|
||||||
|
/// The single source of truth the view reads for both "show the title
|
||||||
|
/// field" and "is the text view editable" — when separate editing is
|
||||||
|
/// off there's no Edit/Done mode at all, the document is just always
|
||||||
|
/// editable (assuming permission; there's no per-document permission
|
||||||
|
/// field to pre-check against, so an unauthorized edit simply fails to
|
||||||
|
/// save rather than being blocked client-side up front).
|
||||||
|
var isEffectivelyEditable: Bool {
|
||||||
|
separateEditingEnabled ? isEditing : true
|
||||||
|
}
|
||||||
|
|
||||||
|
private var autosaveTask: Task<Void, Never>?
|
||||||
|
/// Tracks the last known-synced-with-the-server values so
|
||||||
|
/// `scheduleAutosave()` can no-op when called just because `text`/
|
||||||
|
/// `title` were reassigned *from* a server response (initial load, or
|
||||||
|
/// a completed save) rather than actually edited — without this, every
|
||||||
|
/// document open in the always-editable mode would fire one pointless
|
||||||
|
/// autosave round-trip immediately, re-sending exactly what was just
|
||||||
|
/// received.
|
||||||
|
private var lastSyncedText: String
|
||||||
|
private var lastSyncedTitle: String
|
||||||
|
|
||||||
/// Recent viewers, `views.list` filtered to entries that actually have a
|
/// Recent viewers, `views.list` filtered to entries that actually have a
|
||||||
/// `lastViewedAt` — this is historical/aggregated view data, not live
|
/// `lastViewedAt` — this is historical/aggregated view data, not live
|
||||||
/// "viewing right now" presence (that needs the Hocuspocus collaboration
|
/// "viewing right now" presence (that needs the Hocuspocus collaboration
|
||||||
@@ -28,19 +62,29 @@ final class DocumentReaderViewModel {
|
|||||||
private var pinId: String?
|
private var pinId: String?
|
||||||
private(set) var isSubscribed = false
|
private(set) var isSubscribed = false
|
||||||
private var subscriptionId: String?
|
private var subscriptionId: String?
|
||||||
private(set) var share: OutlineShare?
|
|
||||||
|
/// `nil` until checked. Inferred from whether `documents.insights`
|
||||||
|
/// succeeds or fails — `insightsEnabled` isn't readable back off
|
||||||
|
/// `Document` in the vendored spec, so there's no direct field to read.
|
||||||
|
/// This is a heuristic, not confirmed server behavior.
|
||||||
|
private(set) var isInsightsEnabled: Bool?
|
||||||
|
|
||||||
let documentId: String
|
let documentId: String
|
||||||
private let apiClient: OutlineAPIClient
|
private let apiClient: OutlineAPIClient
|
||||||
|
|
||||||
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
|
init(apiClient: OutlineAPIClient, document: OutlineDocument, separateEditingEnabled: Bool = true) {
|
||||||
self.apiClient = apiClient
|
self.apiClient = apiClient
|
||||||
self.documentId = document.id
|
self.documentId = document.id
|
||||||
self.title = document.title
|
self.title = document.title
|
||||||
self.emoji = document.emoji
|
self.emoji = document.emoji
|
||||||
self.text = document.text
|
self.text = document.text
|
||||||
self.collectionId = document.collectionId
|
self.collectionId = document.collectionId
|
||||||
|
self.parentDocumentId = document.parentDocumentId
|
||||||
|
self.publishedAt = document.publishedAt
|
||||||
self.isFullWidth = document.fullWidth ?? false
|
self.isFullWidth = document.fullWidth ?? false
|
||||||
|
self.separateEditingEnabled = separateEditingEnabled
|
||||||
|
self.lastSyncedText = document.text
|
||||||
|
self.lastSyncedTitle = document.title
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The list endpoint's copy of a document isn't guaranteed to be the full,
|
/// The list endpoint's copy of a document isn't guaranteed to be the full,
|
||||||
@@ -56,26 +100,29 @@ final class DocumentReaderViewModel {
|
|||||||
emoji = full.emoji
|
emoji = full.emoji
|
||||||
text = full.text
|
text = full.text
|
||||||
collectionId = full.collectionId
|
collectionId = full.collectionId
|
||||||
|
parentDocumentId = full.parentDocumentId
|
||||||
|
publishedAt = full.publishedAt
|
||||||
isFullWidth = full.fullWidth ?? false
|
isFullWidth = full.fullWidth ?? false
|
||||||
|
lastSyncedText = full.text
|
||||||
|
lastSyncedTitle = full.title
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Couldn't load this document. Check your connection and try again."
|
errorMessage = "Couldn't load this document. Check your connection and try again."
|
||||||
}
|
}
|
||||||
|
|
||||||
children = (try? await apiClient.listDocuments(
|
|
||||||
collectionId: nil,
|
|
||||||
parentDocumentId: documentId,
|
|
||||||
offset: 0,
|
|
||||||
limit: 100
|
|
||||||
)) ?? []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadViewers() async {
|
func loadViewers() async {
|
||||||
guard let views = try? await apiClient.listViews(ListViewsRequest(documentId: documentId)) else { return }
|
// A single blip here used to just leave `viewers` empty forever with
|
||||||
|
// no sign anything went wrong — retry-with-backoff absorbs that;
|
||||||
|
// `try?` still covers the "still failing after retries" case, same
|
||||||
|
// silent-but-harmless fallback as before (an empty viewers list).
|
||||||
|
guard let views = try? await RetryPolicy.withRetry({ try await apiClient.listViews(ListViewsRequest(documentId: documentId)) }) else { return }
|
||||||
viewers = views.filter { $0.lastViewedAt != nil }
|
viewers = views.filter { $0.lastViewedAt != nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadPinAndSubscriptionState() async {
|
func loadPinAndSubscriptionState() async {
|
||||||
if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collectionId)),
|
// `collectionId: nil` = Home pins. This menu's Pin action is "Pin to
|
||||||
|
// Home", not "Pin to Collection" — those are distinct on the server.
|
||||||
|
if let pins = try? await RetryPolicy.withRetry({ try await apiClient.listPins(ListPinsRequest(collectionId: nil)) }),
|
||||||
let match = pins.first(where: { $0.documentId == documentId }) {
|
let match = pins.first(where: { $0.documentId == documentId }) {
|
||||||
isPinned = true
|
isPinned = true
|
||||||
pinId = match.id
|
pinId = match.id
|
||||||
@@ -84,7 +131,7 @@ final class DocumentReaderViewModel {
|
|||||||
pinId = nil
|
pinId = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if let subscriptions = try? await apiClient.listSubscriptions(ListSubscriptionsRequest(documentId: documentId)),
|
if let subscriptions = try? await RetryPolicy.withRetry({ try await apiClient.listSubscriptions(ListSubscriptionsRequest(documentId: documentId)) }),
|
||||||
let match = subscriptions.first {
|
let match = subscriptions.first {
|
||||||
isSubscribed = true
|
isSubscribed = true
|
||||||
subscriptionId = match.id
|
subscriptionId = match.id
|
||||||
@@ -94,8 +141,13 @@ final class DocumentReaderViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadShare() async {
|
func loadInsightsEnabledState() async {
|
||||||
share = try? await apiClient.shareInfo(documentId: documentId)
|
do {
|
||||||
|
_ = try await apiClient.documentInsights(DocumentInsightsRequest(id: documentId))
|
||||||
|
isInsightsEnabled = true
|
||||||
|
} catch {
|
||||||
|
isInsightsEnabled = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func togglePin() async throws {
|
func togglePin() async throws {
|
||||||
@@ -110,7 +162,7 @@ final class DocumentReaderViewModel {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: collectionId))
|
let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: nil))
|
||||||
pinId = pin.id
|
pinId = pin.id
|
||||||
isPinned = true
|
isPinned = true
|
||||||
}
|
}
|
||||||
@@ -134,24 +186,55 @@ final class DocumentReaderViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createOrLoadShare() async throws {
|
/// Turning editing off saves; turning it on is just a mode switch. Only
|
||||||
share = try await apiClient.createShare(CreateShareRequest(documentId: documentId))
|
/// meaningful when `separateEditingEnabled` — the always-editable path
|
||||||
}
|
/// uses `scheduleAutosave()` instead.
|
||||||
|
|
||||||
/// Turning editing off saves; turning it on is just a mode switch.
|
|
||||||
func toggleEditing() async {
|
func toggleEditing() async {
|
||||||
guard isEditing else {
|
guard isEditing else {
|
||||||
isEditing = true
|
isEditing = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
await save()
|
||||||
|
if saveErrorMessage == nil {
|
||||||
|
isEditing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debounced save for the always-editable (separate editing off) path —
|
||||||
|
/// cancels any pending save and starts a fresh countdown on every call,
|
||||||
|
/// so a save only actually fires once typing pauses, not on every
|
||||||
|
/// keystroke. Goes through the same `updateDocument` call the explicit
|
||||||
|
/// Done-button save uses, which is already offline-queue-aware
|
||||||
|
/// (`CachingOutlineAPIClient`), so autosave while offline just queues
|
||||||
|
/// like any other edit instead of needing separate handling here.
|
||||||
|
func scheduleAutosave() {
|
||||||
|
guard text != lastSyncedText || title != lastSyncedTitle else { return }
|
||||||
|
autosaveTask?.cancel()
|
||||||
|
autosaveTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: .seconds(1.5))
|
||||||
|
guard let self, !Task.isCancelled else { return }
|
||||||
|
await self.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() async {
|
||||||
isSaving = true
|
isSaving = true
|
||||||
saveErrorMessage = nil
|
saveErrorMessage = nil
|
||||||
defer { isSaving = false }
|
defer { isSaving = false }
|
||||||
|
let sentTitle = title
|
||||||
|
let sentText = text
|
||||||
do {
|
do {
|
||||||
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: title, text: text))
|
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: sentTitle, text: sentText))
|
||||||
title = updated.title
|
// Only reconcile with the server's response if nothing changed
|
||||||
text = updated.text
|
// locally while the request was in flight — otherwise this
|
||||||
isEditing = false
|
// would clobber keystrokes typed during a debounced autosave's
|
||||||
|
// round trip. Whatever's newer goes out on the next autosave
|
||||||
|
// cycle regardless, since `scheduleAutosave()` keeps getting
|
||||||
|
// re-triggered by continued typing.
|
||||||
|
if title == sentTitle { title = updated.title }
|
||||||
|
if text == sentText { text = updated.text }
|
||||||
|
lastSyncedTitle = sentTitle
|
||||||
|
lastSyncedText = sentText
|
||||||
} catch {
|
} catch {
|
||||||
saveErrorMessage = outlineErrorMessage(error, fallback: "Couldn't save this document.")
|
saveErrorMessage = outlineErrorMessage(error, fallback: "Couldn't save this document.")
|
||||||
}
|
}
|
||||||
@@ -168,14 +251,14 @@ final class DocumentReaderViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fire-and-forget: `insightsEnabled` isn't readable back off `Document`
|
func toggleViewerInsights() async throws {
|
||||||
/// in the vendored spec, so there's no state to reflect as a checkmark.
|
let newValue = !(isInsightsEnabled ?? false)
|
||||||
func enableViewerInsights() async throws {
|
isInsightsEnabled = newValue
|
||||||
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, insightsEnabled: true))
|
do {
|
||||||
}
|
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, insightsEnabled: newValue))
|
||||||
|
} catch {
|
||||||
/// Fire-and-forget, speculative field — see `UpdateDocumentRequest.documentEmbeds`.
|
isInsightsEnabled = !newValue
|
||||||
func enableEmbeds() async throws {
|
throw error
|
||||||
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, documentEmbeds: true))
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,26 @@ struct DocumentSearchSheet: View {
|
|||||||
@State private var currentMatchIndex = 0
|
@State private var currentMatchIndex = 0
|
||||||
@FocusState private var isSearchFieldFocused: Bool
|
@FocusState private var isSearchFieldFocused: Bool
|
||||||
|
|
||||||
private var matchingLineIndices: [Int] {
|
/// Recomputed only when `query`/`lines` actually change (`recomputeMatches()`)
|
||||||
guard !query.isEmpty else { return [] }
|
/// instead of being a computed property — this used to re-scan the whole
|
||||||
return lines.indices.filter { lines[$0].localizedCaseInsensitiveContains(query) }
|
/// document on every access, and it's read multiple times per row
|
||||||
|
/// (`isCurrentMatch`, the highlight check) on every SwiftUI re-render, so a
|
||||||
|
/// large document turned into an O(n²) case-insensitive scan per frame.
|
||||||
|
@State private var matchingLineIndices: [Int] = []
|
||||||
|
/// O(1) membership for the per-row highlight check below — `matchingLineIndices`
|
||||||
|
/// stays an ordered array (needed for `currentMatchIndex`/stepping), this is
|
||||||
|
/// just a parallel lookup so a common search term with many matches doesn't
|
||||||
|
/// make every row's highlight check an O(k) linear scan.
|
||||||
|
@State private var matchingLineIndexSet: Set<Int> = []
|
||||||
|
|
||||||
|
private func recomputeMatches() {
|
||||||
|
guard !query.isEmpty else {
|
||||||
|
matchingLineIndices = []
|
||||||
|
matchingLineIndexSet = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
matchingLineIndices = lines.indices.filter { lines[$0].localizedCaseInsensitiveContains(query) }
|
||||||
|
matchingLineIndexSet = Set(matchingLineIndices)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -35,7 +52,10 @@ struct DocumentSearchSheet: View {
|
|||||||
TextField("Search in \"\(document.title.isEmpty ? "Untitled" : document.title)\"", text: $query)
|
TextField("Search in \"\(document.title.isEmpty ? "Untitled" : document.title)\"", text: $query)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.focused($isSearchFieldFocused)
|
.focused($isSearchFieldFocused)
|
||||||
.onChange(of: query) { currentMatchIndex = 0 }
|
.onChange(of: query) {
|
||||||
|
currentMatchIndex = 0
|
||||||
|
recomputeMatches()
|
||||||
|
}
|
||||||
|
|
||||||
if !matchingLineIndices.isEmpty {
|
if !matchingLineIndices.isEmpty {
|
||||||
Text("\(currentMatchIndex + 1) of \(matchingLineIndices.count)")
|
Text("\(currentMatchIndex + 1) of \(matchingLineIndices.count)")
|
||||||
@@ -83,7 +103,7 @@ struct DocumentSearchSheet: View {
|
|||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
.background(
|
.background(
|
||||||
isCurrentMatch(index) ? Color.yellow.opacity(0.4)
|
isCurrentMatch(index) ? Color.yellow.opacity(0.4)
|
||||||
: matchingLineIndices.contains(index) ? Color.yellow.opacity(0.15)
|
: matchingLineIndexSet.contains(index) ? Color.yellow.opacity(0.15)
|
||||||
: Color.clear
|
: Color.clear
|
||||||
)
|
)
|
||||||
.id(index)
|
.id(index)
|
||||||
@@ -131,6 +151,7 @@ struct DocumentSearchSheet: View {
|
|||||||
do {
|
do {
|
||||||
let text = try await apiClient.documentInfo(id: document.id).text
|
let text = try await apiClient.documentInfo(id: document.id).text
|
||||||
lines = text.components(separatedBy: "\n")
|
lines = text.components(separatedBy: "\n")
|
||||||
|
recomputeMatches()
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load this document.")
|
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load this document.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,68 +3,306 @@ import AppKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
|
||||||
|
/// Content of the Share popover anchored to the reader toolbar's Share
|
||||||
|
/// button (see `DocumentReaderView`'s `.popover(isPresented:)`). Was a
|
||||||
|
/// modal `.sheet` originally — moved to a popover so it reads as "options
|
||||||
|
/// for this button" instead of interrupting the whole window.
|
||||||
@MainActor
|
@MainActor
|
||||||
struct DocumentShareSheet: View {
|
struct DocumentShareSheet: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
let apiClient: OutlineAPIClient
|
let apiClient: OutlineAPIClient
|
||||||
let documentId: String
|
let documentId: String
|
||||||
|
|
||||||
@State private var share: OutlineShare?
|
@State private var share: OutlineShare?
|
||||||
@State private var isLoading = false
|
@State private var titleOverride = ""
|
||||||
@State private var isUpdating = false
|
@State private var isLoadingShare = false
|
||||||
@State private var errorMessage: String?
|
@State private var isUpdatingShare = false
|
||||||
|
@State private var isRevoking = false
|
||||||
|
@State private var isShowingRevokeConfirmation = false
|
||||||
|
@State private var isShowingTitleField = false
|
||||||
|
@State private var shareErrorMessage: String?
|
||||||
@State private var didCopy = false
|
@State private var didCopy = false
|
||||||
|
|
||||||
var body: some View {
|
@State private var members: [OutlineDocumentMember] = []
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
@State private var isLoadingMembers = false
|
||||||
HStack {
|
@State private var isShowingAddPerson = false
|
||||||
Text("Share")
|
@State private var userSearchQuery = ""
|
||||||
.font(.headline)
|
@State private var userSearchResults: [OutlineUser] = []
|
||||||
Spacer()
|
@State private var isSearchingUsers = false
|
||||||
Button("Done") { dismiss() }
|
@State private var selectedPermission = "read"
|
||||||
}
|
@State private var isAddingUser = false
|
||||||
|
@State private var actionErrorMessage: String?
|
||||||
|
|
||||||
if isLoading {
|
var body: some View {
|
||||||
ProgressView().frame(maxWidth: .infinity)
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
} else if let errorMessage {
|
Text("Share")
|
||||||
Text(errorMessage)
|
.font(.headline)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 14)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
shareLinkSection
|
||||||
|
peopleSection
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
.frame(minHeight: 150, maxHeight: 900)
|
||||||
|
}
|
||||||
|
.frame(width: 280)
|
||||||
|
.task { await loadShare() }
|
||||||
|
.task { await loadMembers() }
|
||||||
|
.task(id: userSearchQuery) {
|
||||||
|
try? await Task.sleep(for: .milliseconds(250))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await searchUsers(userSearchQuery)
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Revoke this share link?",
|
||||||
|
isPresented: $isShowingRevokeConfirmation,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Revoke", role: .destructive) {
|
||||||
|
Task { await revoke() }
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Anyone using this link will no longer be able to access the document.")
|
||||||
|
}
|
||||||
|
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
|
||||||
|
Button("OK") { actionErrorMessage = nil }
|
||||||
|
} message: {
|
||||||
|
Text(actionErrorMessage ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Link section
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var shareLinkSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
sectionHeader(icon: "link", title: "Public Link")
|
||||||
|
|
||||||
|
if isLoadingShare {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
} else if let shareErrorMessage {
|
||||||
|
Text(shareErrorMessage)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
} else if let share {
|
} else if let share {
|
||||||
HStack {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(share.url)
|
if let url = share.url {
|
||||||
.font(.callout)
|
HStack(spacing: 8) {
|
||||||
.lineLimit(1)
|
Image(systemName: "globe")
|
||||||
.truncationMode(.middle)
|
.foregroundStyle(.secondary)
|
||||||
Spacer()
|
.font(.callout)
|
||||||
Button {
|
Text(url)
|
||||||
copyLink(share.url)
|
.font(.callout)
|
||||||
} label: {
|
.lineLimit(1)
|
||||||
Image(systemName: didCopy ? "checkmark" : "doc.on.doc")
|
.truncationMode(.middle)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Button {
|
||||||
|
copyLink(url)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: didCopy ? "checkmark" : "doc.on.doc")
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(didCopy ? .green : .secondary)
|
||||||
|
.help("Copy link")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isShowingTitleField {
|
||||||
|
TextField("Public page title", text: $titleOverride)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.font(.callout)
|
||||||
|
.disabled(isUpdatingShare)
|
||||||
|
.onSubmit {
|
||||||
|
Task { await setTitle(titleOverride) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(isShowingTitleField ? "Hide title field" : "Set public title") {
|
||||||
|
isShowingTitleField.toggle()
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
.font(.caption)
|
||||||
.padding(8)
|
.foregroundStyle(.secondary)
|
||||||
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6))
|
|
||||||
|
|
||||||
Toggle(
|
Spacer()
|
||||||
"Published — accessible without sign-in",
|
|
||||||
isOn: Binding(
|
Button("Revoke", role: .destructive) {
|
||||||
get: { share.published },
|
isShowingRevokeConfirmation = true
|
||||||
set: { newValue in Task { await setPublished(newValue) } }
|
}
|
||||||
)
|
.buttonStyle(.plain)
|
||||||
)
|
.font(.caption)
|
||||||
.disabled(isUpdating)
|
.foregroundStyle(.red)
|
||||||
|
.disabled(isRevoking)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Button("Create Share Link") {
|
Button {
|
||||||
Task { await create() }
|
Task { await create() }
|
||||||
|
} label: {
|
||||||
|
Label("Create Share Link", systemImage: "link.badge.plus")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.regular)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - People section
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var peopleSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
sectionHeader(icon: "person.2", title: "People with Access")
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
isShowingAddPerson.toggle()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: isShowingAddPerson ? "xmark.circle.fill" : "person.badge.plus")
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.help("Add a person")
|
||||||
|
}
|
||||||
|
|
||||||
|
if isShowingAddPerson {
|
||||||
|
addPersonSection
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLoadingMembers {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
} else if members.isEmpty {
|
||||||
|
Text("No one else has explicit access yet.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
ForEach(members) { member in
|
||||||
|
memberRow(member)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(20)
|
}
|
||||||
.frame(width: 380)
|
|
||||||
.task { await load() }
|
private func sectionHeader(icon: String, title: String) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(title)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var addPersonSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
TextField("Search people by name or email", text: $userSearchQuery)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
|
||||||
|
Picker("Permission", selection: $selectedPermission) {
|
||||||
|
Text("Can view").tag("read")
|
||||||
|
Text("Can edit").tag("read_write")
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.labelsHidden()
|
||||||
|
|
||||||
|
if isSearchingUsers {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
} else if !userSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
if userSearchResults.isEmpty {
|
||||||
|
Text("No matches.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
ForEach(userSearchResults) { user in
|
||||||
|
Button {
|
||||||
|
Task { await addUser(user) }
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
avatar(for: user.name)
|
||||||
|
Text(user.name)
|
||||||
|
.font(.callout)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Image(systemName: "plus.circle")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(isAddingUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func memberRow(_ member: OutlineDocumentMember) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
avatar(for: member.name)
|
||||||
|
Text(member.name)
|
||||||
|
.font(.callout)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
if let permission = member.permission {
|
||||||
|
Text(permission == "read_write" ? "Can edit" : "Can view")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(.fill.tertiary, in: Capsule())
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
Task { await removeUser(member) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func avatar(for name: String) -> some View {
|
||||||
|
Circle()
|
||||||
|
.fill(.fill.secondary)
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
.overlay {
|
||||||
|
Text(initials(for: name))
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func initials(for name: String) -> String {
|
||||||
|
let parts = name.split(separator: " ").prefix(2)
|
||||||
|
let letters = parts.compactMap { $0.first }
|
||||||
|
return letters.isEmpty ? "?" : String(letters).uppercased()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func copyLink(_ url: String) {
|
private func copyLink(_ url: String) {
|
||||||
@@ -78,34 +316,96 @@ struct DocumentShareSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func load() async {
|
private func loadShare() async {
|
||||||
isLoading = true
|
isLoadingShare = true
|
||||||
defer { isLoading = false }
|
defer { isLoadingShare = false }
|
||||||
do {
|
do {
|
||||||
share = try await apiClient.shareInfo(documentId: documentId)
|
share = try await apiClient.shareInfo(documentId: documentId)
|
||||||
|
titleOverride = share?.title ?? ""
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.")
|
shareErrorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func create() async {
|
private func create() async {
|
||||||
isLoading = true
|
isLoadingShare = true
|
||||||
defer { isLoading = false }
|
defer { isLoadingShare = false }
|
||||||
do {
|
do {
|
||||||
share = try await apiClient.createShare(CreateShareRequest(documentId: documentId))
|
share = try await apiClient.createShare(CreateShareRequest(documentId: documentId))
|
||||||
|
titleOverride = share?.title ?? ""
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't create a share link.")
|
shareErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a share link.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setPublished(_ published: Bool) async {
|
private func setTitle(_ title: String) async {
|
||||||
guard let share else { return }
|
guard let share else { return }
|
||||||
isUpdating = true
|
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
defer { isUpdating = false }
|
guard trimmed != (share.title ?? "") else { return }
|
||||||
|
isUpdatingShare = true
|
||||||
|
defer { isUpdatingShare = false }
|
||||||
do {
|
do {
|
||||||
self.share = try await apiClient.updateShare(UpdateShareRequest(id: share.id, published: published))
|
self.share = try await apiClient.updateShare(
|
||||||
|
UpdateShareRequest(id: share.id, published: share.published, title: trimmed)
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.")
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func revoke() async {
|
||||||
|
guard let share else { return }
|
||||||
|
isRevoking = true
|
||||||
|
defer { isRevoking = false }
|
||||||
|
do {
|
||||||
|
try await apiClient.revokeShare(id: share.id)
|
||||||
|
self.share = nil
|
||||||
|
titleOverride = ""
|
||||||
|
isShowingTitleField = false
|
||||||
|
} catch {
|
||||||
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't revoke this share link.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadMembers() async {
|
||||||
|
isLoadingMembers = true
|
||||||
|
defer { isLoadingMembers = false }
|
||||||
|
members = (try? await RetryPolicy.withRetry({ try await apiClient.documentUsers(ListDocumentUsersRequest(id: documentId)) })) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func searchUsers(_ query: String) async {
|
||||||
|
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else {
|
||||||
|
userSearchResults = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isSearchingUsers = true
|
||||||
|
defer { isSearchingUsers = false }
|
||||||
|
userSearchResults = (try? await RetryPolicy.withRetry({ try await apiClient.listUsers(ListUsersRequest(query: trimmed)) })) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addUser(_ user: OutlineUser) async {
|
||||||
|
isAddingUser = true
|
||||||
|
defer { isAddingUser = false }
|
||||||
|
do {
|
||||||
|
_ = try await apiClient.addDocumentUser(
|
||||||
|
AddDocumentUserRequest(id: documentId, userId: user.id, permission: selectedPermission)
|
||||||
|
)
|
||||||
|
userSearchQuery = ""
|
||||||
|
userSearchResults = []
|
||||||
|
isShowingAddPerson = false
|
||||||
|
await loadMembers()
|
||||||
|
} catch {
|
||||||
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't add this person.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeUser(_ member: OutlineDocumentMember) async {
|
||||||
|
do {
|
||||||
|
try await apiClient.removeDocumentUser(RemoveDocumentUserRequest(id: documentId, userId: member.id))
|
||||||
|
await loadMembers()
|
||||||
|
} catch {
|
||||||
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't remove this person.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import SwiftUI
|
||||||
|
import OutlineKit
|
||||||
|
|
||||||
|
/// The one shared "create a document" dialog — every "New Document" entry
|
||||||
|
/// point (sidebar collection, sidebar document, reader toolbar/menu, Home)
|
||||||
|
/// opens this instead of silently creating an "Untitled" document. Mirrors
|
||||||
|
/// `MoveDocumentSheet`'s collection+parent picker pattern.
|
||||||
|
@MainActor
|
||||||
|
struct NewDocumentSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let apiClient: OutlineAPIClient
|
||||||
|
/// Pre-selected collection — e.g. opened from a specific collection's
|
||||||
|
/// "New Document". `nil` when opened from Home, where nothing is
|
||||||
|
/// pre-selected and the user must choose.
|
||||||
|
let initialCollectionID: String?
|
||||||
|
/// Pre-selected parent — e.g. opened from a document's "New Document",
|
||||||
|
/// which creates a child of that document.
|
||||||
|
let initialParentDocument: OutlineDocument?
|
||||||
|
let onCreated: (OutlineDocument) -> Void
|
||||||
|
|
||||||
|
@State private var title = ""
|
||||||
|
@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 isCreating = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
@FocusState private var isTitleFocused: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
apiClient: OutlineAPIClient,
|
||||||
|
initialCollectionID: String? = nil,
|
||||||
|
initialParentDocument: OutlineDocument? = nil,
|
||||||
|
onCreated: @escaping (OutlineDocument) -> Void
|
||||||
|
) {
|
||||||
|
self.apiClient = apiClient
|
||||||
|
self.initialCollectionID = initialCollectionID
|
||||||
|
self.initialParentDocument = initialParentDocument
|
||||||
|
self.onCreated = onCreated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `rootDocuments` is only root-level (mirroring `MoveDocumentSheet`'s
|
||||||
|
/// intentionally shallow picker) — if the initial parent is nested
|
||||||
|
/// deeper than that, it wouldn't otherwise appear as a selectable option
|
||||||
|
/// even though it's already the selection.
|
||||||
|
private var parentOptions: [OutlineDocument] {
|
||||||
|
var options = rootDocuments
|
||||||
|
if let initialParentDocument,
|
||||||
|
initialParentDocument.collectionId == selectedCollectionID,
|
||||||
|
!options.contains(where: { $0.id == initialParentDocument.id }) {
|
||||||
|
options.insert(initialParentDocument, at: 0)
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("New Document")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
TextField("Title", text: $title)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.focused($isTitleFocused)
|
||||||
|
.onSubmit { Task { await create() } }
|
||||||
|
|
||||||
|
if isLoadingCollections {
|
||||||
|
ProgressView().frame(maxWidth: .infinity)
|
||||||
|
} else {
|
||||||
|
Picker("Collection", selection: $selectedCollectionID) {
|
||||||
|
Text("Draft (not published)").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(parentOptions) { candidate in
|
||||||
|
Text(candidate.title.isEmpty ? "Untitled" : candidate.title).tag(Optional(candidate.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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 {
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel", role: .cancel) { dismiss() }
|
||||||
|
Button(selectedCollectionID == nil ? "Save as Draft" : "Create & Publish") {
|
||||||
|
Task { await create() }
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.disabled(isCreating)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(width: 380)
|
||||||
|
.task {
|
||||||
|
await loadCollections()
|
||||||
|
// "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
|
||||||
|
}
|
||||||
|
.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 create() async {
|
||||||
|
isCreating = true
|
||||||
|
defer { isCreating = false }
|
||||||
|
do {
|
||||||
|
let document = try await apiClient.createDocument(
|
||||||
|
CreateDocumentRequest(
|
||||||
|
title: title.isEmpty ? "Untitled" : title,
|
||||||
|
text: "",
|
||||||
|
collectionId: selectedCollectionID,
|
||||||
|
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)
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
errorMessage = outlineErrorMessage(error, fallback: "Couldn't create this document.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user